Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 172 additions & 62 deletions cmd/aviator/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,20 @@ var verifyFlags struct {
TargetBranch string
Spec string
AuthorEmail string
EvaluatorOnly bool
Force bool
JSON bool
}

// verifySubmitOnlyFlags are meaningless when triggering a run on an existing
// session, so trigger mode rejects them by name.
var verifySubmitOnlyFlags = []string{
"repo", "intent", "criteria", "criteria-file",
"working-branch", "target-branch", "spec", "author-email",
}

var verifyTriggerOnlyFlags = []string{"evaluator-only", "force"}

// noWorkingBranchWarning covers the one thing a submission gives up without
// --working-branch: with no branch to match on, the session can only reach a PR
// through the "Runbook: <url>" line in the PR body.
Expand All @@ -30,79 +41,152 @@ const noWorkingBranchWarning = "no --working-branch given, so this session can o
" Pass --working-branch <branch> to have the PR opened from that branch bind automatically."

var verifyCmd = &cobra.Command{
Use: "verify",
Short: "Submit an intent and acceptance criteria for verification",
Long: "Create a verification from an intent and a set of acceptance criteria.\n" +
"Pass --working-branch to tie it to the branch the work lives on so a PR\n" +
"opened from that branch is verified against these criteria.\n" +
Use: "verify [r/<number>]",
Short: "Submit acceptance criteria for verification, or trigger a run on a session",
Long: "With no argument, create a verification from an intent and a set of\n" +
"acceptance criteria. Pass --working-branch to tie it to the branch the\n" +
"work lives on so a PR opened from that branch is verified against these\n" +
"criteria.\n" +
"\n" +
"One verify session tracks exactly one PR. Stacked or multi-PR work needs\n" +
"one submission per PR, each with its own --working-branch, intent, and\n" +
"acceptance criteria. A single submission cannot cover a stack.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()

repo, err := parseRepo(verifyFlags.Repo)
if err != nil {
return err
}
if verifyFlags.Intent == "" {
return errors.New("--intent is required")
}
criteria, err := collectCriteria(verifyFlags.Criteria, verifyFlags.CriteriaFile)
if err != nil {
return err
}
if len(criteria) == 0 {
return errors.New("at least one --criteria (or --criteria-file) is required")
"acceptance criteria. A single submission cannot cover a stack.\n" +
"\n" +
"With a session ID (aviator verify r/123), trigger a verification run on\n" +
"that existing session — including its first run. If an equivalent\n" +
"non-error run already exists for the current head commit and criteria,\n" +
"the server returns that run instead of starting a new one, so this is\n" +
"safe to call liberally. Pass --force to start a fresh full run anyway,\n" +
"or --evaluator-only to re-judge the evidence an earlier run already\n" +
"collected instead of collecting it again — the cheap path after a\n" +
"criteria edit; it falls back to a full run when there is nothing to\n" +
"re-judge.",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 1 {
return runVerifyTrigger(cmd, args[0])
}
return runVerifySubmit(cmd)
},
}

