Skip to content
Open
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
95 changes: 80 additions & 15 deletions backend/internal/cli/review.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cli

import (
"encoding/json"
"errors"
"fmt"
"io"
Expand All @@ -15,29 +16,42 @@ import (

// reviewRun mirrors the daemon's domain.ReviewRun for the CLI client.
type reviewRun struct {
ID string `json:"id"`
SessionID string `json:"sessionId"`
Harness string `json:"harness"`
PRURL string `json:"prUrl"`
TargetSHA string `json:"targetSha"`
Status string `json:"status"`
Verdict string `json:"verdict"`
Body string `json:"body"`
CreatedAt time.Time `json:"createdAt"`
ID string `json:"id"`
SessionID string `json:"sessionId"`
BatchID string `json:"batchId"`
Harness string `json:"harness"`
PRURL string `json:"prUrl"`
TargetSHA string `json:"targetSha"`
Status string `json:"status"`
Verdict string `json:"verdict"`
Body string `json:"body"`
GithubReviewID string `json:"githubReviewId"`
CreatedAt time.Time `json:"createdAt"`
DeliveredAt *time.Time `json:"deliveredAt,omitempty"`
}

// reviewRunResponse mirrors controllers.ReviewRunResponse.
type reviewRunResponse struct {
Review reviewRun `json:"review"`
ReviewerHandleID string `json:"reviewerHandleId"`
Review reviewRun `json:"review"`
Reviews []reviewRun `json:"reviews"`
ReviewerHandleID string `json:"reviewerHandleId"`
}

// submitReviewRequest mirrors controllers.SubmitReviewInput.
type submitReviewRequest struct {
// submitReviewItem mirrors controllers.SubmitReviewItem.
type submitReviewItem struct {
RunID string `json:"runId"`
Verdict string `json:"verdict"`
Body string `json:"body"`
GithubReviewID string `json:"githubReviewId"`
Body string `json:"body,omitempty"`
GithubReviewID string `json:"githubReviewId,omitempty"`
}

// submitReviewRequest mirrors controllers.SubmitReviewInput.
type submitReviewRequest struct {
RunID string `json:"runId,omitempty"`
Verdict string `json:"verdict,omitempty"`
Body string `json:"body,omitempty"`
GithubReviewID string `json:"githubReviewId,omitempty"`
Reviews []submitReviewItem `json:"reviews,omitempty"`
}

type reviewSubmitOptions struct {
Expand All @@ -46,6 +60,7 @@ type reviewSubmitOptions struct {
verdict string
body string
reviewID string
reviews string
}

func newReviewCommand(ctx *commandContext) *cobra.Command {
Expand Down Expand Up @@ -77,6 +92,7 @@ func newReviewSubmitCommand(ctx *commandContext) *cobra.Command {
cmd.Flags().StringVar(&opts.verdict, "verdict", "", "Review verdict: approved or changes_requested (required)")
cmd.Flags().StringVar(&opts.body, "body", "", "Review body: a path to a Markdown file, or - to read from stdin (so nothing is written into the worktree)")
cmd.Flags().StringVar(&opts.reviewID, "review-id", "", "Id of the GitHub PR review just posted (the .id from the gh api POST that created the review)")
cmd.Flags().StringVar(&opts.reviews, "reviews", "", "JSON review results array or object: a path, or - to read from stdin")
return cmd
}

Expand All @@ -88,6 +104,9 @@ func (c *commandContext) submitReview(cmd *cobra.Command, args []string, opts re
if session == "" {
return usageError{errors.New("usage: worker session id is required (positional or --session)")}
}
if strings.TrimSpace(opts.reviews) != "" {
return c.submitReviewBatch(cmd, session, opts)
}
runID := strings.TrimSpace(opts.runID)
if runID == "" {
return usageError{errors.New("usage: --run is required")}
Expand Down Expand Up @@ -121,3 +140,49 @@ func (c *commandContext) submitReview(cmd *cobra.Command, args []string, opts re
_, err := fmt.Fprintf(cmd.OutOrStdout(), "recorded %s review for %s\n", res.Review.Verdict, session)
return err
}

func (c *commandContext) submitReviewBatch(cmd *cobra.Command, session string, opts reviewSubmitOptions) error {
if strings.TrimSpace(opts.runID) != "" || strings.TrimSpace(opts.verdict) != "" || strings.TrimSpace(opts.body) != "" || strings.TrimSpace(opts.reviewID) != "" {
return usageError{errors.New("usage: --reviews cannot be combined with --run, --verdict, --body, or --review-id")}
}
reviews, err := readReviewItems(cmd, strings.TrimSpace(opts.reviews))
if err != nil {
return err
}
path := "sessions/" + url.PathEscape(session) + "/reviews/submit"
var res reviewRunResponse
if err := c.postJSON(cmd.Context(), path, submitReviewRequest{Reviews: reviews}, &res); err != nil {
return err
}
count := len(res.Reviews)
if count == 0 {
count = len(reviews)
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "recorded %d review(s) for %s\n", count, session)
return err
}

func readReviewItems(cmd *cobra.Command, path string) ([]submitReviewItem, error) {
var raw []byte
var err error
if path == "-" {
raw, err = io.ReadAll(cmd.InOrStdin())
} else {
raw, err = os.ReadFile(path)
}
if err != nil {
return nil, usageError{fmt.Errorf("read review results: %w", err)}
}
var req submitReviewRequest
if err := json.Unmarshal(raw, &req); err == nil && len(req.Reviews) > 0 {
return req.Reviews, nil
}
var reviews []submitReviewItem
if err := json.Unmarshal(raw, &reviews); err != nil {
return nil, usageError{fmt.Errorf("decode review results JSON: %w", err)}
}
if len(reviews) == 0 {
return nil, usageError{errors.New("usage: --reviews requires at least one review result")}
}
return reviews, nil
}
26 changes: 26 additions & 0 deletions backend/internal/cli/review_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,32 @@ func TestReviewSubmitAcceptsUnderscoreFlags(t *testing.T) {
}
}

func TestReviewSubmitBatchReadsReviewsFromStdin(t *testing.T) {
cfg := setConfigEnv(t)
srv, capture := reviewServer(t, http.StatusOK, `{"reviews":[{"id":"run-1","verdict":"changes_requested"},{"id":"run-2","verdict":"approved"}]}`)
writeRunFileFor(t, cfg, srv)

deps := aliveDeps()
deps.In = strings.NewReader(`{"reviews":[{"runId":"run-1","verdict":"changes_requested","body":"fix auth","githubReviewId":"101"},{"runId":"run-2","verdict":"approved","body":"looks good"}]}`)
out, errOut, err := executeCLI(t, deps, "review", "submit", "mer-1", "--reviews", "-")
if err != nil {
t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut)
}
if !strings.Contains(out, "recorded 2 review(s) for mer-1") {
t.Fatalf("stdout = %q", out)
}
var req submitReviewRequest
if err := json.Unmarshal([]byte(capture.body), &req); err != nil {
t.Fatalf("decode body: %v", err)
}
if len(req.Reviews) != 2 || req.Reviews[0].RunID != "run-1" || req.Reviews[0].GithubReviewID != "101" || req.Reviews[1].Verdict != "approved" {
t.Fatalf("request = %+v", req)
}
if req.RunID != "" || req.Verdict != "" {
t.Fatalf("batch request should not also set legacy fields: %+v", req)
}
}

func TestReviewSubmitUsesSessionFlag(t *testing.T) {
cfg := setConfigEnv(t)
srv, capture := reviewServer(t, http.StatusOK, `{"review":{"verdict":"approved"}}`)
Expand Down
14 changes: 9 additions & 5 deletions backend/internal/domain/review.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,15 @@ type Review struct {

// ReviewRun is one review pass against a worker's PR.
type ReviewRun struct {
ID string `json:"id"`
ReviewID string `json:"reviewId"`
SessionID SessionID `json:"sessionId"`
Harness ReviewerHarness `json:"harness"`
PRURL string `json:"prUrl"`
ID string `json:"id"`
ReviewID string `json:"reviewId"`
SessionID SessionID `json:"sessionId"`
// BatchID groups review runs created by one trigger so worker feedback can
// be delivered once after the whole trigger batch is terminal. Empty marks
// legacy/single-run delivery.
BatchID string `json:"batchId"`
Harness ReviewerHarness `json:"harness"`
PRURL string `json:"prUrl"`
// TargetSHA is the PR head commit this pass reviewed.
TargetSHA string `json:"targetSha"`
Status ReviewRunStatus `json:"status"`
Expand Down
75 changes: 70 additions & 5 deletions backend/internal/httpd/apispec/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1243,13 +1243,13 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ReviewRunResponse'
$ref: '#/components/schemas/TriggerReviewResponse'
description: OK
"201":
content:
application/json:
schema:
$ref: '#/components/schemas/ReviewRunResponse'
$ref: '#/components/schemas/TriggerReviewResponse'
description: Created
"404":
content:
Expand Down Expand Up @@ -1636,7 +1636,7 @@ components:
type: string
reviews:
items:
$ref: '#/components/schemas/ReviewRun'
$ref: '#/components/schemas/PRReviewState'
type: array
required:
- reviewerHandleId
Expand Down Expand Up @@ -1772,6 +1772,33 @@ components:
- id
- projectId
type: object
PRReviewState:
properties:
latestRun:
$ref: '#/components/schemas/ReviewRun'
prNumber:
type: integer
prUrl:
type: string
status:
enum:
- needs_review
- running
- up_to_date
- changes_requested
- ineligible
type: string
targetSha:
type: string
title:
type: string
required:
- prUrl
- prNumber
- title
- targetSha
- status
type: object
Project:
properties:
agent:
Expand Down Expand Up @@ -1933,6 +1960,8 @@ components:
type: object
ReviewRun:
properties:
batchId:
type: string
body:
type: string
createdAt:
Expand Down Expand Up @@ -1965,6 +1994,7 @@ components:
- id
- reviewId
- sessionId
- batchId
- harness
- prUrl
- targetSha
Expand All @@ -1980,8 +2010,13 @@ components:
$ref: '#/components/schemas/ReviewRun'
reviewerHandleId:
type: string
reviews:
items:
$ref: '#/components/schemas/ReviewRun'
type: array
required:
- review
- reviews
- reviewerHandleId
type: object
RoleOverride:
Expand Down Expand Up @@ -2384,6 +2419,26 @@ components:
- projectId
type: object
SubmitReviewInput:
properties:
body:
description: Review body recorded by AO. Required for changes_requested.
type: string
githubReviewId:
description: Id of the GitHub PR review the reviewer posted, if any.
type: string
reviews:
description: Batched review results recorded by one reviewer CLI command.
items:
$ref: '#/components/schemas/SubmitReviewItem'
type: array
runId:
description: Review run id being completed.
type: string
verdict:
description: 'Review verdict: approved or changes_requested.'
type: string
type: object
SubmitReviewItem:
properties:
body:
description: Review body recorded by AO. Required for changes_requested.
Expand All @@ -2400,8 +2455,18 @@ components:
required:
- runId
- verdict
- body
- githubReviewId
type: object
TriggerReviewResponse:
properties:
reviewerHandleId:
type: string
reviews:
items:
$ref: '#/components/schemas/PRReviewState'
type: array
required:
- reviewerHandleId
- reviews
type: object
WorkspaceRepo:
properties:
Expand Down
15 changes: 9 additions & 6 deletions backend/internal/httpd/apispec/specgen/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,11 +181,14 @@ var schemaNames = map[string]string{
"ControllersResolveCommentsRequest": "ResolveCommentsRequest",
"ControllersResolveCommentsResponse": "ResolveCommentsResponse",
// httpd/controllers — review wire envelopes
"ControllersListReviewsResponse": "ListReviewsResponse",
"ControllersReviewRunResponse": "ReviewRunResponse",
"ControllersSubmitReviewInput": "SubmitReviewInput",
"ControllersListReviewsResponse": "ListReviewsResponse",
"ControllersReviewRunResponse": "ReviewRunResponse",
"ControllersTriggerReviewResponse": "TriggerReviewResponse",
"ControllersSubmitReviewItem": "SubmitReviewItem",
"ControllersSubmitReviewInput": "SubmitReviewInput",
// domain review entities
"DomainReviewRun": "ReviewRun",
"DomainReviewRun": "ReviewRun",
"ReviewPRReviewState": "PRReviewState",
// service/project entities + DTOs
"ProjectProject": "Project",
"ProjectSummary": "ProjectSummary",
Expand Down Expand Up @@ -345,8 +348,8 @@ func reviewOperations() []operation {
summary: "Trigger a code review of a worker's PR",
pathParams: []any{controllers.SessionIDParam{}},
resps: []respUnit{
{http.StatusOK, controllers.ReviewRunResponse{}},
{http.StatusCreated, controllers.ReviewRunResponse{}},
{http.StatusOK, controllers.TriggerReviewResponse{}},
{http.StatusCreated, controllers.TriggerReviewResponse{}},
{http.StatusUnprocessableEntity, envelope.APIError{}},
{http.StatusNotFound, envelope.APIError{}},
{http.StatusNotImplemented, envelope.APIError{}},
Expand Down
Loading
Loading