mirror of
https://gitea.com/gitea/tea.git
synced 2026-09-10 07:26:33 -04:00
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:
parent
ee531914cd
commit
a2561f2309
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
146
cmd/pulls/review_helpers_test.go
Normal file
146
cmd/pulls/review_helpers_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue