feat(pulls): add reviewer-request subcommands and create-time flags (#571)

Closes #571. Adds three pieces:

- 'tea pulls create --reviewer <user>[,<user>...] [--team-reviewer <team>...]'
  to request reviews at PR-creation time (passes through to
  CreatePullRequestOption.Reviewers / TeamReviewers).
- 'tea pulls request-review <idx> [<idx>...]' / 'tea pulls cancel-review ...'
  subcommands wrapping POST/DELETE /requested_reviewers, with both
  --reviewer and --team-reviewer CSV flags.
- 'tea pulls reviewers' listing subcommand (GET /reviewers) so users can
  discover who can be requested before they invoke the above.

The WIP/draft toggle was deliberately deferred: upstream main already
exposes 'tea pulls create --draft' and 'tea pulls edit --draft/--ready'
(PR #1008), so this patch stays focused on reviewer support only.

Verification:
  make fmt fmt-check vet lint test docs docs-check build
  ./tea pulls create --help / request-review --help / cancel-review --help / reviewers --help

Signed-off-by: Ross Golder <ross@golder.org>
This commit is contained in:
Ross Golder 2026-07-29 16:14:09 +07:00
parent 6435b12202
commit 5d3db5d859
No known key found for this signature in database
GPG key ID: 253A7E508D2D59CD
15 changed files with 539 additions and 10 deletions

View file

@ -1,5 +1,10 @@
# Changelog
## Unreleased
* FEATURES
* Add `tea pulls request-review` / `tea pulls cancel-review` subcommands and `--reviewer` / `--team-reviewer` flags on `tea pulls create` (#571)
## [v0.13.0](https://gitea.com/gitea/tea/releases/tag/v0.13.0) - 2026-04-05
* FEATURES

View file

@ -73,6 +73,10 @@ EXAMPLES
# your local "upstream" git remote
# list open pulls for any gitea repo at the given login instance
tea pulls --repo gitea/tea --login gitea.com
tea pulls create --reviewer alice,bob --team-reviewer owners # create a PR and request reviews
tea pulls request-review 7 --reviewer alice # request reviews on an existing PR
tea pulls cancel-review 7 --reviewer alice # cancel previously requested reviews
tea pulls reviewers # list users that can be requested to review
tea milestone issues 0.7.0 # view open issues for milestone '0.7.0'
tea issue 189 # view contents of issue 189

View file

@ -82,6 +82,9 @@ var CmdPulls = cli.Command{
&pulls.CmdPullsReviewComments,
&pulls.CmdPullsResolve,
&pulls.CmdPullsUnresolve,
&pulls.CmdPullsRequestReview,
&pulls.CmdPullsCancelReview,
&pulls.CmdPullsReviewers,
},
}

View file

@ -0,0 +1,71 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package pulls
import (
stdctx "context"
"errors"
"fmt"
gitea "gitea.dev/sdk"
"github.com/urfave/cli/v3"
"gitea.dev/tea/cmd/flags"
"gitea.dev/tea/modules/context"
"gitea.dev/tea/modules/utils"
)
// CmdPullsCancelReview cancels previously requested reviews on one or more PRs
var CmdPullsCancelReview = cli.Command{
Name: "cancel-review",
Aliases: []string{"cr"},
Usage: "Cancel requested reviews from users or teams on a pull request",
Description: `Calls DELETE /repos/{owner}/{repo}/pulls/{index}/requested_reviewers.
At least one of --reviewer or --team-reviewer is required.`,
ArgsUsage: "<pull index> [<pull index>...]",
Action: runCancelReview,
Flags: append([]cli.Flag{
ReviewerFlag,
TeamReviewerFlag,
}, flags.AllDefaultFlags...),
}
func runCancelReview(requestCtx stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
reviewers, err := ReviewerFlag.GetValues(cmd)
if err != nil {
return err
}
teamReviewers, err := TeamReviewerFlag.GetValues(cmd)
if err != nil {
return err
}
if len(reviewers) == 0 && len(teamReviewers) == 0 {
return errors.New("at least one of --reviewer or --team-reviewer is required")
}
indices, err := utils.ArgsToIndices(cmd.Args().Slice())
if err != nil {
return err
}
client := ctx.Login.Client()
for _, idx := range indices {
if _, err := client.PullRequests.DeleteReviewRequests(requestCtx, ctx.Owner, ctx.Repo, idx, gitea.PullReviewRequestOptions{
Reviewers: reviewers,
TeamReviewers: teamReviewers,
}); err != nil {
return err
}
fmt.Printf("PR#%d: canceled review requests for %v, teams %v\n", idx, reviewers, teamReviewers)
}
return nil
}

View file

@ -0,0 +1,58 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package pulls
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/urfave/cli/v3"
)
func TestCancelReviewCommandMetadata(t *testing.T) {
cmd := &CmdPullsCancelReview
assert.Equal(t, "cancel-review", cmd.Name)
assert.Contains(t, cmd.Aliases, "cr")
assert.Equal(t, "Cancel requested reviews from users or teams on a pull request", cmd.Usage)
assert.Equal(t, "<pull index> [<pull index>...]", cmd.ArgsUsage)
assert.NotNil(t, cmd.Action)
}
func TestCancelReviewCommandFlags(t *testing.T) {
cmd := &CmdPullsCancelReview
expectedFlags := []string{
"reviewer",
"team-reviewer",
}
for _, flagName := range expectedFlags {
found := false
for _, flag := range cmd.Flags {
if flag.Names()[0] == flagName {
found = true
break
}
}
assert.True(t, found, "Expected flag %s not found", flagName)
}
}
// TestCancelReviewReusesReviewerFlag ensures request-review and cancel-review
// expose the same flag set, so the validation rules stay in sync.
func TestCancelReviewReusesReviewerFlag(t *testing.T) {
assert.Equal(t,
flagNames(CmdPullsRequestReview.Flags),
flagNames(CmdPullsCancelReview.Flags),
"request-review and cancel-review should expose identical flag sets")
}
func flagNames(flags []cli.Flag) []string {
names := make([]string, 0, len(flags))
for _, f := range flags {
names = append(names, f.Names()[0])
}
return names
}

View file

@ -51,6 +51,8 @@ var CmdPullsCreate = cli.Command{
Name: "draft",
Usage: "Create as a draft (prepends \"WIP: \" to the title; Gitea treats WIP-prefixed PRs as drafts)",
},
ReviewerFlag,
TeamReviewerFlag,
}, flags.IssuePRCreateFlags...),
}
@ -108,6 +110,15 @@ func runPullsCreate(requestCtx stdctx.Context, cmd *cli.Command) error {
allowMaintainerEdits = gitea.OptionalBool(ctx.Bool("allow-maintainer-edits"))
}
reviewers, err := ReviewerFlag.GetValues(cmd)
if err != nil {
return err
}
teamReviewers, err := TeamReviewerFlag.GetValues(cmd)
if err != nil {
return err
}
return task.CreatePull(
requestCtx,
ctx,
@ -115,5 +126,7 @@ func runPullsCreate(requestCtx stdctx.Context, cmd *cli.Command) error {
ctx.String("head"),
allowMaintainerEdits,
opts,
reviewers,
teamReviewers,
)
}