var spec *api.SpecFile
if verifyFlags.Spec != "" {
spec, err = readSpecFile(verifyFlags.Spec)
if err != nil {
return err
}
}
func runVerifySubmit(cmd *cobra.Command) error {
ctx := cmd.Context()

client, err := api.NewClient()
if err != nil {
return err
for _, name := range verifyTriggerOnlyFlags {
if cmd.Flags().Changed(name) {
return errors.Errorf(
"--%s only applies when triggering a run on an existing session (aviator verify r/<number>)",
name)
}
}

resp, err := client.SubmitVerify(ctx, api.SubmitVerifyRequest{
Repository: repo,
Intent: verifyFlags.Intent,
AcceptanceCriteria: criteria,
WorkingBranch: verifyFlags.WorkingBranch,
TargetBranch: verifyFlags.TargetBranch,
SpecFile: spec,
AuthorEmail: verifyFlags.AuthorEmail,
})
if verifyFlags.Repo == "" {
return errors.New("--repo is required")
}
repo, err := parseRepo(verifyFlags.Repo)
if err != nil {
return err
}
if verifyFlags.Intent == "" {
return errors.New("--intent is required")
}
criteria, err := collectCriteria(verifyFlags.Criteria, verifyFlags.CriteriaFile)
if err != nil {
return err
}
if len(criteria) == 0 {
return errors.New("at least one --criteria (or --criteria-file) is required")
}

var spec *api.SpecFile
if verifyFlags.Spec != "" {
spec, err = readSpecFile(verifyFlags.Spec)
if err != nil {
return err
}
}

if verifyFlags.WorkingBranch == "" {
// stderr, so --json consumers still get a clean object on stdout.
fmt.Fprintf(os.Stderr, "%s %s\n", colors.Warning("warning:"), noWorkingBranchWarning)
}
if verifyFlags.JSON {
return printJSON(newVerifySubmitJSON(resp))
}
client, err := api.NewClient()
if err != nil {
return err
}

fmt.Printf("%s Verify submission created: %s\n", colors.Success("✓"), resp.URL)
fmt.Printf(" Runbook #%d\n", resp.RunbookNumber)
if resp.WorkingBranch != "" {
fmt.Printf(" Working branch: %s\n", resp.WorkingBranch)
}
if resp.TargetBranch != "" {
fmt.Printf(" Target branch: %s\n", resp.TargetBranch)
resp, err := client.SubmitVerify(ctx, api.SubmitVerifyRequest{
Repository: repo,
Intent: verifyFlags.Intent,
AcceptanceCriteria: criteria,
WorkingBranch: verifyFlags.WorkingBranch,
TargetBranch: verifyFlags.TargetBranch,
SpecFile: spec,
AuthorEmail: verifyFlags.AuthorEmail,
})
if err != nil {
return err
}

if verifyFlags.WorkingBranch == "" {
// stderr, so --json consumers still get a clean object on stdout.
fmt.Fprintf(os.Stderr, "%s %s\n", colors.Warning("warning:"), noWorkingBranchWarning)
}
if verifyFlags.JSON {
return printJSON(newVerifySubmitJSON(resp))
}

fmt.Printf("%s Verify submission created: %s\n", colors.Success("✓"), resp.URL)
fmt.Printf(" Runbook #%d\n", resp.RunbookNumber)
if resp.WorkingBranch != "" {
fmt.Printf(" Working branch: %s\n", resp.WorkingBranch)
}
if resp.TargetBranch != "" {
fmt.Printf(" Target branch: %s\n", resp.TargetBranch)
}
fmt.Printf(" Criteria: %d\n", len(resp.AcceptanceCriteria))
return nil
}

func runVerifyTrigger(cmd *cobra.Command, arg string) error {
for _, name := range verifySubmitOnlyFlags {
if cmd.Flags().Changed(name) {
return errors.Errorf(
"--%s only applies when submitting a new verification, not when triggering a run on an existing session",
name)
}
fmt.Printf(" Criteria: %d\n", len(resp.AcceptanceCriteria))
return nil
},
}

runbookNumber, err := parseRunbookID(arg)
if err != nil {
return err
}
client, err := api.NewClient()
if err != nil {
return err
}
resp, err := client.TriggerVerifyRun(cmd.Context(), runbookNumber, api.TriggerVerifyRunRequest{
EvaluatorOnly: verifyFlags.EvaluatorOnly,
Force: verifyFlags.Force,
})
if err != nil {
return err
}
if verifyFlags.JSON {
return printJSON(newVerifyRunJSON(resp))
}

id := formatRunbookID(resp.RunbookNumber)
if resp.Deduplicated {
fmt.Printf("%s %s already has an equivalent run at this commit and criteria version\n",
colors.Success("✓"), id)
} else {
fmt.Printf("%s Verification run started for %s\n", colors.Success("✓"), id)
}
if resp.Message != "" {
fmt.Printf(" %s\n", resp.Message)
}
fmt.Printf(" Run status: %s\n", resp.RunStatus)
fmt.Printf(" URL: %s\n", resp.URL)
fmt.Printf(" %s\n", colors.Faint("Poll with: aviator results "+id))
return nil
}

func init() {
Expand All @@ -114,9 +198,11 @@ func init() {
f.StringVar(&verifyFlags.TargetBranch, "target-branch", "", "base branch to verify against (defaults to the repo default)")
f.StringVar(&verifyFlags.Spec, "spec", "", "path to an optional spec file")
f.StringVar(&verifyFlags.AuthorEmail, "author-email", "", "attribute the submission to this user")
f.BoolVar(&verifyFlags.JSON, "json", false, "print the submission as a single JSON object instead of the human summary")
_ = verifyCmd.MarkFlagRequired("repo")
_ = verifyCmd.MarkFlagRequired("intent")
f.BoolVar(&verifyFlags.EvaluatorOnly, "evaluator-only", false,
"re-judge the evidence an earlier run collected instead of collecting it again (trigger mode only)")
f.BoolVar(&verifyFlags.Force, "force", false,
"start a fresh full run even if an equivalent run already exists (trigger mode only)")
f.BoolVar(&verifyFlags.JSON, "json", false, "print the result as a single JSON object instead of the human summary")
}

// verifySubmitJSON is the --json shape of a verify submission. It is its own
Expand All @@ -141,3 +227,27 @@ func newVerifySubmitJSON(resp *api.SubmitVerifyResponse) verifySubmitJSON {
CriteriaCount: len(resp.AcceptanceCriteria),
}
}

// verifyRunJSON is the --json shape of a triggered verification run, its own
// struct for the same key-stability reason as verifySubmitJSON.
type verifyRunJSON struct {
RunbookNumber int `json:"runbook_number"`
RunbookID string `json:"runbook_id"`
URL string `json:"url"`
RunID int `json:"run_id"`
RunStatus string `json:"run_status"`
Deduplicated bool `json:"deduplicated"`
Message string `json:"message"`
}

func newVerifyRunJSON(resp *api.TriggerVerifyRunResponse) verifyRunJSON {
return verifyRunJSON{
RunbookNumber: resp.RunbookNumber,
RunbookID: formatRunbookID(resp.RunbookNumber),
URL: resp.URL,
RunID: resp.RunID,
RunStatus: resp.RunStatus,
Deduplicated: resp.Deduplicated,
Message: resp.Message,
}
}
37 changes: 37 additions & 0 deletions cmd/aviator/verify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,43 @@ func TestVerifySubmitJSONKeepsEmptyFields(t *testing.T) {
})
}

