From a2561f2309f638f7969f62fc3e2927920c7cc9ba Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Fri, 21 Aug 2026 09:52:44 -0700 Subject: [PATCH] feat: allow non-interactive pull reviews with inline comments Add --state, --description and --comments-file flags to 'tea pulls review' so reviews can be submitted from scripts and CI. The non-interactive path reuses ParseDiffComments to turn an annotated diff into code comments and submits them through CreatePullReview without opening an editor. Fixes: https://gitea.com/gitea/tea/issues/1094 Signed-off-by: Lunny Xiao --- cmd/pulls/review.go | 34 +++++-- cmd/pulls/review_helpers.go | 78 +++++++++++++++++ cmd/pulls/review_helpers_test.go | 146 +++++++++++++++++++++++++++++++ docs/CLI.md | 8 +- 4 files changed, 260 insertions(+), 6 deletions(-) create mode 100644 cmd/pulls/review_helpers_test.go diff --git a/cmd/pulls/review.go b/cmd/pulls/review.go index c9b5b063..a3c486cf 100644 --- a/cmd/pulls/review.go +++ b/cmd/pulls/review.go @@ -17,11 +17,27 @@ import ( "github.com/urfave/cli/v3" ) -// CmdPullsReview starts an interactive review session +var pullReviewFlags = append([]cli.Flag{ + &cli.StringFlag{ + Name: "state", + Usage: "Review state: comment, approve or request-changes (enables non-interactive mode)", + }, + &cli.StringFlag{ + Name: "description", + Aliases: []string{"d"}, + Usage: "Concluding review comment (non-interactive). If omitted and stdin is piped, the comment is read from stdin", + }, + &cli.StringFlag{ + Name: "comments-file", + Usage: "Annotated diff file containing inline code comments (non-interactive)", + }, +}, flags.AllDefaultFlags...) + +// CmdPullsReview starts an interactive or non-interactive review session var CmdPullsReview = cli.Command{ Name: "review", - Usage: "Interactively review a pull request", - Description: "Interactively review a pull request", + Usage: "Review a pull request", + Description: "Review a pull request interactively, or non-interactively when --state is provided", ArgsUsage: "", Action: func(requestCtx stdctx.Context, cmd *cli.Command) error { ctx, err := context.InitCommand(cmd) @@ -36,10 +52,18 @@ var CmdPullsReview = cli.Command{ return fmt.Errorf("must specify at least one PR index") } + if ctx.IsSet("state") { + return runNonInteractivePullReview(requestCtx, ctx, ctx.String("state"), ctx.String("description"), ctx.String("comments-file")) + } + + if ctx.IsSet("description") || ctx.IsSet("comments-file") { + return fmt.Errorf("--state is required for non-interactive review") + } + // This command is intentionally interactive. Fail early in CI / non-TTY // contexts rather than hanging on prompts. if os.Getenv("CI") != "" || !print.IsInteractive() || interact.IsStdinPiped() { - return fmt.Errorf("pull review requires an interactive terminal") + return fmt.Errorf("pull review requires an interactive terminal; use --state for a non-interactive review") } for _, arg := range ctx.Args().Slice() { @@ -55,5 +79,5 @@ var CmdPullsReview = cli.Command{ return nil }, - Flags: flags.AllDefaultFlags, + Flags: pullReviewFlags, } diff --git a/cmd/pulls/review_helpers.go b/cmd/pulls/review_helpers.go index 82db65e8..cf07827e 100644 --- a/cmd/pulls/review_helpers.go +++ b/cmd/pulls/review_helpers.go @@ -50,6 +50,84 @@ func runPullReview(requestCtx stdctx.Context, ctx *context.TeaContext, state git return task.CreatePullReview(requestCtx, ctx, idx, state, comment, nil) } +// runNonInteractivePullReview submits one review without prompting. It keeps the +// existing multi-PR behavior for plain reviews, but restricts inline comments to +// a single PR because their diff anchors are specific to that PR. +func runNonInteractivePullReview(requestCtx stdctx.Context, ctx *context.TeaContext, stateName, description, commentsFile string) error { + state, err := parseReviewState(stateName) + if err != nil { + return err + } + + if commentsFile != "" && ctx.Args().Len() > 1 { + return errors.New("--comments-file can only be used with a single pull request") + } + + comment, err := resolveReviewBody(description, interact.IsStdinPiped(), ctx.Reader) + if err != nil { + return err + } + + var codeComments []gitea.CreatePullReviewComment + if commentsFile != "" { + codeComments, err = task.ParseDiffComments(commentsFile) + if err != nil { + return err + } + } + + if state == gitea.ReviewStateRequestChanges && len(strings.TrimSpace(comment)) == 0 { + return errors.New("a concluding comment is required when requesting changes") + } + if state != gitea.ReviewStateApproved && len(strings.TrimSpace(comment)) == 0 && len(codeComments) == 0 { + return errors.New("a concluding comment or inline comments are required") + } + + for _, arg := range ctx.Args().Slice() { + idx, err := utils.ArgToIndex(arg) + if err != nil { + return err + } + + if err := task.CreatePullReview(requestCtx, ctx, idx, state, comment, codeComments); err != nil { + return err + } + } + + return nil +} + +func parseReviewState(state string) (gitea.ReviewStateType, error) { + normalized := strings.ToLower(strings.TrimSpace(state)) + normalized = strings.NewReplacer("-", " ", "_", " ").Replace(normalized) + normalized = strings.Join(strings.Fields(normalized), " ") + + switch normalized { + case "approve", "approved": + return gitea.ReviewStateApproved, nil + case "comment": + return gitea.ReviewStateComment, nil + case "request changes", "changes requested": + return gitea.ReviewStateRequestChanges, nil + default: + return "", fmt.Errorf("invalid review state %q (expected comment, approve or request-changes)", state) + } +} + +func resolveReviewBody(description string, stdinPiped bool, stdin io.Reader) (string, error) { + if len(description) != 0 { + return description, nil + } + if stdinPiped { + content, err := io.ReadAll(stdin) + if err != nil { + return "", err + } + return string(content), nil + } + return "", nil +} + // runResolveComment handles the common logic for resolving/unresolving review comments func runResolveComment(requestCtx stdctx.Context, ctx *context.TeaContext, action func(stdctx.Context, *context.TeaContext, int64) error) error { if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { diff --git a/cmd/pulls/review_helpers_test.go b/cmd/pulls/review_helpers_test.go new file mode 100644 index 00000000..d94deb2e --- /dev/null +++ b/cmd/pulls/review_helpers_test.go @@ -0,0 +1,146 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls + +import ( + "io" + "strings" + "testing" + + gitea "gitea.dev/sdk" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseReviewState(t *testing.T) { + tests := []struct { + name string + state string + want gitea.ReviewStateType + wantErr bool + errContains string + }{ + { + name: "comment", + state: "comment", + want: gitea.ReviewStateComment, + }, + { + name: "approve", + state: "approve", + want: gitea.ReviewStateApproved, + }, + { + name: "approved", + state: "APPROVED", + want: gitea.ReviewStateApproved, + }, + { + name: "request changes", + state: "request-changes", + want: gitea.ReviewStateRequestChanges, + }, + { + name: "request changes with underscore", + state: "request_changes", + want: gitea.ReviewStateRequestChanges, + }, + { + name: "changes requested", + state: "changes requested", + want: gitea.ReviewStateRequestChanges, + }, + { + name: "empty", + state: "", + wantErr: true, + errContains: "invalid review state", + }, + { + name: "invalid", + state: "maybe", + wantErr: true, + errContains: "invalid review state", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseReviewState(tt.state) + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestResolveReviewBody(t *testing.T) { + tests := []struct { + name string + description string + stdinPiped bool + stdin string + want string + }{ + { + name: "description flag", + description: "from -d", + want: "from -d", + }, + { + name: "description wins over piped stdin", + description: "from -d", + stdinPiped: true, + stdin: "from stdin", + want: "from -d", + }, + { + name: "piped stdin", + stdinPiped: true, + stdin: "from stdin", + want: "from stdin", + }, + { + name: "stdin ignored when not piped", + stdinPiped: false, + stdin: "should never be read", + want: "", + }, + { + name: "empty", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveReviewBody(tt.description, tt.stdinPiped, strings.NewReader(tt.stdin)) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestResolveReviewBodyDoesNotReadStdinWhenDescriptionGiven(t *testing.T) { + reader := &trackingReader{} + + body, err := resolveReviewBody("from -d", true, reader) + require.NoError(t, err) + assert.Equal(t, "from -d", body) + assert.False(t, reader.read, "stdin must not be read when a description is supplied") +} + +type trackingReader struct { + read bool +} + +func (r *trackingReader) Read(p []byte) (int, error) { + r.read = true + return 0, io.EOF +} diff --git a/docs/CLI.md b/docs/CLI.md index 3f74a38e..86378475 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -463,7 +463,11 @@ Edit one or more pull requests ### review -Interactively review a pull request +Review a pull request + +**--comments-file**="": Annotated diff file containing inline code comments (non-interactive) + +**--description, -d**="": Concluding review comment (non-interactive). If omitted and stdin is piped, the comment is read from stdin **--login, -l**="": Use a different Gitea Login. Optional @@ -473,6 +477,8 @@ Interactively review a pull request **--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional +**--state**="": Review state: comment, approve or request-changes (enables non-interactive mode) + ### approve, lgtm, a Approve a pull request