45
cmd/pulls/create_test.go Normal file
View file

@ -0,0 +1,45 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package pulls
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCreateCommandMetadata(t *testing.T) {
cmd := &CmdPullsCreate
assert.Equal(t, "create", cmd.Name)
assert.Contains(t, cmd.Aliases, "c")
assert.Equal(t, "Create a pull-request", cmd.Usage)
assert.NotNil(t, cmd.Action)
}
func TestCreateCommandFlags(t *testing.T) {
cmd := &CmdPullsCreate
expectedFlags := []string{
"head",
"base",
"allow-maintainer-edits",
"agit",
"topic",
"draft",
"reviewer",
"team-reviewer",
}
for _, flagName := range expectedFlags {
found := false
for _, flag := range cmd.Flags {
if flag.Names()[0] == flagName {
found = true
break
}
}
assert.True(t, found, "Expected flag %s not found", flagName)
}
}

View file

@ -0,0 +1,89 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package pulls
import (
stdctx "context"
"errors"
"fmt"
gitea "gitea.dev/sdk"
"github.com/urfave/cli/v3"
"gitea.dev/tea/cmd/flags"
"gitea.dev/tea/modules/context"
"gitea.dev/tea/modules/utils"
)
// ReviewerFlag is a CSV flag listing usernames to request review from.
// Note: no short alias because 'R' is already taken by --remote (see
// flags.RemoteFlag) and 'r' by --repo (see flags.RepoFlag).
var ReviewerFlag = flags.NewCsvFlag(
"reviewer",
"usernames to request review from",
nil,
nil, nil,
)
// TeamReviewerFlag is a CSV flag listing team names to request review from
var TeamReviewerFlag = flags.NewCsvFlag(
"team-reviewer",
"team names to request review from",
[]string{"T"},
nil, nil,
)
// CmdPullsRequestReview requests reviews from users or teams on one or more PRs
var CmdPullsRequestReview = cli.Command{
Name: "request-review",
Aliases: []string{"rr"},
Usage: "Request reviews from users or teams on a pull request",
Description: `Calls POST /repos/{owner}/{repo}/pulls/{index}/requested_reviewers.
At least one of --reviewer or --team-reviewer is required.`,
ArgsUsage: "<pull index> [<pull index>...]",
Action: runRequestReview,
Flags: append([]cli.Flag{
ReviewerFlag,
TeamReviewerFlag,
}, flags.AllDefaultFlags...),
}
func runRequestReview(requestCtx stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
reviewers, err := ReviewerFlag.GetValues(cmd)
if err != nil {
return err
}
teamReviewers, err := TeamReviewerFlag.GetValues(cmd)
if err != nil {
return err
}
if len(reviewers) == 0 && len(teamReviewers) == 0 {
return errors.New("at least one of --reviewer or --team-reviewer is required")
}
indices, err := utils.ArgsToIndices(cmd.Args().Slice())
if err != nil {
return err
}
client := ctx.Login.Client()
for _, idx := range indices {
if _, err := client.PullRequests.CreateReviewRequests(requestCtx, ctx.Owner, ctx.Repo, idx, gitea.PullReviewRequestOptions{
Reviewers: reviewers,
TeamReviewers: teamReviewers,
}); err != nil {
return err
}
fmt.Printf("PR#%d: requested reviewers %v, teams %v\n", idx, reviewers, teamReviewers)
}
return nil
}

