diff --git a/.claude/agent-memory/project-manager/MEMORY.md b/.claude/agent-memory/project-manager/MEMORY.md deleted file mode 100644 index 72dc2a7..0000000 --- a/.claude/agent-memory/project-manager/MEMORY.md +++ /dev/null @@ -1,18 +0,0 @@ -# Project Manager Memory - -## Docket CLI Reference Corrections -- `docket issue link add` relation types: `blocks`, `depends_on`, `relates_to`, `duplicates` (NOT `blocked-by`) -- File attachments may duplicate if `-f` is used on create AND `file add` after -- use one or the other - -## Project Structure -- QA tests: `scripts/qa/test_*.sh` (29 scripts, alphabetical naming: a-z, then za, zb, zc...) -- No CLI-level Go tests exist (`internal/cli/` has no `*_test.go` files) -- Go unit tests are in `internal/db/`, `internal/model/`, `internal/output/`, `internal/render/`, `internal/planner/` -- TTY detection pattern: `term.IsTerminal(int(os.Stdout.Fd()))` used across 12+ CLI files -- Testing spec: `docs/spec/testing.md`; TDDs: `docs/tdd/`; UX specs: `docs/ux/` - -## Docket Vote Subcommand -- Files: `internal/cli/vote.go`, `vote_cast.go`, `vote_create.go`, `vote_show.go`, `vote_list.go`, `vote_result.go`, `vote_link.go`, `vote_commit.go` -- TDD: `docs/tdd/vote-subcommand.md` -- No QA shell tests for vote commands yet (next slot: `test_zd_vote_cast.sh`) -- `config.DefaultAuthor()` resolves via `git config user.name` with 2s timeout diff --git a/go.mod b/go.mod index 3021099..fd9b832 100644 --- a/go.mod +++ b/go.mod @@ -3,19 +3,19 @@ module github.com/ALT-F4-LLC/docket go 1.26.0 require ( - github.com/ALT-F4-LLC/vorpal/sdk/go v0.0.0-20260522183231-0898253a54a2 + github.com/ALT-F4-LLC/vorpal/sdk/go v0.0.0-20260602231358-501858cf198b github.com/charmbracelet/glamour v1.0.0 github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/dustin/go-humanize v1.0.1 github.com/spf13/cobra v1.10.2 golang.org/x/term v0.43.0 - modernc.org/sqlite v1.50.1 + modernc.org/sqlite v1.52.0 ) require ( github.com/BurntSushi/toml v1.6.0 // indirect - github.com/alecthomas/chroma/v2 v2.25.0 // indirect + github.com/alecthomas/chroma/v2 v2.26.1 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect @@ -25,12 +25,12 @@ require ( github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect - github.com/charmbracelet/x/exp/slice v0.0.0-20260525135217-abeec2b8bf0b // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20260607010151-cd19a2bba55f // indirect github.com/charmbracelet/x/exp/strings v0.1.0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/dlclark/regexp2/v2 v2.1.0 // indirect + github.com/dlclark/regexp2/v2 v2.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.1 // indirect @@ -38,7 +38,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.23 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect diff --git a/go.sum b/go.sum index 2ada3da..b2c6ac3 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,13 @@ -github.com/ALT-F4-LLC/vorpal/sdk/go v0.0.0-20260522183231-0898253a54a2 h1:JjkN0bRj36DMemGK/roI9p1GwePny82oqDRzkvOxVY8= -github.com/ALT-F4-LLC/vorpal/sdk/go v0.0.0-20260522183231-0898253a54a2/go.mod h1:LnxjOk1cYJPm2s1mmsn26qvquhz3CgxVaxU9RR6CcDY= +github.com/ALT-F4-LLC/vorpal/sdk/go v0.0.0-20260602231358-501858cf198b h1:Blw9y7PQL5suojzBKkq1xN6ouYXKfHJu0PVcidp/vC0= +github.com/ALT-F4-LLC/vorpal/sdk/go v0.0.0-20260602231358-501858cf198b/go.mod h1:LnxjOk1cYJPm2s1mmsn26qvquhz3CgxVaxU9RR6CcDY= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/chroma/v2 v2.25.0 h1:DWkVlxrNpxPf+Qcfe04LBqUArxUiybK8ZQ9T7OFu68E= -github.com/alecthomas/chroma/v2 v2.25.0/go.mod h1:+95AZrRWlpW9g6qXD7S7UdHviopsGP/kCIrtJcU3QoQ= +github.com/alecthomas/chroma/v2 v2.26.1 h1:2X21EdxGZNv5GF9mG5u+uzc02GCFyGxbcBm3Grd9A78= +github.com/alecthomas/chroma/v2 v2.26.1/go.mod h1:lxhRRa9H4hPmRLOOdYga4zkQIQjq3dtrrdwQeCfu78Y= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= @@ -44,8 +44,8 @@ github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9 github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/exp/slice v0.0.0-20260525135217-abeec2b8bf0b h1:CR084j/4jhbEi5jd5GsU0KExMWZLJac3vsZX+5+i+B4= -github.com/charmbracelet/x/exp/slice v0.0.0-20260525135217-abeec2b8bf0b/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= +github.com/charmbracelet/x/exp/slice v0.0.0-20260607010151-cd19a2bba55f h1:qfvRQEbGZ0bNU/IlPz4SGpyVfx9hSSPB2h8kGnE6Jpc= +github.com/charmbracelet/x/exp/slice v0.0.0-20260607010151-cd19a2bba55f/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA= github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= @@ -61,8 +61,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/dlclark/regexp2/v2 v2.1.0 h1:jHXRmHRZGbuQzDZjMlCAXOvQb75iv3HyLDzXGj5H1AY= -github.com/dlclark/regexp2/v2 v2.1.0/go.mod h1:Bz5TMy5d8fPK0ximH0Yi9KvsRHNnvXqUx9XG6a4wB+I= +github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= +github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= @@ -94,8 +94,8 @@ github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= -github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= @@ -189,8 +189,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w= -modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo= +modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/internal/cli/cli_helpers_test.go b/internal/cli/cli_helpers_test.go new file mode 100644 index 0000000..29db0d5 --- /dev/null +++ b/internal/cli/cli_helpers_test.go @@ -0,0 +1,80 @@ +package cli + +import ( + "bytes" + "context" + "database/sql" + "testing" + + "github.com/ALT-F4-LLC/docket/internal/db" + "github.com/ALT-F4-LLC/docket/internal/model" + "github.com/ALT-F4-LLC/docket/internal/output" + "github.com/spf13/cobra" +) + +func newTestDB(t *testing.T) *sql.DB { + t.Helper() + conn, err := db.Open(":memory:") + if err != nil { + t.Fatalf("Open(:memory:): %v", err) + } + t.Cleanup(func() { conn.Close() }) + if err := db.Initialize(conn); err != nil { + t.Fatalf("Initialize: %v", err) + } + if err := db.Migrate(conn); err != nil { + t.Fatalf("Migrate: %v", err) + } + return conn +} + +func cmdWithDB(conn *sql.DB) *cobra.Command { + cmd := &cobra.Command{} + cmd.Flags().Bool("json", false, "") + cmd.Flags().Bool("quiet", false, "") + cmd.Flags().Bool("watch", false, "") + cmd.SetContext(context.WithValue(context.Background(), dbKey, conn)) + return cmd +} + +func bufWriter(jsonMode bool) (*output.Writer, *bytes.Buffer) { + buf := &bytes.Buffer{} + w := &output.Writer{JSONMode: jsonMode, Stdout: buf, Stderr: &bytes.Buffer{}} + return w, buf +} + +func createIssue(t *testing.T, conn *sql.DB, title string, status model.Status, priority model.Priority) int { + t.Helper() + id, err := db.CreateIssue(conn, &model.Issue{ + Title: title, + Status: status, + Priority: priority, + Kind: model.IssueKindFeature, + }, nil, nil) + if err != nil { + t.Fatalf("CreateIssue(%q): %v", title, err) + } + return id +} + +func createDoc(t *testing.T, conn *sql.DB, title, typ, status string) int { + t.Helper() + id, err := db.CreateDoc(conn, &model.Doc{ + Title: title, + Type: typ, + Status: status, + Body: "body", + Author: "tester", + }) + if err != nil { + t.Fatalf("CreateDoc(%q): %v", title, err) + } + return id +} + +func linkDocIssue(t *testing.T, conn *sql.DB, docID, issueID int) { + t.Helper() + if err := db.LinkDocIssue(conn, docID, issueID); err != nil { + t.Fatalf("LinkDocIssue(%d,%d): %v", docID, issueID, err) + } +} diff --git a/internal/cli/doc.go b/internal/cli/doc.go new file mode 100644 index 0000000..7613f89 --- /dev/null +++ b/internal/cli/doc.go @@ -0,0 +1,13 @@ +package cli + +import "github.com/spf13/cobra" + +var docCmd = &cobra.Command{ + Use: "doc", + Short: "Manage documents", + Aliases: []string{"d"}, +} + +func init() { + rootCmd.AddCommand(docCmd) +} diff --git a/internal/cli/doc_comment.go b/internal/cli/doc_comment.go new file mode 100644 index 0000000..85f5630 --- /dev/null +++ b/internal/cli/doc_comment.go @@ -0,0 +1,137 @@ +package cli + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + + "github.com/ALT-F4-LLC/docket/internal/config" + "github.com/ALT-F4-LLC/docket/internal/db" + "github.com/ALT-F4-LLC/docket/internal/model" + "github.com/ALT-F4-LLC/docket/internal/output" + "github.com/spf13/cobra" +) + +var docCommentCmd = &cobra.Command{ + Use: "comment", + Short: "Manage comments", +} + +var docCommentAddCmd = &cobra.Command{ + Use: "add [id]", + Short: "Add a comment to a document", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + w := getWriter(cmd) + conn := getDB(cmd) + + id, err := model.ParseDocID(args[0]) + if err != nil { + return cmdErr(fmt.Errorf("invalid doc ID: %w", err), output.ErrValidation) + } + + doc, err := db.GetDoc(conn, id) + if err != nil { + if errors.Is(err, db.ErrNotFound) { + return cmdErr(fmt.Errorf("doc %s not found", args[0]), output.ErrNotFound) + } + return cmdErr(fmt.Errorf("fetching doc: %w", err), output.ErrGeneral) + } + + jsonMode, _ := cmd.Flags().GetBool("json") + body, _ := cmd.Flags().GetString("message") + + if !cmd.Flags().Changed("message") { + stat, err := os.Stdin.Stat() + if err == nil && (stat.Mode()&os.ModeCharDevice) == 0 { + const maxStdinSize = 1 << 20 + lr := &io.LimitedReader{R: os.Stdin, N: maxStdinSize + 1} + data, err := io.ReadAll(lr) + if err != nil { + return cmdErr(fmt.Errorf("reading comment from stdin: %w", err), output.ErrGeneral) + } + if int64(len(data)) > maxStdinSize { + return cmdErr(fmt.Errorf("comment body exceeds %d bytes", maxStdinSize), output.ErrValidation) + } + body = strings.TrimSpace(string(data)) + } + } + + if body == "" && jsonMode { + return cmdErr(fmt.Errorf("message is required in JSON mode"), output.ErrValidation) + } + + if body == "" { + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "vi" + } + + tmpFile, err := os.CreateTemp("", "docket-comment-*.md") + if err != nil { + return cmdErr(fmt.Errorf("creating temp file: %w", err), output.ErrGeneral) + } + tmpPath := tmpFile.Name() + if err := tmpFile.Close(); err != nil { + os.Remove(tmpPath) + return cmdErr(fmt.Errorf("closing temp file: %w", err), output.ErrGeneral) + } + defer os.Remove(tmpPath) + + editorCmd := exec.Command(editor, tmpPath) + editorCmd.Stdin = os.Stdin + editorCmd.Stdout = os.Stdout + editorCmd.Stderr = os.Stderr + + if err := editorCmd.Run(); err != nil { + return cmdErr(fmt.Errorf("editor exited with error: %w", err), output.ErrGeneral) + } + + content, err := os.ReadFile(tmpPath) + if err != nil { + return cmdErr(fmt.Errorf("reading temp file: %w", err), output.ErrGeneral) + } + + body = strings.TrimSpace(string(content)) + } + + if body == "" { + w.Info("Cancelled.") + return nil + } + + author := config.DefaultAuthor() + + comment := model.DocComment{ + DocID: id, + Body: body, + Author: author, + } + + commentID, err := db.CreateDocComment(conn, &comment) + if err != nil { + if errors.Is(err, db.ErrNotFound) { + return cmdErr(fmt.Errorf("doc %s not found", args[0]), output.ErrNotFound) + } + return cmdErr(fmt.Errorf("creating comment: %w", err), output.ErrGeneral) + } + + created, err := db.GetDocComment(conn, commentID) + if err != nil { + return cmdErr(fmt.Errorf("fetching created comment: %w", err), output.ErrGeneral) + } + + w.Success(created, fmt.Sprintf("Comment added to %s: %s", model.FormatDocID(id), doc.Title)) + + return nil + }, +} + +func init() { + docCommentAddCmd.Flags().StringP("message", "m", "", "Comment body") + docCommentCmd.AddCommand(docCommentAddCmd) + docCmd.AddCommand(docCommentCmd) +} diff --git a/internal/cli/doc_comment_list.go b/internal/cli/doc_comment_list.go new file mode 100644 index 0000000..936b917 --- /dev/null +++ b/internal/cli/doc_comment_list.go @@ -0,0 +1,84 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/ALT-F4-LLC/docket/internal/db" + "github.com/ALT-F4-LLC/docket/internal/model" + "github.com/ALT-F4-LLC/docket/internal/output" + "github.com/ALT-F4-LLC/docket/internal/render" + "github.com/ALT-F4-LLC/docket/internal/watch" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +var docCommentListCmd = &cobra.Command{ + Use: "list [id]", + Short: "List comments on a document", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + watchMode, _ := cmd.Flags().GetBool("watch") + if watchMode { + interval, _ := cmd.Flags().GetDuration("interval") + jsonMode, _ := cmd.Flags().GetBool("json") + quietMode, _ := cmd.Flags().GetBool("quiet") + ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM) + defer stop() + return watch.RunWatch(ctx, watch.Options{ + Interval: interval, + JSONMode: jsonMode, + QuietMode: quietMode, + IsTTY: term.IsTerminal(int(os.Stdout.Fd())), + Stdout: os.Stdout, + Stderr: os.Stderr, + }, func(ctx context.Context, w *output.Writer) error { + return runDocCommentList(cmd, args, w) + }) + } + return runDocCommentList(cmd, args, getWriter(cmd)) + }, +} + +func runDocCommentList(cmd *cobra.Command, args []string, w *output.Writer) error { + conn := getDB(cmd) + + id, err := model.ParseDocID(args[0]) + if err != nil { + return cmdErr(fmt.Errorf("invalid doc ID: %w", err), output.ErrValidation) + } + + comments, err := db.ListDocComments(conn, id) + if err != nil { + if errors.Is(err, db.ErrNotFound) { + return cmdErr(fmt.Errorf("doc %s not found", args[0]), output.ErrNotFound) + } + return cmdErr(fmt.Errorf("fetching comments: %w", err), output.ErrGeneral) + } + + if w.JSONMode { + w.Success(comments, "") + return nil + } + + if len(comments) == 0 { + msg := render.EmptyState( + fmt.Sprintf("No comments on %s", model.FormatDocID(id)), + fmt.Sprintf("Add one with: docket doc comment add %s -m \"...\"", model.FormatDocID(id)), + w.QuietMode, + ) + w.Success(nil, msg) + return nil + } + + w.Success(comments, render.RenderDocCommentList(comments)) + return nil +} + +func init() { + docCommentCmd.AddCommand(docCommentListCmd) +} diff --git a/internal/cli/doc_create.go b/internal/cli/doc_create.go new file mode 100644 index 0000000..68881a4 --- /dev/null +++ b/internal/cli/doc_create.go @@ -0,0 +1,146 @@ +package cli + +import ( + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/ALT-F4-LLC/docket/internal/config" + "github.com/ALT-F4-LLC/docket/internal/db" + "github.com/ALT-F4-LLC/docket/internal/model" + "github.com/ALT-F4-LLC/docket/internal/output" + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +const maxDocBodySize = 1 << 20 + +func loadDocBody(body string) (string, error) { + switch { + case strings.HasPrefix(body, "@"): + path := body[1:] + if path == "" { + return "", cmdErr(fmt.Errorf("empty file path after @"), output.ErrValidation) + } + f, err := os.Open(path) + if err != nil { + return "", cmdErr(fmt.Errorf("reading body from %q: %w", path, err), output.ErrValidation) + } + defer f.Close() + lr := &io.LimitedReader{R: f, N: maxDocBodySize + 1} + data, err := io.ReadAll(lr) + if err != nil { + return "", cmdErr(fmt.Errorf("reading body from %q: %w", path, err), output.ErrValidation) + } + if int64(len(data)) > maxDocBodySize { + return "", cmdErr(fmt.Errorf("body from %q exceeds %d bytes", path, maxDocBodySize), output.ErrValidation) + } + return strings.TrimRight(string(data), "\n"), nil + case body == "-": + lr := &io.LimitedReader{R: os.Stdin, N: maxDocBodySize + 1} + data, err := io.ReadAll(lr) + if err != nil { + return "", cmdErr(fmt.Errorf("reading body from stdin: %w", err), output.ErrGeneral) + } + if int64(len(data)) > maxDocBodySize { + return "", cmdErr(fmt.Errorf("body from stdin exceeds %d bytes", maxDocBodySize), output.ErrValidation) + } + return strings.TrimRight(string(data), "\n"), nil + default: + return body, nil + } +} + +var docCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new document", + RunE: func(cmd *cobra.Command, args []string) error { + w := getWriter(cmd) + conn := getDB(cmd) + + title, _ := cmd.Flags().GetString("title") + body, _ := cmd.Flags().GetString("description") + docType, _ := cmd.Flags().GetString("type") + status, _ := cmd.Flags().GetString("status") + jsonMode, _ := cmd.Flags().GetBool("json") + + if jsonMode && title == "" { + return cmdErr(fmt.Errorf("--title is required in JSON mode"), output.ErrValidation) + } + + if !jsonMode && title == "" { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return cmdErr(fmt.Errorf("non-interactive environment detected; provide all required flags: --title"), output.ErrValidation) + } + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Title"). + Value(&title). + Validate(func(s string) error { + if strings.TrimSpace(s) == "" { + return fmt.Errorf("title is required") + } + return nil + }), + huh.NewText(). + Title("Body"). + Value(&body), + huh.NewInput(). + Title("Type"). + Value(&docType), + huh.NewInput(). + Title("Status"). + Value(&status), + ), + ) + + if err := form.Run(); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + w.Info("Cancelled.") + return nil + } + return cmdErr(fmt.Errorf("interactive form failed: %w", err), output.ErrGeneral) + } + } + + loadedBody, err := loadDocBody(body) + if err != nil { + return err + } + body = loadedBody + + doc := model.Doc{ + Type: docType, + Status: status, + Title: title, + Body: body, + Author: config.DefaultAuthor(), + } + + id, err := db.CreateDoc(conn, &doc) + if err != nil { + return cmdErr(fmt.Errorf("creating doc: %w", err), output.ErrGeneral) + } + + created, err := db.GetDoc(conn, id) + if err != nil { + return cmdErr(fmt.Errorf("fetching created doc: %w", err), output.ErrGeneral) + } + + w.Success(created, fmt.Sprintf("Created %s: %s", model.FormatDocID(id), created.Title)) + + return nil + }, +} + +func init() { + docCreateCmd.Flags().StringP("title", "t", "", "Document title") + docCreateCmd.Flags().StringP("description", "d", "", "Document body (use \"@path\" for a file or \"-\" for stdin)") + docCreateCmd.Flags().StringP("type", "T", "", "Document type") + docCreateCmd.Flags().StringP("status", "s", "", "Document status") + docCmd.AddCommand(docCreateCmd) +} diff --git a/internal/cli/doc_create_test.go b/internal/cli/doc_create_test.go new file mode 100644 index 0000000..270a80f --- /dev/null +++ b/internal/cli/doc_create_test.go @@ -0,0 +1,78 @@ +package cli + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ALT-F4-LLC/docket/internal/output" +) + +func writeTempFile(t *testing.T, size int) string { + t.Helper() + path := filepath.Join(t.TempDir(), "body.txt") + if err := os.WriteFile(path, []byte(strings.Repeat("a", size)), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + return path +} + +func TestLoadDocBodyPathWithinCap(t *testing.T) { + path := writeTempFile(t, maxDocBodySize) + + body, err := loadDocBody("@" + path) + if err != nil { + t.Fatalf("loadDocBody: unexpected error: %v", err) + } + if len(body) != maxDocBodySize { + t.Fatalf("loadDocBody: got %d bytes, want %d", len(body), maxDocBodySize) + } +} + +func TestLoadDocBodyPathOverCap(t *testing.T) { + path := writeTempFile(t, maxDocBodySize+1) + + _, err := loadDocBody("@" + path) + if err == nil { + t.Fatal("loadDocBody: expected error for over-cap file, got nil") + } + + var cmdError *CmdError + if !errors.As(err, &cmdError) { + t.Fatalf("loadDocBody: error is not *CmdError: %v", err) + } + if cmdError.Code != output.ErrValidation { + t.Fatalf("loadDocBody: got code %v, want %v", cmdError.Code, output.ErrValidation) + } + if !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("loadDocBody: error %q does not mention exceeding the cap", err.Error()) + } +} + +func TestLoadDocBodyPathFarOverCap(t *testing.T) { + path := writeTempFile(t, maxDocBodySize*4) + + _, err := loadDocBody("@" + path) + if err == nil { + t.Fatal("loadDocBody: expected error for far-over-cap file, got nil") + } + if !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("loadDocBody: error %q does not mention exceeding the cap", err.Error()) + } +} + +func TestLoadDocBodyEmptyPath(t *testing.T) { + _, err := loadDocBody("@") + if err == nil { + t.Fatal("loadDocBody: expected error for empty path, got nil") + } + var cmdError *CmdError + if !errors.As(err, &cmdError) { + t.Fatalf("loadDocBody: error is not *CmdError: %v", err) + } + if cmdError.Code != output.ErrValidation { + t.Fatalf("loadDocBody: got code %v, want %v", cmdError.Code, output.ErrValidation) + } +} diff --git a/internal/cli/doc_delete.go b/internal/cli/doc_delete.go new file mode 100644 index 0000000..dac7de4 --- /dev/null +++ b/internal/cli/doc_delete.go @@ -0,0 +1,89 @@ +package cli + +import ( + "errors" + "fmt" + "os" + + "github.com/ALT-F4-LLC/docket/internal/db" + "github.com/ALT-F4-LLC/docket/internal/model" + "github.com/ALT-F4-LLC/docket/internal/output" + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +type docDeleteResult struct { + ID string `json:"id"` +} + +var docDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a document", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + w := getWriter(cmd) + conn := getDB(cmd) + + cascade, _ := cmd.Flags().GetBool("cascade") + force, _ := cmd.Flags().GetBool("force") + + id, err := model.ParseDocID(args[0]) + if err != nil { + return cmdErr(fmt.Errorf("invalid doc ID: %w", err), output.ErrValidation) + } + + doc, err := db.GetDoc(conn, id) + if err != nil { + if errors.Is(err, db.ErrNotFound) { + return cmdErr(fmt.Errorf("doc %s not found", model.FormatDocID(id)), output.ErrNotFound) + } + return cmdErr(fmt.Errorf("fetching doc: %w", err), output.ErrGeneral) + } + + if !force && !w.JSONMode { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return cmdErr(fmt.Errorf("non-interactive environment detected; pass --force to delete %s or use --json", model.FormatDocID(id)), output.ErrValidation) + } + var confirmed bool + form := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title(fmt.Sprintf("Delete %s: %s?", model.FormatDocID(id), doc.Title)). + Value(&confirmed), + ), + ) + if err := form.Run(); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + w.Info("Cancelled.") + return nil + } + return cmdErr(fmt.Errorf("interactive form failed: %w", err), output.ErrGeneral) + } + if !confirmed { + w.Info("Cancelled.") + return nil + } + } + + if err := db.DeleteDoc(conn, id, cascade); err != nil { + if errors.Is(err, db.ErrNotFound) { + return cmdErr(fmt.Errorf("doc %s not found", model.FormatDocID(id)), output.ErrNotFound) + } + if errors.Is(err, db.ErrConflict) { + return cmdErr(err, output.ErrConflict) + } + return cmdErr(fmt.Errorf("deleting doc: %w", err), output.ErrGeneral) + } + + w.Success(docDeleteResult{ID: model.FormatDocID(id)}, fmt.Sprintf("Deleted %s: %s", model.FormatDocID(id), doc.Title)) + + return nil + }, +} + +func init() { + docDeleteCmd.Flags().Bool("cascade", false, "Also remove this document's issue/proposal links (the issues and proposals themselves are not deleted)") + docDeleteCmd.Flags().BoolP("force", "f", false, "Skip the interactive confirmation prompt") + docCmd.AddCommand(docDeleteCmd) +} diff --git a/internal/cli/doc_edit.go b/internal/cli/doc_edit.go new file mode 100644 index 0000000..81df1cf --- /dev/null +++ b/internal/cli/doc_edit.go @@ -0,0 +1,107 @@ +package cli + +import ( + "errors" + "fmt" + + "github.com/ALT-F4-LLC/docket/internal/config" + "github.com/ALT-F4-LLC/docket/internal/db" + "github.com/ALT-F4-LLC/docket/internal/model" + "github.com/ALT-F4-LLC/docket/internal/output" + "github.com/spf13/cobra" +) + +var docEditCmd = &cobra.Command{ + Use: "edit ", + Short: "Edit an existing document", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + w := getWriter(cmd) + conn := getDB(cmd) + + id, err := model.ParseDocID(args[0]) + if err != nil { + return cmdErr(fmt.Errorf("invalid doc ID: %w", err), output.ErrValidation) + } + + if _, err := db.GetDoc(conn, id); err != nil { + if errors.Is(err, db.ErrNotFound) { + return cmdErr(fmt.Errorf("doc %s not found", model.FormatDocID(id)), output.ErrNotFound) + } + return cmdErr(fmt.Errorf("fetching doc: %w", err), output.ErrGeneral) + } + + upd := db.DocUpdate{Author: config.DefaultAuthor()} + + if cmd.Flags().Changed("title") { + title, _ := cmd.Flags().GetString("title") + upd.Title = &title + } + + if cmd.Flags().Changed("type") { + docType, _ := cmd.Flags().GetString("type") + upd.Type = &docType + } + + if cmd.Flags().Changed("status") { + status, _ := cmd.Flags().GetString("status") + upd.Status = &status + } + + if cmd.Flags().Changed("description") { + body, _ := cmd.Flags().GetString("description") + loadedBody, err := loadDocBody(body) + if err != nil { + return err + } + upd.Body = &loadedBody + } + + if upd.Title == nil && upd.Type == nil && upd.Status == nil && upd.Body == nil { + doc, err := db.GetDoc(conn, id) + if err != nil { + return cmdErr(fmt.Errorf("fetching doc: %w", err), output.ErrGeneral) + } + if w.JSONMode { + w.Success(doc, "") + } else { + w.Info("No changes specified") + } + return nil + } + + rev, err := db.UpdateDoc(conn, id, upd) + if err != nil { + if errors.Is(err, db.ErrNotFound) { + return cmdErr(fmt.Errorf("doc %s not found", model.FormatDocID(id)), output.ErrNotFound) + } + return cmdErr(fmt.Errorf("updating doc: %w", err), output.ErrGeneral) + } + + doc, err := db.GetDoc(conn, id) + if err != nil { + return cmdErr(fmt.Errorf("fetching updated doc: %w", err), output.ErrGeneral) + } + + if rev == 0 { + if w.JSONMode { + w.Success(doc, "") + } else { + w.Info("No changes specified") + } + return nil + } + + w.Success(doc, fmt.Sprintf("Updated %s: %s", model.FormatDocID(id), doc.Title)) + + return nil + }, +} + +func init() { + docEditCmd.Flags().StringP("title", "t", "", "Document title") + docEditCmd.Flags().StringP("description", "d", "", "Document body (use \"@path\" for a file or \"-\" for stdin)") + docEditCmd.Flags().StringP("type", "T", "", "Document type") + docEditCmd.Flags().StringP("status", "s", "", "Document status") + docCmd.AddCommand(docEditCmd) +} diff --git a/internal/cli/doc_link.go b/internal/cli/doc_link.go new file mode 100644 index 0000000..3837a36 --- /dev/null +++ b/internal/cli/doc_link.go @@ -0,0 +1,108 @@ +package cli + +import ( + "errors" + "fmt" + + "github.com/ALT-F4-LLC/docket/internal/db" + "github.com/ALT-F4-LLC/docket/internal/model" + "github.com/ALT-F4-LLC/docket/internal/output" + "github.com/spf13/cobra" +) + +type docLinkResult struct { + DocID string `json:"doc_id"` + IssueID string `json:"issue_id"` +} + +var docLinkCmd = &cobra.Command{ + Use: "link", + Short: "Manage document links", +} + +var docLinkAddCmd = &cobra.Command{ + Use: "add --issue ", + Short: "Link a document to an issue", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + w := getWriter(cmd) + conn := getDB(cmd) + + docID, err := model.ParseDocID(args[0]) + if err != nil { + return cmdErr(fmt.Errorf("invalid doc ID: %w", err), output.ErrValidation) + } + + issueArg, _ := cmd.Flags().GetString("issue") + issueID, err := model.ParseID(issueArg) + if err != nil { + return cmdErr(fmt.Errorf("invalid issue ID: %w", err), output.ErrValidation) + } + + if err := db.LinkDocIssue(conn, docID, issueID); err != nil { + if errors.Is(err, db.ErrNotFound) { + return cmdErr(fmt.Errorf("doc or issue not found"), output.ErrNotFound) + } + if errors.Is(err, db.ErrConflict) { + return cmdErr(fmt.Errorf("link already exists"), output.ErrConflict) + } + return cmdErr(fmt.Errorf("linking doc to issue: %w", err), output.ErrGeneral) + } + + result := docLinkResult{ + DocID: model.FormatDocID(docID), + IssueID: model.FormatID(issueID), + } + + w.Success(result, fmt.Sprintf("Linked %s to %s", + model.FormatDocID(docID), model.FormatID(issueID))) + return nil + }, +} + +var docLinkRemoveCmd = &cobra.Command{ + Use: "remove --issue ", + Short: "Remove a link between a document and an issue", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + w := getWriter(cmd) + conn := getDB(cmd) + + docID, err := model.ParseDocID(args[0]) + if err != nil { + return cmdErr(fmt.Errorf("invalid doc ID: %w", err), output.ErrValidation) + } + + issueArg, _ := cmd.Flags().GetString("issue") + issueID, err := model.ParseID(issueArg) + if err != nil { + return cmdErr(fmt.Errorf("invalid issue ID: %w", err), output.ErrValidation) + } + + if err := db.UnlinkDocIssue(conn, docID, issueID); err != nil { + if errors.Is(err, db.ErrNotFound) { + return cmdErr(fmt.Errorf("link not found"), output.ErrNotFound) + } + return cmdErr(fmt.Errorf("unlinking doc from issue: %w", err), output.ErrGeneral) + } + + result := docLinkResult{ + DocID: model.FormatDocID(docID), + IssueID: model.FormatID(issueID), + } + + w.Success(result, fmt.Sprintf("Unlinked %s from %s", + model.FormatDocID(docID), model.FormatID(issueID))) + return nil + }, +} + +func init() { + docLinkAddCmd.Flags().String("issue", "", "Issue ID to link (e.g. DKT-5)") + _ = docLinkAddCmd.MarkFlagRequired("issue") + docLinkRemoveCmd.Flags().String("issue", "", "Issue ID to unlink (e.g. DKT-5)") + _ = docLinkRemoveCmd.MarkFlagRequired("issue") + docLinkCmd.AddCommand(docLinkAddCmd) + docLinkCmd.AddCommand(docLinkRemoveCmd) + docCmd.AddCommand(docLinkCmd) +} diff --git a/internal/cli/doc_list.go b/internal/cli/doc_list.go new file mode 100644 index 0000000..fb01318 --- /dev/null +++ b/internal/cli/doc_list.go @@ -0,0 +1,112 @@ +package cli + +import ( + "context" + "fmt" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/ALT-F4-LLC/docket/internal/db" + "github.com/ALT-F4-LLC/docket/internal/model" + "github.com/ALT-F4-LLC/docket/internal/output" + "github.com/ALT-F4-LLC/docket/internal/render" + "github.com/ALT-F4-LLC/docket/internal/watch" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +type docListResult struct { + Docs []*model.Doc `json:"docs"` + Total int `json:"total"` +} + +var docListCmd = &cobra.Command{ + Use: "list", + Short: "List documents", + Aliases: []string{"ls"}, + RunE: func(cmd *cobra.Command, args []string) error { + watchMode, _ := cmd.Flags().GetBool("watch") + if watchMode { + interval, _ := cmd.Flags().GetDuration("interval") + jsonMode, _ := cmd.Flags().GetBool("json") + quietMode, _ := cmd.Flags().GetBool("quiet") + ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM) + defer stop() + return watch.RunWatch(ctx, watch.Options{ + Interval: interval, + JSONMode: jsonMode, + QuietMode: quietMode, + IsTTY: term.IsTerminal(int(os.Stdout.Fd())), + Stdout: os.Stdout, + Stderr: os.Stderr, + }, func(ctx context.Context, w *output.Writer) error { + return runDocList(cmd, args, w) + }) + } + return runDocList(cmd, args, getWriter(cmd)) + }, +} + +func runDocList(cmd *cobra.Command, args []string, w *output.Writer) error { + conn := getDB(cmd) + + types, _ := cmd.Flags().GetStringSlice("type") + statuses, _ := cmd.Flags().GetStringSlice("status") + author, _ := cmd.Flags().GetString("author") + sortFlag, _ := cmd.Flags().GetString("sort") + limit, _ := cmd.Flags().GetInt("limit") + + opts := db.DocListOptions{ + Types: types, + Statuses: statuses, + Author: author, + Limit: limit, + } + + if sortFlag != "" { + parts := strings.SplitN(sortFlag, ":", 2) + opts.Sort = parts[0] + if len(parts) > 1 { + opts.SortDir = parts[1] + } + } + + summaries, total, err := db.ListDocsWithCounts(conn, opts) + if err != nil { + return cmdErr(fmt.Errorf("listing docs: %w", err), output.ErrGeneral) + } + + docs := make([]*model.Doc, 0, len(summaries)) + for _, s := range summaries { + docs = append(docs, s.Doc) + } + + result := docListResult{Docs: docs, Total: total} + + var message string + if !w.JSONMode { + rows := make([]render.DocRow, 0, len(summaries)) + for _, s := range summaries { + rows = append(rows, render.DocRow{ + Doc: s.Doc, + CurrentRevision: s.CurrentRevision, + RevisionsCount: s.RevisionsCount, + }) + } + message = render.RenderDocList(rows) + } + w.Success(result, message) + + return nil +} + +func init() { + docListCmd.Flags().StringSliceP("type", "T", nil, "Filter by type (repeatable)") + docListCmd.Flags().StringSliceP("status", "s", nil, "Filter by status (repeatable)") + docListCmd.Flags().StringP("author", "a", "", "Filter by author") + docListCmd.Flags().String("sort", "", "Sort by field:direction (e.g. updated_at:desc)") + docListCmd.Flags().Int("limit", 50, "Maximum number of results") + docCmd.AddCommand(docListCmd) +} diff --git a/internal/cli/doc_show.go b/internal/cli/doc_show.go new file mode 100644 index 0000000..ae2edd6 --- /dev/null +++ b/internal/cli/doc_show.go @@ -0,0 +1,194 @@ +package cli + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/ALT-F4-LLC/docket/internal/db" + "github.com/ALT-F4-LLC/docket/internal/model" + "github.com/ALT-F4-LLC/docket/internal/output" + "github.com/ALT-F4-LLC/docket/internal/render" + "github.com/ALT-F4-LLC/docket/internal/watch" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +type docShowResult struct { + Doc *model.Doc + Revisions []*model.DocRevision + Comments []*model.DocComment + LinkedIssues []model.IssueRef + LinkedProposals []int +} + +type docShowResultJSON struct { + ID string `json:"id"` + Type string `json:"type"` + Status string `json:"status"` + Title string `json:"title"` + Body string `json:"body"` + Author string `json:"author"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Revisions []*model.DocRevision `json:"revisions"` + Comments []*model.DocComment `json:"comments"` + LinkedIssues []model.IssueRef `json:"linked_issues"` + LinkedProposals []string `json:"linked_proposals"` +} + +func (s docShowResult) MarshalJSON() ([]byte, error) { + d := s.Doc + + revisions := s.Revisions + if revisions == nil { + revisions = []*model.DocRevision{} + } + comments := s.Comments + if comments == nil { + comments = []*model.DocComment{} + } + + linkedIssues := s.LinkedIssues + if linkedIssues == nil { + linkedIssues = []model.IssueRef{} + } + linkedProposals := make([]string, 0, len(s.LinkedProposals)) + for _, id := range s.LinkedProposals { + linkedProposals = append(linkedProposals, model.FormatProposalID(id)) + } + + return json.Marshal(docShowResultJSON{ + ID: model.FormatDocID(d.ID), + Type: d.Type, + Status: d.Status, + Title: d.Title, + Body: d.Body, + Author: d.Author, + CreatedAt: d.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: d.UpdatedAt.UTC().Format(time.RFC3339), + Revisions: revisions, + Comments: comments, + LinkedIssues: linkedIssues, + LinkedProposals: linkedProposals, + }) +} + +var docShowCmd = &cobra.Command{ + Use: "show [id]", + Short: "Show document details", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + watchMode, _ := cmd.Flags().GetBool("watch") + if watchMode { + interval, _ := cmd.Flags().GetDuration("interval") + jsonMode, _ := cmd.Flags().GetBool("json") + quietMode, _ := cmd.Flags().GetBool("quiet") + ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM) + defer stop() + return watch.RunWatch(ctx, watch.Options{ + Interval: interval, + JSONMode: jsonMode, + QuietMode: quietMode, + IsTTY: term.IsTerminal(int(os.Stdout.Fd())), + Stdout: os.Stdout, + Stderr: os.Stderr, + }, func(ctx context.Context, w *output.Writer) error { + return runDocShow(cmd, args, w) + }) + } + return runDocShow(cmd, args, getWriter(cmd)) + }, +} + +func runDocShow(cmd *cobra.Command, args []string, w *output.Writer) error { + conn := getDB(cmd) + + id, err := model.ParseDocID(args[0]) + if err != nil { + return cmdErr(fmt.Errorf("invalid doc ID: %w", err), output.ErrValidation) + } + + if cmd.Flags().Changed("rev") { + rev, _ := cmd.Flags().GetInt("rev") + return runDocShowRevision(conn, w, args[0], id, rev) + } + + doc, err := db.GetDoc(conn, id) + if err != nil { + if errors.Is(err, db.ErrNotFound) { + return cmdErr(fmt.Errorf("doc %s not found", args[0]), output.ErrNotFound) + } + return cmdErr(fmt.Errorf("fetching doc: %w", err), output.ErrGeneral) + } + + revisions, err := db.ListDocRevisions(conn, id) + if err != nil { + return cmdErr(fmt.Errorf("fetching revisions: %w", err), output.ErrGeneral) + } + + comments, err := db.ListDocComments(conn, id) + if err != nil { + return cmdErr(fmt.Errorf("fetching comments: %w", err), output.ErrGeneral) + } + + linkedIssuesByDoc, err := db.HydrateLinkedIssues(conn, []int{id}) + if err != nil { + return cmdErr(fmt.Errorf("fetching linked issues: %w", err), output.ErrGeneral) + } + linkedIssues := linkedIssuesByDoc[id] + + linkedProposals, err := db.GetDocProposals(conn, id) + if err != nil { + return cmdErr(fmt.Errorf("fetching linked proposals: %w", err), output.ErrGeneral) + } + + result := docShowResult{ + Doc: doc, + Revisions: revisions, + Comments: comments, + LinkedIssues: linkedIssues, + LinkedProposals: linkedProposals, + } + + var message string + if !w.JSONMode { + message = render.RenderDocDetail(doc, revisions, comments, linkedIssues, linkedProposals) + } + w.Success(result, message) + + return nil +} + +func runDocShowRevision(conn *sql.DB, w *output.Writer, idArg string, id, rev int) error { + revision, err := db.GetDocRevision(conn, id, rev) + if err != nil { + switch { + case errors.Is(err, db.ErrValidation): + return cmdErr(err, output.ErrValidation) + case errors.Is(err, db.ErrNotFound): + return cmdErr(fmt.Errorf("doc %s revision %d not found", idArg, rev), output.ErrNotFound) + default: + return cmdErr(fmt.Errorf("fetching revision: %w", err), output.ErrGeneral) + } + } + + var message string + if !w.JSONMode { + message = render.RenderDocRevisionHistory([]*model.DocRevision{revision}) + } + w.Success(revision, message) + + return nil +} + +func init() { + docShowCmd.Flags().Int("rev", 0, "Show a specific revision number") + docCmd.AddCommand(docShowCmd) +} diff --git a/internal/cli/export.go b/internal/cli/export.go index f32aad5..086ecf4 100644 --- a/internal/cli/export.go +++ b/internal/cli/export.go @@ -73,6 +73,51 @@ var exportCmd = &cobra.Command{ return cmdErr(fmt.Errorf("fetching file mappings: %w", err), output.ErrGeneral) } + activityLog, err := db.ListAllActivity(conn) + if err != nil { + return cmdErr(fmt.Errorf("fetching activity log: %w", err), output.ErrGeneral) + } + + docs, err := db.ListAllDocs(conn) + if err != nil { + return cmdErr(fmt.Errorf("fetching docs: %w", err), output.ErrGeneral) + } + + docRevisions, err := db.ListAllDocRevisions(conn) + if err != nil { + return cmdErr(fmt.Errorf("fetching doc revisions: %w", err), output.ErrGeneral) + } + + docComments, err := db.ListAllDocComments(conn) + if err != nil { + return cmdErr(fmt.Errorf("fetching doc comments: %w", err), output.ErrGeneral) + } + + docIssueLinks, err := db.ListAllDocIssueLinks(conn) + if err != nil { + return cmdErr(fmt.Errorf("fetching doc-issue links: %w", err), output.ErrGeneral) + } + + proposalDocs, err := db.ListAllProposalDocs(conn) + if err != nil { + return cmdErr(fmt.Errorf("fetching proposal-doc links: %w", err), output.ErrGeneral) + } + + proposals, err := db.ListAllProposals(conn) + if err != nil { + return cmdErr(fmt.Errorf("fetching proposals: %w", err), output.ErrGeneral) + } + + votes, err := db.ListAllVotes(conn) + if err != nil { + return cmdErr(fmt.Errorf("fetching votes: %w", err), output.ErrGeneral) + } + + proposalIssues, err := db.ListAllProposalIssues(conn) + if err != nil { + return cmdErr(fmt.Errorf("fetching proposal-issue links: %w", err), output.ErrGeneral) + } + // Apply filters if provided. if len(statuses) > 0 || len(labels) > 0 { issues = filterIssues(issues, statuses, labels) @@ -119,6 +164,89 @@ var exportCmd = &cobra.Command{ } fileMappings = filteredFileMappings + // Filter activity log to only entries for filtered issues. + filteredActivity := make([]*model.Activity, 0, len(activityLog)) + for _, a := range activityLog { + if issueIDs[a.IssueID] { + filteredActivity = append(filteredActivity, a) + } + } + activityLog = filteredActivity + + // Filter doc-issue links to only those whose issue survives the filter. + filteredDocIssueLinks := make([]model.DocIssueLink, 0, len(docIssueLinks)) + for _, l := range docIssueLinks { + if issueIDs[l.IssueID] { + filteredDocIssueLinks = append(filteredDocIssueLinks, l) + } + } + docIssueLinks = filteredDocIssueLinks + + // Filter proposal-issue links to only those whose issue survives the filter. + filteredProposalIssues := make([]model.ProposalIssueLink, 0, len(proposalIssues)) + for _, l := range proposalIssues { + if issueIDs[l.IssueID] { + filteredProposalIssues = append(filteredProposalIssues, l) + } + } + proposalIssues = filteredProposalIssues + + survivingDocIDs := make(map[int]bool, len(docIssueLinks)) + for _, l := range docIssueLinks { + survivingDocIDs[l.DocID] = true + } + filteredDocs := make([]*model.Doc, 0, len(docs)) + for _, d := range docs { + if survivingDocIDs[d.ID] { + filteredDocs = append(filteredDocs, d) + } + } + docs = filteredDocs + + filteredDocRevisions := make([]*model.DocRevision, 0, len(docRevisions)) + for _, r := range docRevisions { + if survivingDocIDs[r.DocID] { + filteredDocRevisions = append(filteredDocRevisions, r) + } + } + docRevisions = filteredDocRevisions + + filteredDocComments := make([]*model.DocComment, 0, len(docComments)) + for _, c := range docComments { + if survivingDocIDs[c.DocID] { + filteredDocComments = append(filteredDocComments, c) + } + } + docComments = filteredDocComments + + survivingProposalIDs := make(map[int]bool, len(proposalIssues)) + for _, l := range proposalIssues { + survivingProposalIDs[l.ProposalID] = true + } + filteredProposals := make([]*model.Proposal, 0, len(proposals)) + for _, p := range proposals { + if survivingProposalIDs[p.ID] { + filteredProposals = append(filteredProposals, p) + } + } + proposals = filteredProposals + + filteredVotes := make([]*model.Vote, 0, len(votes)) + for _, v := range votes { + if survivingProposalIDs[v.ProposalID] { + filteredVotes = append(filteredVotes, v) + } + } + votes = filteredVotes + + filteredProposalDocs := make([]model.ProposalDocLink, 0, len(proposalDocs)) + for _, l := range proposalDocs { + if survivingProposalIDs[l.ProposalID] && survivingDocIDs[l.DocID] { + filteredProposalDocs = append(filteredProposalDocs, l) + } + } + proposalDocs = filteredProposalDocs + // Filter labels to only those referenced by remaining mappings. usedLabelIDs := make(map[int]bool) for _, m := range mappings { @@ -143,6 +271,15 @@ var exportCmd = &cobra.Command{ Labels: allLabels, IssueLabelMappings: mappings, IssueFileMappings: fileMappings, + ActivityLog: activityLog, + Docs: docs, + DocRevisions: docRevisions, + DocComments: docComments, + DocIssueLinks: docIssueLinks, + Proposals: proposals, + Votes: votes, + ProposalIssues: proposalIssues, + ProposalDocs: proposalDocs, } // Ensure nil slices become empty arrays in JSON. @@ -164,6 +301,33 @@ var exportCmd = &cobra.Command{ if data.IssueFileMappings == nil { data.IssueFileMappings = []model.IssueFileMapping{} } + if data.ActivityLog == nil { + data.ActivityLog = []*model.Activity{} + } + if data.Docs == nil { + data.Docs = []*model.Doc{} + } + if data.DocRevisions == nil { + data.DocRevisions = []*model.DocRevision{} + } + if data.DocComments == nil { + data.DocComments = []*model.DocComment{} + } + if data.DocIssueLinks == nil { + data.DocIssueLinks = []model.DocIssueLink{} + } + if data.Proposals == nil { + data.Proposals = []*model.Proposal{} + } + if data.Votes == nil { + data.Votes = []*model.Vote{} + } + if data.ProposalIssues == nil { + data.ProposalIssues = []model.ProposalIssueLink{} + } + if data.ProposalDocs == nil { + data.ProposalDocs = []model.ProposalDocLink{} + } // Generate output based on format. var raw string @@ -231,6 +395,17 @@ func filterIssues(issues []*model.Issue, statuses, labels []string) []*model.Iss } filtered = append(filtered, issue) } + + survivingIDs := make(map[int]bool, len(filtered)) + for _, issue := range filtered { + survivingIDs[issue.ID] = true + } + for _, issue := range filtered { + if issue.ParentID != nil && !survivingIDs[*issue.ParentID] { + issue.ParentID = nil + } + } + return filtered } @@ -266,14 +441,14 @@ func renderExportCSV(issues []*model.Issue) (string, error) { row := []string{ model.FormatID(issue.ID), parentID, - issue.Title, - issue.Description, + csvSafe(issue.Title), + csvSafe(issue.Description), string(issue.Status), string(issue.Priority), string(issue.Kind), - issue.Assignee, - labelsStr, - filesStr, + csvSafe(issue.Assignee), + csvSafe(labelsStr), + csvSafe(filesStr), issue.CreatedAt.UTC().Format(time.RFC3339), issue.UpdatedAt.UTC().Format(time.RFC3339), } @@ -290,6 +465,18 @@ func renderExportCSV(issues []*model.Issue) (string, error) { return buf.String(), nil } +func csvSafe(s string) string { + if s == "" { + return s + } + switch s[0] { + case '=', '+', '-', '@', '\t', '\r': + return "'" + s + default: + return s + } +} + // escapeMarkdown replaces characters that have special meaning in Markdown so // that arbitrary user text can be safely embedded in headings and inline spans. func escapeMarkdown(s string) string { diff --git a/internal/cli/export_test.go b/internal/cli/export_test.go new file mode 100644 index 0000000..c479d27 --- /dev/null +++ b/internal/cli/export_test.go @@ -0,0 +1,79 @@ +package cli + +import ( + "encoding/csv" + "strings" + "testing" + "time" + + "github.com/ALT-F4-LLC/docket/internal/model" +) + +func TestCsvSafe(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"equals", "=HYPERLINK(\"evil\")", "'=HYPERLINK(\"evil\")"}, + {"plus", "+1234", "'+1234"}, + {"minus", "-cmd", "'-cmd"}, + {"at", "@SUM(A1)", "'@SUM(A1)"}, + {"tab", "\t=cmd", "'\t=cmd"}, + {"carriage return", "\r=cmd", "'\r=cmd"}, + {"benign", "normal title", "normal title"}, + {"empty", "", ""}, + {"interior trigger", "a=b", "a=b"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := csvSafe(tc.in); got != tc.want { + t.Errorf("csvSafe(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestRenderExportCSVNeutralizesFormulaInjection(t *testing.T) { + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + issues := []*model.Issue{ + { + ID: 1, + Title: "=HYPERLINK(\"evil\")", + Description: "benign description", + Status: model.StatusTodo, + Priority: model.PriorityMedium, + Kind: model.IssueKindFeature, + Assignee: "@alice", + Labels: []string{"bug"}, + Files: []string{"a.go"}, + CreatedAt: now, + UpdatedAt: now, + }, + } + + out, err := renderExportCSV(issues) + if err != nil { + t.Fatalf("renderExportCSV: %v", err) + } + + records, err := csv.NewReader(strings.NewReader(out)).ReadAll() + if err != nil { + t.Fatalf("parse CSV: %v", err) + } + if len(records) != 2 { + t.Fatalf("got %d records, want 2 (header + 1 row)", len(records)) + } + + row := records[1] + if got := row[2]; got != "'=HYPERLINK(\"evil\")" { + t.Errorf("title cell = %q, want %q", got, "'=HYPERLINK(\"evil\")") + } + if got := row[3]; got != "benign description" { + t.Errorf("description cell = %q, want untouched %q", got, "benign description") + } + if got := row[7]; got != "'@alice" { + t.Errorf("assignee cell = %q, want %q", got, "'@alice") + } +} diff --git a/internal/cli/import.go b/internal/cli/import.go index c77d0d8..a919872 100644 --- a/internal/cli/import.go +++ b/internal/cli/import.go @@ -86,10 +86,6 @@ var importCmd = &cobra.Command{ return nil } } - - if err := db.ClearAllData(conn); err != nil { - return cmdErr(fmt.Errorf("clearing database: %w", err), output.ErrGeneral) - } } else if !merge { // Default mode: require empty database. count, err := db.CountIssues(conn) @@ -105,7 +101,7 @@ var importCmd = &cobra.Command{ } // Perform the import within a single transaction. - result, err := doImport(conn, &export) + result, err := doImport(conn, &export, replace) if err != nil { return cmdErr(fmt.Errorf("importing data: %w", err), output.ErrGeneral) } @@ -151,18 +147,39 @@ func validateExportData(export *model.ExportData) []string { } } + for _, p := range export.Proposals { + if err := model.ValidateCriticality(p.Criticality); err != nil { + errs = append(errs, fmt.Sprintf("proposal %s: %s", model.FormatProposalID(p.ID), err)) + } + if err := model.ValidateProposalStatus(p.Status); err != nil { + errs = append(errs, fmt.Sprintf("proposal %s: %s", model.FormatProposalID(p.ID), err)) + } + } + + for _, v := range export.Votes { + if err := model.ValidateVerdict(v.Verdict); err != nil { + errs = append(errs, fmt.Sprintf("vote %d: %s", v.ID, err)) + } + } + return errs } // doImport inserts all export data into the database. In merge mode, existing // IDs are skipped. Returns counts of imported and skipped entities. -func doImport(conn *sql.DB, export *model.ExportData) (*importResult, error) { +func doImport(conn *sql.DB, export *model.ExportData, replace bool) (*importResult, error) { tx, err := conn.Begin() if err != nil { return nil, fmt.Errorf("beginning transaction: %w", err) } defer tx.Rollback() + if replace { + if err := db.ClearAllDataTx(tx); err != nil { + return nil, fmt.Errorf("clearing database: %w", err) + } + } + var imported, skipped int // 1. Labels (no FK dependencies). @@ -206,6 +223,13 @@ func doImport(conn *sql.DB, export *model.ExportData) (*importResult, error) { // Now restore parent_id references for newly inserted issues. for issueID, parentID := range parentIDs { + var parentExists bool + if err := tx.QueryRow("SELECT EXISTS(SELECT 1 FROM issues WHERE id = ?)", *parentID).Scan(&parentExists); err != nil { + return nil, fmt.Errorf("checking parent for issue %s: %w", model.FormatID(issueID), err) + } + if !parentExists { + continue + } _, err := tx.Exec(`UPDATE issues SET parent_id = ? WHERE id = ?`, *parentID, issueID) if err != nil { return nil, fmt.Errorf("setting parent_id for issue %s: %w", model.FormatID(issueID), err) @@ -264,6 +288,123 @@ func doImport(conn *sql.DB, export *model.ExportData) (*importResult, error) { } } + // 7. Activity log (FK: issues). + for _, a := range export.ActivityLog { + inserted, err := db.InsertActivityWithID(tx, a) + if err != nil { + return nil, fmt.Errorf("inserting activity %d: %w", a.ID, err) + } + if inserted { + imported++ + } else { + skipped++ + } + } + + // 8. Proposals (FK: none; must precede votes/proposal_issues/proposal_docs). + for _, p := range export.Proposals { + inserted, err := db.InsertProposalWithID(tx, p) + if err != nil { + return nil, fmt.Errorf("inserting proposal %s: %w", model.FormatProposalID(p.ID), err) + } + if inserted { + imported++ + } else { + skipped++ + } + } + + // 9. Votes (FK: proposals). + for _, v := range export.Votes { + inserted, err := db.InsertVoteWithID(tx, v) + if err != nil { + return nil, fmt.Errorf("inserting vote %d: %w", v.ID, err) + } + if inserted { + imported++ + } else { + skipped++ + } + } + + // 10. Proposal-issue links (FK: proposals, issues). + for _, l := range export.ProposalIssues { + inserted, err := db.InsertProposalIssueLink(tx, l.ProposalID, l.IssueID) + if err != nil { + return nil, fmt.Errorf("inserting proposal-issue link (proposal=%d, issue=%d): %w", l.ProposalID, l.IssueID, err) + } + if inserted { + imported++ + } else { + skipped++ + } + } + + // 11. Docs (FK: none; must precede revisions/comments/links). + for _, doc := range export.Docs { + inserted, err := db.InsertDocWithID(tx, doc) + if err != nil { + return nil, fmt.Errorf("inserting doc %s: %w", model.FormatDocID(doc.ID), err) + } + if inserted { + imported++ + } else { + skipped++ + } + } + + // 12. Doc revisions (FK: docs). + for _, rev := range export.DocRevisions { + inserted, err := db.InsertDocRevisionWithID(tx, rev) + if err != nil { + return nil, fmt.Errorf("inserting doc revision %d: %w", rev.ID, err) + } + if inserted { + imported++ + } else { + skipped++ + } + } + + // 13. Doc comments (FK: docs). + for _, c := range export.DocComments { + inserted, err := db.InsertDocCommentWithID(tx, c) + if err != nil { + return nil, fmt.Errorf("inserting doc comment %d: %w", c.ID, err) + } + if inserted { + imported++ + } else { + skipped++ + } + } + + // 14. Doc-issue links (FK: docs, issues). + for _, l := range export.DocIssueLinks { + inserted, err := db.InsertDocIssueLink(tx, l.DocID, l.IssueID, l.CreatedAt) + if err != nil { + return nil, fmt.Errorf("inserting doc-issue link (doc=%d, issue=%d): %w", l.DocID, l.IssueID, err) + } + if inserted { + imported++ + } else { + skipped++ + } + } + + // 15. Proposal-doc links (FK: proposals, docs — both inserted above). + for _, l := range export.ProposalDocs { + inserted, err := db.InsertProposalDocLink(tx, l.ProposalID, l.DocID, l.CreatedAt) + if err != nil { + return nil, fmt.Errorf("inserting proposal-doc link (proposal=%d, doc=%d): %w", l.ProposalID, l.DocID, err) + } + if inserted { + imported++ + } else { + skipped++ + } + } + if err := tx.Commit(); err != nil { return nil, fmt.Errorf("committing transaction: %w", err) } diff --git a/internal/cli/import_test.go b/internal/cli/import_test.go new file mode 100644 index 0000000..76ee688 --- /dev/null +++ b/internal/cli/import_test.go @@ -0,0 +1,684 @@ +package cli + +import ( + "context" + "database/sql" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/ALT-F4-LLC/docket/internal/db" + "github.com/ALT-F4-LLC/docket/internal/model" + "github.com/spf13/cobra" +) + +func buildExport(t *testing.T, conn *sql.DB) *model.ExportData { + t.Helper() + + issues, err := db.ListAllIssues(conn) + if err != nil { + t.Fatalf("ListAllIssues: %v", err) + } + comments, err := db.ListAllComments(conn) + if err != nil { + t.Fatalf("ListAllComments: %v", err) + } + relations, err := db.GetAllRelations(conn) + if err != nil { + t.Fatalf("GetAllRelations: %v", err) + } + labels, err := db.ListAllLabelsRaw(conn) + if err != nil { + t.Fatalf("ListAllLabelsRaw: %v", err) + } + labelMappings, err := db.ListAllIssueLabelMappings(conn) + if err != nil { + t.Fatalf("ListAllIssueLabelMappings: %v", err) + } + fileMappings, err := db.ListAllIssueFileMappings(conn) + if err != nil { + t.Fatalf("ListAllIssueFileMappings: %v", err) + } + docs, err := db.ListAllDocs(conn) + if err != nil { + t.Fatalf("ListAllDocs: %v", err) + } + docRevisions, err := db.ListAllDocRevisions(conn) + if err != nil { + t.Fatalf("ListAllDocRevisions: %v", err) + } + docComments, err := db.ListAllDocComments(conn) + if err != nil { + t.Fatalf("ListAllDocComments: %v", err) + } + docIssueLinks, err := db.ListAllDocIssueLinks(conn) + if err != nil { + t.Fatalf("ListAllDocIssueLinks: %v", err) + } + proposalDocs, err := db.ListAllProposalDocs(conn) + if err != nil { + t.Fatalf("ListAllProposalDocs: %v", err) + } + proposals, err := db.ListAllProposals(conn) + if err != nil { + t.Fatalf("ListAllProposals: %v", err) + } + votes, err := db.ListAllVotes(conn) + if err != nil { + t.Fatalf("ListAllVotes: %v", err) + } + proposalIssues, err := db.ListAllProposalIssues(conn) + if err != nil { + t.Fatalf("ListAllProposalIssues: %v", err) + } + activityLog, err := db.ListAllActivity(conn) + if err != nil { + t.Fatalf("ListAllActivity: %v", err) + } + + return &model.ExportData{ + Version: 1, + ExportedAt: "2026-01-01T00:00:00Z", + Issues: issues, + Comments: comments, + Relations: relations, + Labels: labels, + IssueLabelMappings: labelMappings, + IssueFileMappings: fileMappings, + Docs: docs, + DocRevisions: docRevisions, + DocComments: docComments, + DocIssueLinks: docIssueLinks, + ActivityLog: activityLog, + Proposals: proposals, + Votes: votes, + ProposalIssues: proposalIssues, + ProposalDocs: proposalDocs, + } +} + +func TestDoImportRoundTripPreservesDocs(t *testing.T) { + src := newTestDB(t) + + issueID := createIssue(t, src, "linked issue", model.StatusTodo, model.PriorityMedium) + + docID := createDoc(t, src, "design doc", "tdd", "draft") + revisedBody := "second revision body" + if _, err := db.UpdateDoc(src, docID, db.DocUpdate{Body: &revisedBody, Author: "editor"}); err != nil { + t.Fatalf("UpdateDoc: %v", err) + } + + otherDocID := createDoc(t, src, "decision record", "adr", "accepted") + + for _, c := range []*model.DocComment{ + {DocID: docID, Body: "first comment", Author: "alice"}, + {DocID: docID, Body: "second comment", Author: "bob"}, + {DocID: otherDocID, Body: "third comment", Author: "carol"}, + } { + if _, err := db.CreateDocComment(src, c); err != nil { + t.Fatalf("CreateDocComment: %v", err) + } + } + + linkDocIssue(t, src, docID, issueID) + + export := buildExport(t, src) + + dst := newTestDB(t) + if _, err := doImport(dst, export, false); err != nil { + t.Fatalf("doImport: %v", err) + } + + gotDocs, err := db.ListAllDocs(dst) + if err != nil { + t.Fatalf("ListAllDocs(dst): %v", err) + } + if len(gotDocs) != 2 { + t.Fatalf("expected 2 docs after import, got %d", len(gotDocs)) + } + + gotRevisions, err := db.ListAllDocRevisions(dst) + if err != nil { + t.Fatalf("ListAllDocRevisions(dst): %v", err) + } + // docID: create + body edit = 2 revisions; otherDocID: create = 1 revision. + if len(gotRevisions) != 3 { + t.Fatalf("expected 3 doc revisions after import, got %d", len(gotRevisions)) + } + + gotComments, err := db.ListAllDocComments(dst) + if err != nil { + t.Fatalf("ListAllDocComments(dst): %v", err) + } + if len(gotComments) != 3 { + t.Fatalf("expected 3 doc comments after import, got %d", len(gotComments)) + } + + gotLinks, err := db.ListAllDocIssueLinks(dst) + if err != nil { + t.Fatalf("ListAllDocIssueLinks(dst): %v", err) + } + if len(gotLinks) != 1 { + t.Fatalf("expected 1 doc-issue link after import, got %d", len(gotLinks)) + } + if gotLinks[0].DocID != docID || gotLinks[0].IssueID != issueID { + t.Errorf("expected link (doc=%d, issue=%d), got (doc=%d, issue=%d)", + docID, issueID, gotLinks[0].DocID, gotLinks[0].IssueID) + } + + gotDoc, err := db.GetDoc(dst, docID) + if err != nil { + t.Fatalf("GetDoc(dst, %d): %v", docID, err) + } + if gotDoc.Body != revisedBody { + t.Errorf("expected doc body %q after import, got %q", revisedBody, gotDoc.Body) + } + if gotDoc.Title != "design doc" { + t.Errorf("expected doc title %q, got %q", "design doc", gotDoc.Title) + } +} + +func TestDoImportRoundTripPreservesProposalsSubsystem(t *testing.T) { + src := newTestDB(t) + + issueID := createIssue(t, src, "linked issue", model.StatusTodo, model.PriorityMedium) + docID := createDoc(t, src, "linked doc", "tdd", "draft") + + score := 0.84 + proposalID, err := db.CreateProposal(src, &model.Proposal{ + Description: "should we ship", + Criticality: model.CriticalityHigh, + Status: model.ProposalStatusOpen, + RequiredVoters: 3, + Threshold: 0.67, + WeightedScore: &score, + CreatedBy: "@team-lead", + Rationale: "because", + DomainTags: []string{"backend", "data"}, + FilesChanged: []string{"a.go", "b.go"}, + }) + if err != nil { + t.Fatalf("CreateProposal: %v", err) + } + + if _, err := db.CastVote(src, &model.Vote{ + ProposalID: proposalID, + VoterName: "@senior-engineer", + VoterRole: "senior-engineer", + Verdict: model.VerdictApprove, + Confidence: 0.9, + DomainRelevance: 0.8, + Summary: "looks correct", + FindingsJSON: &model.Findings{Blockers: nil, Concerns: []string{"one nit"}, Suggestions: []string{"rename x"}}, + }); err != nil { + t.Fatalf("CastVote: %v", err) + } + + if err := db.LinkProposalIssue(src, proposalID, issueID); err != nil { + t.Fatalf("LinkProposalIssue: %v", err) + } + if err := db.LinkProposalDoc(src, proposalID, docID); err != nil { + t.Fatalf("LinkProposalDoc: %v", err) + } + + export := buildExport(t, src) + + dst := newTestDB(t) + if _, err := doImport(dst, export, false); err != nil { + t.Fatalf("doImport: %v", err) + } + + gotProposals, err := db.ListAllProposals(dst) + if err != nil { + t.Fatalf("ListAllProposals(dst): %v", err) + } + if len(gotProposals) != 1 { + t.Fatalf("expected 1 proposal after import, got %d", len(gotProposals)) + } + p := gotProposals[0] + if p.ID != proposalID || p.Description != "should we ship" { + t.Errorf("proposal mismatch: got id=%d desc=%q", p.ID, p.Description) + } + if p.WeightedScore == nil || *p.WeightedScore != score { + t.Errorf("expected weighted_score %v, got %v", score, p.WeightedScore) + } + if len(p.DomainTags) != 2 || len(p.FilesChanged) != 2 { + t.Errorf("expected domain_tags/files_changed to round-trip, got %v / %v", p.DomainTags, p.FilesChanged) + } + + gotVotes, err := db.ListAllVotes(dst) + if err != nil { + t.Fatalf("ListAllVotes(dst): %v", err) + } + if len(gotVotes) != 1 { + t.Fatalf("expected 1 vote after import, got %d", len(gotVotes)) + } + if gotVotes[0].FindingsJSON == nil || len(gotVotes[0].FindingsJSON.Concerns) != 1 { + t.Errorf("expected findings_json to round-trip with 1 concern, got %+v", gotVotes[0].FindingsJSON) + } + + gotProposalIssues, err := db.ListAllProposalIssues(dst) + if err != nil { + t.Fatalf("ListAllProposalIssues(dst): %v", err) + } + if len(gotProposalIssues) != 1 || gotProposalIssues[0].ProposalID != proposalID || gotProposalIssues[0].IssueID != issueID { + t.Errorf("expected proposal-issue link (%d,%d), got %+v", proposalID, issueID, gotProposalIssues) + } + + gotProposalDocs, err := db.ListAllProposalDocs(dst) + if err != nil { + t.Fatalf("ListAllProposalDocs(dst): %v", err) + } + if len(gotProposalDocs) != 1 || gotProposalDocs[0].ProposalID != proposalID || gotProposalDocs[0].DocID != docID { + t.Errorf("expected proposal-doc link (%d,%d), got %+v", proposalID, docID, gotProposalDocs) + } +} + +func TestDoImportRoundTripPreservesActivityLog(t *testing.T) { + src := newTestDB(t) + + issueID := createIssue(t, src, "tracked issue", model.StatusTodo, model.PriorityMedium) + if err := db.RecordActivity(src, issueID, "status", "todo", "in-progress", "@senior-engineer"); err != nil { + t.Fatalf("RecordActivity: %v", err) + } + + wantActivity, err := db.ListAllActivity(src) + if err != nil { + t.Fatalf("ListAllActivity(src): %v", err) + } + if len(wantActivity) < 2 { + t.Fatalf("expected at least 2 activity rows in source (created + status), got %d", len(wantActivity)) + } + + export := buildExport(t, src) + + dst := newTestDB(t) + if err := db.ClearAllData(dst); err != nil { + t.Fatalf("ClearAllData(dst): %v", err) + } + if _, err := doImport(dst, export, false); err != nil { + t.Fatalf("doImport: %v", err) + } + + gotActivity, err := db.ListAllActivity(dst) + if err != nil { + t.Fatalf("ListAllActivity(dst): %v", err) + } + if len(gotActivity) != len(wantActivity) { + t.Fatalf("expected %d activity rows after import, got %d", len(wantActivity), len(gotActivity)) + } + for i := range wantActivity { + w, g := wantActivity[i], gotActivity[i] + if g.ID != w.ID { + t.Errorf("activity[%d] id mismatch: want %d, got %d", i, w.ID, g.ID) + } + if g.IssueID != w.IssueID || g.FieldChanged != w.FieldChanged || + g.OldValue != w.OldValue || g.NewValue != w.NewValue || g.ChangedBy != w.ChangedBy { + t.Errorf("activity[%d] field mismatch: want %+v, got %+v", i, w, g) + } + } + + rows, err := dst.Query("PRAGMA foreign_key_check") + if err != nil { + t.Fatalf("PRAGMA foreign_key_check: %v", err) + } + defer rows.Close() + if rows.Next() { + t.Errorf("expected no foreign key violations after import, found at least one") + } +} + +func TestDoImportReplaceRollsBackOnFailure(t *testing.T) { + dst := newTestDB(t) + seededIssueID := createIssue(t, dst, "must survive", model.StatusTodo, model.PriorityHigh) + if err := db.AddLabelToIssue(dst, seededIssueID, "keep-me", "", "tester"); err != nil { + t.Fatalf("AddLabelToIssue: %v", err) + } + + src := newTestDB(t) + createIssue(t, src, "incoming", model.StatusTodo, model.PriorityMedium) + docID := createDoc(t, src, "incoming doc", "tdd", "draft") + + export := buildExport(t, src) + export.DocIssueLinks = append(export.DocIssueLinks, model.DocIssueLink{ + DocID: docID, + IssueID: 999999, + CreatedAt: "2026-01-01T00:00:00Z", + }) + + if _, err := doImport(dst, export, true); err == nil { + t.Fatal("expected doImport(replace=true) to fail on dangling doc-issue link, got nil") + } + + gotIssues, err := db.ListAllIssues(dst) + if err != nil { + t.Fatalf("ListAllIssues(dst): %v", err) + } + if len(gotIssues) != 1 || gotIssues[0].ID != seededIssueID || gotIssues[0].Title != "must survive" { + t.Fatalf("expected seeded issue preserved after rollback, got %+v", gotIssues) + } + + gotLabels, err := db.ListAllLabelsRaw(dst) + if err != nil { + t.Fatalf("ListAllLabelsRaw(dst): %v", err) + } + if len(gotLabels) != 1 || gotLabels[0].Name != "keep-me" { + t.Fatalf("expected seeded label preserved after rollback, got %+v", gotLabels) + } +} + +func TestDoImportReplaceClearsThenImports(t *testing.T) { + dst := newTestDB(t) + createIssue(t, dst, "old data", model.StatusTodo, model.PriorityHigh) + + src := newTestDB(t) + createIssue(t, src, "new data", model.StatusTodo, model.PriorityMedium) + + export := buildExport(t, src) + + if _, err := doImport(dst, export, true); err != nil { + t.Fatalf("doImport(replace=true): %v", err) + } + + gotIssues, err := db.ListAllIssues(dst) + if err != nil { + t.Fatalf("ListAllIssues(dst): %v", err) + } + if len(gotIssues) != 1 || gotIssues[0].Title != "new data" { + t.Fatalf("expected only imported issue after successful replace, got %+v", gotIssues) + } +} + +func createChildIssue(t *testing.T, conn *sql.DB, title string, status model.Status, parentID int) int { + t.Helper() + id, err := db.CreateIssue(conn, &model.Issue{ + Title: title, + Status: status, + Priority: model.PriorityMedium, + Kind: model.IssueKindFeature, + ParentID: &parentID, + }, nil, nil) + if err != nil { + t.Fatalf("CreateIssue(child %q): %v", title, err) + } + return id +} + +func runFilteredExport(t *testing.T, conn *sql.DB, statuses []string) *model.ExportData { + t.Helper() + + cmd := &cobra.Command{} + cmd.Flags().StringP("format", "o", "json", "") + cmd.Flags().StringP("file", "f", "", "") + cmd.Flags().StringSliceP("status", "s", nil, "") + cmd.Flags().StringSliceP("label", "l", nil, "") + cmd.SetContext(context.WithValue(context.Background(), dbKey, conn)) + + outPath := filepath.Join(t.TempDir(), "export.json") + if err := cmd.Flags().Set("file", outPath); err != nil { + t.Fatalf("set file flag: %v", err) + } + for _, s := range statuses { + if err := cmd.Flags().Set("status", s); err != nil { + t.Fatalf("set status flag: %v", err) + } + } + + if err := exportCmd.RunE(cmd, nil); err != nil { + t.Fatalf("exportCmd.RunE: %v", err) + } + + raw, err := os.ReadFile(outPath) + if err != nil { + t.Fatalf("ReadFile(%s): %v", outPath, err) + } + var export model.ExportData + if err := json.Unmarshal(raw, &export); err != nil { + t.Fatalf("Unmarshal export: %v", err) + } + return &export +} + +func TestFilteredExportRoundTripDropsUnlinkedAndNullsParent(t *testing.T) { + src := newTestDB(t) + + parentID := createIssue(t, src, "in-progress parent", model.StatusInProgress, model.PriorityMedium) + childID := createChildIssue(t, src, "done child", model.StatusDone, parentID) + + linkedDocID := createDoc(t, src, "linked design doc", "tdd", "draft") + linkDocIssue(t, src, linkedDocID, childID) + if _, err := db.CreateDocComment(src, &model.DocComment{DocID: linkedDocID, Body: "on linked doc", Author: "alice"}); err != nil { + t.Fatalf("CreateDocComment(linked): %v", err) + } + + standaloneDocID := createDoc(t, src, "standalone adr", "adr", "accepted") + if _, err := db.CreateDocComment(src, &model.DocComment{DocID: standaloneDocID, Body: "on standalone doc", Author: "bob"}); err != nil { + t.Fatalf("CreateDocComment(standalone): %v", err) + } + + linkedProposalID, err := db.CreateProposal(src, &model.Proposal{ + Description: "linked proposal", Criticality: model.CriticalityMedium, + Status: model.ProposalStatusOpen, RequiredVoters: 1, Threshold: 0.5, CreatedBy: "@team-lead", + }) + if err != nil { + t.Fatalf("CreateProposal(linked): %v", err) + } + if _, err := db.CastVote(src, &model.Vote{ + ProposalID: linkedProposalID, VoterName: "@senior-engineer", VoterRole: "senior-engineer", + Verdict: model.VerdictApprove, Confidence: 0.9, DomainRelevance: 0.8, Summary: "ok", + }); err != nil { + t.Fatalf("CastVote(linked): %v", err) + } + if err := db.LinkProposalIssue(src, linkedProposalID, childID); err != nil { + t.Fatalf("LinkProposalIssue(linked): %v", err) + } + if err := db.LinkProposalDoc(src, linkedProposalID, linkedDocID); err != nil { + t.Fatalf("LinkProposalDoc(linked): %v", err) + } + + standaloneProposalID, err := db.CreateProposal(src, &model.Proposal{ + Description: "standalone proposal", Criticality: model.CriticalityLow, + Status: model.ProposalStatusOpen, RequiredVoters: 1, Threshold: 0.5, CreatedBy: "@team-lead", + }) + if err != nil { + t.Fatalf("CreateProposal(standalone): %v", err) + } + if _, err := db.CastVote(src, &model.Vote{ + ProposalID: standaloneProposalID, VoterName: "@sdet", VoterRole: "sdet", + Verdict: model.VerdictApprove, Confidence: 0.5, DomainRelevance: 0.5, Summary: "ok", + }); err != nil { + t.Fatalf("CastVote(standalone): %v", err) + } + + export := runFilteredExport(t, src, []string{string(model.StatusDone)}) + + if len(export.Issues) != 1 || export.Issues[0].ID != childID { + t.Fatalf("expected only the done child in filtered export, got %+v", export.Issues) + } + if export.Issues[0].ParentID != nil { + t.Errorf("expected filtered-out parent to be nulled, got parent_id=%v", *export.Issues[0].ParentID) + } + if len(export.Docs) != 1 || export.Docs[0].ID != linkedDocID { + t.Errorf("expected only the linked doc in filtered export, got %+v", export.Docs) + } + for _, c := range export.DocComments { + if c.DocID == standaloneDocID { + t.Errorf("standalone doc comment leaked into filtered export: %+v", c) + } + } + if len(export.Proposals) != 1 || export.Proposals[0].ID != linkedProposalID { + t.Errorf("expected only the linked proposal in filtered export, got %+v", export.Proposals) + } + for _, v := range export.Votes { + if v.ProposalID == standaloneProposalID { + t.Errorf("standalone proposal's vote leaked into filtered export: %+v", v) + } + } + if len(export.ProposalDocs) != 1 || export.ProposalDocs[0].ProposalID != linkedProposalID || export.ProposalDocs[0].DocID != linkedDocID { + t.Errorf("expected single surviving proposal-doc link, got %+v", export.ProposalDocs) + } + + dst := newTestDB(t) + if err := db.ClearAllData(dst); err != nil { + t.Fatalf("ClearAllData(dst): %v", err) + } + if _, err := doImport(dst, export, false); err != nil { + t.Fatalf("doImport of filtered export: %v", err) + } + + gotIssues, err := db.ListAllIssues(dst) + if err != nil { + t.Fatalf("ListAllIssues(dst): %v", err) + } + if len(gotIssues) != 1 || gotIssues[0].ID != childID { + t.Fatalf("expected single child issue imported, got %+v", gotIssues) + } + if gotIssues[0].ParentID != nil { + t.Errorf("expected imported child to have NULL parent_id, got %v", *gotIssues[0].ParentID) + } + + gotDocs, err := db.ListAllDocs(dst) + if err != nil { + t.Fatalf("ListAllDocs(dst): %v", err) + } + if len(gotDocs) != 1 || gotDocs[0].ID != linkedDocID { + t.Errorf("expected only linked doc imported, got %+v", gotDocs) + } + + gotProposals, err := db.ListAllProposals(dst) + if err != nil { + t.Fatalf("ListAllProposals(dst): %v", err) + } + if len(gotProposals) != 1 || gotProposals[0].ID != linkedProposalID { + t.Errorf("expected only linked proposal imported, got %+v", gotProposals) + } + + rows, err := dst.Query("PRAGMA foreign_key_check") + if err != nil { + t.Fatalf("PRAGMA foreign_key_check: %v", err) + } + defer rows.Close() + if rows.Next() { + t.Errorf("expected no foreign key violations after import, found at least one") + } +} + +func TestFilteredExportReplaceImportRoundTripsAndDropsStandalone(t *testing.T) { + src := newTestDB(t) + + parentID := createIssue(t, src, "in-progress parent", model.StatusInProgress, model.PriorityMedium) + childID := createChildIssue(t, src, "done child", model.StatusDone, parentID) + + linkedDocID := createDoc(t, src, "linked design doc", "tdd", "draft") + linkDocIssue(t, src, linkedDocID, childID) + standaloneDocID := createDoc(t, src, "standalone adr", "adr", "accepted") + + export := runFilteredExport(t, src, []string{string(model.StatusDone)}) + + dst := newTestDB(t) + staleID := createIssue(t, dst, "stale data to be replaced", model.StatusTodo, model.PriorityHigh) + + if _, err := doImport(dst, export, true); err != nil { + t.Fatalf("doImport(filtered, replace=true): %v", err) + } + + gotIssues, err := db.ListAllIssues(dst) + if err != nil { + t.Fatalf("ListAllIssues(dst): %v", err) + } + if len(gotIssues) != 1 || gotIssues[0].ID != childID { + t.Fatalf("expected only the filtered child after replace, got %+v", gotIssues) + } + if gotIssues[0].ID == staleID { + t.Fatalf("stale issue survived --replace import") + } + if gotIssues[0].ParentID != nil { + t.Errorf("expected dangling parent nulled after filtered replace import, got parent_id=%v", *gotIssues[0].ParentID) + } + + gotDocs, err := db.ListAllDocs(dst) + if err != nil { + t.Fatalf("ListAllDocs(dst): %v", err) + } + if len(gotDocs) != 1 || gotDocs[0].ID != linkedDocID { + t.Errorf("expected only linked doc after filtered replace import, got %+v", gotDocs) + } + for _, d := range gotDocs { + if d.ID == standaloneDocID { + t.Errorf("standalone doc leaked into filtered replace import: %+v", d) + } + } + + rows, err := dst.Query("PRAGMA foreign_key_check") + if err != nil { + t.Fatalf("PRAGMA foreign_key_check: %v", err) + } + defer rows.Close() + if rows.Next() { + t.Errorf("expected no foreign key violations after filtered replace import, found at least one") + } +} + +func TestFilteredExportReplaceImportRollsBackOnFailure(t *testing.T) { + src := newTestDB(t) + parentID := createIssue(t, src, "in-progress parent", model.StatusInProgress, model.PriorityMedium) + childID := createChildIssue(t, src, "done child", model.StatusDone, parentID) + docID := createDoc(t, src, "linked doc", "tdd", "draft") + linkDocIssue(t, src, docID, childID) + + export := runFilteredExport(t, src, []string{string(model.StatusDone)}) + export.DocIssueLinks = append(export.DocIssueLinks, model.DocIssueLink{ + DocID: docID, + IssueID: 999999, + CreatedAt: "2026-01-01T00:00:00Z", + }) + + dst := newTestDB(t) + survivorID := createIssue(t, dst, "must survive failed replace", model.StatusTodo, model.PriorityHigh) + if err := db.AddLabelToIssue(dst, survivorID, "keep-me", "", "tester"); err != nil { + t.Fatalf("AddLabelToIssue: %v", err) + } + + if _, err := doImport(dst, export, true); err == nil { + t.Fatal("expected doImport(filtered, replace=true) to fail on dangling doc-issue link, got nil") + } + + gotIssues, err := db.ListAllIssues(dst) + if err != nil { + t.Fatalf("ListAllIssues(dst): %v", err) + } + if len(gotIssues) != 1 || gotIssues[0].ID != survivorID || gotIssues[0].Title != "must survive failed replace" { + t.Fatalf("expected pre-existing data preserved after failed filtered replace, got %+v", gotIssues) + } + + gotLabels, err := db.ListAllLabelsRaw(dst) + if err != nil { + t.Fatalf("ListAllLabelsRaw(dst): %v", err) + } + if len(gotLabels) != 1 || gotLabels[0].Name != "keep-me" { + t.Fatalf("expected pre-existing label preserved after failed filtered replace, got %+v", gotLabels) + } +} + +func TestUnfilteredExportIncludesStandaloneDocsAndProposals(t *testing.T) { + src := newTestDB(t) + + createIssue(t, src, "some issue", model.StatusTodo, model.PriorityMedium) + createDoc(t, src, "standalone doc", "adr", "accepted") + if _, err := db.CreateProposal(src, &model.Proposal{ + Description: "standalone proposal", Criticality: model.CriticalityLow, + Status: model.ProposalStatusOpen, RequiredVoters: 1, Threshold: 0.5, CreatedBy: "@team-lead", + }); err != nil { + t.Fatalf("CreateProposal: %v", err) + } + + export := runFilteredExport(t, src, nil) + + if len(export.Docs) != 1 { + t.Errorf("unfiltered export should include standalone doc, got %d docs", len(export.Docs)) + } + if len(export.Proposals) != 1 { + t.Errorf("unfiltered export should include standalone proposal, got %d proposals", len(export.Proposals)) + } +} diff --git a/internal/cli/issue_list.go b/internal/cli/issue_list.go index 24c873d..254ee62 100644 --- a/internal/cli/issue_list.go +++ b/internal/cli/issue_list.go @@ -115,6 +115,10 @@ func runIssueList(cmd *cobra.Command, args []string, w *output.Writer) error { return cmdErr(fmt.Errorf("listing issues: %w", err), output.ErrGeneral) } + if err := db.HydrateDocs(conn, issues); err != nil { + return cmdErr(fmt.Errorf("fetching linked docs: %w", err), output.ErrGeneral) + } + result := listResult{Issues: issues, Total: total} // Fetch parent issues and sub-issue progress for the grouped display. diff --git a/internal/cli/issue_list_test.go b/internal/cli/issue_list_test.go new file mode 100644 index 0000000..063a5af --- /dev/null +++ b/internal/cli/issue_list_test.go @@ -0,0 +1,101 @@ +package cli + +import ( + "database/sql" + "encoding/json" + "testing" + + "github.com/ALT-F4-LLC/docket/internal/model" + "github.com/spf13/cobra" +) + +func listCmdWithDB(conn *sql.DB) *cobra.Command { + cmd := cmdWithDB(conn) + cmd.Flags().StringSlice("status", nil, "") + cmd.Flags().StringSlice("priority", nil, "") + cmd.Flags().StringSlice("label", nil, "") + cmd.Flags().StringSlice("type", nil, "") + cmd.Flags().String("assignee", "", "") + cmd.Flags().String("parent", "", "") + cmd.Flags().Bool("roots", false, "") + cmd.Flags().Bool("tree", false, "") + cmd.Flags().String("sort", "", "") + cmd.Flags().Int("limit", 50, "") + cmd.Flags().Bool("all", false, "") + return cmd +} + +type listJSON struct { + Data struct { + Issues []struct { + ID string `json:"id"` + Files []string `json:"files"` + Docs []struct { + ID string `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + Status string `json:"status"` + } `json:"docs"` + } `json:"issues"` + Total int `json:"total"` + } `json:"data"` +} + +func TestListJSON_HydratesFilesAndDocs(t *testing.T) { + conn := newTestDB(t) + issueID := createIssueWithFile(t, conn, "ready", "internal/db/doc_links.go") + doc := createDoc(t, conn, "Docket Doc CLI", "tdd", "approved") + linkDocIssue(t, conn, doc, issueID) + + cmd := listCmdWithDB(conn) + w, buf := bufWriter(true) + if err := runIssueList(cmd, nil, w); err != nil { + t.Fatalf("runIssueList: %v", err) + } + + var lj listJSON + if err := json.Unmarshal(buf.Bytes(), &lj); err != nil { + t.Fatalf("unmarshal: %v\n%s", err, buf.String()) + } + if len(lj.Data.Issues) != 1 { + t.Fatalf("issues = %d, want 1", len(lj.Data.Issues)) + } + iss := lj.Data.Issues[0] + if len(iss.Files) != 1 || iss.Files[0] != "internal/db/doc_links.go" { + t.Errorf("files = %v, want [internal/db/doc_links.go]", iss.Files) + } + if len(iss.Docs) != 1 { + t.Fatalf("docs = %d, want 1", len(iss.Docs)) + } + if iss.Docs[0].ID != "DOC-1" || iss.Docs[0].Type != "tdd" || iss.Docs[0].Status != "approved" || iss.Docs[0].Title != "Docket Doc CLI" { + t.Errorf("doc shape wrong: %+v", iss.Docs[0]) + } +} + +func TestListJSON_FilesAndDocsEmptyAreArrays(t *testing.T) { + conn := newTestDB(t) + createIssue(t, conn, "ready no context", model.StatusTodo, model.PriorityHigh) + + cmd := listCmdWithDB(conn) + w, buf := bufWriter(true) + if err := runIssueList(cmd, nil, w); err != nil { + t.Fatalf("runIssueList: %v", err) + } + + var env struct { + Data struct { + Issues []map[string]json.RawMessage `json:"issues"` + } `json:"data"` + } + if err := json.Unmarshal(buf.Bytes(), &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(env.Data.Issues) != 1 { + t.Fatalf("issues = %d, want 1", len(env.Data.Issues)) + } + for _, key := range []string{"files", "docs"} { + if got := string(env.Data.Issues[0][key]); got != "[]" { + t.Errorf("%s = %s, want []", key, got) + } + } +} diff --git a/internal/cli/issue_show.go b/internal/cli/issue_show.go index 6beed75..98bc855 100644 --- a/internal/cli/issue_show.go +++ b/internal/cli/issue_show.go @@ -22,32 +22,35 @@ import ( // showResult composes the issue fields with additional detail fields // (sub-issues, relations, comments, activity) into a single flat JSON object. type showResult struct { - Issue *model.Issue `json:"-"` - SubIssues []*model.Issue `json:"sub_issues"` - Relations []model.Relation `json:"relations"` - Comments []*model.Comment `json:"comments"` - Activity []model.Activity `json:"activity"` + Issue *model.Issue `json:"-"` + SubIssues []*model.Issue `json:"sub_issues"` + Relations []model.Relation `json:"relations"` + LinkedProposals []model.Proposal `json:"-"` + Comments []*model.Comment `json:"comments"` + Activity []model.Activity `json:"activity"` } // showResultJSON is the wire format that explicitly lists all fields, // avoiding the fragile marshal-unmarshal-remarshal pattern. type showResultJSON struct { - ID string `json:"id"` - ParentID *string `json:"parent_id,omitempty"` - Title string `json:"title"` - Description string `json:"description"` - Status string `json:"status"` - Priority string `json:"priority"` - Kind string `json:"kind"` - Assignee string `json:"assignee"` - Labels []string `json:"labels"` - Files []string `json:"files"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - SubIssues []*model.Issue `json:"sub_issues"` - Relations []model.Relation `json:"relations"` - Comments []*model.Comment `json:"comments"` - Activity []model.Activity `json:"activity"` + ID string `json:"id"` + ParentID *string `json:"parent_id,omitempty"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + Priority string `json:"priority"` + Kind string `json:"kind"` + Assignee string `json:"assignee"` + Labels []string `json:"labels"` + Files []string `json:"files"` + Docs []model.DocRef `json:"docs"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + SubIssues []*model.Issue `json:"sub_issues"` + Relations []model.Relation `json:"relations"` + LinkedProposals []string `json:"linked_proposals"` + Comments []*model.Comment `json:"comments"` + Activity []model.Activity `json:"activity"` } func (s showResult) MarshalJSON() ([]byte, error) { @@ -61,6 +64,10 @@ func (s showResult) MarshalJSON() ([]byte, error) { if files == nil { files = []string{} } + docs := i.Docs + if docs == nil { + docs = []model.DocRef{} + } subIssues := s.SubIssues if subIssues == nil { subIssues = []*model.Issue{} @@ -69,6 +76,10 @@ func (s showResult) MarshalJSON() ([]byte, error) { if relations == nil { relations = []model.Relation{} } + linkedProposals := make([]string, 0, len(s.LinkedProposals)) + for _, p := range s.LinkedProposals { + linkedProposals = append(linkedProposals, model.FormatProposalID(p.ID)) + } comments := s.Comments if comments == nil { comments = []*model.Comment{} @@ -79,21 +90,23 @@ func (s showResult) MarshalJSON() ([]byte, error) { } j := showResultJSON{ - ID: model.FormatID(i.ID), - Title: i.Title, - Description: i.Description, - Status: string(i.Status), - Priority: string(i.Priority), - Kind: string(i.Kind), - Assignee: i.Assignee, - Labels: labels, - Files: files, - CreatedAt: i.CreatedAt.UTC().Format(time.RFC3339), - UpdatedAt: i.UpdatedAt.UTC().Format(time.RFC3339), - SubIssues: subIssues, - Relations: relations, - Comments: comments, - Activity: activity, + ID: model.FormatID(i.ID), + Title: i.Title, + Description: i.Description, + Status: string(i.Status), + Priority: string(i.Priority), + Kind: string(i.Kind), + Assignee: i.Assignee, + Labels: labels, + Files: files, + Docs: docs, + CreatedAt: i.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: i.UpdatedAt.UTC().Format(time.RFC3339), + SubIssues: subIssues, + Relations: relations, + LinkedProposals: linkedProposals, + Comments: comments, + Activity: activity, } if i.ParentID != nil { @@ -159,6 +172,10 @@ func runIssueShow(cmd *cobra.Command, args []string, w *output.Writer) error { return cmdErr(fmt.Errorf("fetching files: %w", err), output.ErrGeneral) } + if err := db.HydrateDocs(conn, []*model.Issue{issue}); err != nil { + return cmdErr(fmt.Errorf("fetching linked docs: %w", err), output.ErrGeneral) + } + subIssues, err := db.GetSubIssues(conn, id) if err != nil { return cmdErr(fmt.Errorf("fetching sub-issues: %w", err), output.ErrGeneral) @@ -169,6 +186,11 @@ func runIssueShow(cmd *cobra.Command, args []string, w *output.Writer) error { return cmdErr(fmt.Errorf("fetching relations: %w", err), output.ErrGeneral) } + linkedProposals, err := db.GetIssueProposals(conn, id) + if err != nil { + return cmdErr(fmt.Errorf("fetching linked proposals: %w", err), output.ErrGeneral) + } + comments, err := db.ListComments(conn, id) if err != nil { return cmdErr(fmt.Errorf("fetching comments: %w", err), output.ErrGeneral) @@ -180,16 +202,17 @@ func runIssueShow(cmd *cobra.Command, args []string, w *output.Writer) error { } result := showResult{ - Issue: issue, - SubIssues: subIssues, - Relations: relations, - Comments: comments, - Activity: activity, + Issue: issue, + SubIssues: subIssues, + Relations: relations, + LinkedProposals: linkedProposals, + Comments: comments, + Activity: activity, } var message string if !w.JSONMode { - message = render.RenderDetail(issue, subIssues, relations, comments, activity) + message = render.RenderDetail(issue, subIssues, relations, linkedProposals, comments, activity) } w.Success(result, message) diff --git a/internal/cli/issue_show_test.go b/internal/cli/issue_show_test.go new file mode 100644 index 0000000..5c77c73 --- /dev/null +++ b/internal/cli/issue_show_test.go @@ -0,0 +1,355 @@ +package cli + +import ( + "database/sql" + "encoding/json" + "strings" + "testing" + + "github.com/ALT-F4-LLC/docket/internal/db" + "github.com/ALT-F4-LLC/docket/internal/model" +) + +func TestIssueShow_RendersLinkedDocsSection(t *testing.T) { + t.Setenv("TERM", "xterm-256color") + conn := newTestDB(t) + issueID := createIssue(t, conn, "issue with docs", model.StatusTodo, model.PriorityHigh) + docB := createDoc(t, conn, "Beta TDD", "tdd", "approved") + docA := createDoc(t, conn, "Alpha UX", "ux", "draft") + linkDocIssue(t, conn, docB, issueID) + linkDocIssue(t, conn, docA, issueID) + + cmd := cmdWithDB(conn) + w, buf := bufWriter(false) + if err := runIssueShow(cmd, []string{model.FormatID(issueID)}, w); err != nil { + t.Fatalf("runIssueShow: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "Linked Docs") { + t.Fatalf("output missing Linked Docs header:\n%s", out) + } + if !strings.Contains(out, "▸") { + t.Errorf("styled output missing ▸ prefix:\n%s", out) + } + for _, want := range []string{"DOC-1", "DOC-2", "tdd", "ux", "approved", "draft", "Beta TDD", "Alpha UX"} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } + if strings.Index(out, "DOC-1") > strings.Index(out, "DOC-2") { + t.Errorf("docs not ordered by id ascending:\n%s", out) + } +} + +func TestIssueShow_RendersLinkedDocsSectionPlain(t *testing.T) { + t.Setenv("NO_COLOR", "1") + conn := newTestDB(t) + issueID := createIssue(t, conn, "issue with docs", model.StatusTodo, model.PriorityHigh) + doc := createDoc(t, conn, "Docket Doc CLI", "tdd", "approved") + linkDocIssue(t, conn, doc, issueID) + + cmd := cmdWithDB(conn) + w, buf := bufWriter(false) + if err := runIssueShow(cmd, []string{model.FormatID(issueID)}, w); err != nil { + t.Fatalf("runIssueShow: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "Linked Docs") { + t.Fatalf("plain output missing Linked Docs header:\n%s", out) + } + if !strings.Contains(out, " > DOC-1 tdd approved Docket Doc CLI") { + t.Errorf("plain output missing expected doc line:\n%s", out) + } + if strings.Contains(out, "▸") { + t.Errorf("plain output should not contain ▸:\n%s", out) + } +} + +func TestIssueShow_OmitsLinkedDocsWhenEmpty(t *testing.T) { + for _, tc := range []struct { + name string + noColor bool + }{ + {"styled", false}, + {"plain", true}, + } { + t.Run(tc.name, func(t *testing.T) { + if tc.noColor { + t.Setenv("NO_COLOR", "1") + } else { + t.Setenv("TERM", "xterm-256color") + } + conn := newTestDB(t) + issueID := createIssue(t, conn, "no docs", model.StatusTodo, model.PriorityHigh) + + cmd := cmdWithDB(conn) + w, buf := bufWriter(false) + if err := runIssueShow(cmd, []string{model.FormatID(issueID)}, w); err != nil { + t.Fatalf("runIssueShow: %v", err) + } + if strings.Contains(buf.String(), "Linked Docs") { + t.Errorf("empty issue should omit Linked Docs section:\n%s", buf.String()) + } + }) + } +} + +func TestIssueShowJSON_DocsArrayShapeAndOrder(t *testing.T) { + conn := newTestDB(t) + issueID := createIssue(t, conn, "issue", model.StatusTodo, model.PriorityHigh) + docB := createDoc(t, conn, "Beta", "adr", "accepted") + docA := createDoc(t, conn, "Alpha", "tdd", "approved") + linkDocIssue(t, conn, docB, issueID) + linkDocIssue(t, conn, docA, issueID) + + cmd := cmdWithDB(conn) + w, buf := bufWriter(true) + if err := runIssueShow(cmd, []string{model.FormatID(issueID)}, w); err != nil { + t.Fatalf("runIssueShow: %v", err) + } + + var env struct { + Data struct { + Docs []struct { + ID string `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + Status string `json:"status"` + } `json:"docs"` + } `json:"data"` + } + if err := json.Unmarshal(buf.Bytes(), &env); err != nil { + t.Fatalf("unmarshal: %v\n%s", err, buf.String()) + } + docs := env.Data.Docs + if len(docs) != 2 { + t.Fatalf("len(docs) = %d, want 2:\n%s", len(docs), buf.String()) + } + if docs[0].ID != "DOC-1" || docs[1].ID != "DOC-2" { + t.Errorf("docs not ordered by id asc: got %s, %s", docs[0].ID, docs[1].ID) + } + if docs[0].Type != "adr" || docs[0].Title != "Beta" || docs[0].Status != "accepted" { + t.Errorf("doc[0] shape wrong: %+v", docs[0]) + } +} + +func createProposal(t *testing.T, conn *sql.DB, description, status string) int { + t.Helper() + id, err := db.CreateProposal(conn, &model.Proposal{ + Description: description, + Criticality: model.CriticalityMedium, + Status: model.ProposalStatus(status), + RequiredVoters: 1, + Threshold: 0.67, + }) + if err != nil { + t.Fatalf("CreateProposal(%q): %v", description, err) + } + return id +} + +func linkProposalIssue(t *testing.T, conn *sql.DB, proposalID, issueID int) { + t.Helper() + if err := db.LinkProposalIssue(conn, proposalID, issueID); err != nil { + t.Fatalf("LinkProposalIssue(%d,%d): %v", proposalID, issueID, err) + } +} + +func TestIssueShow_LinksLinkedProposals(t *testing.T) { + t.Setenv("TERM", "xterm-256color") + conn := newTestDB(t) + issueID := createIssue(t, conn, "issue with proposals", model.StatusTodo, model.PriorityHigh) + p1 := createProposal(t, conn, "Adopt new schema", string(model.ProposalStatusOpen)) + p2 := createProposal(t, conn, "Deprecate old API", string(model.ProposalStatusApproved)) + linkProposalIssue(t, conn, p2, issueID) + linkProposalIssue(t, conn, p1, issueID) + + cmd := cmdWithDB(conn) + w, buf := bufWriter(false) + if err := runIssueShow(cmd, []string{model.FormatID(issueID)}, w); err != nil { + t.Fatalf("runIssueShow: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "Linked Proposals") { + t.Fatalf("output missing Linked Proposals header:\n%s", out) + } + if !strings.Contains(out, "▸") { + t.Errorf("styled output missing ▸ prefix:\n%s", out) + } + for _, want := range []string{"DKT-V1", "DKT-V2", "open", "approved", "Adopt new schema", "Deprecate old API"} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } + if strings.Index(out, "DKT-V1") > strings.Index(out, "DKT-V2") { + t.Errorf("proposals not ordered by id ascending:\n%s", out) + } +} + +func TestIssueShow_LinksLinkedProposalsPlain(t *testing.T) { + t.Setenv("NO_COLOR", "1") + conn := newTestDB(t) + issueID := createIssue(t, conn, "issue with proposals", model.StatusTodo, model.PriorityHigh) + pid := createProposal(t, conn, "Adopt new schema", string(model.ProposalStatusOpen)) + linkProposalIssue(t, conn, pid, issueID) + + cmd := cmdWithDB(conn) + w, buf := bufWriter(false) + if err := runIssueShow(cmd, []string{model.FormatID(issueID)}, w); err != nil { + t.Fatalf("runIssueShow: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "Linked Proposals") { + t.Fatalf("plain output missing Linked Proposals header:\n%s", out) + } + if !strings.Contains(out, " > DKT-V1 open Adopt new schema") { + t.Errorf("plain output missing expected proposal line:\n%s", out) + } + if strings.Contains(out, "▸") { + t.Errorf("plain output should not contain ▸:\n%s", out) + } +} + +func TestIssueShow_LinkedProposalsDescriptionTruncated(t *testing.T) { + t.Setenv("NO_COLOR", "1") + conn := newTestDB(t) + issueID := createIssue(t, conn, "issue", model.StatusTodo, model.PriorityHigh) + longDesc := "This proposal description is far longer than forty characters and must be truncated" + pid := createProposal(t, conn, longDesc, string(model.ProposalStatusOpen)) + linkProposalIssue(t, conn, pid, issueID) + + cmd := cmdWithDB(conn) + w, buf := bufWriter(false) + if err := runIssueShow(cmd, []string{model.FormatID(issueID)}, w); err != nil { + t.Fatalf("runIssueShow: %v", err) + } + + out := buf.String() + if strings.Contains(out, longDesc) { + t.Errorf("long description should be truncated, got full text:\n%s", out) + } + if !strings.Contains(out, "This proposal description is far long...") { + t.Errorf("expected truncated description with ellipsis:\n%s", out) + } +} + +func TestIssueShow_OmitsLinkedProposalsWhenEmpty(t *testing.T) { + for _, tc := range []struct { + name string + noColor bool + }{ + {"styled", false}, + {"plain", true}, + } { + t.Run(tc.name, func(t *testing.T) { + if tc.noColor { + t.Setenv("NO_COLOR", "1") + } else { + t.Setenv("TERM", "xterm-256color") + } + conn := newTestDB(t) + issueID := createIssue(t, conn, "no proposals", model.StatusTodo, model.PriorityHigh) + + cmd := cmdWithDB(conn) + w, buf := bufWriter(false) + if err := runIssueShow(cmd, []string{model.FormatID(issueID)}, w); err != nil { + t.Fatalf("runIssueShow: %v", err) + } + if strings.Contains(buf.String(), "Linked Proposals") { + t.Errorf("empty issue should omit Linked Proposals section:\n%s", buf.String()) + } + }) + } +} + +func TestIssueShowJSON_LinkedProposalsArrayShapeAndOrder(t *testing.T) { + conn := newTestDB(t) + issueID := createIssue(t, conn, "issue", model.StatusTodo, model.PriorityHigh) + p1 := createProposal(t, conn, "Adopt new schema", string(model.ProposalStatusOpen)) + p2 := createProposal(t, conn, "Deprecate old API", string(model.ProposalStatusApproved)) + linkProposalIssue(t, conn, p2, issueID) + linkProposalIssue(t, conn, p1, issueID) + + cmd := cmdWithDB(conn) + w, buf := bufWriter(true) + if err := runIssueShow(cmd, []string{model.FormatID(issueID)}, w); err != nil { + t.Fatalf("runIssueShow: %v", err) + } + + var env struct { + Data struct { + LinkedProposals []string `json:"linked_proposals"` + } `json:"data"` + } + if err := json.Unmarshal(buf.Bytes(), &env); err != nil { + t.Fatalf("unmarshal: %v\n%s", err, buf.String()) + } + got := env.Data.LinkedProposals + want := []string{"DKT-V1", "DKT-V2"} + if len(got) != len(want) { + t.Fatalf("len(linked_proposals) = %d, want %d:\n%s", len(got), len(want), buf.String()) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("linked_proposals[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestIssueShowJSON_LinkedProposalsEmptyIsArray(t *testing.T) { + conn := newTestDB(t) + issueID := createIssue(t, conn, "issue", model.StatusTodo, model.PriorityHigh) + + cmd := cmdWithDB(conn) + w, buf := bufWriter(true) + if err := runIssueShow(cmd, []string{model.FormatID(issueID)}, w); err != nil { + t.Fatalf("runIssueShow: %v", err) + } + + var raw map[string]json.RawMessage + if err := json.Unmarshal(buf.Bytes(), &raw); err != nil { + t.Fatalf("unmarshal envelope: %v", err) + } + var data map[string]json.RawMessage + if err := json.Unmarshal(raw["data"], &data); err != nil { + t.Fatalf("unmarshal data: %v", err) + } + proposalsRaw, ok := data["linked_proposals"] + if !ok { + t.Fatalf("linked_proposals key absent:\n%s", buf.String()) + } + if string(proposalsRaw) != "[]" { + t.Errorf("empty linked_proposals = %s, want []", proposalsRaw) + } +} + +func TestIssueShowJSON_DocsEmptyIsArray(t *testing.T) { + conn := newTestDB(t) + issueID := createIssue(t, conn, "issue", model.StatusTodo, model.PriorityHigh) + + cmd := cmdWithDB(conn) + w, buf := bufWriter(true) + if err := runIssueShow(cmd, []string{model.FormatID(issueID)}, w); err != nil { + t.Fatalf("runIssueShow: %v", err) + } + + var raw map[string]json.RawMessage + if err := json.Unmarshal(buf.Bytes(), &raw); err != nil { + t.Fatalf("unmarshal envelope: %v", err) + } + var data map[string]json.RawMessage + if err := json.Unmarshal(raw["data"], &data); err != nil { + t.Fatalf("unmarshal data: %v", err) + } + docsRaw, ok := data["docs"] + if !ok { + t.Fatalf("docs key absent:\n%s", buf.String()) + } + if string(docsRaw) != "[]" { + t.Errorf("empty docs = %s, want []", docsRaw) + } +} diff --git a/internal/cli/next.go b/internal/cli/next.go index d55fda8..74b275b 100644 --- a/internal/cli/next.go +++ b/internal/cli/next.go @@ -109,6 +109,10 @@ func runNext(cmd *cobra.Command, args []string, w *output.Writer) error { ready = ready[:limit] } + if err := db.HydrateDocs(conn, ready); err != nil { + return cmdErr(fmt.Errorf("fetching linked docs: %w", err), output.ErrGeneral) + } + result := nextResult{Issues: ready, Total: len(ready)} var message string diff --git a/internal/cli/next_test.go b/internal/cli/next_test.go new file mode 100644 index 0000000..5a28f90 --- /dev/null +++ b/internal/cli/next_test.go @@ -0,0 +1,182 @@ +package cli + +import ( + "database/sql" + "encoding/json" + "strings" + "testing" + + "github.com/ALT-F4-LLC/docket/internal/db" + "github.com/ALT-F4-LLC/docket/internal/model" + "github.com/spf13/cobra" +) + +func nextCmdWithDB(conn *sql.DB, limit int) *cobra.Command { + cmd := cmdWithDB(conn) + cmd.Flags().StringSlice("status", nil, "") + cmd.Flags().StringSlice("priority", nil, "") + cmd.Flags().StringSlice("label", nil, "") + cmd.Flags().StringSlice("type", nil, "") + cmd.Flags().Int("limit", limit, "") + return cmd +} + +type nextJSON struct { + Data struct { + Issues []struct { + ID string `json:"id"` + Files []string `json:"files"` + Docs []struct { + ID string `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + Status string `json:"status"` + } `json:"docs"` + } `json:"issues"` + Total int `json:"total"` + } `json:"data"` +} + +func runNextJSON(t *testing.T, conn *sql.DB, limit int) nextJSON { + t.Helper() + cmd := nextCmdWithDB(conn, limit) + w, buf := bufWriter(true) + if err := runNext(cmd, nil, w); err != nil { + t.Fatalf("runNext: %v", err) + } + var nj nextJSON + if err := json.Unmarshal(buf.Bytes(), &nj); err != nil { + t.Fatalf("unmarshal: %v\n%s", err, buf.String()) + } + return nj +} + +func createIssueWithFile(t *testing.T, conn *sql.DB, title, file string) int { + t.Helper() + id, err := db.CreateIssue(conn, &model.Issue{ + Title: title, + Status: model.StatusTodo, + Priority: model.PriorityHigh, + Kind: model.IssueKindFeature, + }, nil, []string{file}) + if err != nil { + t.Fatalf("CreateIssue(%q): %v", title, err) + } + return id +} + +func TestNextJSON_HydratesFilesAndDocs(t *testing.T) { + conn := newTestDB(t) + issueID := createIssueWithFile(t, conn, "ready", "internal/db/doc_links.go") + doc := createDoc(t, conn, "Docket Doc CLI", "tdd", "approved") + linkDocIssue(t, conn, doc, issueID) + + nj := runNextJSON(t, conn, 10) + if len(nj.Data.Issues) != 1 { + t.Fatalf("issues = %d, want 1", len(nj.Data.Issues)) + } + iss := nj.Data.Issues[0] + if len(iss.Files) != 1 || iss.Files[0] != "internal/db/doc_links.go" { + t.Errorf("files = %v, want [internal/db/doc_links.go]", iss.Files) + } + if len(iss.Docs) != 1 { + t.Fatalf("docs = %d, want 1", len(iss.Docs)) + } + if iss.Docs[0].ID != "DOC-1" || iss.Docs[0].Type != "tdd" || iss.Docs[0].Status != "approved" || iss.Docs[0].Title != "Docket Doc CLI" { + t.Errorf("doc shape wrong: %+v", iss.Docs[0]) + } +} + +func TestNextJSON_FilesAndDocsEmptyAreArrays(t *testing.T) { + conn := newTestDB(t) + createIssue(t, conn, "ready no context", model.StatusTodo, model.PriorityHigh) + + cmd := nextCmdWithDB(conn, 10) + w, buf := bufWriter(true) + if err := runNext(cmd, nil, w); err != nil { + t.Fatalf("runNext: %v", err) + } + + var env struct { + Data struct { + Issues []map[string]json.RawMessage `json:"issues"` + } `json:"data"` + } + if err := json.Unmarshal(buf.Bytes(), &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(env.Data.Issues) != 1 { + t.Fatalf("issues = %d, want 1", len(env.Data.Issues)) + } + for _, key := range []string{"files", "docs"} { + if got := string(env.Data.Issues[0][key]); got != "[]" { + t.Errorf("%s = %s, want []", key, got) + } + } +} + +func TestNextHumanTableUnchanged(t *testing.T) { + t.Setenv("NO_COLOR", "1") + + withContext := newTestDB(t) + a := createIssueWithFile(t, withContext, "Alpha", "internal/db/doc_links.go") + createIssue(t, withContext, "Beta", model.StatusTodo, model.PriorityMedium) + doc := createDoc(t, withContext, "Some Doc", "tdd", "approved") + linkDocIssue(t, withContext, doc, a) + + without := newTestDB(t) + createIssue(t, without, "Alpha", model.StatusTodo, model.PriorityHigh) + createIssue(t, without, "Beta", model.StatusTodo, model.PriorityMedium) + + gotWith := runNextHuman(t, withContext) + gotWithout := runNextHuman(t, without) + + if gotWith != gotWithout { + t.Errorf("next human table changed by linked docs/files.\n--- with context ---\n%q\n--- without ---\n%q", gotWith, gotWithout) + } + if strings.Contains(gotWith, "Some Doc") || strings.Contains(gotWith, "doc_links.go") { + t.Errorf("next human table leaked doc/file data:\n%s", gotWith) + } +} + +func runNextHuman(t *testing.T, conn *sql.DB) string { + t.Helper() + cmd := nextCmdWithDB(conn, 10) + w, buf := bufWriter(false) + if err := runNext(cmd, nil, w); err != nil { + t.Fatalf("runNext: %v", err) + } + return buf.String() +} + +func TestNext_HydratesPostLimitOnly(t *testing.T) { + conn := newTestDB(t) + first := createIssue(t, conn, "First", model.StatusTodo, model.PriorityHigh) + second := createIssue(t, conn, "Second", model.StatusTodo, model.PriorityLow) + + docFirst := createDoc(t, conn, "First Doc", "tdd", "approved") + docSecond := createDoc(t, conn, "Second Doc", "ux", "draft") + linkDocIssue(t, conn, docFirst, first) + linkDocIssue(t, conn, docSecond, second) + + nj := runNextJSON(t, conn, 1) + if len(nj.Data.Issues) != 1 { + t.Fatalf("issues = %d, want 1 (limit)", len(nj.Data.Issues)) + } + emitted := nj.Data.Issues[0] + if emitted.ID != model.FormatID(first) { + t.Fatalf("emitted = %s, want %s (priority order)", emitted.ID, model.FormatID(first)) + } + if len(emitted.Docs) != 1 || emitted.Docs[0].Title != "First Doc" { + t.Errorf("emitted issue not hydrated with its doc: %+v", emitted.Docs) + } + + cmd := nextCmdWithDB(conn, 1) + w, buf := bufWriter(true) + if err := runNext(cmd, nil, w); err != nil { + t.Fatalf("runNext: %v", err) + } + if strings.Contains(buf.String(), "Second Doc") { + t.Errorf("beyond-limit issue's doc leaked into output:\n%s", buf.String()) + } +} diff --git a/internal/cli/plan.go b/internal/cli/plan.go index 8980620..b0946d4 100644 --- a/internal/cli/plan.go +++ b/internal/cli/plan.go @@ -86,9 +86,8 @@ func runPlan(cmd *cobra.Command, args []string, w *output.Writer) error { return cmdErr(fmt.Errorf("listing issues: %w", err), output.ErrGeneral) } - // Hydrate file attachments so the planner can detect file collisions. - if err := db.HydrateFiles(conn, issues); err != nil { - return cmdErr(fmt.Errorf("hydrating files: %w", err), output.ErrGeneral) + if err := db.HydrateDocs(conn, issues); err != nil { + return cmdErr(fmt.Errorf("hydrating docs: %w", err), output.ErrGeneral) } // Fetch all directional relations. diff --git a/internal/cli/plan_test.go b/internal/cli/plan_test.go new file mode 100644 index 0000000..b5d9f20 --- /dev/null +++ b/internal/cli/plan_test.go @@ -0,0 +1,106 @@ +package cli + +import ( + "database/sql" + "encoding/json" + "testing" + + "github.com/ALT-F4-LLC/docket/internal/model" + "github.com/spf13/cobra" +) + +func planCmdWithDB(conn *sql.DB) *cobra.Command { + cmd := cmdWithDB(conn) + cmd.Flags().StringSlice("status", nil, "") + cmd.Flags().StringSlice("label", nil, "") + cmd.Flags().String("root", "", "") + return cmd +} + +type planJSON struct { + Data struct { + Phases []struct { + Phase int `json:"phase"` + Issues []struct { + ID string `json:"id"` + Files []string `json:"files"` + Docs []struct { + ID string `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + Status string `json:"status"` + } `json:"docs"` + } `json:"issues"` + } `json:"phases"` + TotalIssues int `json:"total_issues"` + } `json:"data"` +} + +func runPlanJSON(t *testing.T, conn *sql.DB) planJSON { + t.Helper() + cmd := planCmdWithDB(conn) + w, buf := bufWriter(true) + if err := runPlan(cmd, nil, w); err != nil { + t.Fatalf("runPlan: %v", err) + } + var pj planJSON + if err := json.Unmarshal(buf.Bytes(), &pj); err != nil { + t.Fatalf("unmarshal: %v\n%s", err, buf.String()) + } + return pj +} + +func TestPlanJSON_HydratesFilesAndDocs(t *testing.T) { + conn := newTestDB(t) + issueID := createIssueWithFile(t, conn, "ready", "internal/cli/plan.go") + doc := createDoc(t, conn, "Plan Doc", "tdd", "approved") + linkDocIssue(t, conn, doc, issueID) + + pj := runPlanJSON(t, conn) + if len(pj.Data.Phases) != 1 { + t.Fatalf("phases = %d, want 1", len(pj.Data.Phases)) + } + if len(pj.Data.Phases[0].Issues) != 1 { + t.Fatalf("issues = %d, want 1", len(pj.Data.Phases[0].Issues)) + } + iss := pj.Data.Phases[0].Issues[0] + if len(iss.Files) != 1 || iss.Files[0] != "internal/cli/plan.go" { + t.Errorf("files = %v, want [internal/cli/plan.go]", iss.Files) + } + if len(iss.Docs) != 1 { + t.Fatalf("docs = %d, want 1", len(iss.Docs)) + } + if iss.Docs[0].ID != "DOC-1" || iss.Docs[0].Type != "tdd" || iss.Docs[0].Status != "approved" || iss.Docs[0].Title != "Plan Doc" { + t.Errorf("doc shape wrong: %+v", iss.Docs[0]) + } +} + +func TestPlanJSON_FilesAndDocsEmptyAreArrays(t *testing.T) { + conn := newTestDB(t) + createIssue(t, conn, "no context", model.StatusTodo, model.PriorityHigh) + + cmd := planCmdWithDB(conn) + w, buf := bufWriter(true) + if err := runPlan(cmd, nil, w); err != nil { + t.Fatalf("runPlan: %v", err) + } + + var env struct { + Data struct { + Phases []struct { + Issues []map[string]json.RawMessage `json:"issues"` + } `json:"phases"` + } `json:"data"` + } + if err := json.Unmarshal(buf.Bytes(), &env); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(env.Data.Phases) != 1 || len(env.Data.Phases[0].Issues) != 1 { + t.Fatalf("expected 1 phase with 1 issue, got %+v", env.Data.Phases) + } + for _, key := range []string{"files", "docs"} { + if got := string(env.Data.Phases[0].Issues[0][key]); got != "[]" { + t.Errorf("%s = %s, want []", key, got) + } + } +} diff --git a/internal/cli/vote_show.go b/internal/cli/vote_show.go index 51794e4..d13c5e0 100644 --- a/internal/cli/vote_show.go +++ b/internal/cli/vote_show.go @@ -25,6 +25,7 @@ type voteShowResult struct { Proposal *model.Proposal `json:"-"` Votes []*model.Vote `json:"-"` LinkedIssues []int `json:"-"` + LinkedDocs []int `json:"-"` } // voteShowResultJSON is the wire format for vote show. @@ -46,6 +47,7 @@ type voteShowResultJSON struct { UpdatedAt string `json:"updated_at"` Votes []*model.Vote `json:"votes"` LinkedIssues []string `json:"linked_issues"` + LinkedDocs []string `json:"linked_docs"` } func (r voteShowResult) MarshalJSON() ([]byte, error) { @@ -61,6 +63,11 @@ func (r voteShowResult) MarshalJSON() ([]byte, error) { linkedIssues = append(linkedIssues, model.FormatID(id)) } + linkedDocs := make([]string, 0, len(r.LinkedDocs)) + for _, id := range r.LinkedDocs { + linkedDocs = append(linkedDocs, model.FormatDocID(id)) + } + domainTags := p.DomainTags if domainTags == nil { domainTags = []string{} @@ -88,6 +95,7 @@ func (r voteShowResult) MarshalJSON() ([]byte, error) { UpdatedAt: p.UpdatedAt.UTC().Format(time.RFC3339), Votes: votes, LinkedIssues: linkedIssues, + LinkedDocs: linkedDocs, } return json.Marshal(j) @@ -147,15 +155,21 @@ func runVoteShow(cmd *cobra.Command, args []string, w *output.Writer) error { return cmdErr(fmt.Errorf("fetching linked issues: %w", err), output.ErrGeneral) } + linkedDocs, err := db.GetProposalDocs(conn, id) + if err != nil { + return cmdErr(fmt.Errorf("fetching linked docs: %w", err), output.ErrGeneral) + } + result := voteShowResult{ Proposal: proposal, Votes: votes, LinkedIssues: linkedIssues, + LinkedDocs: linkedDocs, } var message string if !w.JSONMode { - message = render.RenderProposalDetail(proposal, votes, linkedIssues) + message = render.RenderProposalDetail(proposal, votes, linkedIssues, linkedDocs) } w.Success(result, message) diff --git a/internal/cli/vote_show_test.go b/internal/cli/vote_show_test.go new file mode 100644 index 0000000..04633e6 --- /dev/null +++ b/internal/cli/vote_show_test.go @@ -0,0 +1,130 @@ +package cli + +import ( + "database/sql" + "encoding/json" + "strings" + "testing" + + "github.com/ALT-F4-LLC/docket/internal/db" + "github.com/ALT-F4-LLC/docket/internal/model" +) + +func linkProposalDoc(t *testing.T, conn *sql.DB, proposalID, docID int) { + t.Helper() + if err := db.LinkProposalDoc(conn, proposalID, docID); err != nil { + t.Fatalf("LinkProposalDoc(%d,%d): %v", proposalID, docID, err) + } +} + +func TestVoteShowJSON_LinkedDocsArrayShapeAndOrder(t *testing.T) { + conn := newTestDB(t) + pid := createProposal(t, conn, "Ratify the TDD", string(model.ProposalStatusOpen)) + docB := createDoc(t, conn, "Beta", "adr", "accepted") + docA := createDoc(t, conn, "Alpha", "tdd", "approved") + linkProposalDoc(t, conn, pid, docB) + linkProposalDoc(t, conn, pid, docA) + + cmd := cmdWithDB(conn) + w, buf := bufWriter(true) + if err := runVoteShow(cmd, []string{model.FormatProposalID(pid)}, w); err != nil { + t.Fatalf("runVoteShow: %v", err) + } + + var env struct { + Data struct { + LinkedDocs []string `json:"linked_docs"` + } `json:"data"` + } + if err := json.Unmarshal(buf.Bytes(), &env); err != nil { + t.Fatalf("unmarshal: %v\n%s", err, buf.String()) + } + got := env.Data.LinkedDocs + want := []string{"DOC-1", "DOC-2"} + if len(got) != len(want) { + t.Fatalf("len(linked_docs) = %d, want %d:\n%s", len(got), len(want), buf.String()) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("linked_docs[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestVoteShowJSON_LinkedDocsEmptyIsArray(t *testing.T) { + conn := newTestDB(t) + pid := createProposal(t, conn, "No docs", string(model.ProposalStatusOpen)) + + cmd := cmdWithDB(conn) + w, buf := bufWriter(true) + if err := runVoteShow(cmd, []string{model.FormatProposalID(pid)}, w); err != nil { + t.Fatalf("runVoteShow: %v", err) + } + + var raw map[string]json.RawMessage + if err := json.Unmarshal(buf.Bytes(), &raw); err != nil { + t.Fatalf("unmarshal envelope: %v", err) + } + var data map[string]json.RawMessage + if err := json.Unmarshal(raw["data"], &data); err != nil { + t.Fatalf("unmarshal data: %v", err) + } + docsRaw, ok := data["linked_docs"] + if !ok { + t.Fatalf("linked_docs key absent:\n%s", buf.String()) + } + if string(docsRaw) != "[]" { + t.Errorf("empty linked_docs = %s, want []", docsRaw) + } +} + +func TestVoteShow_RendersLinkedDocsSection(t *testing.T) { + t.Setenv("TERM", "xterm-256color") + conn := newTestDB(t) + pid := createProposal(t, conn, "Ratify the TDD", string(model.ProposalStatusOpen)) + doc := createDoc(t, conn, "Docket Doc CLI", "tdd", "approved") + linkProposalDoc(t, conn, pid, doc) + + cmd := cmdWithDB(conn) + w, buf := bufWriter(false) + if err := runVoteShow(cmd, []string{model.FormatProposalID(pid)}, w); err != nil { + t.Fatalf("runVoteShow: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "Linked Docs") { + t.Fatalf("styled output missing Linked Docs header:\n%s", out) + } + if !strings.Contains(out, "DOC-1") { + t.Errorf("styled output missing DOC-1:\n%s", out) + } +} + +func TestVoteShow_OmitsLinkedDocsWhenEmpty(t *testing.T) { + for _, tc := range []struct { + name string + noColor bool + }{ + {"styled", false}, + {"plain", true}, + } { + t.Run(tc.name, func(t *testing.T) { + if tc.noColor { + t.Setenv("NO_COLOR", "1") + } else { + t.Setenv("TERM", "xterm-256color") + } + conn := newTestDB(t) + pid := createProposal(t, conn, "No docs", string(model.ProposalStatusOpen)) + + cmd := cmdWithDB(conn) + w, buf := bufWriter(false) + if err := runVoteShow(cmd, []string{model.FormatProposalID(pid)}, w); err != nil { + t.Fatalf("runVoteShow: %v", err) + } + if strings.Contains(buf.String(), "Linked Docs") { + t.Errorf("empty proposal should omit Linked Docs section:\n%s", buf.String()) + } + }) + } +} diff --git a/internal/cli/watch_commands.go b/internal/cli/watch_commands.go index 8523f98..2b0dbe3 100644 --- a/internal/cli/watch_commands.go +++ b/internal/cli/watch_commands.go @@ -5,19 +5,22 @@ import "github.com/spf13/cobra" // watchEligible is the set of command paths that support --watch mode. // Keys are Cobra CommandPath() values for unambiguous matching. var watchEligible = map[string]bool{ - "docket board": true, - "docket issue list": true, - "docket issue show": true, - "docket issue log": true, - "docket issue graph": true, - "docket issue comment list": true, - "docket next": true, - "docket plan": true, - "docket stats": true, - "docket config": true, - "docket vote list": true, - "docket vote show": true, - "docket vote result": true, + "docket board": true, + "docket issue list": true, + "docket issue show": true, + "docket issue log": true, + "docket issue graph": true, + "docket issue comment list": true, + "docket doc list": true, + "docket doc show": true, + "docket doc comment list": true, + "docket next": true, + "docket plan": true, + "docket stats": true, + "docket config": true, + "docket vote list": true, + "docket vote show": true, + "docket vote result": true, } func isWatchEligible(cmd *cobra.Command) bool { diff --git a/internal/db/activity.go b/internal/db/activity.go index 81fb4ca..d5d7013 100644 --- a/internal/db/activity.go +++ b/internal/db/activity.go @@ -72,3 +72,60 @@ func GetActivity(db *sql.DB, issueID int, limit int) ([]model.Activity, error) { return activities, nil } + +// ListAllActivity returns every activity_log row ordered by id ASC, for a full +// export. +func ListAllActivity(db *sql.DB) ([]*model.Activity, error) { + rows, err := db.Query( + `SELECT id, issue_id, field_changed, old_value, new_value, changed_by, created_at + FROM activity_log ORDER BY id ASC`, + ) + if err != nil { + return nil, fmt.Errorf("querying all activity: %w", err) + } + defer rows.Close() + + var activities []*model.Activity + for rows.Next() { + var a model.Activity + var oldVal, newVal, changedBy sql.NullString + var createdAt string + if err := rows.Scan(&a.ID, &a.IssueID, &a.FieldChanged, &oldVal, &newVal, &changedBy, &createdAt); err != nil { + return nil, fmt.Errorf("scanning activity row: %w", err) + } + a.OldValue = oldVal.String + a.NewValue = newVal.String + a.ChangedBy = changedBy.String + + t, err := time.Parse(time.RFC3339, createdAt) + if err != nil { + return nil, fmt.Errorf("parsing activity created_at: %w", err) + } + a.CreatedAt = t + + activities = append(activities, &a) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating activity rows: %w", err) + } + + return activities, nil +} + +// InsertActivityWithID inserts an activity_log row with a caller-supplied ID, +// skipping if the ID already exists. Must be called within an existing +// transaction. Returns true if inserted. Mirrors InsertIssueWithID. +func InsertActivityWithID(tx *sql.Tx, a *model.Activity) (bool, error) { + res, err := tx.Exec( + `INSERT OR IGNORE INTO activity_log + (id, issue_id, field_changed, old_value, new_value, changed_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + a.ID, a.IssueID, a.FieldChanged, a.OldValue, a.NewValue, a.ChangedBy, + a.CreatedAt.UTC().Format(time.RFC3339), + ) + if err != nil { + return false, fmt.Errorf("inserting activity with id %d: %w", a.ID, err) + } + n, _ := res.RowsAffected() + return n > 0, nil +} diff --git a/internal/db/db_test.go b/internal/db/db_test.go index f3dfe0e..3f27165 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -181,7 +181,6 @@ func TestMigrateNoOpAtLatestVersion(t *testing.T) { t.Fatalf("Initialize failed: %v", err) } - // Migrate applies pending migrations (v1 -> v2). if err := Migrate(db); err != nil { t.Fatalf("Migrate failed: %v", err) } @@ -190,16 +189,205 @@ func TestMigrateNoOpAtLatestVersion(t *testing.T) { if err != nil { t.Fatalf("SchemaVersion failed: %v", err) } - if v != 3 { - t.Errorf("schema_version = %d after Migrate, want 3", v) + if v != currentSchemaVersion { + t.Errorf("schema_version = %d after Migrate, want %d", v, currentSchemaVersion) } - // Second Migrate should be a no-op at version 3. if err := Migrate(db); err != nil { t.Fatalf("second Migrate failed: %v", err) } } +// docV4Tables lists the five tables that v3→v4 must create. +var docV4Tables = []string{ + "docs", + "doc_revisions", + "doc_comments", + "doc_issue_links", + "proposal_docs", +} + +func assertTableExists(t *testing.T, db *sql.DB, name string) { + t.Helper() + var got string + err := db.QueryRow( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", name, + ).Scan(&got) + if err != nil { + t.Errorf("table %q not found: %v", name, err) + } +} + +func TestMigrateV3ToV4_CleanDB(t *testing.T) { + db := mustOpen(t) + + if err := Initialize(db); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + if err := Migrate(db); err != nil { + t.Fatalf("Migrate failed: %v", err) + } + + v, err := SchemaVersion(db) + if err != nil { + t.Fatalf("SchemaVersion failed: %v", err) + } + if v != 4 { + t.Errorf("schema_version = %d, want 4", v) + } + + for _, tbl := range docV4Tables { + assertTableExists(t, db, tbl) + } +} + +func TestMigrateV3ToV4_FromExistingV3DB(t *testing.T) { + db := mustOpen(t) + + if err := Initialize(db); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + // Bring DB up to v3 explicitly, then stamp schema_version=3 so Migrate's + // next invocation will only run v3→v4 (simulates a real upgrade path). + tx, err := db.Begin() + if err != nil { + t.Fatalf("Begin failed: %v", err) + } + if err := migrateV1ToV2(tx); err != nil { + t.Fatalf("migrateV1ToV2 failed: %v", err) + } + if err := migrateV2ToV3(tx); err != nil { + t.Fatalf("migrateV2ToV3 failed: %v", err) + } + if _, err := tx.Exec(`UPDATE meta SET value = '3' WHERE key = 'schema_version'`); err != nil { + t.Fatalf("stamping v3 failed: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("Commit failed: %v", err) + } + + now := time.Now().UTC().Format(time.RFC3339) + res, err := db.Exec( + "INSERT INTO issues (title, status, priority, kind, created_at, updated_at) VALUES ('preexisting', 'backlog', 'none', 'task', ?, ?)", + now, now, + ) + if err != nil { + t.Fatalf("inserting pre-v4 issue failed: %v", err) + } + preID, _ := res.LastInsertId() + + if err := Migrate(db); err != nil { + t.Fatalf("v3→v4 Migrate failed: %v", err) + } + + v, err := SchemaVersion(db) + if err != nil { + t.Fatalf("SchemaVersion failed: %v", err) + } + if v != 4 { + t.Errorf("schema_version = %d after v3→v4 Migrate, want 4", v) + } + for _, tbl := range docV4Tables { + assertTableExists(t, db, tbl) + } + + var title string + if err := db.QueryRow("SELECT title FROM issues WHERE id = ?", preID).Scan(&title); err != nil { + t.Fatalf("pre-existing issue row lost after migrate: %v", err) + } + if title != "preexisting" { + t.Errorf("pre-existing issue title = %q, want %q", title, "preexisting") + } +} + +func TestMigrateV3ToV4_Idempotent(t *testing.T) { + db := mustOpen(t) + + if err := Initialize(db); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + if err := Migrate(db); err != nil { + t.Fatalf("first Migrate failed: %v", err) + } + if err := Migrate(db); err != nil { + t.Fatalf("second Migrate failed: %v", err) + } + + v, err := SchemaVersion(db) + if err != nil { + t.Fatalf("SchemaVersion failed: %v", err) + } + if v != 4 { + t.Errorf("schema_version = %d after two Migrates, want 4", v) + } + for _, tbl := range docV4Tables { + assertTableExists(t, db, tbl) + } +} + +func TestMigrateV3ToV4_BuggyStampDefensive(t *testing.T) { + db := mustOpen(t) + + if err := Initialize(db); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + // Bring DB through v2 and v3 (proposals must exist so the v2 defensive + // guard does not also fire), but skip v4 DDL and forge schema_version=4. + tx, err := db.Begin() + if err != nil { + t.Fatalf("Begin failed: %v", err) + } + if err := migrateV1ToV2(tx); err != nil { + t.Fatalf("migrateV1ToV2 failed: %v", err) + } + if err := migrateV2ToV3(tx); err != nil { + t.Fatalf("migrateV2ToV3 failed: %v", err) + } + if _, err := tx.Exec(`UPDATE meta SET value = '4' WHERE key = 'schema_version'`); err != nil { + t.Fatalf("buggy v4 stamp failed: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("Commit failed: %v", err) + } + + var hasDocs bool + if err := db.QueryRow( + `SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='docs')`, + ).Scan(&hasDocs); err != nil { + t.Fatalf("checking docs presence failed: %v", err) + } + if hasDocs { + t.Fatal("precondition violated: docs table exists before defensive Migrate") + } + + if err := Migrate(db); err != nil { + t.Fatalf("defensive Migrate failed: %v", err) + } + + for _, tbl := range docV4Tables { + assertTableExists(t, db, tbl) + } + + v, err := SchemaVersion(db) + if err != nil { + t.Fatalf("SchemaVersion failed: %v", err) + } + if v != 4 { + t.Errorf("schema_version = %d after defensive Migrate, want 4", v) + } +} + +func TestDB_PinnedToSingleConnection(t *testing.T) { + db := mustOpen(t) + + if got := db.Stats().MaxOpenConnections; got != 1 { + t.Errorf("MaxOpenConnections = %d, want 1 "+ + "(load-bearing for the single-writer invariant — see TDD §5.4)", got) + } +} + func TestIssueRelationsUniqueConstraint(t *testing.T) { db := mustOpen(t) diff --git a/internal/db/doc_comments.go b/internal/db/doc_comments.go new file mode 100644 index 0000000..bdf2594 --- /dev/null +++ b/internal/db/doc_comments.go @@ -0,0 +1,171 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" + "time" + + "github.com/ALT-F4-LLC/docket/internal/model" +) + +// CreateDocComment inserts a comment on a doc and returns its ID. The doc +// existence check and insert run in a single transaction. Returns +// ErrNotFound if the doc does not exist. +func CreateDocComment(db *sql.DB, c *model.DocComment) (int, error) { + tx, err := db.Begin() + if err != nil { + return 0, fmt.Errorf("beginning transaction: %w", err) + } + defer tx.Rollback() + + var exists bool + if err := tx.QueryRow("SELECT EXISTS(SELECT 1 FROM docs WHERE id = ?)", c.DocID).Scan(&exists); err != nil { + return 0, fmt.Errorf("checking doc existence: %w", err) + } + if !exists { + return 0, ErrNotFound + } + + now := time.Now().UTC().Format(time.RFC3339) + + res, err := tx.Exec( + `INSERT INTO doc_comments (doc_id, body, author, created_at) + VALUES (?, ?, ?, ?)`, + c.DocID, c.Body, c.Author, now, + ) + if err != nil { + return 0, fmt.Errorf("inserting doc comment: %w", err) + } + + if _, err := tx.Exec(`UPDATE docs SET updated_at = ? WHERE id = ?`, now, c.DocID); err != nil { + return 0, fmt.Errorf("touching doc updated_at: %w", err) + } + + id64, err := res.LastInsertId() + if err != nil { + return 0, fmt.Errorf("getting last insert id: %w", err) + } + + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("committing transaction: %w", err) + } + + return int(id64), nil +} + +// ListDocComments returns all comments for a doc ordered by created_at ASC. +// Returns an empty slice (not nil) when the doc has no comments; returns +// ErrNotFound when the doc itself is missing. +func ListDocComments(db *sql.DB, docID int) ([]*model.DocComment, error) { + var exists bool + if err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM docs WHERE id = ?)", docID).Scan(&exists); err != nil { + return nil, fmt.Errorf("checking doc existence: %w", err) + } + if !exists { + return nil, ErrNotFound + } + + rows, err := db.Query( + `SELECT id, doc_id, body, author, created_at + FROM doc_comments WHERE doc_id = ? ORDER BY created_at ASC`, docID, + ) + if err != nil { + return nil, fmt.Errorf("querying doc comments: %w", err) + } + defer rows.Close() + + out := make([]*model.DocComment, 0) + for rows.Next() { + c, err := scanDocCommentFrom(rows) + if err != nil { + return nil, fmt.Errorf("scanning doc comment row: %w", err) + } + out = append(out, c) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating doc comment rows: %w", err) + } + return out, nil +} + +// GetDocComment returns a single doc comment by ID, or ErrNotFound. +func GetDocComment(db *sql.DB, id int) (*model.DocComment, error) { + row := db.QueryRow( + `SELECT id, doc_id, body, author, created_at + FROM doc_comments WHERE id = ?`, id, + ) + c, err := scanDocCommentFrom(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("scanning doc comment: %w", err) + } + return c, nil +} + +// InsertDocCommentWithID inserts a doc_comments row with a caller-supplied ID, +// skipping if the ID already exists. Returns true if inserted. Must be called +// within an existing transaction. Mirrors InsertCommentWithID. +func InsertDocCommentWithID(tx *sql.Tx, c *model.DocComment) (bool, error) { + res, err := tx.Exec( + `INSERT OR IGNORE INTO doc_comments (id, doc_id, body, author, created_at) + VALUES (?, ?, ?, ?, ?)`, + c.ID, c.DocID, c.Body, c.Author, + c.CreatedAt.UTC().Format(time.RFC3339), + ) + if err != nil { + return false, fmt.Errorf("inserting doc comment with id %d: %w", c.ID, err) + } + n, _ := res.RowsAffected() + return n > 0, nil +} + +// ListAllDocComments returns every doc_comments row ordered by id ASC, for a +// full export. +func ListAllDocComments(db *sql.DB) ([]*model.DocComment, error) { + rows, err := db.Query( + `SELECT id, doc_id, body, author, created_at + FROM doc_comments ORDER BY id ASC`, + ) + if err != nil { + return nil, fmt.Errorf("querying all doc comments: %w", err) + } + defer rows.Close() + + var comments []*model.DocComment + for rows.Next() { + c, err := scanDocCommentFrom(rows) + if err != nil { + return nil, fmt.Errorf("scanning doc comment row: %w", err) + } + comments = append(comments, c) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating doc comment rows: %w", err) + } + return comments, nil +} + +// scanDocCommentFrom scans a single doc_comments row from any scanner. Author +// is stored as sql.NullString and projected to a plain string per S5. +func scanDocCommentFrom(s scanner) (*model.DocComment, error) { + var c model.DocComment + var author sql.NullString + var createdAt string + + if err := s.Scan(&c.ID, &c.DocID, &c.Body, &author, &createdAt); err != nil { + return nil, err + } + + c.Author = author.String + + t, err := time.Parse(time.RFC3339, createdAt) + if err != nil { + return nil, fmt.Errorf("parsing created_at: %w", err) + } + c.CreatedAt = t + + return &c, nil +} diff --git a/internal/db/doc_comments_test.go b/internal/db/doc_comments_test.go new file mode 100644 index 0000000..35f202a --- /dev/null +++ b/internal/db/doc_comments_test.go @@ -0,0 +1,158 @@ +package db + +import ( + "errors" + "testing" + + "github.com/ALT-F4-LLC/docket/internal/model" +) + +func TestCreateDocComment(t *testing.T) { + db := mustInitAndMigrate(t) + docID := mustCreateDoc(t, db, "d", "tdd", "draft", "body") + + c := &model.DocComment{ + DocID: docID, + Body: "looks good", + Author: "reviewer", + } + id, err := CreateDocComment(db, c) + if err != nil { + t.Fatalf("CreateDocComment: %v", err) + } + if id <= 0 { + t.Fatalf("returned id = %d, want > 0", id) + } + + got, err := GetDocComment(db, id) + if err != nil { + t.Fatalf("GetDocComment: %v", err) + } + if got.DocID != docID { + t.Errorf("DocID = %d, want %d", got.DocID, docID) + } + if got.Body != "looks good" { + t.Errorf("Body = %q", got.Body) + } + if got.Author != "reviewer" { + t.Errorf("Author = %q", got.Author) + } + if got.CreatedAt.IsZero() { + t.Error("CreatedAt is zero") + } +} + +func TestCreateDocComment_DocNotFound(t *testing.T) { + db := mustInitAndMigrate(t) + _, err := CreateDocComment(db, &model.DocComment{DocID: 999, Body: "x", Author: "a"}) + if !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestListDocComments(t *testing.T) { + db := mustInitAndMigrate(t) + docID := mustCreateDoc(t, db, "d", "tdd", "draft", "b") + + for i, body := range []string{"a", "b", "c"} { + if _, err := CreateDocComment(db, &model.DocComment{ + DocID: docID, Body: body, Author: "u", + }); err != nil { + t.Fatalf("CreateDocComment %d: %v", i, err) + } + } + + got, err := ListDocComments(db, docID) + if err != nil { + t.Fatalf("ListDocComments: %v", err) + } + if len(got) != 3 { + t.Fatalf("len(got) = %d, want 3", len(got)) + } + // Ascending order by created_at means insertion order is preserved. + for i, want := range []string{"a", "b", "c"} { + if got[i].Body != want { + t.Errorf("got[%d].Body = %q, want %q", i, got[i].Body, want) + } + } +} + +func TestListDocComments_DocNotFound(t *testing.T) { + db := mustInitAndMigrate(t) + _, err := ListDocComments(db, 999) + if !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestGetDocComment_NotFound(t *testing.T) { + db := mustInitAndMigrate(t) + _, err := GetDocComment(db, 999) + if !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestCreateDocComment_TouchesDocUpdatedAt(t *testing.T) { + db := mustInitAndMigrate(t) + docID := mustCreateDoc(t, db, "d", "tdd", "draft", "body") + before, _ := GetDoc(db, docID) + + // Sleep is sufficient: RFC3339 has 1-second resolution. Use a body update + // path instead to deterministically tick the timestamp. + newBody := "v2" + if _, err := UpdateDoc(db, docID, DocUpdate{Body: &newBody, Author: "x"}); err != nil { + t.Fatalf("UpdateDoc: %v", err) + } + + if _, err := CreateDocComment(db, &model.DocComment{ + DocID: docID, Body: "c", Author: "u", + }); err != nil { + t.Fatalf("CreateDocComment: %v", err) + } + + after, _ := GetDoc(db, docID) + if !after.UpdatedAt.After(before.UpdatedAt) && !after.UpdatedAt.Equal(before.UpdatedAt) { + t.Errorf("docs.updated_at went backwards: before=%v after=%v", before.UpdatedAt, after.UpdatedAt) + } +} + +func TestInsertDocCommentWithID_RoundTrip(t *testing.T) { + db := mustInitAndMigrate(t) + docID := mustCreateDoc(t, db, "d", "tdd", "draft", "b") + + tx, err := db.Begin() + if err != nil { + t.Fatalf("Begin: %v", err) + } + + c := &model.DocComment{ID: 77, DocID: docID, Body: "imported", Author: "ops"} + inserted, err := InsertDocCommentWithID(tx, c) + if err != nil { + t.Fatalf("InsertDocCommentWithID: %v", err) + } + if !inserted { + t.Error("inserted = false, want true") + } + + // Second call with same ID — skipped. + inserted2, err := InsertDocCommentWithID(tx, c) + if err != nil { + t.Fatalf("InsertDocCommentWithID 2: %v", err) + } + if inserted2 { + t.Error("inserted2 = true, want false") + } + + if err := tx.Commit(); err != nil { + t.Fatalf("Commit: %v", err) + } + + got, err := GetDocComment(db, 77) + if err != nil { + t.Fatalf("GetDocComment(77): %v", err) + } + if got.Body != "imported" { + t.Errorf("Body = %q, want imported", got.Body) + } +} diff --git a/internal/db/doc_links.go b/internal/db/doc_links.go new file mode 100644 index 0000000..e92edef --- /dev/null +++ b/internal/db/doc_links.go @@ -0,0 +1,354 @@ +package db + +import ( + "database/sql" + "fmt" + "strings" + "time" + + "github.com/ALT-F4-LLC/docket/internal/model" +) + +// LinkDocIssue links a doc to an issue. Returns ErrNotFound if either side is +// missing; ErrConflict if the link already exists. +func LinkDocIssue(db *sql.DB, docID, issueID int) error { + if err := assertDocExists(db, docID); err != nil { + return err + } + if err := assertIssueExists(db, issueID); err != nil { + return err + } + + _, err := db.Exec( + `INSERT INTO doc_issue_links (doc_id, issue_id, created_at) VALUES (?, ?, ?)`, + docID, issueID, time.Now().UTC().Format(time.RFC3339), + ) + if err != nil { + if isUniqueOrPKConflict(err) { + return ErrConflict + } + return fmt.Errorf("linking doc to issue: %w", err) + } + return nil +} + +// UnlinkDocIssue removes a doc↔issue link. Returns ErrNotFound if no such +// link exists. +func UnlinkDocIssue(db *sql.DB, docID, issueID int) error { + res, err := db.Exec( + `DELETE FROM doc_issue_links WHERE doc_id = ? AND issue_id = ?`, + docID, issueID, + ) + if err != nil { + return fmt.Errorf("unlinking doc from issue: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("checking rows affected: %w", err) + } + if n == 0 { + return ErrNotFound + } + return nil +} + +// GetDocIssues returns issue IDs linked to a doc, ordered by issue_id ASC. +func GetDocIssues(db *sql.DB, docID int) ([]int, error) { + return queryLinkIDs(db, + `SELECT issue_id FROM doc_issue_links WHERE doc_id = ? ORDER BY issue_id ASC`, + docID, + ) +} + +// GetIssueDocs returns doc IDs linked to an issue, ordered by doc_id ASC. +func GetIssueDocs(db *sql.DB, issueID int) ([]int, error) { + return queryLinkIDs(db, + `SELECT doc_id FROM doc_issue_links WHERE issue_id = ? ORDER BY doc_id ASC`, + issueID, + ) +} + +func HydrateDocs(db *sql.DB, issues []*model.Issue) error { + if len(issues) == 0 { + return nil + } + + ids := make([]any, len(issues)) + issueMap := make(map[int]*model.Issue, len(issues)) + for i, issue := range issues { + ids[i] = issue.ID + issueMap[issue.ID] = issue + } + + placeholders := makePlaceholders(len(ids)) + query := fmt.Sprintf( + `SELECT l.issue_id, d.id, d.type, d.status, d.title + FROM doc_issue_links l + JOIN docs d ON d.id = l.doc_id + WHERE l.issue_id IN (%s) + ORDER BY d.id ASC`, placeholders, + ) + + rows, err := db.Query(query, ids...) + if err != nil { + return fmt.Errorf("querying docs: %w", err) + } + defer rows.Close() + + for rows.Next() { + var issueID int + var ref model.DocRef + if err := rows.Scan(&issueID, &ref.ID, &ref.Type, &ref.Status, &ref.Title); err != nil { + return fmt.Errorf("scanning doc: %w", err) + } + if issue, ok := issueMap[issueID]; ok { + issue.Docs = append(issue.Docs, ref) + } + } + return rows.Err() +} + +func HydrateLinkedIssues(db *sql.DB, docIDs []int) (map[int][]model.IssueRef, error) { + out := make(map[int][]model.IssueRef, len(docIDs)) + if len(docIDs) == 0 { + return out, nil + } + + ids := make([]any, len(docIDs)) + for i, id := range docIDs { + ids[i] = id + } + + placeholders := makePlaceholders(len(ids)) + query := fmt.Sprintf( + `SELECT l.doc_id, i.id, i.kind, i.status, i.title + FROM doc_issue_links l + JOIN issues i ON i.id = l.issue_id + WHERE l.doc_id IN (%s) + ORDER BY i.id ASC`, placeholders, + ) + + rows, err := db.Query(query, ids...) + if err != nil { + return nil, fmt.Errorf("querying linked issues: %w", err) + } + defer rows.Close() + + for rows.Next() { + var docID int + var ref model.IssueRef + if err := rows.Scan(&docID, &ref.ID, &ref.Kind, &ref.Status, &ref.Title); err != nil { + return nil, fmt.Errorf("scanning linked issue: %w", err) + } + out[docID] = append(out[docID], ref) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating linked issue rows: %w", err) + } + return out, nil +} + +// LinkProposalDoc links a proposal (vote) to a doc. Returns ErrNotFound if +// either side is missing; ErrConflict if the link already exists. +func LinkProposalDoc(db *sql.DB, proposalID, docID int) error { + if err := assertProposalExists(db, proposalID); err != nil { + return err + } + if err := assertDocExists(db, docID); err != nil { + return err + } + + _, err := db.Exec( + `INSERT INTO proposal_docs (proposal_id, doc_id, created_at) VALUES (?, ?, ?)`, + proposalID, docID, time.Now().UTC().Format(time.RFC3339), + ) + if err != nil { + if isUniqueOrPKConflict(err) { + return ErrConflict + } + return fmt.Errorf("linking proposal to doc: %w", err) + } + return nil +} + +// UnlinkProposalDoc removes a proposal↔doc link. Returns ErrNotFound if no +// such link exists. +func UnlinkProposalDoc(db *sql.DB, proposalID, docID int) error { + res, err := db.Exec( + `DELETE FROM proposal_docs WHERE proposal_id = ? AND doc_id = ?`, + proposalID, docID, + ) + if err != nil { + return fmt.Errorf("unlinking proposal from doc: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("checking rows affected: %w", err) + } + if n == 0 { + return ErrNotFound + } + return nil +} + +// GetProposalDocs returns doc IDs linked to a proposal, ordered by doc_id ASC. +func GetProposalDocs(db *sql.DB, proposalID int) ([]int, error) { + return queryLinkIDs(db, + `SELECT doc_id FROM proposal_docs WHERE proposal_id = ? ORDER BY doc_id ASC`, + proposalID, + ) +} + +// GetDocProposals returns proposal IDs linked to a doc, ordered by +// proposal_id ASC. +func GetDocProposals(db *sql.DB, docID int) ([]int, error) { + return queryLinkIDs(db, + `SELECT proposal_id FROM proposal_docs WHERE doc_id = ? ORDER BY proposal_id ASC`, + docID, + ) +} + +// InsertDocIssueLink inserts a doc_issue_links row, skipping on PK conflict. +// Used by export/import round-trip. Must be called within a transaction. +// Returns true if inserted. +func InsertDocIssueLink(tx *sql.Tx, docID, issueID int, createdAt string) (bool, error) { + res, err := tx.Exec( + `INSERT OR IGNORE INTO doc_issue_links (doc_id, issue_id, created_at) VALUES (?, ?, ?)`, + docID, issueID, createdAt, + ) + if err != nil { + return false, fmt.Errorf("inserting doc_issue_link (%d,%d): %w", docID, issueID, err) + } + n, _ := res.RowsAffected() + return n > 0, nil +} + +// InsertProposalDocLink inserts a proposal_docs row, skipping on PK conflict. +// Must be called within a transaction. Returns true if inserted. +func InsertProposalDocLink(tx *sql.Tx, proposalID, docID int, createdAt string) (bool, error) { + res, err := tx.Exec( + `INSERT OR IGNORE INTO proposal_docs (proposal_id, doc_id, created_at) VALUES (?, ?, ?)`, + proposalID, docID, createdAt, + ) + if err != nil { + return false, fmt.Errorf("inserting proposal_doc (%d,%d): %w", proposalID, docID, err) + } + n, _ := res.RowsAffected() + return n > 0, nil +} + +// ListAllDocIssueLinks returns every doc_issue_links row ordered by (doc_id, +// issue_id), for a full export. +func ListAllDocIssueLinks(db *sql.DB) ([]model.DocIssueLink, error) { + rows, err := db.Query( + `SELECT doc_id, issue_id, created_at + FROM doc_issue_links ORDER BY doc_id ASC, issue_id ASC`, + ) + if err != nil { + return nil, fmt.Errorf("querying all doc_issue_links: %w", err) + } + defer rows.Close() + + out := make([]model.DocIssueLink, 0) + for rows.Next() { + var l model.DocIssueLink + if err := rows.Scan(&l.DocID, &l.IssueID, &l.CreatedAt); err != nil { + return nil, fmt.Errorf("scanning doc_issue_link row: %w", err) + } + out = append(out, l) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating doc_issue_link rows: %w", err) + } + return out, nil +} + +// ListAllProposalDocs returns every proposal_docs row ordered by (proposal_id, +// doc_id), for a full export. +func ListAllProposalDocs(db *sql.DB) ([]model.ProposalDocLink, error) { + rows, err := db.Query( + `SELECT proposal_id, doc_id, created_at + FROM proposal_docs ORDER BY proposal_id ASC, doc_id ASC`, + ) + if err != nil { + return nil, fmt.Errorf("querying all proposal_docs: %w", err) + } + defer rows.Close() + + out := make([]model.ProposalDocLink, 0) + for rows.Next() { + var l model.ProposalDocLink + if err := rows.Scan(&l.ProposalID, &l.DocID, &l.CreatedAt); err != nil { + return nil, fmt.Errorf("scanning proposal_doc row: %w", err) + } + out = append(out, l) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating proposal_doc rows: %w", err) + } + return out, nil +} + +// --- helpers --- + +func assertDocExists(db *sql.DB, id int) error { + var exists bool + if err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM docs WHERE id = ?)", id).Scan(&exists); err != nil { + return fmt.Errorf("checking doc existence: %w", err) + } + if !exists { + return ErrNotFound + } + return nil +} + +func assertIssueExists(db *sql.DB, id int) error { + var exists bool + if err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM issues WHERE id = ?)", id).Scan(&exists); err != nil { + return fmt.Errorf("checking issue existence: %w", err) + } + if !exists { + return ErrNotFound + } + return nil +} + +func assertProposalExists(db *sql.DB, id int) error { + var exists bool + if err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM proposals WHERE id = ?)", id).Scan(&exists); err != nil { + return fmt.Errorf("checking proposal existence: %w", err) + } + if !exists { + return ErrNotFound + } + return nil +} + +func queryLinkIDs(db *sql.DB, query string, arg int) ([]int, error) { + rows, err := db.Query(query, arg) + if err != nil { + return nil, fmt.Errorf("querying link ids: %w", err) + } + defer rows.Close() + + var out []int + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scanning link id: %w", err) + } + out = append(out, id) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating link id rows: %w", err) + } + return out, nil +} + +func isUniqueOrPKConflict(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "UNIQUE constraint") || strings.Contains(msg, "PRIMARY KEY") +} diff --git a/internal/db/doc_links_test.go b/internal/db/doc_links_test.go new file mode 100644 index 0000000..f978ba8 --- /dev/null +++ b/internal/db/doc_links_test.go @@ -0,0 +1,425 @@ +package db + +import ( + "errors" + "testing" + + "github.com/ALT-F4-LLC/docket/internal/model" +) + +// --- Doc ↔ Issue links --- + +func TestLinkDocIssue(t *testing.T) { + db := mustInitAndMigrate(t) + docID := mustCreateDoc(t, db, "d", "tdd", "draft", "b") + issueID := createTestIssue(t, db, "i", model.StatusTodo, model.PriorityMedium) + + if err := LinkDocIssue(db, docID, issueID); err != nil { + t.Fatalf("LinkDocIssue: %v", err) + } + + ids, err := GetDocIssues(db, docID) + if err != nil { + t.Fatalf("GetDocIssues: %v", err) + } + if len(ids) != 1 || ids[0] != issueID { + t.Errorf("GetDocIssues = %v, want [%d]", ids, issueID) + } +} + +func TestGetIssueDocs(t *testing.T) { + db := mustInitAndMigrate(t) + docA := mustCreateDoc(t, db, "a", "tdd", "draft", "b") + docB := mustCreateDoc(t, db, "b", "tdd", "draft", "b") + issueID := createTestIssue(t, db, "i", model.StatusTodo, model.PriorityMedium) + + if err := LinkDocIssue(db, docA, issueID); err != nil { + t.Fatalf("LinkDocIssue A: %v", err) + } + if err := LinkDocIssue(db, docB, issueID); err != nil { + t.Fatalf("LinkDocIssue B: %v", err) + } + + ids, err := GetIssueDocs(db, issueID) + if err != nil { + t.Fatalf("GetIssueDocs: %v", err) + } + if len(ids) != 2 { + t.Fatalf("len(ids) = %d, want 2", len(ids)) + } + if ids[0] != docA || ids[1] != docB { + t.Errorf("GetIssueDocs = %v, want [%d, %d]", ids, docA, docB) + } +} + +func TestLinkDocIssue_DuplicateReturnsErrConflict(t *testing.T) { + db := mustInitAndMigrate(t) + docID := mustCreateDoc(t, db, "d", "tdd", "draft", "b") + issueID := createTestIssue(t, db, "i", model.StatusTodo, model.PriorityMedium) + + if err := LinkDocIssue(db, docID, issueID); err != nil { + t.Fatalf("first LinkDocIssue: %v", err) + } + err := LinkDocIssue(db, docID, issueID) + if !errors.Is(err, ErrConflict) { + t.Errorf("second LinkDocIssue err = %v, want ErrConflict", err) + } +} + +func TestLinkDocIssue_MissingDoc(t *testing.T) { + db := mustInitAndMigrate(t) + issueID := createTestIssue(t, db, "i", model.StatusTodo, model.PriorityMedium) + err := LinkDocIssue(db, 999, issueID) + if !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestLinkDocIssue_MissingIssue(t *testing.T) { + db := mustInitAndMigrate(t) + docID := mustCreateDoc(t, db, "d", "tdd", "draft", "b") + err := LinkDocIssue(db, docID, 999) + if !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestUnlinkDocIssue(t *testing.T) { + db := mustInitAndMigrate(t) + docID := mustCreateDoc(t, db, "d", "tdd", "draft", "b") + issueID := createTestIssue(t, db, "i", model.StatusTodo, model.PriorityMedium) + + if err := LinkDocIssue(db, docID, issueID); err != nil { + t.Fatalf("LinkDocIssue: %v", err) + } + if err := UnlinkDocIssue(db, docID, issueID); err != nil { + t.Fatalf("UnlinkDocIssue: %v", err) + } + + ids, _ := GetDocIssues(db, docID) + if len(ids) != 0 { + t.Errorf("after unlink, GetDocIssues = %v, want empty", ids) + } + + err := UnlinkDocIssue(db, docID, issueID) + if !errors.Is(err, ErrNotFound) { + t.Errorf("second UnlinkDocIssue err = %v, want ErrNotFound", err) + } +} + +func TestHydrateDocs_BatchNoNPlus1(t *testing.T) { + db := mustInitAndMigrate(t) + + docA := mustCreateDoc(t, db, "Title A", "tdd", "approved", "body") + docB := mustCreateDoc(t, db, "Title B", "adr", "draft", "body") + docC := mustCreateDoc(t, db, "Title C", "ux", "draft", "body") + + id1 := createTestIssue(t, db, "i1", model.StatusTodo, model.PriorityMedium) + id2 := createTestIssue(t, db, "i2", model.StatusTodo, model.PriorityMedium) + id3 := createTestIssue(t, db, "i3", model.StatusTodo, model.PriorityMedium) + + if err := LinkDocIssue(db, docB, id1); err != nil { + t.Fatalf("LinkDocIssue docB→id1: %v", err) + } + if err := LinkDocIssue(db, docA, id1); err != nil { + t.Fatalf("LinkDocIssue docA→id1: %v", err) + } + if err := LinkDocIssue(db, docC, id1); err != nil { + t.Fatalf("LinkDocIssue docC→id1: %v", err) + } + if err := LinkDocIssue(db, docB, id2); err != nil { + t.Fatalf("LinkDocIssue docB→id2: %v", err) + } + + issues := []*model.Issue{{ID: id1}, {ID: id2}, {ID: id3}} + if err := HydrateDocs(db, issues); err != nil { + t.Fatalf("HydrateDocs: %v", err) + } + + if len(issues[0].Docs) != 3 { + t.Fatalf("issue 1: expected 3 docs, got %d", len(issues[0].Docs)) + } + if issues[0].Docs[0].ID != docA || issues[0].Docs[1].ID != docB || issues[0].Docs[2].ID != docC { + t.Errorf("issue 1 docs not ordered by doc_id ASC: %+v", issues[0].Docs) + } + if issues[0].Docs[0].Type != "tdd" || issues[0].Docs[0].Status != "approved" || issues[0].Docs[0].Title != "Title A" { + t.Errorf("issue 1 doc A projection wrong: %+v", issues[0].Docs[0]) + } + + if len(issues[1].Docs) != 1 { + t.Fatalf("issue 2: expected 1 doc, got %d", len(issues[1].Docs)) + } + if issues[1].Docs[0].ID != docB { + t.Errorf("issue 2: expected doc %d, got %d", docB, issues[1].Docs[0].ID) + } + + if len(issues[2].Docs) != 0 { + t.Errorf("issue 3: expected 0 docs, got %d", len(issues[2].Docs)) + } +} + +func TestHydrateDocs_Empty(t *testing.T) { + db := mustInitAndMigrate(t) + + if err := HydrateDocs(db, nil); err != nil { + t.Fatalf("HydrateDocs nil: %v", err) + } + if err := HydrateDocs(db, []*model.Issue{}); err != nil { + t.Fatalf("HydrateDocs empty: %v", err) + } +} + +func TestHydrateLinkedIssues_BatchNoNPlus1(t *testing.T) { + db := mustInitAndMigrate(t) + + docA := mustCreateDoc(t, db, "Doc A", "tdd", "draft", "body") + docB := mustCreateDoc(t, db, "Doc B", "adr", "draft", "body") + + id1 := createTestIssue(t, db, "Alpha", model.StatusInProgress, model.PriorityMedium) + id2 := createTestIssue(t, db, "Beta", model.StatusTodo, model.PriorityMedium) + id3 := createTestIssue(t, db, "Gamma", model.StatusDone, model.PriorityMedium) + + if err := LinkDocIssue(db, docA, id2); err != nil { + t.Fatalf("LinkDocIssue docA→id2: %v", err) + } + if err := LinkDocIssue(db, docA, id1); err != nil { + t.Fatalf("LinkDocIssue docA→id1: %v", err) + } + if err := LinkDocIssue(db, docB, id3); err != nil { + t.Fatalf("LinkDocIssue docB→id3: %v", err) + } + + refs, err := HydrateLinkedIssues(db, []int{docA, docB}) + if err != nil { + t.Fatalf("HydrateLinkedIssues: %v", err) + } + + if len(refs[docA]) != 2 { + t.Fatalf("docA: expected 2 linked issues, got %d", len(refs[docA])) + } + if refs[docA][0].ID != id1 || refs[docA][1].ID != id2 { + t.Errorf("docA refs not ordered by issue_id ASC: %+v", refs[docA]) + } + if refs[docA][0].Kind != "task" || refs[docA][0].Status != string(model.StatusInProgress) || refs[docA][0].Title != "Alpha" { + t.Errorf("docA ref[0] projection wrong: %+v", refs[docA][0]) + } + + if len(refs[docB]) != 1 || refs[docB][0].ID != id3 { + t.Errorf("docB: expected [%d], got %+v", id3, refs[docB]) + } + if refs[docB][0].Status != string(model.StatusDone) || refs[docB][0].Title != "Gamma" { + t.Errorf("docB ref projection wrong: %+v", refs[docB][0]) + } +} + +func TestHydrateLinkedIssues_Empty(t *testing.T) { + db := mustInitAndMigrate(t) + + refs, err := HydrateLinkedIssues(db, nil) + if err != nil { + t.Fatalf("HydrateLinkedIssues nil: %v", err) + } + if len(refs) != 0 { + t.Errorf("expected empty map, got %+v", refs) + } + + docID := mustCreateDoc(t, db, "Unlinked", "tdd", "draft", "b") + refs, err = HydrateLinkedIssues(db, []int{docID}) + if err != nil { + t.Fatalf("HydrateLinkedIssues unlinked: %v", err) + } + if len(refs[docID]) != 0 { + t.Errorf("expected no refs for unlinked doc, got %+v", refs[docID]) + } +} + +// --- Doc ↔ Proposal links --- + +func TestLinkDocProposal(t *testing.T) { + db := mustInitAndMigrate(t) + docID := mustCreateDoc(t, db, "d", "tdd", "draft", "b") + pID, err := CreateProposal(db, &model.Proposal{ + Description: "p", Criticality: model.CriticalityHigh, + Status: model.ProposalStatusOpen, RequiredVoters: 1, Threshold: 0.5, + }) + if err != nil { + t.Fatalf("CreateProposal: %v", err) + } + + if err := LinkProposalDoc(db, pID, docID); err != nil { + t.Fatalf("LinkProposalDoc: %v", err) + } + + ids, err := GetProposalDocs(db, pID) + if err != nil { + t.Fatalf("GetProposalDocs: %v", err) + } + if len(ids) != 1 || ids[0] != docID { + t.Errorf("GetProposalDocs = %v, want [%d]", ids, docID) + } + + docProps, err := GetDocProposals(db, docID) + if err != nil { + t.Fatalf("GetDocProposals: %v", err) + } + if len(docProps) != 1 || docProps[0] != pID { + t.Errorf("GetDocProposals = %v, want [%d]", docProps, pID) + } +} + +func TestGetProposalDocs(t *testing.T) { + db := mustInitAndMigrate(t) + docA := mustCreateDoc(t, db, "a", "tdd", "draft", "b") + docB := mustCreateDoc(t, db, "b", "tdd", "draft", "b") + pID, _ := CreateProposal(db, &model.Proposal{ + Description: "p", Criticality: model.CriticalityHigh, + Status: model.ProposalStatusOpen, RequiredVoters: 1, Threshold: 0.5, + }) + + if err := LinkProposalDoc(db, pID, docA); err != nil { + t.Fatalf("LinkProposalDoc A: %v", err) + } + if err := LinkProposalDoc(db, pID, docB); err != nil { + t.Fatalf("LinkProposalDoc B: %v", err) + } + + ids, err := GetProposalDocs(db, pID) + if err != nil { + t.Fatalf("GetProposalDocs: %v", err) + } + if len(ids) != 2 || ids[0] != docA || ids[1] != docB { + t.Errorf("GetProposalDocs = %v, want [%d, %d]", ids, docA, docB) + } +} + +func TestLinkProposalDoc_DuplicateReturnsErrConflict(t *testing.T) { + db := mustInitAndMigrate(t) + docID := mustCreateDoc(t, db, "d", "tdd", "draft", "b") + pID, _ := CreateProposal(db, &model.Proposal{ + Description: "p", Criticality: model.CriticalityHigh, + Status: model.ProposalStatusOpen, RequiredVoters: 1, Threshold: 0.5, + }) + + if err := LinkProposalDoc(db, pID, docID); err != nil { + t.Fatalf("first LinkProposalDoc: %v", err) + } + err := LinkProposalDoc(db, pID, docID) + if !errors.Is(err, ErrConflict) { + t.Errorf("second LinkProposalDoc err = %v, want ErrConflict", err) + } +} + +func TestLinkProposalDoc_MissingProposal(t *testing.T) { + db := mustInitAndMigrate(t) + docID := mustCreateDoc(t, db, "d", "tdd", "draft", "b") + err := LinkProposalDoc(db, 999, docID) + if !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestLinkProposalDoc_MissingDoc(t *testing.T) { + db := mustInitAndMigrate(t) + pID, _ := CreateProposal(db, &model.Proposal{ + Description: "p", Criticality: model.CriticalityHigh, + Status: model.ProposalStatusOpen, RequiredVoters: 1, Threshold: 0.5, + }) + err := LinkProposalDoc(db, pID, 999) + if !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestUnlinkProposalDoc(t *testing.T) { + db := mustInitAndMigrate(t) + docID := mustCreateDoc(t, db, "d", "tdd", "draft", "b") + pID, _ := CreateProposal(db, &model.Proposal{ + Description: "p", Criticality: model.CriticalityHigh, + Status: model.ProposalStatusOpen, RequiredVoters: 1, Threshold: 0.5, + }) + + if err := LinkProposalDoc(db, pID, docID); err != nil { + t.Fatalf("LinkProposalDoc: %v", err) + } + if err := UnlinkProposalDoc(db, pID, docID); err != nil { + t.Fatalf("UnlinkProposalDoc: %v", err) + } + ids, _ := GetProposalDocs(db, pID) + if len(ids) != 0 { + t.Errorf("after unlink, GetProposalDocs = %v, want empty", ids) + } + + err := UnlinkProposalDoc(db, pID, docID) + if !errors.Is(err, ErrNotFound) { + t.Errorf("second UnlinkProposalDoc err = %v, want ErrNotFound", err) + } +} + +// --- Round-trip helpers --- + +func TestInsertDocIssueLink_RoundTrip(t *testing.T) { + db := mustInitAndMigrate(t) + docID := mustCreateDoc(t, db, "d", "tdd", "draft", "b") + issueID := createTestIssue(t, db, "i", model.StatusTodo, model.PriorityMedium) + + tx, err := db.Begin() + if err != nil { + t.Fatalf("Begin: %v", err) + } + + inserted, err := InsertDocIssueLink(tx, docID, issueID, "2026-05-26T16:00:00Z") + if err != nil { + t.Fatalf("InsertDocIssueLink: %v", err) + } + if !inserted { + t.Error("inserted = false, want true") + } + + inserted2, err := InsertDocIssueLink(tx, docID, issueID, "2026-05-26T16:00:00Z") + if err != nil { + t.Fatalf("InsertDocIssueLink 2: %v", err) + } + if inserted2 { + t.Error("inserted2 = true, want false") + } + + if err := tx.Commit(); err != nil { + t.Fatalf("Commit: %v", err) + } + + ids, _ := GetDocIssues(db, docID) + if len(ids) != 1 { + t.Errorf("len(ids) = %d, want 1", len(ids)) + } +} + +func TestInsertProposalDocLink_RoundTrip(t *testing.T) { + db := mustInitAndMigrate(t) + docID := mustCreateDoc(t, db, "d", "tdd", "draft", "b") + pID, _ := CreateProposal(db, &model.Proposal{ + Description: "p", Criticality: model.CriticalityHigh, + Status: model.ProposalStatusOpen, RequiredVoters: 1, Threshold: 0.5, + }) + + tx, err := db.Begin() + if err != nil { + t.Fatalf("Begin: %v", err) + } + + inserted, err := InsertProposalDocLink(tx, pID, docID, "2026-05-26T16:00:00Z") + if err != nil { + t.Fatalf("InsertProposalDocLink: %v", err) + } + if !inserted { + t.Error("inserted = false, want true") + } + + if err := tx.Commit(); err != nil { + t.Fatalf("Commit: %v", err) + } + + ids, _ := GetProposalDocs(db, pID) + if len(ids) != 1 || ids[0] != docID { + t.Errorf("GetProposalDocs = %v, want [%d]", ids, docID) + } +} diff --git a/internal/db/docs.go b/internal/db/docs.go new file mode 100644 index 0000000..b79cb76 --- /dev/null +++ b/internal/db/docs.go @@ -0,0 +1,729 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "github.com/ALT-F4-LLC/docket/internal/model" +) + +// ErrValidation is returned when an input fails a validation precondition that +// the DB layer enforces (e.g., negative revision number). CLI surfaces map this +// to output.ErrValidation. See TDD docket-doc-cli §6.4. +var ErrValidation = errors.New("validation") + +// Change-kind enum values for doc_revisions.change_kind. Free-form descriptor +// per TDD §5.1 / §5.4 C8. Combined edits join multiple kinds with "+", e.g. +// "status+body". +const ( + docChangeKindCreate = "create" + docChangeKindBody = "body" + docChangeKindStatus = "status" + docChangeKindTitle = "title" + docChangeKindType = "type" +) + +// docBodyEqual compares two doc bodies for equality after stripping trailing +// newlines. Mirrors the convention used by internal/cli/issue_edit.go:54 so +// that "no-op" edits (whitespace-only at end) do not append a revision. +// See TDD §4.4 / §5.4 C6. +func docBodyEqual(a, b string) bool { + return strings.TrimRight(a, "\n") == strings.TrimRight(b, "\n") +} + +// DocListOptions holds filtering, sorting, and pagination options for ListDocs +// and ListDocsWithCounts. Mirrors ListOptions/issues but with the doc-table +// columns. +type DocListOptions struct { + Types []string + Statuses []string + Author string + Sort string + SortDir string + Limit int + Offset int +} + +// validDocSortFields restricts which columns may appear in ORDER BY. +// WARNING: keys are interpolated directly into SQL. +var validDocSortFields = map[string]bool{ + "id": true, + "type": true, + "status": true, + "title": true, + "created_at": true, + "updated_at": true, +} + +// DocSummary is a row from ListDocsWithCounts: a Doc plus the JOIN-derived +// revision count and current revision number. Returned shape per TDD §6.3. +type DocSummary struct { + Doc *model.Doc + RevisionsCount int + CurrentRevision int +} + +// CreateDoc inserts a new doc and appends revision #1 with change_kind="create" +// in a single transaction. Returns the new doc ID. The supplied doc must have +// Type, Status, Title, Body, and Author set; CreatedAt/UpdatedAt are +// stamped by this function. +func CreateDoc(db *sql.DB, doc *model.Doc) (int, error) { + tx, err := db.Begin() + if err != nil { + return 0, fmt.Errorf("beginning transaction: %w", err) + } + defer tx.Rollback() + + now := time.Now().UTC().Format(time.RFC3339) + + res, err := tx.Exec( + `INSERT INTO docs (type, status, title, body, author, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + doc.Type, doc.Status, doc.Title, doc.Body, doc.Author, now, now, + ) + if err != nil { + return 0, fmt.Errorf("inserting doc: %w", err) + } + + id64, err := res.LastInsertId() + if err != nil { + return 0, fmt.Errorf("getting last insert id: %w", err) + } + id := int(id64) + + if _, err := appendDocRevision(tx, id, doc.Body, docChangeKindCreate, doc.Author, now); err != nil { + return 0, err + } + + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("committing transaction: %w", err) + } + + doc.ID = id + // Reflect the values the DB now holds so callers don't see stale zero times. + createdAt, _ := time.Parse(time.RFC3339, now) + doc.CreatedAt = createdAt + doc.UpdatedAt = createdAt + + return id, nil +} + +// GetDoc returns the doc with the given ID, or ErrNotFound. +func GetDoc(db *sql.DB, id int) (*model.Doc, error) { + row := db.QueryRow( + `SELECT id, type, status, title, body, author, created_at, updated_at + FROM docs WHERE id = ?`, id, + ) + d, err := scanDocFrom(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("scanning doc: %w", err) + } + return d, nil +} + +// ListDocs returns docs matching opts, ordered and paginated. Returns the +// matching rows and the total count before limit/offset. +func ListDocs(db *sql.DB, opts DocListOptions) ([]*model.Doc, int, error) { + whereSQL, args, err := buildDocWhere(opts, "") + if err != nil { + return nil, 0, err + } + + var total int + if err := db.QueryRow("SELECT COUNT(*) FROM docs "+whereSQL, args...).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("counting docs: %w", err) + } + + orderSQL, err := buildDocOrder(opts, "") + if err != nil { + return nil, 0, err + } + + query := "SELECT id, type, status, title, body, author, created_at, updated_at FROM docs " + + whereSQL + " " + orderSQL + queryArgs := append([]any{}, args...) + if opts.Limit > 0 { + query += " LIMIT ?" + queryArgs = append(queryArgs, opts.Limit) + if opts.Offset > 0 { + query += " OFFSET ?" + queryArgs = append(queryArgs, opts.Offset) + } + } else if opts.Offset > 0 { + query += " LIMIT -1 OFFSET ?" + queryArgs = append(queryArgs, opts.Offset) + } + + rows, err := db.Query(query, queryArgs...) + if err != nil { + return nil, 0, fmt.Errorf("listing docs: %w", err) + } + defer rows.Close() + + var docs []*model.Doc + for rows.Next() { + d, err := scanDocFrom(rows) + if err != nil { + return nil, 0, fmt.Errorf("scanning doc row: %w", err) + } + docs = append(docs, d) + } + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("iterating doc rows: %w", err) + } + + return docs, total, nil +} + +// listDocsWithCountsBaseSQL is the single-query body used by ListDocsWithCounts +// and by tests that EXPLAIN it to verify the JOIN strategy (TDD §5.4 S1, hard +// constraint — must remain a single statement with no per-row sub-selects). +// Filters / ORDER / LIMIT are appended at call time. +const listDocsWithCountsBaseSQL = `SELECT d.id, d.type, d.status, d.title, d.body, d.author, d.created_at, d.updated_at, + COALESCE(COUNT(r.id), 0) AS revisions_count, + COALESCE(MAX(r.revision_number), 0) AS current_revision +FROM docs d +LEFT JOIN doc_revisions r ON r.doc_id = d.id` + +// ListDocsWithCounts returns DocSummary rows including JOIN-derived +// revisions_count and current_revision in a single query (TDD §5.4 S1 — no +// N+1). Returns total count (before limit) as the second value. +func ListDocsWithCounts(db *sql.DB, opts DocListOptions) ([]*DocSummary, int, error) { + whereSQL, args, err := buildDocWhere(opts, "d.") + if err != nil { + return nil, 0, err + } + + countWhere, countArgs, err := buildDocWhere(opts, "") + if err != nil { + return nil, 0, err + } + var total int + if err := db.QueryRow("SELECT COUNT(*) FROM docs "+countWhere, countArgs...).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("counting docs: %w", err) + } + + orderSQL, err := buildDocOrder(opts, "d.") + if err != nil { + return nil, 0, err + } + + query := listDocsWithCountsBaseSQL + if whereSQL != "" { + query += " " + whereSQL + } + query += " GROUP BY d.id " + orderSQL + queryArgs := append([]any{}, args...) + if opts.Limit > 0 { + query += " LIMIT ?" + queryArgs = append(queryArgs, opts.Limit) + if opts.Offset > 0 { + query += " OFFSET ?" + queryArgs = append(queryArgs, opts.Offset) + } + } else if opts.Offset > 0 { + query += " LIMIT -1 OFFSET ?" + queryArgs = append(queryArgs, opts.Offset) + } + + rows, err := db.Query(query, queryArgs...) + if err != nil { + return nil, 0, fmt.Errorf("listing docs with counts: %w", err) + } + defer rows.Close() + + var out []*DocSummary + for rows.Next() { + var ( + d model.Doc + author sql.NullString + createdAt, upd string + revsCount, cur int + ) + if err := rows.Scan( + &d.ID, &d.Type, &d.Status, &d.Title, &d.Body, &author, + &createdAt, &upd, &revsCount, &cur, + ); err != nil { + return nil, 0, fmt.Errorf("scanning doc summary: %w", err) + } + d.Author = author.String + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { + d.CreatedAt = t + } + if t, err := time.Parse(time.RFC3339, upd); err == nil { + d.UpdatedAt = t + } + out = append(out, &DocSummary{ + Doc: &d, + RevisionsCount: revsCount, + CurrentRevision: cur, + }) + } + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("iterating doc summary rows: %w", err) + } + + return out, total, nil +} + +// DocUpdate is the set of fields UpdateDoc may change. Nil pointers mean +// "leave unchanged"; non-nil with the current value is detected as a no-op +// (body equality additionally applies trailing-newline trimming — C6). +type DocUpdate struct { + Title *string + Body *string + Status *string + Type *string + Author string +} + +// UpdateDoc applies the changes in upd to the doc with the given ID and +// appends one revision row capturing the combined change_kind. Returns the +// new revision number (0 if no revision appended because nothing changed). +// +// Per TDD §5.4 C8, every persisted field change appends one revision; the +// change_kind is comma-joined ("+" separator) for multi-field edits. Body +// equality uses strings.TrimRight(s, "\n") so trailing-newline-only edits +// are no-ops and do NOT append a revision (C6). +// +// Returns ErrNotFound if id does not exist. +func UpdateDoc(db *sql.DB, id int, upd DocUpdate) (int, error) { + tx, err := db.Begin() + if err != nil { + return 0, fmt.Errorf("beginning transaction: %w", err) + } + defer tx.Rollback() + + current, err := getDocTx(tx, id) + if err != nil { + return 0, err + } + + var setClauses []string + var args []any + var kinds []string + + newBody := current.Body + + if upd.Title != nil && *upd.Title != current.Title { + setClauses = append(setClauses, "title = ?") + args = append(args, *upd.Title) + kinds = append(kinds, docChangeKindTitle) + } + if upd.Type != nil && *upd.Type != current.Type { + setClauses = append(setClauses, "type = ?") + args = append(args, *upd.Type) + kinds = append(kinds, docChangeKindType) + } + if upd.Status != nil && *upd.Status != current.Status { + setClauses = append(setClauses, "status = ?") + args = append(args, *upd.Status) + kinds = append(kinds, docChangeKindStatus) + } + if upd.Body != nil && !docBodyEqual(*upd.Body, current.Body) { + setClauses = append(setClauses, "body = ?") + args = append(args, *upd.Body) + kinds = append(kinds, docChangeKindBody) + newBody = *upd.Body + } + + if len(kinds) == 0 { + return 0, nil + } + + now := time.Now().UTC().Format(time.RFC3339) + setClauses = append(setClauses, "updated_at = ?") + args = append(args, now) + args = append(args, id) + + query := fmt.Sprintf("UPDATE docs SET %s WHERE id = ?", strings.Join(setClauses, ", ")) + if _, err := tx.Exec(query, args...); err != nil { + return 0, fmt.Errorf("updating doc: %w", err) + } + + author := upd.Author + if author == "" { + author = current.Author + } + + rev, err := appendDocRevision(tx, id, newBody, strings.Join(kinds, "+"), author, now) + if err != nil { + return 0, err + } + + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("committing transaction: %w", err) + } + + return rev, nil +} + +// GetDocRevision returns revision rev of the doc with the given ID. Per TDD +// §3.3 / §6.3 (Q1): rev < 0 → ErrValidation; rev > MAX → ErrNotFound. rev == 0 +// is treated as "the current revision" (the most-recent one). +func GetDocRevision(db *sql.DB, docID, rev int) (*model.DocRevision, error) { + if rev < 0 { + return nil, fmt.Errorf("%w: revision number must be >= 0, got %d", ErrValidation, rev) + } + + var exists bool + if err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM docs WHERE id = ?)", docID).Scan(&exists); err != nil { + return nil, fmt.Errorf("checking doc existence: %w", err) + } + if !exists { + return nil, ErrNotFound + } + + if rev == 0 { + row := db.QueryRow( + `SELECT id, doc_id, revision_number, body, change_kind, author, created_at + FROM doc_revisions WHERE doc_id = ? ORDER BY revision_number DESC LIMIT 1`, + docID, + ) + r, err := scanDocRevisionFrom(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("scanning doc revision: %w", err) + } + return r, nil + } + + row := db.QueryRow( + `SELECT id, doc_id, revision_number, body, change_kind, author, created_at + FROM doc_revisions WHERE doc_id = ? AND revision_number = ?`, + docID, rev, + ) + r, err := scanDocRevisionFrom(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("scanning doc revision: %w", err) + } + return r, nil +} + +// ListDocRevisions returns every revision row for the doc, ordered by +// revision_number ascending. Returns ErrNotFound when the doc itself is +// missing. +func ListDocRevisions(db *sql.DB, docID int) ([]*model.DocRevision, error) { + var exists bool + if err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM docs WHERE id = ?)", docID).Scan(&exists); err != nil { + return nil, fmt.Errorf("checking doc existence: %w", err) + } + if !exists { + return nil, ErrNotFound + } + + rows, err := db.Query( + `SELECT id, doc_id, revision_number, body, change_kind, author, created_at + FROM doc_revisions WHERE doc_id = ? ORDER BY revision_number ASC`, + docID, + ) + if err != nil { + return nil, fmt.Errorf("querying doc revisions: %w", err) + } + defer rows.Close() + + var out []*model.DocRevision + for rows.Next() { + r, err := scanDocRevisionFrom(rows) + if err != nil { + return nil, fmt.Errorf("scanning doc revision row: %w", err) + } + out = append(out, r) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating doc revision rows: %w", err) + } + return out, nil +} + +// DeleteDoc removes the doc with the given ID. When cascade is true, FK +// cascades drop doc_revisions, doc_comments, doc_issue_links, proposal_docs. +// When cascade is false, the call returns ErrConflict if any links (issue or +// proposal) exist for the doc — comments and revisions are part of the doc's +// own history and never block deletion. +// Returns ErrNotFound if no doc with that ID exists. +func DeleteDoc(db *sql.DB, id int, cascade bool) error { + if !cascade { + var existing int + err := db.QueryRow( + `SELECT + (SELECT COUNT(*) FROM doc_issue_links WHERE doc_id = ?) + + (SELECT COUNT(*) FROM proposal_docs WHERE doc_id = ?)`, + id, id, + ).Scan(&existing) + if err != nil { + return fmt.Errorf("checking doc links: %w", err) + } + if existing > 0 { + return fmt.Errorf("%w: doc %s has %d link(s); use --cascade to remove", + ErrConflict, model.FormatDocID(id), existing) + } + } + + res, err := db.Exec("DELETE FROM docs WHERE id = ?", id) + if err != nil { + return fmt.Errorf("deleting doc: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("checking rows affected: %w", err) + } + if n == 0 { + return ErrNotFound + } + return nil +} + +// InsertDocWithID inserts a doc row with a caller-supplied ID, skipping if the +// ID already exists. Mirrors InsertIssueWithID (TDD §5.3 round-trip helpers). +// Must be called within an existing transaction. Returns true if inserted. +func InsertDocWithID(tx *sql.Tx, doc *model.Doc) (bool, error) { + res, err := tx.Exec( + `INSERT OR IGNORE INTO docs (id, type, status, title, body, author, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + doc.ID, doc.Type, doc.Status, doc.Title, doc.Body, doc.Author, + doc.CreatedAt.UTC().Format(time.RFC3339), + doc.UpdatedAt.UTC().Format(time.RFC3339), + ) + if err != nil { + return false, fmt.Errorf("inserting doc with id %d: %w", doc.ID, err) + } + n, _ := res.RowsAffected() + return n > 0, nil +} + +// InsertDocRevisionWithID inserts a doc_revisions row with a caller-supplied +// ID, skipping if the ID already exists. Must be called within a transaction. +// Returns true if inserted. Mirrors InsertIssueWithID. +func InsertDocRevisionWithID(tx *sql.Tx, r *model.DocRevision) (bool, error) { + res, err := tx.Exec( + `INSERT OR IGNORE INTO doc_revisions (id, doc_id, revision_number, body, change_kind, author, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + r.ID, r.DocID, r.RevisionNumber, r.Body, r.ChangeKind, r.Author, + r.CreatedAt.UTC().Format(time.RFC3339), + ) + if err != nil { + return false, fmt.Errorf("inserting doc revision with id %d: %w", r.ID, err) + } + n, _ := res.RowsAffected() + return n > 0, nil +} + +// ListAllDocs returns every doc row ordered by id ASC, for a full export. +func ListAllDocs(db *sql.DB) ([]*model.Doc, error) { + rows, err := db.Query( + `SELECT id, type, status, title, body, author, created_at, updated_at + FROM docs ORDER BY id ASC`, + ) + if err != nil { + return nil, fmt.Errorf("querying all docs: %w", err) + } + defer rows.Close() + + var docs []*model.Doc + for rows.Next() { + d, err := scanDocFrom(rows) + if err != nil { + return nil, fmt.Errorf("scanning doc row: %w", err) + } + docs = append(docs, d) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating doc rows: %w", err) + } + return docs, nil +} + +// ListAllDocRevisions returns every doc_revisions row ordered by id ASC, for a +// full export. +func ListAllDocRevisions(db *sql.DB) ([]*model.DocRevision, error) { + rows, err := db.Query( + `SELECT id, doc_id, revision_number, body, change_kind, author, created_at + FROM doc_revisions ORDER BY id ASC`, + ) + if err != nil { + return nil, fmt.Errorf("querying all doc revisions: %w", err) + } + defer rows.Close() + + var revs []*model.DocRevision + for rows.Next() { + r, err := scanDocRevisionFrom(rows) + if err != nil { + return nil, fmt.Errorf("scanning doc revision row: %w", err) + } + revs = append(revs, r) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating doc revision rows: %w", err) + } + return revs, nil +} + +// --- private helpers --- + +// appendDocRevision inserts the next revision row for docID inside the supplied +// transaction. Revision numbers are monotonic per doc, computed as MAX(rev)+1 +// inside the same transaction; UNIQUE(doc_id, revision_number) defends against +// external concurrent writers (the app pins MaxOpenConns=1, so this can only +// race with a sidecar process). Returns the assigned revision_number. +func appendDocRevision(tx *sql.Tx, docID int, body, changeKind, author, createdAt string) (int, error) { + var maxRev sql.NullInt64 + if err := tx.QueryRow( + `SELECT MAX(revision_number) FROM doc_revisions WHERE doc_id = ?`, docID, + ).Scan(&maxRev); err != nil { + return 0, fmt.Errorf("computing next revision_number: %w", err) + } + next := 1 + if maxRev.Valid { + next = int(maxRev.Int64) + 1 + } + + _, err := tx.Exec( + `INSERT INTO doc_revisions (doc_id, revision_number, body, change_kind, author, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + docID, next, body, changeKind, author, createdAt, + ) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE constraint") || strings.Contains(err.Error(), "PRIMARY KEY") { + return 0, ErrConflict + } + return 0, fmt.Errorf("inserting doc revision: %w", err) + } + return next, nil +} + +// getDocTx loads a doc within a transaction; ErrNotFound when absent. +func getDocTx(tx *sql.Tx, id int) (*model.Doc, error) { + row := tx.QueryRow( + `SELECT id, type, status, title, body, author, created_at, updated_at + FROM docs WHERE id = ?`, id, + ) + d, err := scanDocFrom(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("scanning doc: %w", err) + } + return d, nil +} + +// scanDocFrom scans a single doc from any scanner. Author is stored as +// sql.NullString and projected to a plain string per S5 (mirrors comments.go). +func scanDocFrom(s scanner) (*model.Doc, error) { + var d model.Doc + var author sql.NullString + var createdAt, updatedAt string + + if err := s.Scan(&d.ID, &d.Type, &d.Status, &d.Title, &d.Body, &author, &createdAt, &updatedAt); err != nil { + return nil, err + } + + d.Author = author.String + + t, err := time.Parse(time.RFC3339, createdAt) + if err != nil { + return nil, fmt.Errorf("parsing created_at: %w", err) + } + d.CreatedAt = t + + t, err = time.Parse(time.RFC3339, updatedAt) + if err != nil { + return nil, fmt.Errorf("parsing updated_at: %w", err) + } + d.UpdatedAt = t + + return &d, nil +} + +// scanDocRevisionFrom scans a single doc_revisions row from any scanner. +func scanDocRevisionFrom(s scanner) (*model.DocRevision, error) { + var r model.DocRevision + var author sql.NullString + var createdAt string + + if err := s.Scan(&r.ID, &r.DocID, &r.RevisionNumber, &r.Body, &r.ChangeKind, &author, &createdAt); err != nil { + return nil, err + } + + r.Author = author.String + + t, err := time.Parse(time.RFC3339, createdAt) + if err != nil { + return nil, fmt.Errorf("parsing created_at: %w", err) + } + r.CreatedAt = t + + return &r, nil +} + +// buildDocWhere constructs the WHERE clause + args for a DocListOptions +// filter. tablePrefix (e.g. "d.") is prepended to column references so the +// same builder can serve aliased JOIN queries and bare single-table queries. +func buildDocWhere(opts DocListOptions, tablePrefix string) (string, []any, error) { + var clauses []string + var args []any + + if len(opts.Types) > 0 { + placeholders := makePlaceholders(len(opts.Types)) + clauses = append(clauses, fmt.Sprintf("%stype IN (%s)", tablePrefix, placeholders)) + for _, t := range opts.Types { + args = append(args, t) + } + } + if len(opts.Statuses) > 0 { + placeholders := makePlaceholders(len(opts.Statuses)) + clauses = append(clauses, fmt.Sprintf("%sstatus IN (%s)", tablePrefix, placeholders)) + for _, s := range opts.Statuses { + args = append(args, s) + } + } + if opts.Author != "" { + clauses = append(clauses, tablePrefix+"author = ?") + args = append(args, opts.Author) + } + + if len(clauses) == 0 { + return "", nil, nil + } + return "WHERE " + strings.Join(clauses, " AND "), args, nil +} + +// buildDocOrder constructs the ORDER BY clause from a DocListOptions. tablePrefix +// (e.g. "d.") is prepended to column names when the query uses table aliases. +func buildDocOrder(opts DocListOptions, tablePrefix string) (string, error) { + field := opts.Sort + if field == "" { + field = "created_at" + } + if !validDocSortFields[field] { + return "", fmt.Errorf("%w: invalid sort field %q", ErrValidation, field) + } + // Defense-in-depth: reject any sort field that doesn't look like a plain + // column name, even if it passed the allowlist check above. + if !safeIdentifier.MatchString(field) { + return "", fmt.Errorf("%w: invalid sort field %q", ErrValidation, field) + } + + dir := strings.ToUpper(opts.SortDir) + if dir == "" { + dir = "DESC" + } + if dir != "ASC" && dir != "DESC" { + return "", fmt.Errorf("%w: invalid sort dir %q", ErrValidation, opts.SortDir) + } + + return fmt.Sprintf("ORDER BY %s%s %s", tablePrefix, field, dir), nil +} diff --git a/internal/db/docs_test.go b/internal/db/docs_test.go new file mode 100644 index 0000000..dd1f11b --- /dev/null +++ b/internal/db/docs_test.go @@ -0,0 +1,778 @@ +package db + +import ( + "database/sql" + "errors" + "strings" + "testing" + + "github.com/ALT-F4-LLC/docket/internal/model" +) + +// mustCreateDoc creates a doc with sensible defaults and returns the new ID. +func mustCreateDoc(t *testing.T, db *sql.DB, title, typ, status, body string) int { + t.Helper() + id, err := CreateDoc(db, &model.Doc{ + Title: title, + Type: typ, + Status: status, + Body: body, + Author: "tester", + }) + if err != nil { + t.Fatalf("CreateDoc(%q): %v", title, err) + } + return id +} + +// --- CreateDoc / AppendDocRevision --- + +func TestCreateDoc_InsertsRevision1(t *testing.T) { + db := mustInitAndMigrate(t) + + id := mustCreateDoc(t, db, "first", "tdd", "draft", "body v1") + + revs, err := ListDocRevisions(db, id) + if err != nil { + t.Fatalf("ListDocRevisions: %v", err) + } + if len(revs) != 1 { + t.Fatalf("len(revs) = %d, want 1", len(revs)) + } + if revs[0].RevisionNumber != 1 { + t.Errorf("RevisionNumber = %d, want 1", revs[0].RevisionNumber) + } + if revs[0].Body != "body v1" { + t.Errorf("Body = %q, want %q", revs[0].Body, "body v1") + } +} + +func TestCreateDoc_ChangeKindCreate(t *testing.T) { + db := mustInitAndMigrate(t) + + id := mustCreateDoc(t, db, "first", "tdd", "draft", "body v1") + + revs, _ := ListDocRevisions(db, id) + if revs[0].ChangeKind != "create" { + t.Errorf("ChangeKind = %q, want %q", revs[0].ChangeKind, "create") + } +} + +func TestCreateDoc_ParityWithDocBody(t *testing.T) { + db := mustInitAndMigrate(t) + id := mustCreateDoc(t, db, "first", "tdd", "draft", "hello\n") + + d, err := GetDoc(db, id) + if err != nil { + t.Fatalf("GetDoc: %v", err) + } + revs, _ := ListDocRevisions(db, id) + if d.Body != revs[0].Body { + t.Errorf("doc.Body = %q, rev.Body = %q (must match)", d.Body, revs[0].Body) + } +} + +// --- UpdateDoc — each persisted field appends one revision --- + +func updateBody(t *testing.T, db *sql.DB, id int, body string) int { + t.Helper() + rev, err := UpdateDoc(db, id, DocUpdate{Body: &body, Author: "editor"}) + if err != nil { + t.Fatalf("UpdateDoc body: %v", err) + } + return rev +} + +func TestUpdateDoc_AppendsRevisionOnBodyChange(t *testing.T) { + db := mustInitAndMigrate(t) + id := mustCreateDoc(t, db, "first", "tdd", "draft", "v1") + + rev := updateBody(t, db, id, "v2") + if rev != 2 { + t.Fatalf("returned revision_number = %d, want 2", rev) + } + + revs, _ := ListDocRevisions(db, id) + if len(revs) != 2 { + t.Fatalf("len(revs) = %d, want 2", len(revs)) + } + if revs[1].ChangeKind != "body" { + t.Errorf("revs[1].ChangeKind = %q, want body", revs[1].ChangeKind) + } +} + +func TestUpdateDoc_AppendsRevisionOnStatusChange(t *testing.T) { + db := mustInitAndMigrate(t) + id := mustCreateDoc(t, db, "first", "tdd", "draft", "v1") + newStatus := "approved" + rev, err := UpdateDoc(db, id, DocUpdate{Status: &newStatus, Author: "voter"}) + if err != nil { + t.Fatalf("UpdateDoc status: %v", err) + } + if rev != 2 { + t.Fatalf("rev = %d, want 2", rev) + } + revs, _ := ListDocRevisions(db, id) + if revs[1].ChangeKind != "status" { + t.Errorf("ChangeKind = %q, want status", revs[1].ChangeKind) + } + // Body is duplicated from the prior revision (metadata-only edit, TDD §5.4 C8). + if revs[1].Body != "v1" { + t.Errorf("revs[1].Body = %q, want v1 (duplicated from prior)", revs[1].Body) + } +} + +func TestUpdateDoc_AppendsRevisionOnTitleChange(t *testing.T) { + db := mustInitAndMigrate(t) + id := mustCreateDoc(t, db, "first", "tdd", "draft", "v1") + newTitle := "second title" + rev, err := UpdateDoc(db, id, DocUpdate{Title: &newTitle, Author: "editor"}) + if err != nil { + t.Fatalf("UpdateDoc title: %v", err) + } + if rev != 2 { + t.Fatalf("rev = %d, want 2", rev) + } + revs, _ := ListDocRevisions(db, id) + if revs[1].ChangeKind != "title" { + t.Errorf("ChangeKind = %q, want title", revs[1].ChangeKind) + } +} + +func TestUpdateDoc_AppendsRevisionOnTypeChange(t *testing.T) { + db := mustInitAndMigrate(t) + id := mustCreateDoc(t, db, "first", "tdd", "draft", "v1") + newType := "adr" + rev, err := UpdateDoc(db, id, DocUpdate{Type: &newType, Author: "editor"}) + if err != nil { + t.Fatalf("UpdateDoc type: %v", err) + } + if rev != 2 { + t.Fatalf("rev = %d, want 2", rev) + } + revs, _ := ListDocRevisions(db, id) + if revs[1].ChangeKind != "type" { + t.Errorf("ChangeKind = %q, want type", revs[1].ChangeKind) + } +} + +func TestUpdateDoc_CombinedChangeKind(t *testing.T) { + db := mustInitAndMigrate(t) + id := mustCreateDoc(t, db, "first", "tdd", "draft", "v1") + newStatus := "approved" + newBody := "v2" + rev, err := UpdateDoc(db, id, DocUpdate{ + Status: &newStatus, + Body: &newBody, + Author: "voter", + }) + if err != nil { + t.Fatalf("UpdateDoc combined: %v", err) + } + if rev != 2 { + t.Fatalf("rev = %d, want 2", rev) + } + revs, _ := ListDocRevisions(db, id) + // UpdateDoc orders the change_kind by field-write order: title, type, status, body. + if revs[1].ChangeKind != "status+body" { + t.Errorf("ChangeKind = %q, want status+body", revs[1].ChangeKind) + } + if revs[1].Body != "v2" { + t.Errorf("Body = %q, want v2", revs[1].Body) + } +} + +func TestUpdateDoc_NoRevisionOnNoOpEdit(t *testing.T) { + db := mustInitAndMigrate(t) + id := mustCreateDoc(t, db, "first", "tdd", "draft", "v1\n") + + // Trailing-newline-only change; TrimRight equality treats this as no-op. + bodyWithExtraNL := "v1\n\n\n" + rev, err := UpdateDoc(db, id, DocUpdate{Body: &bodyWithExtraNL, Author: "editor"}) + if err != nil { + t.Fatalf("UpdateDoc no-op: %v", err) + } + if rev != 0 { + t.Fatalf("rev = %d, want 0 (no-op)", rev) + } + revs, _ := ListDocRevisions(db, id) + if len(revs) != 1 { + t.Fatalf("len(revs) = %d, want 1 (no revision appended on no-op)", len(revs)) + } +} + +func TestUpdateDoc_BodyEqualityAfterTrimRight(t *testing.T) { + db := mustInitAndMigrate(t) + id := mustCreateDoc(t, db, "first", "tdd", "draft", "abc") + + candidates := []string{"abc", "abc\n", "abc\n\n"} + for _, c := range candidates { + c := c + rev, err := UpdateDoc(db, id, DocUpdate{Body: &c, Author: "editor"}) + if err != nil { + t.Fatalf("UpdateDoc(%q): %v", c, err) + } + if rev != 0 { + t.Errorf("UpdateDoc(%q): rev=%d, want 0", c, rev) + } + } + + revs, _ := ListDocRevisions(db, id) + if len(revs) != 1 { + t.Errorf("len(revs) = %d, want 1", len(revs)) + } +} + +func TestUpdateDoc_BodyParity(t *testing.T) { + db := mustInitAndMigrate(t) + id := mustCreateDoc(t, db, "first", "tdd", "draft", "v1") + newBody := "v2 — different" + if _, err := UpdateDoc(db, id, DocUpdate{Body: &newBody, Author: "editor"}); err != nil { + t.Fatalf("UpdateDoc: %v", err) + } + + d, _ := GetDoc(db, id) + revs, _ := ListDocRevisions(db, id) + if d.Body != revs[len(revs)-1].Body { + t.Errorf("docs.body = %q, latest rev body = %q (must match)", d.Body, revs[len(revs)-1].Body) + } +} + +func TestUpdateDoc_NotFound(t *testing.T) { + db := mustInitAndMigrate(t) + newTitle := "nope" + _, err := UpdateDoc(db, 999, DocUpdate{Title: &newTitle, Author: "x"}) + if !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +// --- ListDocsWithCounts — single-JOIN strategy (S1) --- + +func TestListDocsWithCounts_NoNPlusOne(t *testing.T) { + // TDD §5.4 S1 — must be a single statement, no per-row sub-selects. + // We assert this by running EXPLAIN QUERY PLAN against the SQL the + // function uses and verifying it touches docs and doc_revisions exactly + // once each as JOIN partners. If a future refactor introduces N+1 + // (e.g. a correlated subquery in SELECT), this test fails. + db := mustInitAndMigrate(t) + for i := 0; i < 5; i++ { + id := mustCreateDoc(t, db, "title", "tdd", "draft", "body") + // Multiple revisions per doc to exercise the COUNT/MAX aggregation. + b1 := "body+r2" + updateBody(t, db, id, b1) + } + + // Assert the exact query the function uses produces a single-pass plan. + rows, err := db.Query("EXPLAIN QUERY PLAN " + listDocsWithCountsBaseSQL + " GROUP BY d.id") + if err != nil { + t.Fatalf("EXPLAIN QUERY PLAN: %v", err) + } + defer rows.Close() + + var plan []string + for rows.Next() { + var selectID, order, from int + var detail string + if err := rows.Scan(&selectID, &order, &from, &detail); err != nil { + t.Fatalf("scan plan row: %v", err) + } + plan = append(plan, detail) + } + if err := rows.Err(); err != nil { + t.Fatalf("EXPLAIN rows.Err: %v", err) + } + if len(plan) == 0 { + t.Fatalf("EXPLAIN QUERY PLAN returned no rows") + } + + // Verify the plan covers both tables via the LEFT-JOIN partner and contains + // no CORRELATED SUBQUERY / EXECUTE LIST SUBQUERY marker (which would + // indicate N+1). SQLite reports the docs alias as "d" and the + // doc_revisions side via its autoindex name. + joined := strings.Join(plan, "\n") + if !strings.Contains(joined, "SCAN d") { + t.Errorf("plan does not scan docs (alias d):\n%s", joined) + } + if !strings.Contains(joined, "doc_revisions") { + t.Errorf("plan does not reference doc_revisions:\n%s", joined) + } + if !strings.Contains(joined, "LEFT-JOIN") { + t.Errorf("plan does not use a LEFT-JOIN:\n%s", joined) + } + if strings.Contains(joined, "CORRELATED") || strings.Contains(joined, "EXECUTE LIST SUBQUERY") { + t.Errorf("plan contains N+1 marker:\n%s", joined) + } + // The plan should have exactly two rows: one SCAN over docs and one + // SEARCH against doc_revisions. Any extra rows indicate per-doc sub-work. + if len(plan) > 2 { + t.Errorf("plan has %d rows, want <= 2 (one per table):\n%s", len(plan), joined) + } + + // Behavioural check: the call returns the right counts for each doc. + summaries, total, err := ListDocsWithCounts(db, DocListOptions{Sort: "id", SortDir: "asc"}) + if err != nil { + t.Fatalf("ListDocsWithCounts: %v", err) + } + if total != 5 { + t.Errorf("total = %d, want 5", total) + } + if len(summaries) != 5 { + t.Fatalf("len(summaries) = %d, want 5", len(summaries)) + } + for i, s := range summaries { + if s.RevisionsCount != 2 { + t.Errorf("summaries[%d].RevisionsCount = %d, want 2", i, s.RevisionsCount) + } + if s.CurrentRevision != 2 { + t.Errorf("summaries[%d].CurrentRevision = %d, want 2", i, s.CurrentRevision) + } + } +} + +func TestListDocsWithCounts_FilterByType(t *testing.T) { + db := mustInitAndMigrate(t) + mustCreateDoc(t, db, "a", "tdd", "draft", "x") + mustCreateDoc(t, db, "b", "adr", "draft", "x") + mustCreateDoc(t, db, "c", "tdd", "draft", "x") + + summaries, total, err := ListDocsWithCounts(db, DocListOptions{Types: []string{"tdd"}}) + if err != nil { + t.Fatalf("ListDocsWithCounts: %v", err) + } + if total != 2 { + t.Errorf("total = %d, want 2", total) + } + if len(summaries) != 2 { + t.Errorf("len(summaries) = %d, want 2", len(summaries)) + } + for _, s := range summaries { + if s.Doc.Type != "tdd" { + t.Errorf("returned non-tdd doc: %q", s.Doc.Type) + } + } +} + +func TestListDocsWithCounts_FilterByStatus(t *testing.T) { + db := mustInitAndMigrate(t) + mustCreateDoc(t, db, "a", "tdd", "draft", "x") + mustCreateDoc(t, db, "b", "tdd", "approved", "x") + mustCreateDoc(t, db, "c", "tdd", "draft", "x") + + summaries, total, err := ListDocsWithCounts(db, DocListOptions{Statuses: []string{"approved"}}) + if err != nil { + t.Fatalf("ListDocsWithCounts: %v", err) + } + if total != 1 { + t.Errorf("total = %d, want 1", total) + } + if len(summaries) != 1 { + t.Fatalf("len(summaries) = %d, want 1", len(summaries)) + } + if summaries[0].Doc.Status != "approved" { + t.Errorf("status = %q, want approved", summaries[0].Doc.Status) + } +} + +func TestListDocs_OffsetWithoutLimitSkipsRows(t *testing.T) { + db := mustInitAndMigrate(t) + var ids []int + for i := 0; i < 6; i++ { + ids = append(ids, mustCreateDoc(t, db, "doc", "tdd", "draft", "body")) + } + + docs, total, err := ListDocs(db, DocListOptions{Sort: "id", SortDir: "asc", Offset: 2}) + if err != nil { + t.Fatalf("ListDocs: %v", err) + } + if total != 6 { + t.Errorf("total = %d, want 6", total) + } + if len(docs) != 4 { + t.Fatalf("len(docs) = %d, want 4 (offset 2 of 6 honored)", len(docs)) + } + if docs[0].ID != ids[2] { + t.Errorf("first returned doc ID = %d, want %d (3rd doc after skipping 2)", docs[0].ID, ids[2]) + } + if docs[3].ID != ids[5] { + t.Errorf("last returned doc ID = %d, want %d", docs[3].ID, ids[5]) + } +} + +func TestListDocsWithCounts_OffsetWithoutLimitSkipsRows(t *testing.T) { + db := mustInitAndMigrate(t) + var ids []int + for i := 0; i < 6; i++ { + ids = append(ids, mustCreateDoc(t, db, "doc", "tdd", "draft", "body")) + } + + summaries, total, err := ListDocsWithCounts(db, DocListOptions{Sort: "id", SortDir: "asc", Offset: 2}) + if err != nil { + t.Fatalf("ListDocsWithCounts: %v", err) + } + if total != 6 { + t.Errorf("total = %d, want 6", total) + } + if len(summaries) != 4 { + t.Fatalf("len(summaries) = %d, want 4 (offset 2 of 6 honored)", len(summaries)) + } + if summaries[0].Doc.ID != ids[2] { + t.Errorf("first returned doc ID = %d, want %d (3rd doc after skipping 2)", summaries[0].Doc.ID, ids[2]) + } + if summaries[3].Doc.ID != ids[5] { + t.Errorf("last returned doc ID = %d, want %d", summaries[3].Doc.ID, ids[5]) + } +} + +// --- GetDocRevision (TDD §3.3 Q1 semantics) --- + +func TestGetDocRevision(t *testing.T) { + db := mustInitAndMigrate(t) + id := mustCreateDoc(t, db, "first", "tdd", "draft", "v1") + updateBody(t, db, id, "v2") + updateBody(t, db, id, "v3") + + r, err := GetDocRevision(db, id, 2) + if err != nil { + t.Fatalf("GetDocRevision rev=2: %v", err) + } + if r.RevisionNumber != 2 { + t.Errorf("RevisionNumber = %d, want 2", r.RevisionNumber) + } + if r.Body != "v2" { + t.Errorf("Body = %q, want v2", r.Body) + } + + // rev == 0 returns the current revision. + r0, err := GetDocRevision(db, id, 0) + if err != nil { + t.Fatalf("GetDocRevision rev=0: %v", err) + } + if r0.RevisionNumber != 3 { + t.Errorf("rev=0 RevisionNumber = %d, want 3 (current)", r0.RevisionNumber) + } +} + +func TestGetDocRevision_OutOfRange_ErrNotFound(t *testing.T) { + db := mustInitAndMigrate(t) + id := mustCreateDoc(t, db, "first", "tdd", "draft", "v1") + // Only revision 1 exists. + _, err := GetDocRevision(db, id, 999) + if !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestGetDocRevision_NegativeRev_ErrValidation(t *testing.T) { + db := mustInitAndMigrate(t) + id := mustCreateDoc(t, db, "first", "tdd", "draft", "v1") + _, err := GetDocRevision(db, id, -1) + if !errors.Is(err, ErrValidation) { + t.Errorf("err = %v, want ErrValidation", err) + } +} + +func TestGetDocRevision_DocNotFound(t *testing.T) { + db := mustInitAndMigrate(t) + _, err := GetDocRevision(db, 999, 1) + if !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +// --- DeleteDoc + cascade behaviour (AC9) --- + +func TestDeleteDoc_CascadesRevisions(t *testing.T) { + db := mustInitAndMigrate(t) + id := mustCreateDoc(t, db, "first", "tdd", "draft", "v1") + updateBody(t, db, id, "v2") + + if err := DeleteDoc(db, id, true); err != nil { + t.Fatalf("DeleteDoc: %v", err) + } + + var n int + if err := db.QueryRow("SELECT COUNT(*) FROM doc_revisions WHERE doc_id = ?", id).Scan(&n); err != nil { + t.Fatalf("count revisions: %v", err) + } + if n != 0 { + t.Errorf("revisions remaining = %d, want 0", n) + } +} + +func TestDeleteDoc_CascadesLinks(t *testing.T) { + db := mustInitAndMigrate(t) + docID := mustCreateDoc(t, db, "first", "tdd", "draft", "v1") + issueID := createTestIssue(t, db, "an issue", model.StatusTodo, model.PriorityMedium) + + if err := LinkDocIssue(db, docID, issueID); err != nil { + t.Fatalf("LinkDocIssue: %v", err) + } + if err := DeleteDoc(db, docID, true); err != nil { + t.Fatalf("DeleteDoc cascade: %v", err) + } + + var n int + if err := db.QueryRow("SELECT COUNT(*) FROM doc_issue_links WHERE doc_id = ?", docID).Scan(&n); err != nil { + t.Fatalf("count links: %v", err) + } + if n != 0 { + t.Errorf("links remaining = %d, want 0", n) + } +} + +func TestDeleteDoc_NoCascade_BlocksOnLinks(t *testing.T) { + db := mustInitAndMigrate(t) + docID := mustCreateDoc(t, db, "first", "tdd", "draft", "v1") + issueID := createTestIssue(t, db, "an issue", model.StatusTodo, model.PriorityMedium) + + if err := LinkDocIssue(db, docID, issueID); err != nil { + t.Fatalf("LinkDocIssue: %v", err) + } + err := DeleteDoc(db, docID, false) + if !errors.Is(err, ErrConflict) { + t.Errorf("err = %v, want ErrConflict", err) + } + + // Doc must still exist. + if _, err := GetDoc(db, docID); err != nil { + t.Errorf("doc was unexpectedly deleted: %v", err) + } +} + +func TestDeleteDoc_NotFound(t *testing.T) { + db := mustInitAndMigrate(t) + err := DeleteDoc(db, 999, true) + if !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +// --- InsertDocWithID / InsertDocRevisionWithID round-trip helpers --- + +func TestInsertDocWithID_RoundTrip(t *testing.T) { + db := mustInitAndMigrate(t) + tx, err := db.Begin() + if err != nil { + t.Fatalf("Begin: %v", err) + } + + d := &model.Doc{ID: 42, Type: "tdd", Status: "draft", Title: "imported", Body: "x", Author: "operator"} + inserted, err := InsertDocWithID(tx, d) + if err != nil { + t.Fatalf("InsertDocWithID: %v", err) + } + if !inserted { + t.Error("first call: inserted = false, want true") + } + // Second call with same ID: skipped. + inserted2, err := InsertDocWithID(tx, d) + if err != nil { + t.Fatalf("InsertDocWithID 2: %v", err) + } + if inserted2 { + t.Error("second call: inserted = true, want false") + } + + if err := tx.Commit(); err != nil { + t.Fatalf("Commit: %v", err) + } + + got, err := GetDoc(db, 42) + if err != nil { + t.Fatalf("GetDoc(42): %v", err) + } + if got.Title != "imported" { + t.Errorf("Title = %q, want imported", got.Title) + } +} + +func TestInsertDocRevisionWithID_RoundTrip(t *testing.T) { + db := mustInitAndMigrate(t) + + tx, err := db.Begin() + if err != nil { + t.Fatalf("Begin: %v", err) + } + d := &model.Doc{ID: 1, Type: "tdd", Status: "draft", Title: "t", Body: "b", Author: "a"} + if _, err := InsertDocWithID(tx, d); err != nil { + t.Fatalf("InsertDocWithID: %v", err) + } + r := &model.DocRevision{ + ID: 10, + DocID: 1, + RevisionNumber: 1, + Body: "b", + ChangeKind: "create", + Author: "a", + } + inserted, err := InsertDocRevisionWithID(tx, r) + if err != nil { + t.Fatalf("InsertDocRevisionWithID: %v", err) + } + if !inserted { + t.Error("inserted = false, want true") + } + if err := tx.Commit(); err != nil { + t.Fatalf("Commit: %v", err) + } + + revs, _ := ListDocRevisions(db, 1) + if len(revs) != 1 || revs[0].ID != 10 { + t.Errorf("revs[0] = %+v, want ID=10", revs[0]) + } +} + +// --- ClearAllData covering BOTH new doc tables AND pre-existing proposals --- + +func TestClearAllData_DropsProposalsAndDocs(t *testing.T) { + db := mustInitAndMigrate(t) + + // Seed an issue, proposal, vote, proposal_issue link, doc, comment, link. + issueID := createTestIssue(t, db, "i", model.StatusTodo, model.PriorityMedium) + pID, err := CreateProposal(db, &model.Proposal{ + Description: "p", Criticality: model.CriticalityHigh, + Status: model.ProposalStatusOpen, RequiredVoters: 1, Threshold: 0.5, + }) + if err != nil { + t.Fatalf("CreateProposal: %v", err) + } + if err := LinkProposalIssue(db, pID, issueID); err != nil { + t.Fatalf("LinkProposalIssue: %v", err) + } + + docID := mustCreateDoc(t, db, "d", "tdd", "draft", "body") + if _, err := CreateDocComment(db, &model.DocComment{DocID: docID, Body: "c", Author: "a"}); err != nil { + t.Fatalf("CreateDocComment: %v", err) + } + if err := LinkDocIssue(db, docID, issueID); err != nil { + t.Fatalf("LinkDocIssue: %v", err) + } + if err := LinkProposalDoc(db, pID, docID); err != nil { + t.Fatalf("LinkProposalDoc: %v", err) + } + + if err := ClearAllData(db); err != nil { + t.Fatalf("ClearAllData: %v", err) + } + + tables := []string{ + "docs", "doc_revisions", "doc_comments", "doc_issue_links", "proposal_docs", + "proposals", "votes", "proposal_issues", + "issues", "comments", "labels", "issue_labels", "issue_relations", "issue_files", "activity_log", + } + for _, table := range tables { + var n int + if err := db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&n); err != nil { + t.Errorf("counting %s: %v", table, err) + continue + } + if n != 0 { + t.Errorf("expected 0 rows in %s after ClearAllData, got %d", table, n) + } + } +} + +func TestUpdateDoc_CombinedChangeKind_AllFields(t *testing.T) { + db := mustInitAndMigrate(t) + id := mustCreateDoc(t, db, "initial title", "tdd", "draft", "body v1") + + newTitle := "second title" + newType := "adr" + newStatus := "approved" + newBody := "body v2" + + rev, err := UpdateDoc(db, id, DocUpdate{ + Title: &newTitle, + Type: &newType, + Status: &newStatus, + Body: &newBody, + Author: "editor", + }) + if err != nil { + t.Fatalf("UpdateDoc all-fields combined: %v", err) + } + if rev != 2 { + t.Fatalf("rev = %d, want 2", rev) + } + + revs, err := ListDocRevisions(db, id) + if err != nil { + t.Fatalf("ListDocRevisions: %v", err) + } + if len(revs) != 2 { + t.Fatalf("len(revs) = %d, want 2", len(revs)) + } + wantChangeKind := "title+type+status+body" + if revs[1].ChangeKind != wantChangeKind { + t.Errorf("ChangeKind = %q, want %q", revs[1].ChangeKind, wantChangeKind) + } + if revs[1].Body != newBody { + t.Errorf("revs[1].Body = %q, want %q", revs[1].Body, newBody) + } +} + +func TestDeleteDoc_NoCascade_DeletesRevisionsAndComments(t *testing.T) { + db := mustInitAndMigrate(t) + id := mustCreateDoc(t, db, "first", "tdd", "draft", "v1") + + updateBody(t, db, id, "v2") + newStatus := "approved" + if _, err := UpdateDoc(db, id, DocUpdate{Status: &newStatus, Author: "editor"}); err != nil { + t.Fatalf("UpdateDoc status: %v", err) + } + + revsBefore, err := ListDocRevisions(db, id) + if err != nil { + t.Fatalf("ListDocRevisions: %v", err) + } + if len(revsBefore) < 3 { + t.Fatalf("expected >=3 revisions before delete, got %d", len(revsBefore)) + } + + for _, body := range []string{"first comment", "second comment"} { + if _, err := CreateDocComment(db, &model.DocComment{ + DocID: id, + Body: body, + Author: "tester", + }); err != nil { + t.Fatalf("CreateDocComment %q: %v", body, err) + } + } + + var nLinks int + if err := db.QueryRow( + `SELECT (SELECT COUNT(*) FROM doc_issue_links WHERE doc_id = ?) + + (SELECT COUNT(*) FROM proposal_docs WHERE doc_id = ?)`, + id, id, + ).Scan(&nLinks); err != nil { + t.Fatalf("count external links: %v", err) + } + if nLinks != 0 { + t.Fatalf("precondition: external link count = %d, want 0", nLinks) + } + + if err := DeleteDoc(db, id, false); err != nil { + t.Fatalf("DeleteDoc(cascade=false) with no external links: %v", err) + } + + if _, err := GetDoc(db, id); !errors.Is(err, ErrNotFound) { + t.Errorf("GetDoc after delete: err = %v, want ErrNotFound", err) + } + + var nRevs int + if err := db.QueryRow("SELECT COUNT(*) FROM doc_revisions WHERE doc_id = ?", id).Scan(&nRevs); err != nil { + t.Fatalf("count revisions: %v", err) + } + if nRevs != 0 { + t.Errorf("revisions remaining = %d, want 0", nRevs) + } + + var nComments int + if err := db.QueryRow("SELECT COUNT(*) FROM doc_comments WHERE doc_id = ?", id).Scan(&nComments); err != nil { + t.Fatalf("count comments: %v", err) + } + if nComments != 0 { + t.Errorf("comments remaining = %d, want 0", nComments) + } +} diff --git a/internal/db/export_test.go b/internal/db/export_test.go index 7230efa..a99be4f 100644 --- a/internal/db/export_test.go +++ b/internal/db/export_test.go @@ -323,7 +323,7 @@ func TestInsertIssueWithID(t *testing.T) { if err != nil { t.Fatalf("Begin: %v", err) } - if _, err := InsertIssueWithID(tx,issue); err != nil { + if _, err := InsertIssueWithID(tx, issue); err != nil { t.Fatalf("InsertIssueWithID: %v", err) } if err := tx.Commit(); err != nil { @@ -373,7 +373,7 @@ func TestInsertCommentWithID(t *testing.T) { if err != nil { t.Fatalf("Begin: %v", err) } - if _, err := InsertCommentWithID(tx,comment); err != nil { + if _, err := InsertCommentWithID(tx, comment); err != nil { t.Fatalf("InsertCommentWithID: %v", err) } if err := tx.Commit(); err != nil { @@ -426,7 +426,7 @@ func TestInsertRelationWithID(t *testing.T) { if err != nil { t.Fatalf("Begin: %v", err) } - if _, err := InsertRelationWithID(tx,rel); err != nil { + if _, err := InsertRelationWithID(tx, rel); err != nil { t.Fatalf("InsertRelationWithID: %v", err) } if err := tx.Commit(); err != nil { @@ -465,7 +465,7 @@ func TestInsertLabelWithID(t *testing.T) { if err != nil { t.Fatalf("Begin: %v", err) } - if _, err := InsertLabelWithID(tx,label); err != nil { + if _, err := InsertLabelWithID(tx, label); err != nil { t.Fatalf("InsertLabelWithID: %v", err) } if err := tx.Commit(); err != nil { @@ -503,16 +503,16 @@ func TestInsertIssueLabelMapping(t *testing.T) { if err != nil { t.Fatalf("Begin: %v", err) } - if _, err := InsertIssueWithID(tx,&model.Issue{ + if _, err := InsertIssueWithID(tx, &model.Issue{ ID: 10, Title: "issue", Status: model.StatusBacklog, Priority: model.PriorityNone, Kind: model.IssueKindTask, CreatedAt: now, UpdatedAt: now, }); err != nil { t.Fatalf("InsertIssueWithID: %v", err) } - if _, err := InsertLabelWithID(tx,&model.Label{ID: 20, Name: "test-label"}); err != nil { + if _, err := InsertLabelWithID(tx, &model.Label{ID: 20, Name: "test-label"}); err != nil { t.Fatalf("InsertLabelWithID: %v", err) } - if _, err := InsertIssueLabelMapping(tx,10, 20); err != nil { + if _, err := InsertIssueLabelMapping(tx, 10, 20); err != nil { t.Fatalf("InsertIssueLabelMapping: %v", err) } if err := tx.Commit(); err != nil { @@ -540,6 +540,9 @@ func TestExportImportRoundTrip(t *testing.T) { if err := Initialize(srcDB); err != nil { t.Fatalf("Initialize src: %v", err) } + if err := Migrate(srcDB); err != nil { + t.Fatalf("Migrate src: %v", err) + } // Create a parent issue. parentID, err := CreateIssue(srcDB, &model.Issue{ @@ -611,6 +614,76 @@ func TestExportImportRoundTrip(t *testing.T) { t.Fatalf("CreateRelation: %v", err) } + // Create docs with revisions, comments, and an issue link. + doc1ID, err := CreateDoc(srcDB, &model.Doc{ + Type: "tdd", Status: "draft", Title: "design doc", Body: "initial body", Author: "alice", + }) + if err != nil { + t.Fatalf("CreateDoc 1: %v", err) + } + // Append a revision by editing the body. + newBody := "revised body" + if _, err := UpdateDoc(srcDB, doc1ID, DocUpdate{Body: &newBody, Author: "bob"}); err != nil { + t.Fatalf("UpdateDoc 1: %v", err) + } + + doc2ID, err := CreateDoc(srcDB, &model.Doc{ + Type: "adr", Status: "accepted", Title: "decision record", Body: "the decision", Author: "carol", + }) + if err != nil { + t.Fatalf("CreateDoc 2: %v", err) + } + + // Comments on docs. + for _, c := range []*model.DocComment{ + {DocID: doc1ID, Body: "looks good", Author: "bob"}, + {DocID: doc1ID, Body: "one nit", Author: "carol"}, + {DocID: doc2ID, Body: "approved", Author: "alice"}, + } { + if _, err := CreateDocComment(srcDB, c); err != nil { + t.Fatalf("CreateDocComment: %v", err) + } + } + + // Link a doc to an issue. + if err := LinkDocIssue(srcDB, doc1ID, parentID); err != nil { + t.Fatalf("LinkDocIssue: %v", err) + } + + // Create a proposal with a vote and issue/doc links. + proposalID, err := CreateProposal(srcDB, &model.Proposal{ + Description: "ship the feature", + Criticality: model.CriticalityMedium, + Status: model.ProposalStatusOpen, + RequiredVoters: 2, + Threshold: 0.67, + CreatedBy: "@team-lead", + Rationale: "ready", + DomainTags: []string{"backend"}, + FilesChanged: []string{"main.go"}, + }) + if err != nil { + t.Fatalf("CreateProposal: %v", err) + } + if _, err := CastVote(srcDB, &model.Vote{ + ProposalID: proposalID, + VoterName: "@senior-engineer", + VoterRole: "senior-engineer", + Verdict: model.VerdictApprove, + Confidence: 0.9, + DomainRelevance: 0.8, + Summary: "approved", + FindingsJSON: &model.Findings{Concerns: []string{"nit"}}, + }); err != nil { + t.Fatalf("CastVote: %v", err) + } + if err := LinkProposalIssue(srcDB, proposalID, standaloneID); err != nil { + t.Fatalf("LinkProposalIssue: %v", err) + } + if err := LinkProposalDoc(srcDB, proposalID, doc2ID); err != nil { + t.Fatalf("LinkProposalDoc: %v", err) + } + // Phase 2: Export from source DB. srcExport := exportDB(t, srcDB) @@ -625,6 +698,9 @@ func TestExportImportRoundTrip(t *testing.T) { if err := Initialize(dstDB); err != nil { t.Fatalf("Initialize dst: %v", err) } + if err := Migrate(dstDB); err != nil { + t.Fatalf("Migrate dst: %v", err) + } var importData model.ExportData if err := json.Unmarshal(jsonBytes, &importData); err != nil { @@ -661,6 +737,9 @@ func TestImportToEmptyDB(t *testing.T) { if err := Initialize(srcDB); err != nil { t.Fatalf("Initialize src: %v", err) } + if err := Migrate(srcDB); err != nil { + t.Fatalf("Migrate src: %v", err) + } // Populate source. if _, err := CreateIssue(srcDB, &model.Issue{ @@ -680,6 +759,9 @@ func TestImportToEmptyDB(t *testing.T) { if err := Initialize(dstDB); err != nil { t.Fatalf("Initialize dst: %v", err) } + if err := Migrate(dstDB); err != nil { + t.Fatalf("Migrate dst: %v", err) + } var importData model.ExportData if err := json.Unmarshal(jsonBytes, &importData); err != nil { @@ -719,17 +801,17 @@ func TestImportMergeSkipsDuplicates(t *testing.T) { if err != nil { t.Fatalf("Begin: %v", err) } - if _, err := InsertLabelWithID(tx,&model.Label{ID: 1, Name: "existing-label"}); err != nil { + if _, err := InsertLabelWithID(tx, &model.Label{ID: 1, Name: "existing-label"}); err != nil { t.Fatalf("InsertLabelWithID: %v", err) } - if _, err := InsertIssueWithID(tx,&model.Issue{ + if _, err := InsertIssueWithID(tx, &model.Issue{ ID: 1, Title: "existing issue", Status: model.StatusBacklog, Priority: model.PriorityNone, Kind: model.IssueKindTask, CreatedAt: now, UpdatedAt: now, }); err != nil { t.Fatalf("InsertIssueWithID: %v", err) } - if _, err := InsertIssueLabelMapping(tx,1, 1); err != nil { + if _, err := InsertIssueLabelMapping(tx, 1, 1); err != nil { t.Fatalf("InsertIssueLabelMapping: %v", err) } if err := tx.Commit(); err != nil { @@ -888,6 +970,42 @@ func exportDB(t *testing.T, db *sql.DB) *model.ExportData { if err != nil { t.Fatalf("ListAllIssueFileMappings: %v", err) } + docs, err := ListAllDocs(db) + if err != nil { + t.Fatalf("ListAllDocs: %v", err) + } + docRevisions, err := ListAllDocRevisions(db) + if err != nil { + t.Fatalf("ListAllDocRevisions: %v", err) + } + docComments, err := ListAllDocComments(db) + if err != nil { + t.Fatalf("ListAllDocComments: %v", err) + } + docIssueLinks, err := ListAllDocIssueLinks(db) + if err != nil { + t.Fatalf("ListAllDocIssueLinks: %v", err) + } + proposalDocs, err := ListAllProposalDocs(db) + if err != nil { + t.Fatalf("ListAllProposalDocs: %v", err) + } + proposals, err := ListAllProposals(db) + if err != nil { + t.Fatalf("ListAllProposals: %v", err) + } + votes, err := ListAllVotes(db) + if err != nil { + t.Fatalf("ListAllVotes: %v", err) + } + proposalIssues, err := ListAllProposalIssues(db) + if err != nil { + t.Fatalf("ListAllProposalIssues: %v", err) + } + activityLog, err := ListAllActivity(db) + if err != nil { + t.Fatalf("ListAllActivity: %v", err) + } // Ensure nil slices become empty for JSON consistency. if issues == nil { @@ -908,6 +1026,24 @@ func exportDB(t *testing.T, db *sql.DB) *model.ExportData { if fileMappings == nil { fileMappings = []model.IssueFileMapping{} } + if activityLog == nil { + activityLog = []*model.Activity{} + } + if docs == nil { + docs = []*model.Doc{} + } + if docRevisions == nil { + docRevisions = []*model.DocRevision{} + } + if docComments == nil { + docComments = []*model.DocComment{} + } + if proposals == nil { + proposals = []*model.Proposal{} + } + if votes == nil { + votes = []*model.Vote{} + } return &model.ExportData{ Version: 1, @@ -918,6 +1054,15 @@ func exportDB(t *testing.T, db *sql.DB) *model.ExportData { Labels: labels, IssueLabelMappings: mappings, IssueFileMappings: fileMappings, + ActivityLog: activityLog, + Docs: docs, + DocRevisions: docRevisions, + DocComments: docComments, + DocIssueLinks: docIssueLinks, + Proposals: proposals, + Votes: votes, + ProposalIssues: proposalIssues, + ProposalDocs: proposalDocs, } } @@ -933,7 +1078,7 @@ func importAll(t *testing.T, db *sql.DB, data *model.ExportData) { // 1. Labels first (no FK deps). for _, label := range data.Labels { - if _, err := InsertLabelWithID(tx,label); err != nil { + if _, err := InsertLabelWithID(tx, label); err != nil { t.Fatalf("InsertLabelWithID %q: %v", label.Name, err) } } @@ -947,7 +1092,7 @@ func importAll(t *testing.T, db *sql.DB, data *model.ExportData) { parentIDs[issue.ID] = &pid issue.ParentID = nil } - if _, err := InsertIssueWithID(tx,issue); err != nil { + if _, err := InsertIssueWithID(tx, issue); err != nil { issue.ParentID = origParentID t.Fatalf("InsertIssueWithID %d: %v", issue.ID, err) } @@ -961,7 +1106,7 @@ func importAll(t *testing.T, db *sql.DB, data *model.ExportData) { // 3. Issue-label mappings. for _, m := range data.IssueLabelMappings { - if _, err := InsertIssueLabelMapping(tx,m.IssueID, m.LabelID); err != nil { + if _, err := InsertIssueLabelMapping(tx, m.IssueID, m.LabelID); err != nil { t.Fatalf("InsertIssueLabelMapping (%d, %d): %v", m.IssueID, m.LabelID, err) } } @@ -975,20 +1120,82 @@ func importAll(t *testing.T, db *sql.DB, data *model.ExportData) { // 5. Comments. for _, comment := range data.Comments { - if _, err := InsertCommentWithID(tx,comment); err != nil { + if _, err := InsertCommentWithID(tx, comment); err != nil { t.Fatalf("InsertCommentWithID %d: %v", comment.ID, err) } } // 6. Relations. for i := range data.Relations { - if _, err := InsertRelationWithID(tx,&data.Relations[i]); err != nil { + if _, err := InsertRelationWithID(tx, &data.Relations[i]); err != nil { t.Fatalf("InsertRelationWithID %d: %v", data.Relations[i].ID, err) } } + // 7. Activity log. + for _, a := range data.ActivityLog { + if _, err := InsertActivityWithID(tx, a); err != nil { + t.Fatalf("InsertActivityWithID %d: %v", a.ID, err) + } + } + + // 8. Proposals. + for _, p := range data.Proposals { + if _, err := InsertProposalWithID(tx, p); err != nil { + t.Fatalf("InsertProposalWithID %d: %v", p.ID, err) + } + } + + // 9. Votes. + for _, v := range data.Votes { + if _, err := InsertVoteWithID(tx, v); err != nil { + t.Fatalf("InsertVoteWithID %d: %v", v.ID, err) + } + } + + // 10. Proposal-issue links. + for _, l := range data.ProposalIssues { + if _, err := InsertProposalIssueLink(tx, l.ProposalID, l.IssueID); err != nil { + t.Fatalf("InsertProposalIssueLink (%d,%d): %v", l.ProposalID, l.IssueID, err) + } + } + + // 11. Docs. + for _, doc := range data.Docs { + if _, err := InsertDocWithID(tx, doc); err != nil { + t.Fatalf("InsertDocWithID %d: %v", doc.ID, err) + } + } + + // 12. Doc revisions. + for _, rev := range data.DocRevisions { + if _, err := InsertDocRevisionWithID(tx, rev); err != nil { + t.Fatalf("InsertDocRevisionWithID %d: %v", rev.ID, err) + } + } + + // 13. Doc comments. + for _, c := range data.DocComments { + if _, err := InsertDocCommentWithID(tx, c); err != nil { + t.Fatalf("InsertDocCommentWithID %d: %v", c.ID, err) + } + } + + // 14. Doc-issue links. + for _, l := range data.DocIssueLinks { + if _, err := InsertDocIssueLink(tx, l.DocID, l.IssueID, l.CreatedAt); err != nil { + t.Fatalf("InsertDocIssueLink (%d,%d): %v", l.DocID, l.IssueID, err) + } + } + + // 15. Proposal-doc links. + for _, l := range data.ProposalDocs { + if _, err := InsertProposalDocLink(tx, l.ProposalID, l.DocID, l.CreatedAt); err != nil { + t.Fatalf("InsertProposalDocLink (%d,%d): %v", l.ProposalID, l.DocID, err) + } + } + if err := tx.Commit(); err != nil { t.Fatalf("Commit: %v", err) } } - diff --git a/internal/db/issues.go b/internal/db/issues.go index 73dec8c..a5c9b4d 100644 --- a/internal/db/issues.go +++ b/internal/db/issues.go @@ -337,7 +337,7 @@ func ListIssues(db *sql.DB, opts ListOptions) ([]*model.Issue, int, error) { WHEN 'none' THEN 4 ELSE 5 END ASC, - i.created_at ASC` + i.created_at DESC` } // Main query. @@ -949,9 +949,17 @@ func CountByPriority(db *sql.DB) (map[string]int, error) { return countByColumn(db, "priority") } -// ClearAllData deletes all data from all tables (issues, comments, labels, -// issue_labels, issue_relations, activity_log) within a single transaction. -// The schema and meta table are preserved. +// ClearAllData deletes all data from every persistent table within a single +// transaction. The schema and meta table are preserved. +// +// Tables are deleted in FK-correct order — children before parents. FK CASCADE +// would handle dependents implicitly, but the explicit ordering keeps +// behaviour identical to the pre-v4 function and makes the contract auditable +// from the function body. +// +// Doc tables and the pre-existing proposals/votes/proposal_issues tables are +// included; prior to v4 the latter three were silently omitted, which broke +// `--replace` import on any DB containing proposals (TDD §5.4 S4 / R7). func ClearAllData(db *sql.DB) error { tx, err := db.Begin() if err != nil { @@ -959,7 +967,23 @@ func ClearAllData(db *sql.DB) error { } defer tx.Rollback() + if err := ClearAllDataTx(tx); err != nil { + return err + } + + return tx.Commit() +} + +func ClearAllDataTx(tx *sql.Tx) error { tables := []string{ + "doc_comments", + "doc_revisions", + "proposal_docs", + "doc_issue_links", + "docs", + "proposal_issues", + "votes", + "proposals", "activity_log", "issue_relations", "issue_files", @@ -970,11 +994,14 @@ func ClearAllData(db *sql.DB) error { } for _, table := range tables { if _, err := tx.Exec("DELETE FROM " + table); err != nil { + if strings.Contains(err.Error(), "no such table") { + continue + } return fmt.Errorf("clearing %s: %w", table, err) } } - return tx.Commit() + return nil } // InsertIssueWithID inserts an issue with a specific ID (not auto-increment), diff --git a/internal/db/proposals.go b/internal/db/proposals.go index ccead5c..e52e633 100644 --- a/internal/db/proposals.go +++ b/internal/db/proposals.go @@ -426,6 +426,37 @@ func GetProposalIssues(db *sql.DB, proposalID int) ([]int, error) { return ids, nil } +// GetIssueProposals returns the proposals linked to an issue, ordered by +// proposal id ascending. It is the reverse edge of GetProposalIssues. +func GetIssueProposals(db *sql.DB, issueID int) ([]model.Proposal, error) { + rows, err := db.Query( + `SELECT p.id, p.description, p.rationale, p.domain_tags, p.files_changed, p.criticality, p.status, p.final_outcome, p.escalation_reason, p.required_voters, p.threshold, p.weighted_score, p.created_by, p.created_at, p.updated_at + FROM proposals p + JOIN proposal_issues pi ON pi.proposal_id = p.id + WHERE pi.issue_id = ? + ORDER BY p.id ASC`, + issueID, + ) + if err != nil { + return nil, fmt.Errorf("querying issue proposals: %w", err) + } + defer rows.Close() + + var proposals []model.Proposal + for rows.Next() { + p, err := scanProposalFrom(rows) + if err != nil { + return nil, fmt.Errorf("scanning proposal row: %w", err) + } + proposals = append(proposals, *p) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating issue proposal rows: %w", err) + } + + return proposals, nil +} + // CommitProposal transitions an approved proposal to committed status with a final outcome. // If escalationReason is non-empty, it is stored on the proposal. func CommitProposal(db *sql.DB, id int, outcome string, escalationReason string) error { @@ -561,3 +592,168 @@ func scanVoteFrom(s scanner) (*model.Vote, error) { return &v, nil } + +// ListAllProposals returns every proposal row ordered by id ASC, for a full +// export. +func ListAllProposals(db *sql.DB) ([]*model.Proposal, error) { + rows, err := db.Query( + `SELECT id, description, rationale, domain_tags, files_changed, criticality, + status, final_outcome, escalation_reason, required_voters, threshold, + weighted_score, created_by, created_at, updated_at + FROM proposals ORDER BY id ASC`, + ) + if err != nil { + return nil, fmt.Errorf("querying all proposals: %w", err) + } + defer rows.Close() + + var proposals []*model.Proposal + for rows.Next() { + p, err := scanProposalFrom(rows) + if err != nil { + return nil, fmt.Errorf("scanning proposal row: %w", err) + } + proposals = append(proposals, p) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating proposal rows: %w", err) + } + return proposals, nil +} + +// ListAllVotes returns every vote row ordered by id ASC, for a full export. +func ListAllVotes(db *sql.DB) ([]*model.Vote, error) { + rows, err := db.Query( + `SELECT id, proposal_id, voter_name, voter_role, verdict, confidence, + domain_relevance, findings, findings_json, summary, created_at + FROM votes ORDER BY id ASC`, + ) + if err != nil { + return nil, fmt.Errorf("querying all votes: %w", err) + } + defer rows.Close() + + var votes []*model.Vote + for rows.Next() { + v, err := scanVoteFrom(rows) + if err != nil { + return nil, fmt.Errorf("scanning vote row: %w", err) + } + votes = append(votes, v) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating vote rows: %w", err) + } + return votes, nil +} + +// ListAllProposalIssues returns every proposal_issues row ordered by +// (proposal_id, issue_id), for a full export. +func ListAllProposalIssues(db *sql.DB) ([]model.ProposalIssueLink, error) { + rows, err := db.Query( + `SELECT proposal_id, issue_id + FROM proposal_issues ORDER BY proposal_id ASC, issue_id ASC`, + ) + if err != nil { + return nil, fmt.Errorf("querying all proposal_issues: %w", err) + } + defer rows.Close() + + out := make([]model.ProposalIssueLink, 0) + for rows.Next() { + var l model.ProposalIssueLink + if err := rows.Scan(&l.ProposalID, &l.IssueID); err != nil { + return nil, fmt.Errorf("scanning proposal_issue row: %w", err) + } + out = append(out, l) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating proposal_issue rows: %w", err) + } + return out, nil +} + +// InsertProposalWithID inserts a proposal row with a caller-supplied ID, +// skipping if the ID already exists. Must be called within an existing +// transaction. Returns true if inserted. Mirrors InsertIssueWithID; domain_tags +// and files_changed are JSON-encoded identically to CreateProposal. +func InsertProposalWithID(tx *sql.Tx, p *model.Proposal) (bool, error) { + domainTagsJSON, err := json.Marshal(p.DomainTags) + if err != nil { + return false, fmt.Errorf("marshaling domain_tags: %w", err) + } + filesChangedJSON, err := json.Marshal(p.FilesChanged) + if err != nil { + return false, fmt.Errorf("marshaling files_changed: %w", err) + } + + var weightedScore any + if p.WeightedScore != nil { + weightedScore = *p.WeightedScore + } + var escalationReason any + if p.EscalationReason != nil { + escalationReason = *p.EscalationReason + } + + res, err := tx.Exec( + `INSERT OR IGNORE INTO proposals + (id, description, rationale, domain_tags, files_changed, criticality, status, + final_outcome, escalation_reason, required_voters, threshold, weighted_score, + created_by, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + p.ID, p.Description, p.Rationale, string(domainTagsJSON), string(filesChangedJSON), + string(p.Criticality), string(p.Status), p.FinalOutcome, escalationReason, + p.RequiredVoters, p.Threshold, weightedScore, p.CreatedBy, + p.CreatedAt.UTC().Format(time.RFC3339), p.UpdatedAt.UTC().Format(time.RFC3339), + ) + if err != nil { + return false, fmt.Errorf("inserting proposal with id %d: %w", p.ID, err) + } + n, _ := res.RowsAffected() + return n > 0, nil +} + +// InsertVoteWithID inserts a vote row with a caller-supplied ID, skipping if the +// ID already exists. Must be called within an existing transaction. Returns true +// if inserted. findings_json is JSON-encoded (NULL when absent) identically to +// CastVote. +func InsertVoteWithID(tx *sql.Tx, v *model.Vote) (bool, error) { + var findingsJSONStr any + if v.FindingsJSON != nil { + b, err := json.Marshal(v.FindingsJSON) + if err != nil { + return false, fmt.Errorf("marshaling findings_json: %w", err) + } + findingsJSONStr = string(b) + } + + res, err := tx.Exec( + `INSERT OR IGNORE INTO votes + (id, proposal_id, voter_name, voter_role, verdict, confidence, + domain_relevance, findings, findings_json, summary, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + v.ID, v.ProposalID, v.VoterName, v.VoterRole, string(v.Verdict), v.Confidence, + v.DomainRelevance, v.Findings, findingsJSONStr, v.Summary, + v.CreatedAt.UTC().Format(time.RFC3339), + ) + if err != nil { + return false, fmt.Errorf("inserting vote with id %d: %w", v.ID, err) + } + n, _ := res.RowsAffected() + return n > 0, nil +} + +// InsertProposalIssueLink inserts a proposal_issues row, skipping on PK +// conflict. Must be called within a transaction. Returns true if inserted. +func InsertProposalIssueLink(tx *sql.Tx, proposalID, issueID int) (bool, error) { + res, err := tx.Exec( + `INSERT OR IGNORE INTO proposal_issues (proposal_id, issue_id) VALUES (?, ?)`, + proposalID, issueID, + ) + if err != nil { + return false, fmt.Errorf("inserting proposal_issue (%d,%d): %w", proposalID, issueID, err) + } + n, _ := res.RowsAffected() + return n > 0, nil +} diff --git a/internal/db/proposals_test.go b/internal/db/proposals_test.go index d7f6652..596b4f5 100644 --- a/internal/db/proposals_test.go +++ b/internal/db/proposals_test.go @@ -43,13 +43,14 @@ func TestMigrateV1ToV2CreatesProposalTables(t *testing.T) { t.Fatalf("Migrate: %v", err) } - // Schema should now be at v3. + // Migrate advances all the way to head; this test verifies v1→v2 in + // particular, but Migrate is contractually all-or-head. v, err = SchemaVersion(db) if err != nil { t.Fatalf("SchemaVersion: %v", err) } - if v != 3 { - t.Fatalf("schema_version = %d after migration, want 3", v) + if v != currentSchemaVersion { + t.Fatalf("schema_version = %d after migration, want %d", v, currentSchemaVersion) } // Verify new tables exist. @@ -1289,13 +1290,14 @@ func TestMigrateV2ToV3Columns(t *testing.T) { t.Fatalf("Migrate v2->v3: %v", err) } - // Verify version is now 3. + // Verify version is now at head (Migrate advances v2 to the current + // schema version, applying v3 and any later migrations in sequence). v, err = SchemaVersion(db) if err != nil { t.Fatalf("SchemaVersion after migration: %v", err) } - if v != 3 { - t.Fatalf("schema_version = %d after migration, want 3", v) + if v != currentSchemaVersion { + t.Fatalf("schema_version = %d after migration, want %d", v, currentSchemaVersion) } // Verify existing proposal has correct defaults for new columns. @@ -1341,3 +1343,113 @@ func TestMigrateV2ToV3Columns(t *testing.T) { t.Errorf("Description = %q, want 'Pre-migration proposal'", p.Description) } } + +// --- GetIssueProposals (reverse edge of GetProposalIssues) --- + +func TestGetIssueProposalsZero(t *testing.T) { + db := mustInitAndMigrate(t) + iid := createTestIssueForProposal(t, db, "no-proposals") + + proposals, err := GetIssueProposals(db, iid) + if err != nil { + t.Fatalf("GetIssueProposals: %v", err) + } + if len(proposals) != 0 { + t.Errorf("len(proposals) = %d, want 0", len(proposals)) + } +} + +func TestGetIssueProposalsOne(t *testing.T) { + db := mustInitAndMigrate(t) + iid := createTestIssueForProposal(t, db, "one-proposal") + + pid, err := CreateProposal(db, &model.Proposal{ + Description: "Solo proposal", Criticality: model.CriticalityMedium, + Status: model.ProposalStatusOpen, RequiredVoters: 1, Threshold: 0.67, + }) + if err != nil { + t.Fatalf("CreateProposal: %v", err) + } + if err := LinkProposalIssue(db, pid, iid); err != nil { + t.Fatalf("LinkProposalIssue: %v", err) + } + + proposals, err := GetIssueProposals(db, iid) + if err != nil { + t.Fatalf("GetIssueProposals: %v", err) + } + if len(proposals) != 1 { + t.Fatalf("len(proposals) = %d, want 1", len(proposals)) + } + if proposals[0].ID != pid { + t.Errorf("proposals[0].ID = %d, want %d", proposals[0].ID, pid) + } + if proposals[0].Description != "Solo proposal" { + t.Errorf("proposals[0].Description = %q, want 'Solo proposal'", proposals[0].Description) + } + if proposals[0].Status != model.ProposalStatusOpen { + t.Errorf("proposals[0].Status = %q, want %q", proposals[0].Status, model.ProposalStatusOpen) + } +} + +func TestGetIssueProposalsManyMixedStatusDeterministicOrder(t *testing.T) { + db := mustInitAndMigrate(t) + iid := createTestIssueForProposal(t, db, "many-proposals") + other := createTestIssueForProposal(t, db, "unrelated-issue") + + statuses := []model.ProposalStatus{ + model.ProposalStatusOpen, + model.ProposalStatusApproved, + model.ProposalStatusRejected, + model.ProposalStatusCommitted, + } + var want []int + for i, st := range statuses { + pid, err := CreateProposal(db, &model.Proposal{ + Description: "Proposal " + string(rune('A'+i)), Criticality: model.CriticalityMedium, + Status: st, RequiredVoters: 1, Threshold: 0.67, + }) + if err != nil { + t.Fatalf("CreateProposal %d: %v", i, err) + } + want = append(want, pid) + } + + for i := len(want) - 1; i >= 0; i-- { + if err := LinkProposalIssue(db, want[i], iid); err != nil { + t.Fatalf("LinkProposalIssue %d: %v", want[i], err) + } + } + + otherIssuePID, err := CreateProposal(db, &model.Proposal{ + Description: "Other", Criticality: model.CriticalityMedium, + Status: model.ProposalStatusOpen, RequiredVoters: 1, Threshold: 0.67, + }) + if err != nil { + t.Fatalf("CreateProposal other: %v", err) + } + if err := LinkProposalIssue(db, otherIssuePID, other); err != nil { + t.Fatalf("LinkProposalIssue other: %v", err) + } + + proposals, err := GetIssueProposals(db, iid) + if err != nil { + t.Fatalf("GetIssueProposals: %v", err) + } + for _, p := range proposals { + if p.ID == otherIssuePID { + t.Errorf("proposal linked only to another issue must not appear in results, got %v", p.ID) + } + } + if len(proposals) != len(want) { + t.Fatalf("len(proposals) = %d, want %d", len(proposals), len(want)) + } + for i, p := range proposals { + if p.ID != want[i] { + t.Errorf("proposals[%d].ID = %d, want %d (results must be sorted by query, not insertion order)", i, p.ID, want[i]) + } + if p.Status != statuses[i] { + t.Errorf("proposals[%d].Status = %q, want %q", i, p.Status, statuses[i]) + } + } +} diff --git a/internal/db/relations.go b/internal/db/relations.go index bcf7dcf..b4be608 100644 --- a/internal/db/relations.go +++ b/internal/db/relations.go @@ -12,9 +12,9 @@ import ( // Sentinel errors for relation operations. var ( - ErrSelfRelation = errors.New("self-referential relation") + ErrSelfRelation = errors.New("self-referential relation") ErrDuplicateRelation = errors.New("duplicate relation") - ErrCycleDetected = errors.New("cycle detected") + ErrCycleDetected = errors.New("cycle detected") ) // CycleError wraps ErrCycleDetected and carries the path of IDs forming the cycle. diff --git a/internal/db/schema.go b/internal/db/schema.go index b52b577..b163aee 100644 --- a/internal/db/schema.go +++ b/internal/db/schema.go @@ -6,7 +6,7 @@ import ( "strconv" ) -const currentSchemaVersion = 3 +const currentSchemaVersion = 4 // schemaDDL contains the CREATE TABLE statements for the initial schema. const schemaDDL = ` @@ -139,6 +139,7 @@ func SchemaVersion(db *sql.DB) (int, error) { var migrations = map[int]func(tx *sql.Tx) error{ 2: migrateV1ToV2, 3: migrateV2ToV3, + 4: migrateV3ToV4, } // migrateV1ToV2 creates the proposals, votes, and proposal_issues tables. @@ -209,6 +210,66 @@ func migrateV2ToV3(tx *sql.Tx) error { return nil } +// migrateV3ToV4 creates the docs, doc_revisions, doc_comments, doc_issue_links, +// and proposal_docs tables (TDD docket-doc-cli §5.1). +func migrateV3ToV4(tx *sql.Tx) error { + const ddl = ` +CREATE TABLE IF NOT EXISTS docs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft', + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + author TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS doc_revisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + doc_id INTEGER NOT NULL REFERENCES docs(id) ON DELETE CASCADE, + revision_number INTEGER NOT NULL, + body TEXT NOT NULL, + change_kind TEXT NOT NULL DEFAULT 'body', + author TEXT, + created_at TEXT NOT NULL, + UNIQUE(doc_id, revision_number) +); + +CREATE TABLE IF NOT EXISTS doc_comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + doc_id INTEGER NOT NULL REFERENCES docs(id) ON DELETE CASCADE, + body TEXT NOT NULL, + author TEXT, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS doc_issue_links ( + doc_id INTEGER NOT NULL REFERENCES docs(id) ON DELETE CASCADE, + issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + PRIMARY KEY (doc_id, issue_id) +); + +CREATE TABLE IF NOT EXISTS proposal_docs ( + proposal_id INTEGER NOT NULL REFERENCES proposals(id) ON DELETE CASCADE, + doc_id INTEGER NOT NULL REFERENCES docs(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + PRIMARY KEY (proposal_id, doc_id) +); + +CREATE INDEX IF NOT EXISTS idx_docs_type ON docs(type); +CREATE INDEX IF NOT EXISTS idx_docs_status ON docs(status); +CREATE INDEX IF NOT EXISTS idx_docs_created_at ON docs(created_at); +CREATE INDEX IF NOT EXISTS idx_doc_revisions_doc_id ON doc_revisions(doc_id); +CREATE INDEX IF NOT EXISTS idx_doc_comments_doc_id ON doc_comments(doc_id); +CREATE INDEX IF NOT EXISTS idx_doc_issue_links_issue_id ON doc_issue_links(issue_id); +CREATE INDEX IF NOT EXISTS idx_proposal_docs_doc_id ON proposal_docs(doc_id); +` + _, err := tx.Exec(ddl) + return err +} + // Migrate checks the current schema version and applies any pending migrations // sequentially. It is a no-op when already at the latest version. func Migrate(db *sql.DB) error { @@ -230,6 +291,18 @@ func Migrate(db *sql.DB) error { } } + // Same defensive guard for v4 (TDD §5.1 S10): if stamped >=4 but the docs + // table is absent, rewind to v3 and re-run. v4 DDL uses IF NOT EXISTS. + if version >= 4 { + var hasDocs bool + err := db.QueryRow( + `SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='docs')`, + ).Scan(&hasDocs) + if err == nil && !hasDocs { + version = 3 + } + } + if version == currentSchemaVersion { return nil } diff --git a/internal/model/doc.go b/internal/model/doc.go new file mode 100644 index 0000000..51e02ec --- /dev/null +++ b/internal/model/doc.go @@ -0,0 +1,283 @@ +package model + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" +) + +// DocIDPrefix is the prefix used for doc IDs in display and JSON output. +// A single global counter is used across all doc types (see TDD §3.3, C2). +const DocIDPrefix = "DOC" + +// FormatDocID returns the display form of a doc ID, e.g. "DOC-5". +func FormatDocID(id int) string { + return fmt.Sprintf("%s-%d", DocIDPrefix, id) +} + +// ParseDocID accepts both "DOC-5" and "5" and returns the numeric ID. +// The prefix check is case-insensitive. +func ParseDocID(input string) (int, error) { + s := strings.TrimSpace(input) + if s == "" { + return 0, fmt.Errorf("empty doc ID") + } + + prefix := DocIDPrefix + "-" + if strings.HasPrefix(strings.ToUpper(s), prefix) { + s = s[len(prefix):] + } + + id, err := strconv.Atoi(s) + if err != nil { + return 0, fmt.Errorf("invalid doc ID %q: %w", input, err) + } + if id <= 0 { + return 0, fmt.Errorf("invalid doc ID %q: must be positive", input) + } + + return id, nil +} + +// Doc represents a tracked design document (TDD, ADR, PRD, etc.). `Type` and +// `Status` are free-form per TDD §5.4 — no enum validation at the model layer. +type Doc struct { + ID int + Type string + Status string + Title string + Body string + Author string + CreatedAt time.Time + UpdatedAt time.Time +} + +// docJSON is the JSON wire format for Doc. +type docJSON struct { + ID string `json:"id"` + Type string `json:"type"` + Status string `json:"status"` + Title string `json:"title"` + Body string `json:"body"` + Author string `json:"author"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// MarshalJSON implements custom JSON serialization for Doc. +func (d Doc) MarshalJSON() ([]byte, error) { + return json.Marshal(docJSON{ + ID: FormatDocID(d.ID), + Type: d.Type, + Status: d.Status, + Title: d.Title, + Body: d.Body, + Author: d.Author, + CreatedAt: d.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: d.UpdatedAt.UTC().Format(time.RFC3339), + }) +} + +// UnmarshalJSON implements custom JSON deserialization for Doc. +func (d *Doc) UnmarshalJSON(data []byte) error { + var j docJSON + if err := json.Unmarshal(data, &j); err != nil { + return err + } + + id, err := ParseDocID(j.ID) + if err != nil { + return fmt.Errorf("parsing doc id: %w", err) + } + d.ID = id + + d.Type = j.Type + d.Status = j.Status + d.Title = j.Title + d.Body = j.Body + d.Author = j.Author + + createdAt, err := time.Parse(time.RFC3339, j.CreatedAt) + if err != nil { + return fmt.Errorf("parsing created_at: %w", err) + } + d.CreatedAt = createdAt + + updatedAt, err := time.Parse(time.RFC3339, j.UpdatedAt) + if err != nil { + return fmt.Errorf("parsing updated_at: %w", err) + } + d.UpdatedAt = updatedAt + + return nil +} + +type DocRef struct { + ID int + Type string + Status string + Title string +} + +type docRefJSON struct { + ID string `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + Status string `json:"status"` +} + +func (r DocRef) MarshalJSON() ([]byte, error) { + return json.Marshal(docRefJSON{ + ID: FormatDocID(r.ID), + Type: r.Type, + Title: r.Title, + Status: r.Status, + }) +} + +// UnmarshalJSON is a deliberate no-op: DocRef is a render-only output +// projection hydrated from the link table, never parsed back from JSON. +func (r *DocRef) UnmarshalJSON([]byte) error { + return nil +} + +// DocRevision is an append-only history row for a Doc. `ChangeKind` is a +// free-form descriptor: "create", "body", "status", "title", "type", or +// comma-joined for combined edits (e.g. "status+body") per TDD §5.4 C8. +type DocRevision struct { + ID int + DocID int + RevisionNumber int + Body string + ChangeKind string + Author string + CreatedAt time.Time +} + +// docRevisionJSON is the JSON wire format for DocRevision. +type docRevisionJSON struct { + ID int `json:"id"` + DocID string `json:"doc_id"` + RevisionNumber int `json:"revision_number"` + Body string `json:"body"` + ChangeKind string `json:"change_kind"` + Author string `json:"author"` + CreatedAt string `json:"created_at"` +} + +// MarshalJSON implements custom JSON serialization for DocRevision. +func (r DocRevision) MarshalJSON() ([]byte, error) { + return json.Marshal(docRevisionJSON{ + ID: r.ID, + DocID: FormatDocID(r.DocID), + RevisionNumber: r.RevisionNumber, + Body: r.Body, + ChangeKind: r.ChangeKind, + Author: r.Author, + CreatedAt: r.CreatedAt.UTC().Format(time.RFC3339), + }) +} + +// UnmarshalJSON implements custom JSON deserialization for DocRevision. +func (r *DocRevision) UnmarshalJSON(data []byte) error { + var j docRevisionJSON + if err := json.Unmarshal(data, &j); err != nil { + return err + } + + r.ID = j.ID + + docID, err := ParseDocID(j.DocID) + if err != nil { + return fmt.Errorf("parsing doc id: %w", err) + } + r.DocID = docID + + r.RevisionNumber = j.RevisionNumber + r.Body = j.Body + r.ChangeKind = j.ChangeKind + r.Author = j.Author + + createdAt, err := time.Parse(time.RFC3339, j.CreatedAt) + if err != nil { + return fmt.Errorf("parsing created_at: %w", err) + } + r.CreatedAt = createdAt + + return nil +} + +// DocComment represents a comment on a Doc. Mirrors `model.Comment` (author +// stored as plain string; DB scan layer wraps with sql.NullString per S5). +type DocComment struct { + ID int + DocID int + Body string + Author string + CreatedAt time.Time +} + +// docCommentJSON is the JSON wire format for DocComment. +type docCommentJSON struct { + ID int `json:"id"` + DocID string `json:"doc_id"` + Body string `json:"body"` + Author string `json:"author"` + CreatedAt string `json:"created_at"` +} + +// MarshalJSON implements custom JSON serialization for DocComment. +func (c DocComment) MarshalJSON() ([]byte, error) { + return json.Marshal(docCommentJSON{ + ID: c.ID, + DocID: FormatDocID(c.DocID), + Body: c.Body, + Author: c.Author, + CreatedAt: c.CreatedAt.UTC().Format(time.RFC3339), + }) +} + +// UnmarshalJSON implements custom JSON deserialization for DocComment. +func (c *DocComment) UnmarshalJSON(data []byte) error { + var j docCommentJSON + if err := json.Unmarshal(data, &j); err != nil { + return err + } + + c.ID = j.ID + + docID, err := ParseDocID(j.DocID) + if err != nil { + return fmt.Errorf("parsing doc id: %w", err) + } + c.DocID = docID + + c.Body = j.Body + c.Author = j.Author + + createdAt, err := time.Parse(time.RFC3339, j.CreatedAt) + if err != nil { + return fmt.Errorf("parsing created_at: %w", err) + } + c.CreatedAt = createdAt + + return nil +} + +// DocIssueLink is an export-format row from the doc_issue_links join table. +// IDs are plain ints (mirrors IssueLabelMapping); CreatedAt is a string to +// round-trip the on-disk RFC3339 representation verbatim. +type DocIssueLink struct { + DocID int `json:"doc_id"` + IssueID int `json:"issue_id"` + CreatedAt string `json:"created_at"` +} + +// ProposalDocLink is an export-format row from the proposal_docs join table. +type ProposalDocLink struct { + ProposalID int `json:"proposal_id"` + DocID int `json:"doc_id"` + CreatedAt string `json:"created_at"` +} diff --git a/internal/model/doc_test.go b/internal/model/doc_test.go new file mode 100644 index 0000000..1d6b3dd --- /dev/null +++ b/internal/model/doc_test.go @@ -0,0 +1,320 @@ +package model + +import ( + "encoding/json" + "testing" + "time" +) + +func TestFormatDocID(t *testing.T) { + tests := []struct { + id int + want string + }{ + {1, "DOC-1"}, + {5, "DOC-5"}, + {42, "DOC-42"}, + {999, "DOC-999"}, + } + for _, tt := range tests { + if got := FormatDocID(tt.id); got != tt.want { + t.Errorf("FormatDocID(%d) = %q, want %q", tt.id, got, tt.want) + } + } +} + +func TestParseDocID(t *testing.T) { + tests := []struct { + input string + want int + wantErr bool + }{ + {"DOC-5", 5, false}, + {"doc-5", 5, false}, + {" DOC-10 ", 10, false}, + {"5", 5, false}, + {"42", 42, false}, + {"", 0, true}, + {"DOC-", 0, true}, + {"abc", 0, true}, + {"DOC-0", 0, true}, + {"DOC--1", 0, true}, + } + + for _, tt := range tests { + got, err := ParseDocID(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ParseDocID(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + continue + } + if got != tt.want { + t.Errorf("ParseDocID(%q) = %d, want %d", tt.input, got, tt.want) + } + } +} + +func TestFormatParseDocIDRoundTrip(t *testing.T) { + for _, id := range []int{1, 5, 42, 999} { + formatted := FormatDocID(id) + parsed, err := ParseDocID(formatted) + if err != nil { + t.Errorf("ParseDocID(FormatDocID(%d)) error: %v", id, err) + continue + } + if parsed != id { + t.Errorf("ParseDocID(FormatDocID(%d)) = %d", id, parsed) + } + } +} + +func TestDocRef_MarshalJSON(t *testing.T) { + ref := DocRef{ + ID: 3, + Type: "tdd", + Status: "approved", + Title: "Docket Doc CLI", + } + + data, err := json.Marshal(ref) + if err != nil { + t.Fatalf("Marshal error: %v", err) + } + + want := `{"id":"DOC-3","type":"tdd","title":"Docket Doc CLI","status":"approved"}` + if string(data) != want { + t.Errorf("DocRef JSON = %s, want %s", data, want) + } + + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("Unmarshal into map error: %v", err) + } + if raw["id"] != "DOC-3" { + t.Errorf("JSON id = %v, want %q", raw["id"], "DOC-3") + } + if _, hasBody := raw["body"]; hasBody { + t.Error("DocRef JSON must not include body") + } + if _, hasAuthor := raw["author"]; hasAuthor { + t.Error("DocRef JSON must not include author") + } + if _, hasCreated := raw["created_at"]; hasCreated { + t.Error("DocRef JSON must not include created_at") + } +} + +func TestDocJSONRoundTrip(t *testing.T) { + now := time.Date(2026, 5, 26, 16, 0, 0, 0, time.UTC) + doc := Doc{ + ID: 42, + Type: "tdd", + Status: "draft", + Title: "Add docket doc CLI", + Body: "...current body...", + Author: "Erik Reinert", + CreatedAt: now, + UpdatedAt: now, + } + + data, err := json.Marshal(doc) + if err != nil { + t.Fatalf("Marshal error: %v", err) + } + + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("Unmarshal into map error: %v", err) + } + if raw["id"] != "DOC-42" { + t.Errorf("JSON id = %v, want %q", raw["id"], "DOC-42") + } + if raw["type"] != "tdd" { + t.Errorf("JSON type = %v, want %q", raw["type"], "tdd") + } + if raw["status"] != "draft" { + t.Errorf("JSON status = %v, want %q", raw["status"], "draft") + } + if raw["created_at"] != "2026-05-26T16:00:00Z" { + t.Errorf("JSON created_at = %v, want RFC3339 string", raw["created_at"]) + } + + var doc2 Doc + if err := json.Unmarshal(data, &doc2); err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + if doc2.ID != 42 { + t.Errorf("Unmarshaled ID = %d, want 42", doc2.ID) + } + if doc2.Type != "tdd" || doc2.Status != "draft" || doc2.Title != "Add docket doc CLI" { + t.Errorf("Unmarshaled doc = %+v", doc2) + } + if !doc2.CreatedAt.Equal(now) || !doc2.UpdatedAt.Equal(now) { + t.Errorf("Unmarshaled timestamps lost precision: created=%v updated=%v", doc2.CreatedAt, doc2.UpdatedAt) + } +} + +func TestDocJSONFreeFormTypeAndStatus(t *testing.T) { + now := time.Date(2026, 5, 26, 16, 0, 0, 0, time.UTC) + doc := Doc{ + ID: 1, + Type: "custom-shape", + Status: "wip-in-flight", + Title: "Free-form fields", + CreatedAt: now, + UpdatedAt: now, + } + + data, err := json.Marshal(doc) + if err != nil { + t.Fatalf("Marshal error: %v", err) + } + + var doc2 Doc + if err := json.Unmarshal(data, &doc2); err != nil { + t.Fatalf("Unmarshal error (free-form values should not be validated): %v", err) + } + if doc2.Type != "custom-shape" || doc2.Status != "wip-in-flight" { + t.Errorf("Unmarshaled free-form fields = %+v", doc2) + } +} + +func TestDocRevisionJSONRoundTrip(t *testing.T) { + now := time.Date(2026, 5, 26, 16, 15, 0, 0, time.UTC) + rev := DocRevision{ + ID: 7, + DocID: 42, + RevisionNumber: 3, + Body: "revision body", + ChangeKind: "status+body", + Author: "vote-bot", + CreatedAt: now, + } + + data, err := json.Marshal(rev) + if err != nil { + t.Fatalf("Marshal error: %v", err) + } + + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("Unmarshal into map error: %v", err) + } + if raw["doc_id"] != "DOC-42" { + t.Errorf("JSON doc_id = %v, want %q", raw["doc_id"], "DOC-42") + } + if raw["revision_number"] != float64(3) { + t.Errorf("JSON revision_number = %v, want 3", raw["revision_number"]) + } + if raw["change_kind"] != "status+body" { + t.Errorf("JSON change_kind = %v, want %q", raw["change_kind"], "status+body") + } + if raw["created_at"] != "2026-05-26T16:15:00Z" { + t.Errorf("JSON created_at = %v, want RFC3339 string", raw["created_at"]) + } + + var rev2 DocRevision + if err := json.Unmarshal(data, &rev2); err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + if rev2.DocID != 42 || rev2.RevisionNumber != 3 || rev2.ChangeKind != "status+body" { + t.Errorf("Unmarshaled DocRevision = %+v", rev2) + } + if !rev2.CreatedAt.Equal(now) { + t.Errorf("Unmarshaled created_at lost precision: %v", rev2.CreatedAt) + } +} + +func TestDocCommentJSONRoundTrip(t *testing.T) { + now := time.Date(2026, 5, 26, 16, 30, 0, 0, time.UTC) + comment := DocComment{ + ID: 9, + DocID: 42, + Body: "Looks good", + Author: "alice", + CreatedAt: now, + } + + data, err := json.Marshal(comment) + if err != nil { + t.Fatalf("Marshal error: %v", err) + } + + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("Unmarshal into map error: %v", err) + } + if raw["id"] != float64(9) { + t.Errorf("JSON id = %v, want 9", raw["id"]) + } + if raw["doc_id"] != "DOC-42" { + t.Errorf("JSON doc_id = %v, want %q", raw["doc_id"], "DOC-42") + } + + var c2 DocComment + if err := json.Unmarshal(data, &c2); err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + if c2.ID != 9 || c2.DocID != 42 || c2.Author != "alice" || c2.Body != "Looks good" { + t.Errorf("Unmarshaled DocComment = %+v", c2) + } + if !c2.CreatedAt.Equal(now) { + t.Errorf("Unmarshaled created_at lost precision: %v", c2.CreatedAt) + } +} + +func TestDocIssueLinkJSON(t *testing.T) { + link := DocIssueLink{DocID: 42, IssueID: 12, CreatedAt: "2026-05-26T16:00:00Z"} + data, err := json.Marshal(link) + if err != nil { + t.Fatalf("Marshal error: %v", err) + } + + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("Unmarshal into map error: %v", err) + } + if raw["doc_id"] != float64(42) { + t.Errorf("JSON doc_id = %v, want 42 (plain int per export shape)", raw["doc_id"]) + } + if raw["issue_id"] != float64(12) { + t.Errorf("JSON issue_id = %v, want 12", raw["issue_id"]) + } + if raw["created_at"] != "2026-05-26T16:00:00Z" { + t.Errorf("JSON created_at = %v", raw["created_at"]) + } + + var link2 DocIssueLink + if err := json.Unmarshal(data, &link2); err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + if link2 != link { + t.Errorf("Unmarshaled DocIssueLink = %+v, want %+v", link2, link) + } +} + +func TestProposalDocLinkJSON(t *testing.T) { + link := ProposalDocLink{ProposalID: 3, DocID: 42, CreatedAt: "2026-05-26T16:00:00Z"} + data, err := json.Marshal(link) + if err != nil { + t.Fatalf("Marshal error: %v", err) + } + + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("Unmarshal into map error: %v", err) + } + if raw["proposal_id"] != float64(3) { + t.Errorf("JSON proposal_id = %v, want 3 (plain int per export shape)", raw["proposal_id"]) + } + if raw["doc_id"] != float64(42) { + t.Errorf("JSON doc_id = %v, want 42", raw["doc_id"]) + } + + var link2 ProposalDocLink + if err := json.Unmarshal(data, &link2); err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + if link2 != link { + t.Errorf("Unmarshaled ProposalDocLink = %+v, want %+v", link2, link) + } +} diff --git a/internal/model/export.go b/internal/model/export.go index 4454d0f..3c7eea1 100644 --- a/internal/model/export.go +++ b/internal/model/export.go @@ -14,12 +14,21 @@ type IssueFileMapping struct { // ExportData is the top-level structure for a full database export. type ExportData struct { - Version int `json:"version"` - ExportedAt string `json:"exported_at"` - Issues []*Issue `json:"issues"` - Comments []*Comment `json:"comments"` - Relations []Relation `json:"relations"` - Labels []*Label `json:"labels"` + Version int `json:"version"` + ExportedAt string `json:"exported_at"` + Issues []*Issue `json:"issues"` + Comments []*Comment `json:"comments"` + Relations []Relation `json:"relations"` + Labels []*Label `json:"labels"` IssueLabelMappings []IssueLabelMapping `json:"issue_label_mappings"` IssueFileMappings []IssueFileMapping `json:"issue_file_mappings"` + ActivityLog []*Activity `json:"activity_log"` + Docs []*Doc `json:"docs"` + DocRevisions []*DocRevision `json:"doc_revisions"` + DocComments []*DocComment `json:"doc_comments"` + DocIssueLinks []DocIssueLink `json:"doc_issue_links"` + Proposals []*Proposal `json:"proposals"` + Votes []*Vote `json:"votes"` + ProposalIssues []ProposalIssueLink `json:"proposal_issues"` + ProposalDocs []ProposalDocLink `json:"proposal_docs"` } diff --git a/internal/model/issue.go b/internal/model/issue.go index 36d6449..3fe04d5 100644 --- a/internal/model/issue.go +++ b/internal/model/issue.go @@ -248,6 +248,7 @@ type Issue struct { Assignee string Labels []string Files []string + Docs []DocRef CreatedAt time.Time UpdatedAt time.Time } @@ -264,6 +265,7 @@ type issueJSON struct { Assignee string `json:"assignee"` Labels []string `json:"labels"` Files []string `json:"files"` + Docs []DocRef `json:"docs"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } @@ -280,6 +282,11 @@ func (i Issue) MarshalJSON() ([]byte, error) { files = []string{} } + docs := i.Docs + if docs == nil { + docs = []DocRef{} + } + j := issueJSON{ ID: FormatID(i.ID), Title: i.Title, @@ -290,6 +297,7 @@ func (i Issue) MarshalJSON() ([]byte, error) { Assignee: i.Assignee, Labels: labels, Files: files, + Docs: docs, CreatedAt: i.CreatedAt.UTC().Format(time.RFC3339), UpdatedAt: i.UpdatedAt.UTC().Format(time.RFC3339), } @@ -358,3 +366,30 @@ func (i *Issue) UnmarshalJSON(data []byte) error { return nil } + +type IssueRef struct { + ID int + Kind string + Status string + Title string +} + +type issueRefJSON struct { + ID string `json:"id"` + Kind string `json:"kind"` + Title string `json:"title"` + Status string `json:"status"` +} + +func (r IssueRef) MarshalJSON() ([]byte, error) { + return json.Marshal(issueRefJSON{ + ID: FormatID(r.ID), + Kind: r.Kind, + Title: r.Title, + Status: r.Status, + }) +} + +func (r *IssueRef) UnmarshalJSON([]byte) error { + return nil +} diff --git a/internal/model/model_test.go b/internal/model/model_test.go index 278a7f5..08e0a4c 100644 --- a/internal/model/model_test.go +++ b/internal/model/model_test.go @@ -382,6 +382,68 @@ func TestIssueJSONNoParent(t *testing.T) { } } +func TestIssue_MarshalJSON_DocsEmptyIsArray(t *testing.T) { + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + issue := Issue{ + ID: 1, + Title: "Test", + Status: StatusBacklog, + Priority: PriorityNone, + Kind: IssueKindTask, + CreatedAt: now, + UpdatedAt: now, + } + + data, err := json.Marshal(issue) + if err != nil { + t.Fatalf("Marshal error: %v", err) + } + + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("Unmarshal into map error: %v", err) + } + docs, exists := raw["docs"] + if !exists { + t.Fatal("JSON must include docs key") + } + if string(docs) != "[]" { + t.Errorf("docs = %s, want [] (never null)", docs) + } + + hydrated := issue + hydrated.Docs = []DocRef{{ID: 3, Type: "tdd", Status: "approved", Title: "T"}} + data2, err := json.Marshal(hydrated) + if err != nil { + t.Fatalf("Marshal error: %v", err) + } + var raw2 map[string]json.RawMessage + if err := json.Unmarshal(data2, &raw2); err != nil { + t.Fatalf("Unmarshal into map error: %v", err) + } + if string(raw2["docs"]) != `[{"id":"DOC-3","type":"tdd","title":"T","status":"approved"}]` { + t.Errorf("hydrated docs = %s", raw2["docs"]) + } +} + +func TestIssue_UnmarshalJSON_DocsNoOp(t *testing.T) { + input := `{"id":"DKT-1","title":"t","description":"","status":"todo","priority":"none","kind":"task","assignee":"","labels":[],"files":[],"docs":[{"id":"DOC-3","type":"tdd","title":"T","status":"approved"}],"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}` + + var issue Issue + if err := json.Unmarshal([]byte(input), &issue); err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + if issue.Docs != nil { + t.Errorf("Docs = %v, want nil (read-only projection, not parsed from input)", issue.Docs) + } + + noDocs := `{"id":"DKT-1","title":"t","description":"","status":"todo","priority":"none","kind":"task","assignee":"","labels":[],"files":[],"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}` + var issue2 Issue + if err := json.Unmarshal([]byte(noDocs), &issue2); err != nil { + t.Fatalf("Unmarshal without docs key error: %v", err) + } +} + func TestCommentJSONRoundTrip(t *testing.T) { now := time.Date(2026, 2, 13, 12, 0, 0, 0, time.UTC) comment := Comment{ @@ -414,3 +476,35 @@ func TestCommentJSONRoundTrip(t *testing.T) { t.Errorf("Unmarshaled comment: ID=%d IssueID=%d, want 3 and 5", comment2.ID, comment2.IssueID) } } + +func TestIssueRef_MarshalJSON(t *testing.T) { + ref := IssueRef{ + ID: 12, + Kind: "feature", + Status: "in-progress", + Title: "Wire up CLI", + } + + data, err := json.Marshal(ref) + if err != nil { + t.Fatalf("Marshal error: %v", err) + } + + want := `{"id":"DKT-12","kind":"feature","title":"Wire up CLI","status":"in-progress"}` + if string(data) != want { + t.Errorf("IssueRef JSON = %s, want %s", data, want) + } + + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("Unmarshal into map error: %v", err) + } + if raw["id"] != "DKT-12" { + t.Errorf("JSON id = %v, want %q", raw["id"], "DKT-12") + } + for _, excluded := range []string{"description", "assignee", "labels", "files", "docs", "created_at"} { + if _, present := raw[excluded]; present { + t.Errorf("IssueRef JSON must not include %q", excluded) + } + } +} diff --git a/internal/model/proposal.go b/internal/model/proposal.go index b62e7c8..b0148a3 100644 --- a/internal/model/proposal.go +++ b/internal/model/proposal.go @@ -141,21 +141,21 @@ type Proposal struct { // proposalJSON is the JSON wire format for Proposal. type proposalJSON struct { - ID string `json:"id"` - Description string `json:"description"` - Rationale string `json:"rationale"` - DomainTags []string `json:"domain_tags"` - FilesChanged []string `json:"files_changed"` - Criticality string `json:"criticality"` - Status string `json:"status"` - FinalOutcome string `json:"final_outcome"` - EscalationReason *string `json:"escalation_reason"` - RequiredVoters int `json:"required_voters"` - Threshold float64 `json:"threshold"` - WeightedScore *float64 `json:"weighted_score"` - CreatedBy string `json:"created_by"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID string `json:"id"` + Description string `json:"description"` + Rationale string `json:"rationale"` + DomainTags []string `json:"domain_tags"` + FilesChanged []string `json:"files_changed"` + Criticality string `json:"criticality"` + Status string `json:"status"` + FinalOutcome string `json:"final_outcome"` + EscalationReason *string `json:"escalation_reason"` + RequiredVoters int `json:"required_voters"` + Threshold float64 `json:"threshold"` + WeightedScore *float64 `json:"weighted_score"` + CreatedBy string `json:"created_by"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } // MarshalJSON implements custom JSON serialization for Proposal. @@ -245,6 +245,14 @@ func (p *Proposal) UnmarshalJSON(data []byte) error { return nil } +// ProposalIssueLink is an export-format row from the proposal_issues join +// table. IDs are plain ints (mirrors DocIssueLink); the table carries no +// created_at column. +type ProposalIssueLink struct { + ProposalID int `json:"proposal_id"` + IssueID int `json:"issue_id"` +} + // Vote represents an individual vote on a proposal. type Vote struct { ID int @@ -262,18 +270,18 @@ type Vote struct { // voteJSON is the JSON wire format for Vote. type voteJSON struct { - ID int `json:"id"` - ProposalID string `json:"proposal_id,omitempty"` - VoterName string `json:"voter_name"` - VoterRole string `json:"voter_role"` - Verdict string `json:"verdict"` - Confidence float64 `json:"confidence"` - DomainRelevance float64 `json:"domain_relevance"` - EffectiveWeight float64 `json:"effective_weight"` - Findings string `json:"findings"` - FindingsJSON *Findings `json:"findings_json"` - Summary string `json:"summary"` - CreatedAt string `json:"created_at"` + ID int `json:"id"` + ProposalID string `json:"proposal_id,omitempty"` + VoterName string `json:"voter_name"` + VoterRole string `json:"voter_role"` + Verdict string `json:"verdict"` + Confidence float64 `json:"confidence"` + DomainRelevance float64 `json:"domain_relevance"` + EffectiveWeight float64 `json:"effective_weight"` + Findings string `json:"findings"` + FindingsJSON *Findings `json:"findings_json"` + Summary string `json:"summary"` + CreatedAt string `json:"created_at"` } // MarshalJSON implements custom JSON serialization for Vote. diff --git a/internal/model/proposal_test.go b/internal/model/proposal_test.go index 9c11d81..e1bc478 100644 --- a/internal/model/proposal_test.go +++ b/internal/model/proposal_test.go @@ -292,7 +292,7 @@ func TestFindingsJSONRoundTrip(t *testing.T) { }, }, { - name: "nil arrays", + name: "nil arrays", findings: Findings{}, }, } diff --git a/internal/model/relation.go b/internal/model/relation.go index 2f63ecd..649e50a 100644 --- a/internal/model/relation.go +++ b/internal/model/relation.go @@ -11,9 +11,9 @@ import ( type RelationType string const ( - RelationBlocks RelationType = "blocks" - RelationDependsOn RelationType = "depends_on" - RelationRelatesTo RelationType = "relates_to" + RelationBlocks RelationType = "blocks" + RelationDependsOn RelationType = "depends_on" + RelationRelatesTo RelationType = "relates_to" RelationDuplicates RelationType = "duplicates" ) diff --git a/internal/render/detail.go b/internal/render/detail.go index 23f00a5..ce2c267 100644 --- a/internal/render/detail.go +++ b/internal/render/detail.go @@ -13,10 +13,10 @@ import ( ) // RenderDetail renders a full issue detail view including metadata, description, -// sub-issues, relations, comments, and recent activity. -func RenderDetail(issue *model.Issue, subIssues []*model.Issue, relations []model.Relation, comments []*model.Comment, activity []model.Activity) string { +// sub-issues, relations, linked proposals, comments, and recent activity. +func RenderDetail(issue *model.Issue, subIssues []*model.Issue, relations []model.Relation, linkedProposals []model.Proposal, comments []*model.Comment, activity []model.Activity) string { if !ColorsEnabled() { - return renderPlainDetail(issue, subIssues, relations, comments, activity) + return renderPlainDetail(issue, subIssues, relations, linkedProposals, comments, activity) } var sections []string @@ -32,6 +32,10 @@ func RenderDetail(issue *model.Issue, subIssues []*model.Issue, relations []mode sections = append(sections, renderFiles(issue.Files)) } + if len(issue.Docs) > 0 { + sections = append(sections, renderDocRefs(issue.Docs)) + } + // Description if issue.Description != "" { sections = append(sections, renderDescription(issue.Description)) @@ -47,6 +51,10 @@ func RenderDetail(issue *model.Issue, subIssues []*model.Issue, relations []mode sections = append(sections, renderRelations(issue.ID, relations)) } + if len(linkedProposals) > 0 { + sections = append(sections, renderLinkedProposals(linkedProposals)) + } + // Comments if len(comments) > 0 { sections = append(sections, renderComments(comments)) @@ -121,6 +129,63 @@ func renderFiles(files []string) string { return header + "\n" + strings.Join(lines, "\n") } +func renderDocRefs(docs []model.DocRef) string { + sectionStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + idStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("15")) + header := sectionStyle.Render("Linked Docs") + + var idWidth, typeWidth, statusWidth int + for _, d := range docs { + idWidth = max(idWidth, len(model.FormatDocID(d.ID))) + typeWidth = max(typeWidth, len(d.Type)) + statusWidth = max(statusWidth, len(d.Status)) + } + + var lines []string + for _, d := range docs { + id := model.FormatDocID(d.ID) + line := fmt.Sprintf(" %s %s %s %s %s", + dimStyle.Render("▸"), + idStyle.Render(id)+strings.Repeat(" ", idWidth-len(id)), + d.Type+strings.Repeat(" ", typeWidth-len(d.Type)), + d.Status+strings.Repeat(" ", statusWidth-len(d.Status)), + d.Title, + ) + lines = append(lines, line) + } + + return header + "\n" + strings.Join(lines, "\n") +} + +func renderLinkedProposals(proposals []model.Proposal) string { + sectionStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + idStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("15")) + header := sectionStyle.Render("Linked Proposals") + + var idWidth, statusWidth int + for _, p := range proposals { + idWidth = max(idWidth, len(model.FormatProposalID(p.ID))) + statusWidth = max(statusWidth, len(string(p.Status))) + } + + var lines []string + for _, p := range proposals { + id := model.FormatProposalID(p.ID) + status := string(p.Status) + line := fmt.Sprintf(" %s %s %s %s", + dimStyle.Render("▸"), + idStyle.Render(id)+strings.Repeat(" ", idWidth-len(id)), + status+strings.Repeat(" ", statusWidth-len(status)), + truncate(p.Description, maxTitleWidth), + ) + lines = append(lines, line) + } + + return header + "\n" + strings.Join(lines, "\n") +} + func renderDescription(description string) string { sectionStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")) header := sectionStyle.Render("Description") @@ -330,7 +395,7 @@ func renderActivity(activity []model.Activity) string { } // renderPlainDetail renders a detail view without any color or styling. -func renderPlainDetail(issue *model.Issue, subIssues []*model.Issue, relations []model.Relation, comments []*model.Comment, activity []model.Activity) string { +func renderPlainDetail(issue *model.Issue, subIssues []*model.Issue, relations []model.Relation, linkedProposals []model.Proposal, comments []*model.Comment, activity []model.Activity) string { var b strings.Builder // Header @@ -360,6 +425,24 @@ func renderPlainDetail(issue *model.Issue, subIssues []*model.Issue, relations [ } } + if len(issue.Docs) > 0 { + var idWidth, typeWidth, statusWidth int + for _, d := range issue.Docs { + idWidth = max(idWidth, len(model.FormatDocID(d.ID))) + typeWidth = max(typeWidth, len(d.Type)) + statusWidth = max(statusWidth, len(d.Status)) + } + b.WriteString("\nLinked Docs\n") + for _, d := range issue.Docs { + fmt.Fprintf(&b, " > %-*s %-*s %-*s %s\n", + idWidth, model.FormatDocID(d.ID), + typeWidth, d.Type, + statusWidth, d.Status, + d.Title, + ) + } + } + // Description if issue.Description != "" { fmt.Fprintf(&b, "\nDescription\n%s\n", issue.Description) @@ -399,6 +482,22 @@ func renderPlainDetail(issue *model.Issue, subIssues []*model.Issue, relations [ } } + if len(linkedProposals) > 0 { + var idWidth, statusWidth int + for _, p := range linkedProposals { + idWidth = max(idWidth, len(model.FormatProposalID(p.ID))) + statusWidth = max(statusWidth, len(string(p.Status))) + } + b.WriteString("\nLinked Proposals\n") + for _, p := range linkedProposals { + fmt.Fprintf(&b, " > %-*s %-*s %s\n", + idWidth, model.FormatProposalID(p.ID), + statusWidth, string(p.Status), + truncate(p.Description, maxTitleWidth), + ) + } + } + // Comments if len(comments) > 0 { b.WriteString("\nComments\n") diff --git a/internal/render/detail_test.go b/internal/render/detail_test.go new file mode 100644 index 0000000..757cd9b --- /dev/null +++ b/internal/render/detail_test.go @@ -0,0 +1,91 @@ +package render + +import ( + "strings" + "testing" + + "github.com/ALT-F4-LLC/docket/internal/model" +) + +func issueWithDocs(docs []model.DocRef) *model.Issue { + i := makeTestIssue(1, "Issue", model.StatusTodo, model.PriorityHigh, model.IssueKindFeature, nil) + i.Docs = docs + return i +} + +func TestRenderDetail_PlainLinkedDocsAfterFilesBeforeDescription(t *testing.T) { + t.Setenv("NO_COLOR", "1") + issue := issueWithDocs([]model.DocRef{ + {ID: 3, Type: "tdd", Status: "approved", Title: "Docket Doc CLI"}, + }) + issue.Files = []string{"internal/db/doc_links.go"} + issue.Description = "the description" + + out := RenderDetail(issue, nil, nil, nil, nil, nil) + + if !strings.Contains(out, "\nLinked Docs\n") { + t.Fatalf("missing Linked Docs header:\n%s", out) + } + if !strings.Contains(out, " > DOC-3 tdd approved Docket Doc CLI") { + t.Errorf("plain doc line wrong:\n%s", out) + } + files := strings.Index(out, "Files") + docs := strings.Index(out, "Linked Docs") + desc := strings.Index(out, "Description") + if !(files < docs && docs < desc) { + t.Errorf("section order wrong: Files=%d Linked Docs=%d Description=%d", files, docs, desc) + } +} + +func TestRenderDetail_PlainLinkedDocsAligned(t *testing.T) { + t.Setenv("NO_COLOR", "1") + issue := issueWithDocs([]model.DocRef{ + {ID: 3, Type: "tdd", Status: "approved", Title: "Alpha"}, + {ID: 100, Type: "ux", Status: "draft", Title: "Beta"}, + }) + + out := RenderDetail(issue, nil, nil, nil, nil, nil) + + wantLines := []string{ + " > DOC-3 tdd approved Alpha", + " > DOC-100 ux draft Beta", + } + for _, w := range wantLines { + if !strings.Contains(out, w) { + t.Errorf("missing aligned line %q:\n%s", w, out) + } + } +} + +func TestRenderDetail_PlainOmitsLinkedDocsWhenEmpty(t *testing.T) { + t.Setenv("NO_COLOR", "1") + issue := issueWithDocs(nil) + out := RenderDetail(issue, nil, nil, nil, nil, nil) + if strings.Contains(out, "Linked Docs") { + t.Errorf("empty docs should omit section:\n%s", out) + } +} + +func TestRenderDetail_StyledLinkedDocsUsesArrowGlyph(t *testing.T) { + t.Setenv("TERM", "xterm-256color") + issue := issueWithDocs([]model.DocRef{ + {ID: 3, Type: "tdd", Status: "approved", Title: "Docket Doc CLI"}, + }) + + out := RenderDetail(issue, nil, nil, nil, nil, nil) + + if !strings.Contains(out, "Linked Docs") { + t.Fatalf("missing Linked Docs header:\n%s", out) + } + if !strings.Contains(out, "▸") { + t.Errorf("styled output missing ▸ glyph:\n%s", out) + } + if strings.Contains(out, " > DOC-3") { + t.Errorf("styled output used plain > prefix:\n%s", out) + } + for _, want := range []string{"DOC-3", "tdd", "approved", "Docket Doc CLI"} { + if !strings.Contains(out, want) { + t.Errorf("styled output missing %q:\n%s", want, out) + } + } +} diff --git a/internal/render/doc.go b/internal/render/doc.go new file mode 100644 index 0000000..69f8da8 --- /dev/null +++ b/internal/render/doc.go @@ -0,0 +1,357 @@ +package render + +import ( + "fmt" + "strings" + + humanize "github.com/dustin/go-humanize" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + + "github.com/ALT-F4-LLC/docket/internal/model" +) + +type DocRow struct { + Doc *model.Doc + CurrentRevision int + RevisionsCount int +} + +func RenderDocList(rows []DocRow) string { + if len(rows) == 0 { + return EmptyState("No documents found.", "Create one with: docket doc create", false) + } + + if !ColorsEnabled() { + return renderPlainDocList(rows) + } + + headers := []string{"ID", "Type", "Status", "Title", "Author", "Revisions", "Updated"} + + tableRows := make([][]string, 0, len(rows)) + for _, r := range rows { + tableRows = append(tableRows, docToRow(r)) + } + + t := table.New(). + Border(lipgloss.NormalBorder()). + BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("8"))). + Headers(headers...). + Rows(tableRows...). + StyleFunc(func(row, col int) lipgloss.Style { + s := lipgloss.NewStyle().PaddingLeft(1).PaddingRight(1) + + if row == table.HeaderRow { + return s.Bold(true).Foreground(lipgloss.Color("15")) + } + + switch col { + case 0: + return s.Foreground(lipgloss.Color("15")) + case 3: + return s.Bold(true) + default: + return s + } + }) + + return t.Render() +} + +func docToRow(r DocRow) []string { + return []string{ + model.FormatDocID(r.Doc.ID), + r.Doc.Type, + r.Doc.Status, + truncate(r.Doc.Title, maxTitleWidth), + r.Doc.Author, + fmt.Sprintf("%d", r.RevisionsCount), + humanize.Time(r.Doc.UpdatedAt), + } +} + +func renderPlainDocList(rows []DocRow) string { + var b strings.Builder + + fmt.Fprintf(&b, "%-10s %-10s %-12s %-42s %-15s %-10s %s\n", + "ID", "Type", "Status", "Title", "Author", "Revisions", "Updated") + fmt.Fprintf(&b, "%s\n", strings.Repeat("-", 110)) + + for _, r := range rows { + fmt.Fprintf(&b, "%-10s %-10s %-12s %-42s %-15s %-10d %s\n", + model.FormatDocID(r.Doc.ID), + r.Doc.Type, + r.Doc.Status, + truncate(r.Doc.Title, maxTitleWidth), + r.Doc.Author, + r.RevisionsCount, + humanize.Time(r.Doc.UpdatedAt), + ) + } + + return b.String() +} + +func RenderDocDetail(doc *model.Doc, revisions []*model.DocRevision, comments []*model.DocComment, linkedIssues []model.IssueRef, linkedProposals []int) string { + if !ColorsEnabled() { + return renderPlainDocDetail(doc, revisions, comments, linkedIssues, linkedProposals) + } + + var sections []string + + sections = append(sections, renderDocHeader(doc)) + sections = append(sections, renderDocMetadata(doc)) + + if doc.Body != "" { + sections = append(sections, renderDocBody(doc.Body)) + } + + if len(linkedIssues) > 0 { + sections = append(sections, renderDocLinkedIssues(linkedIssues)) + } + + if len(linkedProposals) > 0 { + sections = append(sections, renderDocLinkedProposals(linkedProposals)) + } + + if len(comments) > 0 { + sections = append(sections, renderDocComments(comments)) + } + + if len(revisions) > 0 { + sections = append(sections, RenderDocRevisionHistory(revisions)) + } + + return strings.Join(sections, "\n\n") +} + +func renderDocHeader(doc *model.Doc) string { + idStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")) + titleStyle := lipgloss.NewStyle().Bold(true) + typeStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) + statusStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11")) + + return fmt.Sprintf("%s %s\n%s %s", + idStyle.Render(model.FormatDocID(doc.ID)), + titleStyle.Render(doc.Title), + typeStyle.Render(doc.Type), + statusStyle.Render(doc.Status), + ) +} + +func renderDocMetadata(doc *model.Doc) string { + labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + + var lines []string + if doc.Author != "" { + lines = append(lines, fmt.Sprintf("%s %s", labelStyle.Render("Author:"), doc.Author)) + } + lines = append(lines, fmt.Sprintf("%s %s", labelStyle.Render("Created:"), humanize.Time(doc.CreatedAt))) + lines = append(lines, fmt.Sprintf("%s %s", labelStyle.Render("Updated:"), humanize.Time(doc.UpdatedAt))) + + return strings.Join(lines, "\n") +} + +func renderDocBody(body string) string { + sectionStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")) + header := sectionStyle.Render("Body") + + rendered, err := RenderMarkdown(body) + if err != nil { + rendered = body + } + + return header + "\n" + rendered +} + +func renderDocLinkedIssues(issues []model.IssueRef) string { + sectionStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + idStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("15")) + header := sectionStyle.Render("Linked Issues") + + var idWidth, kindWidth, statusWidth int + for _, i := range issues { + idWidth = max(idWidth, len(model.FormatID(i.ID))) + kindWidth = max(kindWidth, len(i.Kind)) + statusWidth = max(statusWidth, len(i.Status)) + } + + var lines []string + for _, i := range issues { + id := model.FormatID(i.ID) + line := fmt.Sprintf(" %s %s %s %s %s", + dimStyle.Render("▸"), + idStyle.Render(id)+strings.Repeat(" ", idWidth-len(id)), + i.Kind+strings.Repeat(" ", kindWidth-len(i.Kind)), + i.Status+strings.Repeat(" ", statusWidth-len(i.Status)), + i.Title, + ) + lines = append(lines, line) + } + + return header + "\n" + strings.Join(lines, "\n") +} + +func renderDocLinkedProposals(proposalIDs []int) string { + sectionStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")) + header := sectionStyle.Render("Linked Proposals") + + var lines []string + for _, id := range proposalIDs { + lines = append(lines, " "+model.FormatProposalID(id)) + } + + return header + "\n" + strings.Join(lines, "\n") +} + +func renderDocComments(comments []*model.DocComment) string { + sectionStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")) + authorStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) + timeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + + header := sectionStyle.Render("Comments") + + var parts []string + for _, c := range comments { + body, err := RenderMarkdown(c.Body) + if err != nil { + body = c.Body + } + + author := c.Author + if author == "" { + author = "anonymous" + } + + commentHeader := fmt.Sprintf("%s %s", + authorStyle.Render(author), + timeStyle.Render(humanize.Time(c.CreatedAt)), + ) + + parts = append(parts, commentHeader+"\n"+body) + } + + return header + "\n" + strings.Join(parts, "\n\n") +} + +func RenderDocRevisionHistory(revisions []*model.DocRevision) string { + if len(revisions) == 0 { + return EmptyState("No revisions yet.", "", true) + } + + if !ColorsEnabled() { + return renderPlainRevisionHistory(revisions) + } + + sectionStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")) + revStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")) + kindStyle := lipgloss.NewStyle().Bold(true) + timeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + + header := sectionStyle.Render("Revisions") + + var lines []string + for _, r := range revisions { + author := r.Author + if author == "" { + author = "system" + } + line := fmt.Sprintf(" %s %s %s %s", + revStyle.Render(fmt.Sprintf("r%d", r.RevisionNumber)), + kindStyle.Render(r.ChangeKind), + author, + timeStyle.Render(humanize.Time(r.CreatedAt)), + ) + lines = append(lines, line) + } + + return header + "\n" + strings.Join(lines, "\n") +} + +func renderPlainRevisionHistory(revisions []*model.DocRevision) string { + var b strings.Builder + + b.WriteString("Revisions\n") + for _, r := range revisions { + author := r.Author + if author == "" { + author = "system" + } + fmt.Fprintf(&b, " r%d %s %s %s\n", + r.RevisionNumber, + r.ChangeKind, + author, + humanize.Time(r.CreatedAt), + ) + } + + return strings.TrimRight(b.String(), "\n") +} + +func renderPlainDocDetail(doc *model.Doc, revisions []*model.DocRevision, comments []*model.DocComment, linkedIssues []model.IssueRef, linkedProposals []int) string { + var b strings.Builder + + fmt.Fprintf(&b, "%s %s\n", model.FormatDocID(doc.ID), doc.Title) + fmt.Fprintf(&b, "%s %s\n", doc.Type, doc.Status) + + b.WriteString("\n") + if doc.Author != "" { + fmt.Fprintf(&b, "Author: %s\n", doc.Author) + } + fmt.Fprintf(&b, "Created: %s\n", humanize.Time(doc.CreatedAt)) + fmt.Fprintf(&b, "Updated: %s\n", humanize.Time(doc.UpdatedAt)) + + if doc.Body != "" { + fmt.Fprintf(&b, "\nBody\n%s\n", doc.Body) + } + + if len(linkedIssues) > 0 { + var idWidth, kindWidth, statusWidth int + for _, i := range linkedIssues { + idWidth = max(idWidth, len(model.FormatID(i.ID))) + kindWidth = max(kindWidth, len(i.Kind)) + statusWidth = max(statusWidth, len(i.Status)) + } + b.WriteString("\nLinked Issues\n") + for _, i := range linkedIssues { + fmt.Fprintf(&b, " > %-*s %-*s %-*s %s\n", + idWidth, model.FormatID(i.ID), + kindWidth, i.Kind, + statusWidth, i.Status, + i.Title, + ) + } + } + + if len(linkedProposals) > 0 { + b.WriteString("\nLinked Proposals\n") + for _, id := range linkedProposals { + fmt.Fprintf(&b, " %s\n", model.FormatProposalID(id)) + } + } + + if len(comments) > 0 { + b.WriteString("\nComments\n") + for _, c := range comments { + author := c.Author + if author == "" { + author = "anonymous" + } + fmt.Fprintf(&b, " %s %s\n %s\n\n", author, humanize.Time(c.CreatedAt), c.Body) + } + } + + if len(revisions) > 0 { + b.WriteString("\n") + b.WriteString(renderPlainRevisionHistory(revisions)) + b.WriteString("\n") + } + + return strings.TrimRight(b.String(), "\n") +} + +func RenderDocCommentList(comments []*model.DocComment) string { + return renderDocComments(comments) +} diff --git a/internal/render/doc_test.go b/internal/render/doc_test.go new file mode 100644 index 0000000..1c0ddcd --- /dev/null +++ b/internal/render/doc_test.go @@ -0,0 +1,331 @@ +package render + +import ( + "strings" + "testing" + "time" + + "github.com/ALT-F4-LLC/docket/internal/model" +) + +func makeTestDoc(id int, title, docType, status, author string) *model.Doc { + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + return &model.Doc{ + ID: id, + Type: docType, + Status: status, + Title: title, + Body: "", + Author: author, + CreatedAt: now, + UpdatedAt: now, + } +} + +func makeTestRevision(docID, number int, kind, author string) *model.DocRevision { + return &model.DocRevision{ + ID: number, + DocID: docID, + RevisionNumber: number, + Body: "rev body", + ChangeKind: kind, + Author: author, + CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + } +} + +func TestRenderDocList_Empty(t *testing.T) { + t.Setenv("NO_COLOR", "1") + + tests := []struct { + name string + rows []DocRow + }{ + {name: "nil", rows: nil}, + {name: "empty", rows: []DocRow{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := RenderDocList(tt.rows) + if !strings.Contains(got, "No documents found.") { + t.Errorf("expected empty-state message, got:\n%s", got) + } + if !strings.Contains(got, "docket doc create") { + t.Errorf("expected create hint in empty state, got:\n%s", got) + } + }) + } +} + +func TestRenderDocList_PlainColumnsAndRows(t *testing.T) { + t.Setenv("NO_COLOR", "1") + + rows := []DocRow{ + {Doc: makeTestDoc(1, "Add docket doc CLI", "tdd", "draft", "Erik"), CurrentRevision: 3, RevisionsCount: 3}, + {Doc: makeTestDoc(2, "Vote weighting", "adr", "approved", "Alex"), CurrentRevision: 1, RevisionsCount: 1}, + } + + got := RenderDocList(rows) + + headers := []string{"ID", "Type", "Status", "Title", "Author", "Revisions", "Updated"} + for _, h := range headers { + if !strings.Contains(got, h) { + t.Errorf("expected header %q in output, got:\n%s", h, got) + } + } + + for _, id := range []string{"DOC-1", "DOC-2"} { + if !strings.Contains(got, id) { + t.Errorf("expected %s in output, got:\n%s", id, got) + } + } + + for _, val := range []string{"tdd", "adr", "draft", "approved", "Erik", "Alex"} { + if !strings.Contains(got, val) { + t.Errorf("expected %q in output, got:\n%s", val, got) + } + } +} + +func TestRenderDocList_TitleTruncated(t *testing.T) { + t.Setenv("NO_COLOR", "1") + + long := strings.Repeat("A", 80) + rows := []DocRow{ + {Doc: makeTestDoc(1, long, "tdd", "draft", "Erik"), CurrentRevision: 1, RevisionsCount: 1}, + } + + got := RenderDocList(rows) + + if strings.Contains(got, long) { + t.Errorf("expected long title to be truncated, but full title present in output:\n%s", got) + } + if !strings.Contains(got, "...") { + t.Errorf("expected truncated title to contain ellipsis, got:\n%s", got) + } +} + +func TestRenderDocList_ColorPathExecutes(t *testing.T) { + rows := []DocRow{ + {Doc: makeTestDoc(1, "Color", "tdd", "draft", "Erik"), CurrentRevision: 1, RevisionsCount: 1}, + } + got := RenderDocList(rows) + if got == "" { + t.Error("expected non-empty output from color list render") + } +} + +func TestRenderDocDetail_PlainAllSections(t *testing.T) { + t.Setenv("NO_COLOR", "1") + + doc := makeTestDoc(42, "Add docket doc CLI", "tdd", "draft", "Erik") + doc.Body = "doc body content" + revisions := []*model.DocRevision{ + makeTestRevision(42, 1, "create", "Erik"), + makeTestRevision(42, 2, "status", "vote-bot"), + } + comments := []*model.DocComment{ + {ID: 1, DocID: 42, Body: "Looks good", Author: "Alex", CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}, + } + linkedIssues := []model.IssueRef{ + {ID: 12, Kind: "feature", Status: "in-progress", Title: "Wire up CLI"}, + } + linkedProposals := []int{3} + + got := RenderDocDetail(doc, revisions, comments, linkedIssues, linkedProposals) + + wantSubstrings := []string{ + "DOC-42", + "Add docket doc CLI", + "tdd", + "draft", + "Erik", + "Body", + "doc body content", + "Linked Issues", + model.FormatID(12), + "feature", + "in-progress", + "Wire up CLI", + "Linked Proposals", + model.FormatProposalID(3), + "Comments", + "Looks good", + "Revisions", + "r1", + "create", + "r2", + "status", + "vote-bot", + } + for _, want := range wantSubstrings { + if !strings.Contains(got, want) { + t.Errorf("expected %q in detail output, got:\n%s", want, got) + } + } +} + +func TestRenderDocDetail_OmitsEmptySections(t *testing.T) { + t.Setenv("NO_COLOR", "1") + + doc := makeTestDoc(7, "Minimal", "adr", "approved", "Erik") + + got := RenderDocDetail(doc, nil, nil, nil, nil) + + wantPresent := []string{"DOC-7", "Minimal", "adr", "approved", "Erik"} + for _, w := range wantPresent { + if !strings.Contains(got, w) { + t.Errorf("expected %q in detail output, got:\n%s", w, got) + } + } + + wantAbsent := []string{"Body", "Linked Issues", "Linked Proposals", "Comments", "Revisions"} + for _, w := range wantAbsent { + if strings.Contains(got, w) { + t.Errorf("expected %q NOT in detail output when section empty, got:\n%s", w, got) + } + } +} + +func TestRenderDocDetail_AnonymousComment(t *testing.T) { + t.Setenv("NO_COLOR", "1") + + doc := makeTestDoc(1, "Doc", "tdd", "draft", "Erik") + comments := []*model.DocComment{ + {ID: 1, DocID: 1, Body: "no author", Author: "", CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}, + } + + got := RenderDocDetail(doc, nil, comments, nil, nil) + + if !strings.Contains(got, "anonymous") { + t.Errorf("expected anonymous author fallback, got:\n%s", got) + } +} + +func TestRenderDocDetail_ColorPathExecutes(t *testing.T) { + doc := makeTestDoc(1, "Color", "tdd", "draft", "Erik") + doc.Body = "body" + revisions := []*model.DocRevision{makeTestRevision(1, 1, "create", "Erik")} + comments := []*model.DocComment{ + {ID: 1, DocID: 1, Body: "c", Author: "Alex", CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}, + } + + got := RenderDocDetail(doc, revisions, comments, []model.IssueRef{{ID: 2, Kind: "bug", Status: "todo", Title: "T"}}, []int{3}) + if got == "" { + t.Error("expected non-empty output from color detail render") + } +} + +func TestRenderDocLinkedIssues_RichRefsStyledAndPlain(t *testing.T) { + issues := []model.IssueRef{ + {ID: 1, Kind: "feature", Status: "in-progress", Title: "Short"}, + {ID: 200, Kind: "bug", Status: "done", Title: "A longer issue title"}, + } + + got := renderDocLinkedIssues(issues) + + wantSubstrings := []string{ + "Linked Issues", + model.FormatID(1), + model.FormatID(200), + "feature", + "bug", + "in-progress", + "done", + "Short", + "A longer issue title", + } + for _, want := range wantSubstrings { + if !strings.Contains(got, want) { + t.Errorf("expected %q in styled linked-issues output, got:\n%s", want, got) + } + } +} + +func TestRenderDocDetail_PlainLinkedIssuesRichColumns(t *testing.T) { + t.Setenv("NO_COLOR", "1") + + doc := makeTestDoc(5, "Doc", "tdd", "draft", "Erik") + linkedIssues := []model.IssueRef{ + {ID: 7, Kind: "task", Status: "review", Title: "Verify"}, + } + + got := RenderDocDetail(doc, nil, nil, linkedIssues, nil) + + for _, want := range []string{"Linked Issues", model.FormatID(7), "task", "review", "Verify"} { + if !strings.Contains(got, want) { + t.Errorf("expected %q in plain linked-issues output, got:\n%s", want, got) + } + } +} + +func TestRenderDocRevisionHistory_Empty(t *testing.T) { + t.Setenv("NO_COLOR", "1") + + got := RenderDocRevisionHistory(nil) + if !strings.Contains(got, "No revisions yet.") { + t.Errorf("expected empty-state message, got:\n%s", got) + } + + got = RenderDocRevisionHistory([]*model.DocRevision{}) + if !strings.Contains(got, "No revisions yet.") { + t.Errorf("expected empty-state message for empty slice, got:\n%s", got) + } +} + +func TestRenderDocRevisionHistory_PlainOrdering(t *testing.T) { + t.Setenv("NO_COLOR", "1") + + revisions := []*model.DocRevision{ + makeTestRevision(1, 1, "create", "Erik"), + makeTestRevision(1, 2, "body", "Erik"), + makeTestRevision(1, 3, "status", "vote-bot"), + } + + got := RenderDocRevisionHistory(revisions) + + for _, rev := range []string{"r1", "r2", "r3"} { + if !strings.Contains(got, rev) { + t.Errorf("expected %q in revision history, got:\n%s", rev, got) + } + } + for _, kind := range []string{"create", "body", "status"} { + if !strings.Contains(got, kind) { + t.Errorf("expected change_kind %q in revision history, got:\n%s", kind, got) + } + } + + idx1 := strings.Index(got, "r1") + idx2 := strings.Index(got, "r2") + idx3 := strings.Index(got, "r3") + if !(idx1 >= 0 && idx2 > idx1 && idx3 > idx2) { + t.Errorf("expected revisions rendered in input order, got positions r1=%d r2=%d r3=%d", idx1, idx2, idx3) + } +} + +func TestRenderDocRevisionHistory_EmptyAuthorFallsBackToSystem(t *testing.T) { + t.Setenv("NO_COLOR", "1") + + revisions := []*model.DocRevision{ + makeTestRevision(1, 1, "create", ""), + } + + got := RenderDocRevisionHistory(revisions) + + if !strings.Contains(got, "system") { + t.Errorf("expected 'system' fallback for empty author, got:\n%s", got) + } +} + +func TestRenderDocRevisionHistory_ColorPathExecutes(t *testing.T) { + revisions := []*model.DocRevision{ + makeTestRevision(1, 1, "create", "Erik"), + makeTestRevision(1, 2, "status", "vote-bot"), + } + + got := RenderDocRevisionHistory(revisions) + if got == "" { + t.Error("expected non-empty output from color revision history render") + } +} diff --git a/internal/render/vote.go b/internal/render/vote.go index fdcd4f9..9254055 100644 --- a/internal/render/vote.go +++ b/internal/render/vote.go @@ -156,9 +156,9 @@ func renderPlainProposalTable(rows []ProposalRow) string { } // RenderProposalDetail renders a full proposal detail view with votes and linked issues. -func RenderProposalDetail(proposal *model.Proposal, votes []*model.Vote, linkedIssues []int) string { +func RenderProposalDetail(proposal *model.Proposal, votes []*model.Vote, linkedIssues []int, linkedDocs []int) string { if !ColorsEnabled() { - return renderPlainProposalDetail(proposal, votes, linkedIssues) + return renderPlainProposalDetail(proposal, votes, linkedIssues, linkedDocs) } var sections []string @@ -181,6 +181,10 @@ func RenderProposalDetail(proposal *model.Proposal, votes []*model.Vote, linkedI sections = append(sections, renderLinkedIssues(linkedIssues)) } + if len(linkedDocs) > 0 { + sections = append(sections, renderLinkedDocs(linkedDocs)) + } + // Votes if len(votes) > 0 { sections = append(sections, renderVoteList(votes)) @@ -266,6 +270,18 @@ func renderLinkedIssues(issueIDs []int) string { return header + "\n" + strings.Join(lines, "\n") } +func renderLinkedDocs(docIDs []int) string { + sectionStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")) + header := sectionStyle.Render("Linked Docs") + + var lines []string + for _, id := range docIDs { + lines = append(lines, " "+model.FormatDocID(id)) + } + + return header + "\n" + strings.Join(lines, "\n") +} + func renderVoteList(votes []*model.Vote) string { sectionStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")) header := sectionStyle.Render("Votes") @@ -324,7 +340,7 @@ func renderStructuredFindings(f *model.Findings) string { return "\n" + strings.Join(parts, "\n") } -func renderPlainProposalDetail(proposal *model.Proposal, votes []*model.Vote, linkedIssues []int) string { +func renderPlainProposalDetail(proposal *model.Proposal, votes []*model.Vote, linkedIssues []int, linkedDocs []int) string { var b strings.Builder // Header @@ -375,6 +391,13 @@ func renderPlainProposalDetail(proposal *model.Proposal, votes []*model.Vote, li } } + if len(linkedDocs) > 0 { + b.WriteString("\nLinked Docs\n") + for _, id := range linkedDocs { + fmt.Fprintf(&b, " %s\n", model.FormatDocID(id)) + } + } + // Votes if len(votes) > 0 { b.WriteString("\nVotes\n") diff --git a/internal/render/vote_test.go b/internal/render/vote_test.go new file mode 100644 index 0000000..bfb6dab --- /dev/null +++ b/internal/render/vote_test.go @@ -0,0 +1,78 @@ +package render + +import ( + "strings" + "testing" + "time" + + "github.com/ALT-F4-LLC/docket/internal/model" +) + +func makeTestProposal(id int, description string) *model.Proposal { + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + return &model.Proposal{ + ID: id, + Description: description, + Criticality: model.CriticalityMedium, + Status: model.ProposalStatusOpen, + RequiredVoters: 1, + Threshold: 0.67, + CreatedAt: now, + UpdatedAt: now, + } +} + +func TestRenderProposalDetail_RendersLinkedDocsStyled(t *testing.T) { + t.Setenv("TERM", "xterm-256color") + + out := RenderProposalDetail(makeTestProposal(1, "Ratify TDD"), nil, nil, []int{1, 2}) + + if !strings.Contains(out, "Linked Docs") { + t.Fatalf("styled output missing Linked Docs header:\n%s", out) + } + for _, want := range []string{"DOC-1", "DOC-2"} { + if !strings.Contains(out, want) { + t.Errorf("styled output missing %q:\n%s", want, out) + } + } + if strings.Index(out, "DOC-1") > strings.Index(out, "DOC-2") { + t.Errorf("docs not ordered by id ascending:\n%s", out) + } +} + +func TestRenderProposalDetail_RendersLinkedDocsPlain(t *testing.T) { + t.Setenv("NO_COLOR", "1") + + out := RenderProposalDetail(makeTestProposal(1, "Ratify TDD"), nil, nil, []int{3}) + + if !strings.Contains(out, "Linked Docs") { + t.Fatalf("plain output missing Linked Docs header:\n%s", out) + } + if !strings.Contains(out, " DOC-3") { + t.Errorf("plain output missing expected indented doc line:\n%s", out) + } +} + +func TestRenderProposalDetail_OmitsLinkedDocsWhenEmpty(t *testing.T) { + for _, tc := range []struct { + name string + noColor bool + }{ + {"styled", false}, + {"plain", true}, + } { + t.Run(tc.name, func(t *testing.T) { + if tc.noColor { + t.Setenv("NO_COLOR", "1") + } else { + t.Setenv("TERM", "xterm-256color") + } + + out := RenderProposalDetail(makeTestProposal(1, "No docs"), nil, nil, nil) + + if strings.Contains(out, "Linked Docs") { + t.Errorf("empty docs should omit Linked Docs section:\n%s", out) + } + }) + } +}