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 <xiaolunwen@gmail.com>
This commit is contained in:
Lunny Xiao 2026-08-21 09:52:44 -07:00
parent ee531914cd
commit a2561f2309
4 changed files with 260 additions and 6 deletions

View file

@ -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: "<pull index>",
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,
}

View file

@ -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 {

View file

@ -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
}

View file

@ -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