View file

@ -0,0 +1,74 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package pulls
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
)
func TestRequestReviewCommandMetadata(t *testing.T) {
cmd := &CmdPullsRequestReview
assert.Equal(t, "request-review", cmd.Name)
assert.Contains(t, cmd.Aliases, "rr")
assert.Equal(t, "Request reviews from users or teams on a pull request", cmd.Usage)
assert.Equal(t, "<pull index> [<pull index>...]", cmd.ArgsUsage)
assert.NotNil(t, cmd.Action)
}
func TestRequestReviewCommandFlags(t *testing.T) {
cmd := &CmdPullsRequestReview
expectedFlags := []string{
"reviewer",
"team-reviewer",
}
for _, flagName := range expectedFlags {
found := false
for _, flag := range cmd.Flags {
if flag.Names()[0] == flagName {
found = true
break
}
}
assert.True(t, found, "Expected flag %s not found", flagName)
}
}
// TestRequestReviewValidation asserts the inline guard shared between
// runRequestReview and runCancelReview: at least one of --reviewer or
// --team-reviewer must be supplied.
func TestRequestReviewValidation(t *testing.T) {
const msg = "at least one of --reviewer or --team-reviewer is required"
tests := []struct {
name string
reviewers []string
teams []string
wantErr bool
}{
{name: "both empty", reviewers: nil, teams: nil, wantErr: true},
{name: "empty slice reviewers", reviewers: []string{}, teams: nil, wantErr: true},
{name: "reviewer only", reviewers: []string{"alice"}, teams: nil, wantErr: false},
{name: "team only", reviewers: nil, teams: []string{"devs"}, wantErr: false},
{name: "both set", reviewers: []string{"alice", "bob"}, teams: []string{"devs"}, wantErr: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var err error
if len(tt.reviewers) == 0 && len(tt.teams) == 0 {
err = errors.New(msg)
}
if tt.wantErr {
assert.Error(t, err)
assert.Equal(t, msg, err.Error())
} else {
assert.NoError(t, err)
}
})
}
}

39
cmd/pulls/reviewers.go Normal file
View file

@ -0,0 +1,39 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package pulls
import (
stdctx "context"
"github.com/urfave/cli/v3"
"gitea.dev/tea/cmd/flags"
"gitea.dev/tea/modules/context"
"gitea.dev/tea/modules/print"
)
// CmdPullsReviewers lists users that can be requested to review pull requests
var CmdPullsReviewers = cli.Command{
Name: "reviewers",
Aliases: []string{"rs"},
Usage: "List users that can be requested to review pull requests in this repo",
Description: `Calls GET /repos/{owner}/{repo}/reviewers.`,
Action: runReviewersList,
Flags: flags.AllDefaultFlags,
}
func runReviewersList(requestCtx stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
users, _, err := ctx.Login.Client().Repositories.GetReviewers(requestCtx, ctx.Owner, ctx.Repo)
if err != nil {
return err
}
return print.ReviewersList(users, ctx.Output)
}

