diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f4d1b70..2f3080f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 2293b841..b6bf2775 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/pulls.go b/cmd/pulls.go index 4f732c05..a81aa257 100644 --- a/cmd/pulls.go +++ b/cmd/pulls.go @@ -82,6 +82,9 @@ var CmdPulls = cli.Command{ &pulls.CmdPullsReviewComments, &pulls.CmdPullsResolve, &pulls.CmdPullsUnresolve, + &pulls.CmdPullsRequestReview, + &pulls.CmdPullsCancelReview, + &pulls.CmdPullsReviewers, }, } diff --git a/cmd/pulls/cancel_review.go b/cmd/pulls/cancel_review.go new file mode 100644 index 00000000..b2c185d4 --- /dev/null +++ b/cmd/pulls/cancel_review.go @@ -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: " [...]", + 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 +} diff --git a/cmd/pulls/cancel_review_test.go b/cmd/pulls/cancel_review_test.go new file mode 100644 index 00000000..be301039 --- /dev/null +++ b/cmd/pulls/cancel_review_test.go @@ -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, " [...]", 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 +} diff --git a/cmd/pulls/create.go b/cmd/pulls/create.go index fc3e7c12..465005a4 100644 --- a/cmd/pulls/create.go +++ b/cmd/pulls/create.go @@ -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, ) } diff --git a/cmd/pulls/create_test.go b/cmd/pulls/create_test.go new file mode 100644 index 00000000..df8d2e33 --- /dev/null +++ b/cmd/pulls/create_test.go @@ -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) + } +} diff --git a/cmd/pulls/request_review.go b/cmd/pulls/request_review.go new file mode 100644 index 00000000..a4ca542a --- /dev/null +++ b/cmd/pulls/request_review.go @@ -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: " [...]", + 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 +} diff --git a/cmd/pulls/request_review_test.go b/cmd/pulls/request_review_test.go new file mode 100644 index 00000000..1f025fe6 --- /dev/null +++ b/cmd/pulls/request_review_test.go @@ -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, " [...]", 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) + } + }) + } +} diff --git a/cmd/pulls/reviewers.go b/cmd/pulls/reviewers.go new file mode 100644 index 00000000..d214bbf6 --- /dev/null +++ b/cmd/pulls/reviewers.go @@ -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) +} diff --git a/cmd/pulls/reviewers_test.go b/cmd/pulls/reviewers_test.go new file mode 100644 index 00000000..be811e9a --- /dev/null +++ b/cmd/pulls/reviewers_test.go @@ -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) + } +} diff --git a/docs/CLI.md b/docs/CLI.md index 2363574b..ea02fc9a 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -407,6 +407,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 @@ -581,6 +589,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 diff --git a/modules/interact/pull_create.go b/modules/interact/pull_create.go index 0d8a1f3b..7938ccb4 100644 --- a/modules/interact/pull_create.go +++ b/modules/interact/pull_create.go @@ -140,5 +140,7 @@ func CreatePull(requestCtx stdctx.Context, ctx *context.TeaContext) (err error) base, head, &allowMaintainerEdits, - &opts) + &opts, + nil, + nil) } diff --git a/modules/print/pull_reviewers.go b/modules/print/pull_reviewers.go new file mode 100644 index 00000000..ca6816d2 --- /dev/null +++ b/modules/print/pull_reviewers.go @@ -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) +} diff --git a/modules/task/pull_create.go b/modules/task/pull_create.go index f35f5783..6e195d4a 100644 --- a/modules/task/pull_create.go +++ b/modules/task/pull_create.go @@ -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)