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
104 changes: 104 additions & 0 deletions backend/internal/cli/pr.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package cli

import (
"errors"
"fmt"
"net/url"
"strings"

"github.com/spf13/cobra"
)

// mergePRResponse mirrors controllers.MergePRResponse.
type mergePRResponse struct {
OK bool `json:"ok"`
PRNumber int `json:"prNumber"`
Method string `json:"method"`
}

// resolveCommentsRequest mirrors controllers.ResolveCommentsRequest.
type resolveCommentsRequest struct {
CommentIDs []string `json:"commentIds,omitempty"`
}

// resolveCommentsResponse mirrors controllers.ResolveCommentsResponse.
type resolveCommentsResponse struct {
OK bool `json:"ok"`
Resolved int `json:"resolved"`
}

func newPRCommand(ctx *commandContext) *cobra.Command {
cmd := &cobra.Command{
Use: "pr",
Short: "Run pull-request actions through the AO daemon",
}
cmd.AddCommand(newPRMergeCommand(ctx))
cmd.AddCommand(newPRResolveCommentsCommand(ctx))
return cmd
}

func newPRMergeCommand(ctx *commandContext) *cobra.Command {
return &cobra.Command{
Use: "merge <pr-number>",
Short: "Squash-merge a pull request",
Args: onePRIDArg,
RunE: func(cmd *cobra.Command, args []string) error {
prID := strings.TrimSpace(args[0])
var res mergePRResponse
if err := ctx.postJSON(cmd.Context(), "prs/"+url.PathEscape(prID)+"/merge", nil, &res); err != nil {
return err
}
prNumber := res.PRNumber
if prNumber == 0 {
_, err := fmt.Fprintf(cmd.OutOrStdout(), "merged PR %s with %s\n", prID, res.Method)
return err
}
_, err := fmt.Fprintf(cmd.OutOrStdout(), "merged PR #%d with %s\n", prNumber, res.Method)
return err
},
}
}

func newPRResolveCommentsCommand(ctx *commandContext) *cobra.Command {
return &cobra.Command{
Use: "resolve-comments <pr-number> [comment-id...]",
Short: "Resolve review threads on a pull request",
Args: atLeastOnePRArg,
RunE: func(cmd *cobra.Command, args []string) error {
prID := strings.TrimSpace(args[0])
commentIDs := make([]string, 0, len(args)-1)
for _, id := range args[1:] {
if trimmed := strings.TrimSpace(id); trimmed != "" {
commentIDs = append(commentIDs, trimmed)
}
}
var res resolveCommentsResponse
path := "prs/" + url.PathEscape(prID) + "/resolve-comments"
if err := ctx.postJSON(cmd.Context(), path, resolveCommentsRequest{CommentIDs: commentIDs}, &res); err != nil {
return err
}
_, err := fmt.Fprintf(cmd.OutOrStdout(), "resolved %d review comment(s) on PR %s\n", res.Resolved, prID)
return err
},
}
}

func onePRIDArg(cmd *cobra.Command, args []string) error {
if err := cobra.ExactArgs(1)(cmd, args); err != nil {
return usageError{err}
}
if strings.TrimSpace(args[0]) == "" {
return usageError{errors.New("usage: PR number is required")}
}
return nil
}

func atLeastOnePRArg(cmd *cobra.Command, args []string) error {
if err := cobra.MinimumNArgs(1)(cmd, args); err != nil {
return usageError{err}
}
if strings.TrimSpace(args[0]) == "" {
return usageError{errors.New("usage: PR number is required")}
}
return nil
}
122 changes: 122 additions & 0 deletions backend/internal/cli/pr_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package cli

import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"sync"
"testing"
)

type prRequestLog struct {
mu sync.Mutex
requests []string
bodies []string
}