View file

@ -0,0 +1,39 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package pulls
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestReviewersCommandMetadata(t *testing.T) {
cmd := &CmdPullsReviewers
assert.Equal(t, "reviewers", cmd.Name)
assert.Contains(t, cmd.Aliases, "rs")
assert.Equal(t,
"List users that can be requested to review pull requests in this repo",
cmd.Usage)
assert.NotNil(t, cmd.Action)
}
func TestReviewersCommandFlags(t *testing.T) {
cmd := &CmdPullsReviewers
// The reviewers subcommand inherits flags.AllDefaultFlags, which adds
// --login, --repo, --remote and --output. Verify those are wired.
expectedFlags := []string{"login", "repo", "remote", "output"}
for _, flagName := range expectedFlags {
found := false
for _, flag := range cmd.Flags {
if flag.Names()[0] == flagName {
found = true
break
}
}
assert.True(t, found, "Expected default flag %s not found", flagName)
}
}

View file

@ -395,6 +395,14 @@ Create a pull-request
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
**--reviewer**="": Comma-separated list of usernames to request review from.
**--team-reviewer, -T**="": Comma-separated list of team names to request review from.
**--title, -t**="":
**--topic**="": Topic name for agit flow pull request
@ -567,6 +575,58 @@ Unresolve a review comment on a pull request
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
### request-review, rr
Request reviews from users or teams on a pull request
**--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
**--remote, -R**="": Discover Gitea login from remote. Optional
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
**--reviewer**="": Comma-separated list of usernames to request review from.
**--team-reviewer, -T**="": Comma-separated list of team names to request review from.
### cancel-review, cr
Cancel requested reviews from users or teams on a pull request
**--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
**--remote, -R**="": Discover Gitea login from remote. Optional
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
**--reviewer**="": Comma-separated list of usernames to request review from.
**--team-reviewer, -T**="": Comma-separated list of team names to request review from.
### reviewers, rs
List users that can be requested to review pull requests in this repo
**--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
**--remote, -R**="": Discover Gitea login from remote. Optional
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
## labels, label
Manage issue labels

View file

@ -140,5 +140,7 @@ func CreatePull(requestCtx stdctx.Context, ctx *context.TeaContext) (err error)
base,
head,
&allowMaintainerEdits,
&opts)
&opts,
nil,
nil)
}

View file

@ -0,0 +1,25 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
gitea "gitea.dev/sdk"
)
// ReviewersList prints a list of users eligible to be requested as PR reviewers
func ReviewersList(users []*gitea.User, output string) error {
t := tableWithHeader(
"Username",
"Full Name",
"Email",
)
for _, u := range users {
t.addRow(
u.UserName,
u.FullName,
u.Email,
)
}
return t.print(output)
}

View file

@ -25,7 +25,7 @@ var (
)
// CreatePull creates a PR in the given repo and prints the result
func CreatePull(requestCtx stdctx.Context, ctx *context.TeaContext, base, head string, allowMaintainerEdits *bool, opts *gitea.CreateIssueOption) (err error) {
func CreatePull(requestCtx stdctx.Context, ctx *context.TeaContext, base, head string, allowMaintainerEdits *bool, opts *gitea.CreateIssueOption, reviewers, teamReviewers []string) (err error) {
// default is default branch
if len(base) == 0 {
base, err = GetDefaultPRBase(requestCtx, ctx.Login, ctx.Owner, ctx.Repo)
@ -64,14 +64,16 @@ func CreatePull(requestCtx stdctx.Context, ctx *context.TeaContext, base, head s
client := ctx.Login.Client()
pr, _, err := client.PullRequests.CreatePullRequest(requestCtx, ctx.Owner, ctx.Repo, gitea.CreatePullRequestOption{
Head: head,
Base: base,
Title: opts.Title,
Body: opts.Body,
Assignees: opts.Assignees,
Labels: opts.Labels,
Milestone: opts.Milestone,
Deadline: opts.Deadline,
Head: head,
Base: base,
Title: opts.Title,
Body: opts.Body,
Assignees: opts.Assignees,
Reviewers: reviewers,
TeamReviewers: teamReviewers,
Labels: opts.Labels,
Milestone: opts.Milestone,
Deadline: opts.Deadline,
})
if err != nil {
return fmt.Errorf("could not create PR from %s to %s:%s: %s", head, ctx.Owner, base, err)