// Same contract as verifySubmitJSON: callers parse these keys.
func TestVerifyRunJSON(t *testing.T) {
got := decodeJSON(t, newVerifyRunJSON(&api.TriggerVerifyRunResponse{
RunbookNumber: 42,
URL: "https://app.aviator.co/r/42",
RunID: 1234,
RunStatus: "pending",
Deduplicated: false,
Message: "Verification started.",
}))
assertJSONFields(t, got, map[string]any{
"runbook_number": float64(42),
"runbook_id": "r/42",
"url": "https://app.aviator.co/r/42",
"run_id": float64(1234),
"run_status": "pending",
"deduplicated": false,
"message": "Verification started.",
})
}

func TestVerifyRunJSONKeepsEmptyFields(t *testing.T) {
got := decodeJSON(t, newVerifyRunJSON(&api.TriggerVerifyRunResponse{
RunbookNumber: 7,
Deduplicated: true,
}))
assertJSONFields(t, got, map[string]any{
"runbook_number": float64(7),
"runbook_id": "r/7",
"url": "",
"run_id": float64(0),
"run_status": "",
"deduplicated": true,
"message": "",
})
}

// The warning is the only place a caller learns why an unbound session may
// never attach to its PR, so it has to name both the flag and the fallback.
func TestNoWorkingBranchWarning(t *testing.T) {
Expand Down
36 changes: 36 additions & 0 deletions internal/api/verify_run.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package api

import (
"context"
"fmt"
)

// TriggerVerifyRunRequest is the body for POST /api/v1/verify/<number>/run.
type TriggerVerifyRunRequest struct {
EvaluatorOnly bool `json:"evaluator_only,omitempty"`
Force bool `json:"force,omitempty"`
}

// TriggerVerifyRunResponse is the response from POST /api/v1/verify/<number>/run.
// Deduplicated is true when the server returned an existing equivalent run
// instead of enqueueing a new one.
type TriggerVerifyRunResponse struct {
RunbookNumber int `json:"runbook_number"`
URL string `json:"url"`
RunID int `json:"run_id"`
RunStatus string `json:"run_status"`
Deduplicated bool `json:"deduplicated"`
Message string `json:"message"`
}

// TriggerVerifyRun starts a verification run on an existing verify session.
func (c *Client) TriggerVerifyRun(
ctx context.Context, runbookNumber int, req TriggerVerifyRunRequest,
) (*TriggerVerifyRunResponse, error) {
var out TriggerVerifyRunResponse
path := fmt.Sprintf("/api/v1/verify/%d/run", runbookNumber)
if err := c.postJSON(ctx, path, req, &out); err != nil {
return nil, err
}
return &out, nil
}
Loading