func prCommandServer(t *testing.T) (*httptest.Server, *prRequestLog) {
t.Helper()
log := &prRequestLog{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
log.mu.Lock()
appendPrimaryRequest(&log.requests, r)
if requestLogEntry(r) != cliInvokedRequest {
log.bodies = append(log.bodies, string(body))
}
log.mu.Unlock()

w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/prs/42/merge":
_, _ = io.WriteString(w, `{"ok":true,"prNumber":42,"method":"squash"}`)
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/prs/42/resolve-comments":
_, _ = io.WriteString(w, `{"ok":true,"resolved":2}`)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
return srv, log
}

func (l *prRequestLog) all() ([]string, []string) {
l.mu.Lock()
defer l.mu.Unlock()
return append([]string(nil), l.requests...), append([]string(nil), l.bodies...)
}

func TestPRMergeCallsDaemon(t *testing.T) {
cfg := setConfigEnv(t)
srv, log := prCommandServer(t)
writeRunFileFor(t, cfg, srv)

out, errOut, err := executeCLI(t, aliveDeps(), "pr", "merge", "42")
if err != nil {
t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut)
}
if !strings.Contains(out, "merged PR #42 with squash") {
t.Fatalf("stdout = %q", out)
}
requests, bodies := log.all()
if want := []string{"POST /api/v1/prs/42/merge"}; !reflect.DeepEqual(requests, want) {
t.Fatalf("requests = %#v, want %#v", requests, want)
}
if len(bodies) != 1 || bodies[0] != "" {
t.Fatalf("merge body = %#v, want empty body", bodies)
}
}

func TestPRResolveCommentsSendsOptionalCommentIDs(t *testing.T) {
cfg := setConfigEnv(t)
srv, log := prCommandServer(t)
writeRunFileFor(t, cfg, srv)

out, errOut, err := executeCLI(t, aliveDeps(), "pr", "resolve-comments", "42", "comment-a", "comment-b")
if err != nil {
t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut)
}
if !strings.Contains(out, "resolved 2 review comment(s) on PR 42") {
t.Fatalf("stdout = %q", out)
}
requests, bodies := log.all()
if want := []string{"POST /api/v1/prs/42/resolve-comments"}; !reflect.DeepEqual(requests, want) {
t.Fatalf("requests = %#v, want %#v", requests, want)
}
var req resolveCommentsRequest
if err := json.Unmarshal([]byte(bodies[0]), &req); err != nil {
t.Fatalf("decode body: %v", err)
}
if want := []string{"comment-a", "comment-b"}; !reflect.DeepEqual(req.CommentIDs, want) {
t.Fatalf("commentIds = %#v, want %#v", req.CommentIDs, want)
}
}

func TestPRResolveCommentsAllowsAllComments(t *testing.T) {
cfg := setConfigEnv(t)
srv, log := prCommandServer(t)
writeRunFileFor(t, cfg, srv)

if _, errOut, err := executeCLI(t, aliveDeps(), "pr", "resolve-comments", "42"); err != nil {
t.Fatalf("unexpected error: %v\nstderr=%s", err, errOut)
}
_, bodies := log.all()
var req resolveCommentsRequest
if err := json.Unmarshal([]byte(bodies[0]), &req); err != nil {
t.Fatalf("decode body: %v", err)
}
if len(req.CommentIDs) != 0 {
t.Fatalf("commentIds = %#v, want empty/all-comments request", req.CommentIDs)
}
}

func TestPRMergeMissingIDIsUsageError(t *testing.T) {
setConfigEnv(t)
_, _, err := executeCLI(t, aliveDeps(), "pr", "merge")
if got := ExitCode(err); got != 2 {
t.Fatalf("exit code = %d, want 2 (usage); err=%v", got, err)
}
}
115 changes: 115 additions & 0 deletions backend/internal/cli/review.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,22 @@ type reviewRunResponse struct {
ReviewerHandleID string `json:"reviewerHandleId"`
}

// prReviewState mirrors review.PRReviewState.
type prReviewState struct {
PRURL string `json:"prUrl"`
PRNumber int `json:"prNumber"`
Title string `json:"title"`
TargetSHA string `json:"targetSha"`
Status string `json:"status"`
LatestRun *reviewRun `json:"latestRun,omitempty"`
}

// reviewStateResponse mirrors the daemon's list/trigger/cancel review bodies.
type reviewStateResponse struct {
ReviewerHandleID string `json:"reviewerHandleId"`
Reviews []prReviewState `json:"reviews"`
}

