diff --git a/CLAUDE.md b/CLAUDE.md index 8edfd49..edd9269 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,8 @@ cmd/aviator/ # CLI entry point + commands (one file per command) evidence.go # `aviator evidence` -> signed URL / download of one evidence file invariants.go # `aviator invariants ` invariants_format.go # text rendering for the invariants commands + decisions.go # `aviator decisions ` (hidden) + decisions_format.go # text rendering for the decisions commands version.go # `aviator version` helpers.go # parseRepo(s), readSpecFile, collectCriteria, collectBody internal/ @@ -170,6 +172,17 @@ The CLI targets endpoints in the `mergeit` backend: The CLI doesn't follow it with the bearer token: `EvidenceURL` stops at the redirect and the download is a plain unauthenticated GET. +- `/api/v1/verify//decisions` — for `aviator decisions`, which is + `Hidden: true` while it changes. `GET` lists in id order without comments + (`comment_count` instead), `?status=open|settled&page=&per_page=`, with + `has_more`; `GET /` is one decision with its comments. `POST` takes + `{decisions: [...]}` and applies it all or nothing: no `id` creates, an `id` + changes only the keys sent, `outcome` settles (needs `resolution`), + `outcome: null` reopens (needs a comment), and a comment with an `id` edits + it. Anyone can settle, reopen, or comment; only the raiser can change type, + title, summary, or anchor. `aviator decisions apply` sends its file as the + body unchanged and the server's strict models validate it. + `POST` and `PATCH /api/v1/verify`, dismissals, and the listing are gated on `role="user"`: an account-scoped API token resolves to no role and gets a 403, so they need a user token or an `aviator login` session. diff --git a/cmd/aviator/decisions.go b/cmd/aviator/decisions.go new file mode 100644 index 0000000..ca2e134 --- /dev/null +++ b/cmd/aviator/decisions.go @@ -0,0 +1,231 @@ +package main + +import ( + "fmt" + "io" + "os" + "strconv" + "strings" + + "emperror.dev/errors" + "github.com/aviator-co/aviator-cli/internal/api" + "github.com/aviator-co/aviator-cli/internal/utils/colors" + "github.com/dustin/go-humanize/english" + "github.com/spf13/cobra" +) + +// decisionsFlags holds the flags every decisions subcommand shares. +var decisionsFlags struct { + JSON bool +} + +var decisionsCmd = &cobra.Command{ + Use: "decisions", + Aliases: []string{"decision"}, + Short: "Read and record the decisions on a session's pull request", + // Still changing, so left out of help and completion; it runs when called + // by name. + Hidden: true, + Long: "A decision is a question, possible bug, risk, or choice raised about a\n" + + "session's pull request. Each one has a comment thread and is either open\n" + + "or settled with an outcome.\n" + + "\n" + + "list and show read them. apply records a JSON batch that creates,\n" + + "changes, settles, reopens, and comments on decisions in one request, so\n" + + "either all of it is recorded or none of it is.", +} + +var decisionsListFlags struct { + Status string + Page int + Limit int +} + +var decisionsListCmd = &cobra.Command{ + Use: "list ", + Short: "List a session's decisions (e.g. aviator decisions list r/123 --status open)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + runbookNumber, err := parseRunbookID(args[0]) + if err != nil { + return err + } + client, err := api.NewClient() + if err != nil { + return err + } + f := &decisionsListFlags + resp, err := client.ListDecisions(cmd.Context(), runbookNumber, api.ListDecisionsParams{ + Status: f.Status, + Page: f.Page, + PerPage: f.Limit, + }) + if err != nil { + return err + } + if decisionsFlags.JSON { + out := newDecisionListJSON(runbookNumber, resp.Decisions) + out.HasMore = &resp.HasMore + return printJSON(out) + } + if len(resp.Decisions) == 0 { + fmt.Println(noDecisionsMessage(runbookNumber)) + return nil + } + fmt.Print(formatDecisionTable(resp.Decisions)) + if resp.HasMore { + fmt.Printf("\nSee more decisions --page %d\n", max(f.Page, 1)+1) + } + return nil + }, +} + +// noDecisionsMessage says what was listed, and past the last page says that +// instead, since "no decisions" there would be a lie. +func noDecisionsMessage(runbookNumber int) string { + if decisionsListFlags.Page > 1 { + return fmt.Sprintf("Nothing on page %d, the listing ends before it. Try a lower --page.", + decisionsListFlags.Page) + } + if status := decisionsListFlags.Status; status != "" { + return fmt.Sprintf("No %s decisions on %s.", status, formatRunbookID(runbookNumber)) + } + return fmt.Sprintf("No decisions on %s.", formatRunbookID(runbookNumber)) +} + +var decisionsShowCmd = &cobra.Command{ + Use: "show ", + Short: "Show one decision with its comment thread", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + runbookNumber, err := parseRunbookID(args[0]) + if err != nil { + return err + } + decisionID, err := parseDecisionID(args[1]) + if err != nil { + return err + } + client, err := api.NewClient() + if err != nil { + return err + } + d, err := client.GetDecision(cmd.Context(), runbookNumber, decisionID) + if err != nil { + return err + } + if decisionsFlags.JSON { + return printJSON(newDecisionJSON(d)) + } + fmt.Print(formatDecisionDetail(d)) + return nil + }, +} + +var decisionsApplyFlags struct { + File string +} + +var decisionsApplyCmd = &cobra.Command{ + Use: "apply ", + Short: "Create, change, settle, reopen, and comment on decisions from a JSON batch file", + Long: "Record a batch of decisions from a JSON file (- reads stdin), all or\n" + + "nothing. The file is sent as is and the server checks it:\n" + + "\n" + + " {\"decisions\": [\n" + + " {\"author\": {\"role\": \"coding_agent\", \"model\": \"claude-fable-5-1\",\n" + + " \"harness\": \"claude-code\", \"skills\": [\"go-backend\"]},\n" + + " \"type\": \"risk\", \"title\": \"...\", \"summary\": \"...\",\n" + + " \"anchor\": {\"commit_sha\": \"abc1234\", \"file_path\": \"a.go\", \"start_line\": 10, \"end_line\": 20},\n" + + " \"comments\": [{\"body\": \"why it was raised\", \"model_metadata\": {\"subagent\": \"reviewer\"}}]},\n" + + " {\"id\": 7, \"comments\": [{\"author\": {\"role\": \"person\"}, \"body\": \"a comment\"},\n" + + " {\"id\": 23, \"body\": \"an edited comment\"}]},\n" + + " {\"id\": 8, \"author\": {\"role\": \"person\"}, \"outcome\": \"fixed\", \"resolution\": \"why\"},\n" + + " {\"id\": 9, \"author\": {\"role\": \"person\"}, \"outcome\": null,\n" + + " \"comments\": [{\"body\": \"why it was reopened\"}]}\n" + + " ]}\n" + + "\n" + + "An item without an id creates a decision and needs a type and title. One\n" + + "with an id changes only the keys it has: an outcome settles it and needs\n" + + "a resolution, a null outcome reopens it and needs a comment saying why,\n" + + "and null clears summary or anchor. An item that creates or changes a\n" + + "decision needs an author with a role (person, coding_agent,\n" + + "review_agent); add model, harness, and skills when you know them. A new\n" + + "comment without an author takes the item's. A comment with an id edits\n" + + "that comment and has only id and body.\n" + + "\n" + + "Anyone can settle, reopen, and comment. Only the user who raised a\n" + + "decision can change its type, title, summary, or anchor, and a settled\n" + + "decision has to be reopened first.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + runbookNumber, err := parseRunbookID(args[0]) + if err != nil { + return err + } + body, err := readDecisionBatch(decisionsApplyFlags.File) + if err != nil { + return err + } + client, err := api.NewClient() + if err != nil { + return err + } + resp, err := client.ApplyDecisions(cmd.Context(), runbookNumber, body) + if err != nil { + return err + } + if decisionsFlags.JSON { + return printJSON(newDecisionListJSON(runbookNumber, resp.Decisions)) + } + fmt.Printf("%s %s: %s recorded\n", colors.Success("✓"), + formatRunbookID(runbookNumber), english.Plural(len(resp.Decisions), "decision", "")) + fmt.Print(formatDecisionTable(resp.Decisions)) + return nil + }, +} + +// parseDecisionID accepts a bare number or the # form the CLI prints. +func parseDecisionID(arg string) (int, error) { + s := strings.TrimPrefix(strings.TrimSpace(arg), "#") + n, err := strconv.Atoi(s) + if err != nil || n <= 0 { + return 0, errors.Errorf("invalid decision ID %q, expected a positive number", arg) + } + return n, nil +} + +// readDecisionBatch reads apply's file, or stdin for -, unparsed. +func readDecisionBatch(path string) ([]byte, error) { + var data []byte + var err error + if path == "-" { + data, err = io.ReadAll(os.Stdin) + } else { + data, err = os.ReadFile(path) + } + if err != nil { + return nil, errors.Wrapf(err, "failed to read %s", path) + } + return data, nil +} + +func init() { + decisionsCmd.PersistentFlags().BoolVar(&decisionsFlags.JSON, "json", false, + "print the result as a single JSON object instead of the human summary") + + lf := decisionsListCmd.Flags() + lf.StringVar(&decisionsListFlags.Status, "status", "", "only open or settled decisions") + lf.IntVar(&decisionsListFlags.Page, "page", 1, "which page to show") + lf.IntVar(&decisionsListFlags.Limit, "limit", 20, "decisions per page (max 100)") + + decisionsApplyCmd.Flags().StringVar(&decisionsApplyFlags.File, "file", "", "the JSON batch file, or - for stdin") + _ = decisionsApplyCmd.MarkFlagRequired("file") + + decisionsCmd.AddCommand( + decisionsListCmd, + decisionsShowCmd, + decisionsApplyCmd, + ) + rootCmd.AddCommand(decisionsCmd) +} diff --git a/cmd/aviator/decisions_format.go b/cmd/aviator/decisions_format.go new file mode 100644 index 0000000..5611678 --- /dev/null +++ b/cmd/aviator/decisions_format.go @@ -0,0 +1,230 @@ +package main + +import ( + "fmt" + "strings" + "text/tabwriter" + + "github.com/aviator-co/aviator-cli/internal/api" + "github.com/aviator-co/aviator-cli/internal/utils/colors" + "github.com/dustin/go-humanize/english" +) + +// formatDecisionTable renders decisions one per row. Title is the last column +// so the one unpadded cell carries the long text. +func formatDecisionTable(decisions []api.Decision) string { + var b strings.Builder + w := tabwriter.NewWriter(&b, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "ID\tSTATUS\tTYPE\tCOMMENTS\tTITLE") + for i := range decisions { + d := &decisions[i] + fmt.Fprintf(w, "#%d\t%s\t%s\t%d\t%s\n", d.ID, formatDecisionStatus(d), d.Type, d.NumComments(), d.Title) + } + _ = w.Flush() + return b.String() +} + +// formatDecisionStatus renders the status with the outcome once settled. +func formatDecisionStatus(d *api.Decision) string { + if d.Status == "settled" && d.Outcome != "" { + return "settled (" + d.Outcome + ")" + } + return d.Status +} + +// formatDecisionDetail renders one decision and its comment thread. +func formatDecisionDetail(d *api.Decision) string { + var b strings.Builder + fmt.Fprintf(&b, "%s %s\n", colors.Bold(fmt.Sprintf("#%d", d.ID)), colors.Bold(d.Title)) + parts := []string{d.Type, formatDecisionStatus(d)} + if d.RaisedBy != nil { + parts = append(parts, "raised by "+d.RaisedBy.Name) + } + if d.Author != nil { + parts = append(parts, "last changed as "+formatDecisionAuthor(*d.Author)) + } + fmt.Fprintf(&b, " %s\n", strings.Join(parts, " · ")) + if d.Anchor != nil { + fmt.Fprintf(&b, " at %s\n", formatDecisionAnchor(d.Anchor)) + } + if summary := strings.TrimSpace(d.Summary); summary != "" { + fmt.Fprintf(&b, "\n%s\n", indent(summary, " ")) + } + if resolution := strings.TrimSpace(d.Resolution); resolution != "" { + fmt.Fprintf(&b, "\n Resolution:\n%s\n", indent(resolution, " ")) + } + if len(d.Comments) == 0 { + return b.String() + } + fmt.Fprintf(&b, "\n%s\n", english.Plural(len(d.Comments), "comment", "")) + for _, c := range d.Comments { + fmt.Fprintf(&b, " %s %s %s\n", colors.Faint(fmt.Sprintf("#%d", c.ID)), + formatCommentBy(&c), colors.Faint(c.CreatedAt)) + fmt.Fprintf(&b, "%s\n", indent(strings.TrimSpace(c.Body), " ")) + } + return b.String() +} + +// formatCommentBy names who wrote a comment and as what role, leaving out +// whichever the server sent as null. +func formatCommentBy(c *api.DecisionComment) string { + var parts []string + if c.User != nil { + parts = append(parts, c.User.Name) + } + if c.Author != nil { + parts = append(parts, "as "+formatDecisionAuthor(*c.Author)) + } + return strings.Join(parts, " ") +} + +// formatDecisionAuthor renders a role, with its model and harness when known. +func formatDecisionAuthor(a api.DecisionAuthor) string { + var details []string + for _, s := range []string{a.Model, a.Harness} { + if s != "" { + details = append(details, s) + } + } + if len(details) == 0 { + return a.Role + } + return fmt.Sprintf("%s (%s)", a.Role, strings.Join(details, ", ")) +} + +// formatDecisionAnchor renders an anchor as path:start-end @ short-sha. +func formatDecisionAnchor(a *api.DecisionAnchor) string { + s := a.FilePath + switch { + case a.StartLine > 0 && a.EndLine > a.StartLine: + s += fmt.Sprintf(":%d-%d", a.StartLine, a.EndLine) + case a.StartLine > 0: + s += fmt.Sprintf(":%d", a.StartLine) + } + if a.CommitSHA != "" { + sha := a.CommitSHA + if len(sha) > 7 { + sha = sha[:7] + } + s = strings.TrimSpace(s + " @ " + sha) + } + return s +} + +func indent(s, prefix string) string { + return prefix + strings.ReplaceAll(s, "\n", "\n"+prefix) +} + +type decisionAuthorJSON struct { + Role string `json:"role"` + Model string `json:"model,omitempty"` + Harness string `json:"harness,omitempty"` + Skills []string `json:"skills,omitempty"` +} + +type decisionUserJSON struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type decisionAnchorJSON struct { + CommitSHA string `json:"commit_sha,omitempty"` + FilePath string `json:"file_path,omitempty"` + StartLine int `json:"start_line,omitempty"` + EndLine int `json:"end_line,omitempty"` +} + +type decisionCommentJSON struct { + ID int `json:"id"` + User *decisionUserJSON `json:"user"` + Author *decisionAuthorJSON `json:"author"` + Body string `json:"body"` + CreatedAt string `json:"created_at"` + ModifiedAt string `json:"modified_at"` +} + +type decisionJSON struct { + ID int `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + Summary string `json:"summary"` + Status string `json:"status"` + Outcome string `json:"outcome"` + SettledAt string `json:"settled_at"` + Resolution string `json:"resolution"` + Anchor *decisionAnchorJSON `json:"anchor"` + RaisedBy *decisionUserJSON `json:"raised_by"` + Author *decisionAuthorJSON `json:"author"` + // Comments is left out of list, which doesn't carry the thread. + CommentCount int `json:"comment_count"` + Comments []decisionCommentJSON `json:"comments,omitzero"` +} + +type decisionListJSON struct { + ID string `json:"id"` + Decisions []decisionJSON `json:"decisions"` + // HasMore is set by list, the one paged command. + HasMore *bool `json:"has_more,omitempty"` +} + +func newDecisionUserJSON(u *api.DecisionUser) *decisionUserJSON { + if u == nil { + return nil + } + out := decisionUserJSON(*u) + return &out +} + +func newDecisionAuthorJSON(a *api.DecisionAuthor) *decisionAuthorJSON { + if a == nil { + return nil + } + out := decisionAuthorJSON(*a) + return &out +} + +func newDecisionJSON(d *api.Decision) decisionJSON { + out := decisionJSON{ + ID: d.ID, + Type: d.Type, + Title: d.Title, + Summary: d.Summary, + Status: d.Status, + Outcome: d.Outcome, + SettledAt: d.SettledAt, + Resolution: d.Resolution, + RaisedBy: newDecisionUserJSON(d.RaisedBy), + Author: newDecisionAuthorJSON(d.Author), + CommentCount: d.NumComments(), + } + if d.Anchor != nil { + anchor := decisionAnchorJSON(*d.Anchor) + out.Anchor = &anchor + } + if d.Comments != nil { + out.Comments = make([]decisionCommentJSON, 0, len(d.Comments)) + } + for i := range d.Comments { + out.Comments = append(out.Comments, newDecisionCommentJSON(&d.Comments[i])) + } + return out +} + +func newDecisionCommentJSON(c *api.DecisionComment) decisionCommentJSON { + return decisionCommentJSON{ + ID: c.ID, + User: newDecisionUserJSON(c.User), + Author: newDecisionAuthorJSON(c.Author), + Body: c.Body, + CreatedAt: c.CreatedAt, + ModifiedAt: c.ModifiedAt, + } +} + +func newDecisionListJSON(runbookNumber int, decisions []api.Decision) decisionListJSON { + out := decisionListJSON{ID: formatRunbookID(runbookNumber), Decisions: make([]decisionJSON, 0, len(decisions))} + for i := range decisions { + out.Decisions = append(out.Decisions, newDecisionJSON(&decisions[i])) + } + return out +} diff --git a/cmd/aviator/decisions_test.go b/cmd/aviator/decisions_test.go new file mode 100644 index 0000000..44fce8a --- /dev/null +++ b/cmd/aviator/decisions_test.go @@ -0,0 +1,144 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/aviator-co/aviator-cli/internal/api" + "github.com/aviator-co/aviator-cli/internal/config" + "github.com/spf13/cobra" +) + +// useTestServer points the API client at a server that records each request +// as "METHOD path?query body" and answers with reply. Commands are run through +// RunE directly: going through rootCmd would load the real config and point +// the client away from the test server. +func useTestServer(t *testing.T, reply string) *[]string { + t.Helper() + var got []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data, _ := io.ReadAll(r.Body) + got = append(got, r.Method+" "+r.URL.RequestURI()+" "+string(data)) + _, _ = w.Write([]byte(reply)) + })) + t.Cleanup(srv.Close) + host, token := config.Av.Aviator.APIHost, config.Av.Aviator.APIToken + config.Av.Aviator.APIHost, config.Av.Aviator.APIToken = srv.URL, "test-token" + t.Cleanup(func() { config.Av.Aviator.APIHost, config.Av.Aviator.APIToken = host, token }) + return &got +} + +func runCommand(t *testing.T, cmd *cobra.Command, args ...string) string { + t.Helper() + cmd.SetContext(context.Background()) + if err := cmd.ParseFlags(args); err != nil { + t.Fatalf("%v: %v", args, err) + } + stdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + runErr := cmd.RunE(cmd, cmd.Flags().Args()) + os.Stdout = stdout + _ = w.Close() + out, _ := io.ReadAll(r) + if runErr != nil { + t.Fatalf("%v: %v", args, runErr) + } + return string(out) +} + +// TestDecisionsApplySendsFile checks apply sends the file unchanged, so the +// server sees the nulls, zero ids, and unknown keys a Go round trip drops. +func TestDecisionsApplySendsFile(t *testing.T) { + got := useTestServer(t, `{"decisions":[{"id":7,"status":"open","comments":[]}]}`) + body := `{"decisions": [{"id": 0, "summary": null, "anchor": {"file_path": "a.go", "line": 40}}]}` + "\n" + path := filepath.Join(t.TempDir(), "batch.json") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + runCommand(t, decisionsApplyCmd, "r/4", "--file", path) + if want := "POST /api/v1/verify/4/decisions " + body; len(*got) != 1 || (*got)[0] != want { + t.Errorf("requests =\n%s\nwant\n%s", strings.Join(*got, "\n"), want) + } +} + +func TestDecisionsListJSON(t *testing.T) { + got := useTestServer(t, `{"decisions":[{"id":7,"status":"open","comment_count":3}],`+ + `"page":2,"per_page":5,"has_more":true}`) + decisionsFlags.JSON = true + t.Cleanup(func() { + decisionsFlags.JSON = false + decisionsListFlags.Status, decisionsListFlags.Page, decisionsListFlags.Limit = "", 1, 20 + }) + out := runCommand(t, decisionsListCmd, "r/4", "--status", "open", "--page", "2", "--limit", "5") + if want := "GET /api/v1/verify/4/decisions?page=2&per_page=5&status=open "; (*got)[0] != want { + t.Errorf("request = %q, want %q", (*got)[0], want) + } + // The list carries no threads, so comments is left out rather than []. + var parsed map[string]any + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("%v:\n%s", err, out) + } + d := parsed["decisions"].([]any)[0].(map[string]any) + if parsed["id"] != "r/4" || parsed["has_more"] != true || d["comment_count"] != 3.0 || d["comments"] != nil { + t.Errorf("json = %s", out) + } + if _, ok := parsed["session"]; ok { + t.Errorf("json still has session: %s", out) + } +} + +func TestFormatDecisionAnchor(t *testing.T) { + for want, a := range map[string]api.DecisionAnchor{ + "a.go:40-52 @ abcdef1": {FilePath: "a.go", StartLine: 40, EndLine: 52, CommitSHA: "abcdef1234"}, + "a.go:40": {FilePath: "a.go", StartLine: 40, EndLine: 40}, + "@ abc": {CommitSHA: "abc"}, + } { + if got := formatDecisionAnchor(&a); got != want { + t.Errorf("got %q, want %q", got, want) + } + } +} + +func TestFormatDecisionDetail(t *testing.T) { + got := formatDecisionDetail(&api.Decision{ + ID: 7, Type: "risk", Title: "T", Status: "settled", Outcome: "accepted_risk", Resolution: "Rare enough", + RaisedBy: &api.DecisionUser{Name: "Theo"}, + Author: &api.DecisionAuthor{Role: "coding_agent", Model: "m", Harness: "claude-code"}, + Comments: []api.DecisionComment{ + {ID: 1, User: &api.DecisionUser{Name: "Theo"}, Author: &api.DecisionAuthor{Role: "person"}, Body: "hi"}, + {ID: 2, User: &api.DecisionUser{Name: "Theo"}, Body: "no author"}, + }, + }) + for _, want := range []string{ + "risk · settled (accepted_risk) · raised by Theo · last changed as coding_agent (m, claude-code)\n", + "Resolution:\n Rare enough", + "Theo as person", + } { + if !strings.Contains(got, want) { + t.Errorf("detail lacks %q:\n%s", want, got) + } + } + // A null comment author leaves out the role instead of printing it blank. + for _, line := range strings.Split(got, "\n") { + if strings.Contains(line, "#2") && strings.Contains(line, " as ") { + t.Errorf("comment with no author: %q", line) + } + } + + // raised_by and author can be null; they're left out rather than blank. + got = formatDecisionDetail(&api.Decision{ID: 7, Type: "risk", Title: "T", Status: "open"}) + if !strings.Contains(got, " risk · open\n") { + t.Errorf("null raised_by and author:\n%s", got) + } +} diff --git a/cmd/aviator/hidden_test.go b/cmd/aviator/hidden_test.go new file mode 100644 index 0000000..eaaa55b --- /dev/null +++ b/cmd/aviator/hidden_test.go @@ -0,0 +1,27 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +// TestDecisionsCommandHidden keeps the decisions command out of help +// and shell completion, which Homebrew builds from `aviator completion`. +func TestDecisionsCommandHidden(t *testing.T) { + for _, args := range [][]string{{"--help"}, {"help"}, {"__complete", ""}} { + var out bytes.Buffer + rootCmd.SetOut(&out) + rootCmd.SetArgs(args) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("%v: %v", args, err) + } + for _, name := range []string{"decision"} { + if strings.Contains(strings.ToLower(out.String()), name) { + t.Errorf("%v lists %q:\n%s", args, name, out.String()) + } + } + } + rootCmd.SetOut(nil) + rootCmd.SetArgs(nil) +} diff --git a/internal/api/client.go b/internal/api/client.go index b1896d5..f389856 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -81,7 +81,12 @@ func (c *Client) doJSON(ctx context.Context, method, path string, body, out any) return errors.Wrap(err, "failed to encode request") } } + return c.doPayload(ctx, method, path, payload, out) +} +// doPayload sends payload (when non-nil), already-encoded JSON, to path using +// method, and handles the response like doJSON. +func (c *Client) doPayload(ctx context.Context, method, path string, payload []byte, out any) error { status, _, data, err := c.do(ctx, c.http, method, path, payload) if err != nil { return err diff --git a/internal/api/decisions.go b/internal/api/decisions.go new file mode 100644 index 0000000..37cd1ab --- /dev/null +++ b/internal/api/decisions.go @@ -0,0 +1,156 @@ +package api + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" +) + +// DecisionAuthor is the role, and the agent details when known, behind a +// decision write or a comment. Role is person, coding_agent, or review_agent. +type DecisionAuthor struct { + Role string `json:"role"` + Model string `json:"model,omitempty"` + Harness string `json:"harness,omitempty"` + Skills []string `json:"skills,omitempty"` +} + +// DecisionUser is the Aviator user behind a decision or comment. +type DecisionUser struct { + ID int `json:"id"` + Name string `json:"name"` +} + +// DecisionAnchor pins a decision to lines of a file at a commit. +type DecisionAnchor struct { + CommitSHA string `json:"commit_sha,omitempty"` + FilePath string `json:"file_path,omitempty"` + StartLine int `json:"start_line,omitempty"` + EndLine int `json:"end_line,omitempty"` +} + +// DecisionRepo is the repository a decision's pull request is in. +type DecisionRepo struct { + Org string `json:"org"` + Name string `json:"name"` +} + +// DecisionComment is one entry in a decision's thread. User is the account +// that wrote it; Author is the role it was written as. Either can be null. +type DecisionComment struct { + ID int `json:"id"` + User *DecisionUser `json:"user"` + Author *DecisionAuthor `json:"author"` + Body string `json:"body"` + CreatedAt string `json:"created_at"` + ModifiedAt string `json:"modified_at"` +} + +// Decision is a question, possible bug, risk, or choice raised about a +// runbook's pull request. Status is open or settled; Outcome, SettledAt, and +// Resolution are set once it is settled. RaisedBy is the user who created it +// and never changes; Author is the author of the latest change. Either can be +// null. The list carries CommentCount and no Comments; one decision, and the +// decisions a POST returns, carry Comments in id order and no CommentCount. +type Decision struct { + ID int `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + Summary string `json:"summary"` + Status string `json:"status"` + Outcome string `json:"outcome"` + SettledAt string `json:"settled_at"` + Resolution string `json:"resolution"` + Anchor *DecisionAnchor `json:"anchor"` + Repo *DecisionRepo `json:"repo"` + RaisedBy *DecisionUser `json:"raised_by"` + Author *DecisionAuthor `json:"author"` + CreatedAt string `json:"created_at"` + ModifiedAt string `json:"modified_at"` + CommentCount int `json:"comment_count"` + Comments []DecisionComment `json:"comments"` +} + +// NumComments is the length of the thread, from Comments when the response +// carried them and from CommentCount when it didn't. +func (d *Decision) NumComments() int { + if d.Comments != nil { + return len(d.Comments) + } + return d.CommentCount +} + +// ListDecisionsParams filters and pages GET /api/v1/verify//decisions. +// Status is open or settled, blank for both. A zero Page or PerPage leaves it +// to the server (page 1, 20 per page, at most 100). +type ListDecisionsParams struct { + Status string + Page int + PerPage int +} + +// ListDecisionsResponse is one page of a runbook's decisions, in id order. +type ListDecisionsResponse struct { + Decisions []Decision `json:"decisions"` + Page int `json:"page"` + PerPage int `json:"per_page"` + HasMore bool `json:"has_more"` +} + +// ListDecisions fetches one page of a runbook's decisions, without comments. +func (c *Client) ListDecisions( + ctx context.Context, runbookNumber int, params ListDecisionsParams, +) (*ListDecisionsResponse, error) { + query := url.Values{} + if params.Status != "" { + query.Set("status", params.Status) + } + if params.Page > 0 { + query.Set("page", strconv.Itoa(params.Page)) + } + if params.PerPage > 0 { + query.Set("per_page", strconv.Itoa(params.PerPage)) + } + var out ListDecisionsResponse + if err := c.getJSON(ctx, decisionsPath(runbookNumber), query, &out); err != nil { + return nil, err + } + return &out, nil +} + +// GetDecision fetches one decision with its comment thread. +func (c *Client) GetDecision(ctx context.Context, runbookNumber, decisionID int) (*Decision, error) { + var out Decision + if err := c.getJSON(ctx, decisionPath(runbookNumber, decisionID), nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ApplyDecisionsResponse is the decisions a POST touched, in request order, +// each with its comments. +type ApplyDecisionsResponse struct { + Decisions []Decision `json:"decisions"` +} + +// ApplyDecisions sends body, a {"decisions": [...]} batch, to POST +// /api/v1/verify//decisions as is. The server validates it strictly and +// applies it all or nothing; decoding and re-encoding it here would drop +// nulls, zero ids, and unknown keys before the server could see them. +func (c *Client) ApplyDecisions(ctx context.Context, runbookNumber int, body []byte) (*ApplyDecisionsResponse, error) { + var out ApplyDecisionsResponse + if err := c.doPayload(ctx, http.MethodPost, decisionsPath(runbookNumber), body, &out); err != nil { + return nil, err + } + return &out, nil +} + +func decisionsPath(runbookNumber int) string { + return fmt.Sprintf("/api/v1/verify/%d/decisions", runbookNumber) +} + +func decisionPath(runbookNumber, decisionID int) string { + return fmt.Sprintf("%s/%d", decisionsPath(runbookNumber), decisionID) +} diff --git a/internal/api/decisions_test.go b/internal/api/decisions_test.go new file mode 100644 index 0000000..ee0eef3 --- /dev/null +++ b/internal/api/decisions_test.go @@ -0,0 +1,95 @@ +package api + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +const decisionJSON = `{"id":7,"type":"risk","title":"Repos share one rate limit","summary":null,` + + `"status":"settled","outcome":"accepted_risk","settled_at":"2026-10-21T00:00:00Z",` + + `"resolution":"Rare enough to ship",` + + `"anchor":{"commit_sha":"abc","file_path":"a.go","start_line":40,"end_line":52},` + + `"repo":{"org":"acme","name":"web"},"raised_by":{"id":7,"name":"Theo"},` + + `"author":{"role":"coding_agent","model":"claude-fable-5-1","harness":"claude-code","skills":["go"]},` + + `"comments":[{"id":1,"user":{"id":7,"name":"Theo"},` + + `"author":{"role":"person","model":null,"harness":null,"skills":null},"body":"hi",` + + `"created_at":"2026-10-21T00:00:00Z","modified_at":"2026-10-21T00:00:00Z"}],` + + `"created_at":"2026-10-21T00:00:00Z","modified_at":"2026-10-21T00:00:00Z"}` + +func TestListDecisions(t *testing.T) { + srv, got := recordRequests(t, `{"decisions":[{"id":7,"status":"open","comment_count":3}],`+ + `"page":2,"per_page":5,"has_more":true}`) + resp, err := newTestClient(srv).ListDecisions(context.Background(), 123, + ListDecisionsParams{Status: "open", Page: 2, PerPage: 5}) + if err != nil || len(resp.Decisions) != 1 || !resp.HasMore || resp.Page != 2 { + t.Fatalf("resp = %+v, err = %v", resp, err) + } + if d := resp.Decisions[0]; d.NumComments() != 3 || d.Comments != nil { + t.Errorf("decision = %+v", d) + } + if want := "GET /api/v1/verify/123/decisions?page=2&per_page=5&status=open "; (*got)[0] != want { + t.Errorf("request = %q, want %q", (*got)[0], want) + } + + // Left-out params leave the defaults to the server. + if _, err := newTestClient(srv).ListDecisions(context.Background(), 123, ListDecisionsParams{}); err != nil { + t.Fatal(err) + } + if want := "GET /api/v1/verify/123/decisions "; (*got)[1] != want { + t.Errorf("request = %q, want %q", (*got)[1], want) + } +} + +func TestDecisionNullAuthors(t *testing.T) { + var d Decision + if err := json.Unmarshal([]byte(`{"id":7,"raised_by":null,"author":null,`+ + `"comments":[{"id":1,"user":null,"author":null,"body":"b"}]}`), &d); err != nil { + t.Fatal(err) + } + if d.RaisedBy != nil || d.Author != nil || d.Comments[0].User != nil || d.Comments[0].Author != nil { + t.Errorf("decision = %+v", d) + } +} + +// recordRequests serves reply to every request and records each one as +// "METHOD path?query body". +func recordRequests(t *testing.T, reply string) (*httptest.Server, *[]string) { + t.Helper() + var got []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data, _ := io.ReadAll(r.Body) + got = append(got, r.Method+" "+r.URL.RequestURI()+" "+string(data)) + _, _ = w.Write([]byte(reply)) + })) + t.Cleanup(srv.Close) + return srv, &got +} + +func TestGetDecision(t *testing.T) { + srv, got := recordRequests(t, decisionJSON) + d, err := newTestClient(srv).GetDecision(context.Background(), 123, 7) + if err != nil || d.ID != 7 || len(d.Comments) != 1 { + t.Fatalf("decision = %+v, err = %v", d, err) + } + if want := "GET /api/v1/verify/123/decisions/7 "; (*got)[0] != want { + t.Errorf("request = %q, want %q", (*got)[0], want) + } +} + +// TestApplyDecisions checks the body goes out byte for byte: nulls, a zero +// id, unknown keys, and whitespace are the server's to judge. +func TestApplyDecisions(t *testing.T) { + srv, got := recordRequests(t, `{"decisions":[`+decisionJSON+`]}`) + body := `{"decisions": [{"id": 0, "summary": null, "anchor": {"file_path": "a.go", "line": 40}}]}` + "\n" + resp, err := newTestClient(srv).ApplyDecisions(context.Background(), 123, []byte(body)) + if err != nil || len(resp.Decisions) != 1 || resp.Decisions[0].NumComments() != 1 { + t.Fatalf("resp = %+v, err = %v", resp, err) + } + if want := "POST /api/v1/verify/123/decisions " + body; (*got)[0] != want { + t.Errorf("request =\n%s\nwant\n%s", (*got)[0], want) + } +}