// submitReviewItem mirrors controllers.SubmitReviewItem.
type submitReviewItem struct {
RunID string `json:"runId"`
Expand Down Expand Up @@ -78,12 +94,82 @@ func newReviewCommand(ctx *commandContext) *cobra.Command {
Use: "review",
Short: "Manage AO code reviews of a worker's PR",
}
cmd.AddCommand(newReviewListCommand(ctx))
cmd.AddCommand(newReviewTriggerCommand(ctx))
cmd.AddCommand(newReviewCancelCommand(ctx))
cmd.AddCommand(newReviewSubmitCommand(ctx))
cmd.AddCommand(newReviewStopCommand(ctx))
cmd.AddCommand(newReviewRestartCommand(ctx))
return cmd
}

func newReviewListCommand(ctx *commandContext) *cobra.Command {
return &cobra.Command{
Use: "ls <worker-session-id>",
Aliases: []string{"list"},
Short: "List review state for a worker's PRs",
Args: oneSessionIDArg,
RunE: func(cmd *cobra.Command, args []string) error {
session, err := normalizeSessionID(args[0])
if err != nil {
return err
}
var res reviewStateResponse
if err := ctx.getJSON(cmd.Context(), "sessions/"+url.PathEscape(session)+"/reviews", &res); err != nil {
return err
}
return printReviewState(cmd, session, res)
},
}
}

func newReviewTriggerCommand(ctx *commandContext) *cobra.Command {
return &cobra.Command{
Use: "trigger <worker-session-id>",
Aliases: []string{"execute"},
Short: "Trigger a code review for a worker's PRs",
Args: oneSessionIDArg,
RunE: func(cmd *cobra.Command, args []string) error {
session, err := normalizeSessionID(args[0])
if err != nil {
return err
}
var res reviewStateResponse
path := "sessions/" + url.PathEscape(session) + "/reviews/trigger"
if err := ctx.postJSON(cmd.Context(), path, nil, &res); err != nil {
return err
}
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "triggered review for %s\n", session); err != nil {
return err
}
return printReviewState(cmd, session, res)
},
}
}

func newReviewCancelCommand(ctx *commandContext) *cobra.Command {
return &cobra.Command{
Use: "cancel <worker-session-id>",
Short: "Cancel a running code review for a worker",
Args: oneSessionIDArg,
RunE: func(cmd *cobra.Command, args []string) error {
session, err := normalizeSessionID(args[0])
if err != nil {
return err
}
var res reviewStateResponse
path := "sessions/" + url.PathEscape(session) + "/reviews/cancel"
if err := ctx.postJSON(cmd.Context(), path, nil, &res); err != nil {
return err
}
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "cancelled review for %s\n", session); err != nil {
return err
}
return printReviewState(cmd, session, res)
},
}
}

func newReviewSubmitCommand(ctx *commandContext) *cobra.Command {
var opts reviewSubmitOptions
cmd := &cobra.Command{
Expand Down Expand Up @@ -265,3 +351,32 @@ func readReviewItems(cmd *cobra.Command, path string) ([]submitReviewItem, error
}
return reviews, nil
}

func printReviewState(cmd *cobra.Command, session string, res reviewStateResponse) error {
out := cmd.OutOrStdout()
if res.ReviewerHandleID != "" {
if _, err := fmt.Fprintf(out, "reviewer: %s\n", res.ReviewerHandleID); err != nil {
return err
}
}
if len(res.Reviews) == 0 {
_, err := fmt.Fprintf(out, "no reviews for %s\n", session)
return err
}
for _, review := range res.Reviews {
title := strings.TrimSpace(review.Title)
if title != "" {
title = " " + title
}
if review.PRNumber > 0 {
if _, err := fmt.Fprintf(out, "#%d %s %s%s\n", review.PRNumber, review.Status, review.TargetSHA, title); err != nil {
return err
}
continue
}
if _, err := fmt.Fprintf(out, "%s %s %s%s\n", review.PRURL, review.Status, review.TargetSHA, title); err != nil {
return err
}
}
return nil
}
Loading