From f329f6fab26031839ddbc94bd9ba8c6c9d1b221b Mon Sep 17 00:00:00 2001 From: appleboy Date: Sun, 5 Apr 2026 05:35:15 +0000 Subject: [PATCH] feat(pulls): add edit subcommand for pull requests (#944) ## Summary - Add `tea pr edit` subcommand to support editing pull request properties (description, title, milestone, deadline, assignees, labels, reviewers) - Add `--add-reviewers` / `--remove-reviewers` flags for managing PR reviewers via `CreateReviewRequests` / `DeleteReviewRequests` API - Extract shared helpers (`ResolveLabelOpts`, `ApplyLabelChanges`, `ApplyReviewerChanges`, `ResolveMilestoneID`) into `modules/task/labels.go` to reduce duplication between issue and PR editing - Refactor existing `EditIssue` to use the same shared helpers - Wrap original error in `ResolveMilestoneID` to preserve underlying error context ## Usage ```bash # Edit PR description tea pr edit 1 --description "new description" # Edit PR title tea pr edit 1 --title "new title" # Edit multiple fields tea pr edit 1 --title "new title" --description "new desc" --add-labels "bug" # Edit multiple PRs tea pr edit 1 2 3 --add-assignees "user1" # Add reviewers tea pr edit 1 --add-reviewers "user1,user2" # Remove reviewers tea pr edit 1 --remove-reviewers "user1" ``` ## Test plan - [x] `go build .` succeeds - [x] `go test ./...` passes - [x] `make clean && make vet && make lint && make fmt-check && make docs-check && make build` all pass - [x] `tea pr edit --description "test"` updates PR description on a Gitea instance - [x] `tea pr edit --title "test"` updates PR title - [x] `tea pr edit --add-labels "bug"` adds label - [x] `tea pr edit --add-reviewers "user"` requests review - [x] `tea pr edit --remove-reviewers "user"` removes reviewer - [x] Existing `tea issues edit` still works correctly after refactor Reviewed-on: https://gitea.com/gitea/tea/pulls/944 Reviewed-by: Lunny Xiao Co-authored-by: appleboy Co-committed-by: appleboy --- cmd/pulls.go | 1 + cmd/pulls/edit.go | 71 ++++++++++++++++++++++++++++++++++ docs/CLI.md | 30 +++++++++++++++ modules/task/issue_edit.go | 72 ++++++++++++---------------------- modules/task/labels.go | 67 ++++++++++++++++++++++++++++++++ modules/task/pull_edit.go | 79 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 272 insertions(+), 48 deletions(-) create mode 100644 modules/task/pull_edit.go diff --git a/cmd/pulls.go b/cmd/pulls.go index e156428..58d6c57 100644 --- a/cmd/pulls.go +++ b/cmd/pulls.go @@ -72,6 +72,7 @@ var CmdPulls = cli.Command{ &pulls.CmdPullsCreate, &pulls.CmdPullsClose, &pulls.CmdPullsReopen, + &pulls.CmdPullsEdit, &pulls.CmdPullsReview, &pulls.CmdPullsApprove, &pulls.CmdPullsReject, diff --git a/cmd/pulls/edit.go b/cmd/pulls/edit.go index 6fb05b5..b9090b0 100644 --- a/cmd/pulls/edit.go +++ b/cmd/pulls/edit.go @@ -6,15 +6,86 @@ package pulls import ( stdctx "context" "fmt" + "strings" + "code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/print" + "code.gitea.io/tea/modules/task" "code.gitea.io/tea/modules/utils" "code.gitea.io/sdk/gitea" "github.com/urfave/cli/v3" ) +// CmdPullsEdit is the subcommand of pulls to edit pull requests +var CmdPullsEdit = cli.Command{ + Name: "edit", + Aliases: []string{"e"}, + Usage: "Edit one or more pull requests", + Description: `Edit one or more pull requests. To unset a property again, +use an empty string (eg. --milestone "").`, + ArgsUsage: " [...]", + Action: runPullsEdit, + Flags: append(flags.IssuePREditFlags, + &cli.StringFlag{ + Name: "add-reviewers", + Aliases: []string{"r"}, + Usage: "Comma-separated list of usernames to request review from", + }, + &cli.StringFlag{ + Name: "remove-reviewers", + Usage: "Comma-separated list of usernames to remove from reviewers", + }, + ), +} + +func runPullsEdit(_ 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 + } + + if !cmd.Args().Present() { + return fmt.Errorf("must specify at least one pull request index") + } + + opts, err := flags.GetIssuePREditFlags(ctx) + if err != nil { + return err + } + + if cmd.IsSet("add-reviewers") { + opts.AddReviewers = strings.Split(cmd.String("add-reviewers"), ",") + } + if cmd.IsSet("remove-reviewers") { + opts.RemoveReviewers = strings.Split(cmd.String("remove-reviewers"), ",") + } + + indices, err := utils.ArgsToIndices(ctx.Args().Slice()) + if err != nil { + return err + } + + client := ctx.Login.Client() + for _, opts.Index = range indices { + pr, err := task.EditPull(ctx, client, *opts) + if err != nil { + return err + } + if ctx.Args().Len() > 1 { + fmt.Println(pr.HTMLURL) + } else { + print.PullDetails(pr, nil, nil) + } + } + + return nil +} + // editPullState abstracts the arg parsing to edit the given pull request func editPullState(_ stdctx.Context, cmd *cli.Command, opts gitea.EditPullRequestOption) error { ctx, err := context.InitCommand(cmd) diff --git a/docs/CLI.md b/docs/CLI.md index 2d9b40a..8d40cd1 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -399,6 +399,36 @@ Change state of one or more pull requests to 'open' **--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional +### edit, e + +Edit one or more pull requests + +**--add-assignees, -a**="": Comma-separated list of usernames to assign + +**--add-labels, -L**="": Comma-separated list of labels to assign. Takes precedence over --remove-labels + +**--add-reviewers, -r**="": Comma-separated list of usernames to request review from + +**--deadline, -D**="": Deadline timestamp to assign + +**--description, -d**="": + +**--login, -l**="": Use a different Gitea Login. Optional + +**--milestone, -m**="": Milestone to assign + +**--referenced-version, -v**="": commit-hash or tag name to assign + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--remove-labels**="": Comma-separated list of labels to remove + +**--remove-reviewers**="": Comma-separated list of usernames to remove from reviewers + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + +**--title, -t**="": + ### review Interactively review a pull request diff --git a/modules/task/issue_edit.go b/modules/task/issue_edit.go index c35ae0e..6a4b9fd 100644 --- a/modules/task/issue_edit.go +++ b/modules/task/issue_edit.go @@ -13,37 +13,30 @@ import ( // EditIssueOption wraps around gitea.EditIssueOption which has bad & incosistent semantics. type EditIssueOption struct { - Index int64 - Title *string - Body *string - Ref *string - Milestone *string - Deadline *time.Time - AddLabels []string - RemoveLabels []string - AddAssignees []string + Index int64 + Title *string + Body *string + Ref *string + Milestone *string + Deadline *time.Time + AddLabels []string + RemoveLabels []string + AddAssignees []string + AddReviewers []string + RemoveReviewers []string // RemoveAssignees []string // NOTE: with the current go-sdk, clearing assignees is not possible. } // Normalizes the options into parameters that can be passed to the sdk. // the returned value will be nil, when no change to this part of the issue is requested. func (o EditIssueOption) toSdkOptions(ctx *context.TeaContext, client *gitea.Client) (*gitea.EditIssueOption, *gitea.IssueLabelsOption, *gitea.IssueLabelsOption, error) { - // labels have a separate API call, so they get their own options. - var addLabelOpts, rmLabelOpts *gitea.IssueLabelsOption - if o.AddLabels != nil && len(o.AddLabels) != 0 { - ids, err := ResolveLabelNames(client, ctx.Owner, ctx.Repo, o.AddLabels) - if err != nil { - return nil, nil, nil, err - } - addLabelOpts = &gitea.IssueLabelsOption{Labels: ids} + addLabelOpts, err := ResolveLabelOpts(client, ctx.Owner, ctx.Repo, o.AddLabels) + if err != nil { + return nil, nil, nil, err } - - if o.RemoveLabels != nil && len(o.RemoveLabels) != 0 { - ids, err := ResolveLabelNames(client, ctx.Owner, ctx.Repo, o.RemoveLabels) - if err != nil { - return nil, nil, nil, err - } - rmLabelOpts = &gitea.IssueLabelsOption{Labels: ids} + rmLabelOpts, err := ResolveLabelOpts(client, ctx.Owner, ctx.Repo, o.RemoveLabels) + if err != nil { + return nil, nil, nil, err } issueOpts := gitea.EditIssueOption{} @@ -61,15 +54,11 @@ func (o EditIssueOption) toSdkOptions(ctx *context.TeaContext, client *gitea.Cli issueOptsDirty = true } if o.Milestone != nil { - if *o.Milestone == "" { - issueOpts.Milestone = gitea.OptionalInt64(0) - } else { - ms, _, err := client.GetMilestoneByName(ctx.Owner, ctx.Repo, *o.Milestone) - if err != nil { - return nil, nil, nil, fmt.Errorf("Milestone '%s' not found", *o.Milestone) - } - issueOpts.Milestone = &ms.ID + id, err := ResolveMilestoneID(client, ctx.Owner, ctx.Repo, *o.Milestone) + if err != nil { + return nil, nil, nil, err } + issueOpts.Milestone = gitea.OptionalInt64(id) issueOptsDirty = true } if o.Deadline != nil { @@ -79,7 +68,7 @@ func (o EditIssueOption) toSdkOptions(ctx *context.TeaContext, client *gitea.Cli issueOpts.RemoveDeadline = gitea.OptionalBool(true) } } - if o.AddAssignees != nil && len(o.AddAssignees) != 0 { + if len(o.AddAssignees) != 0 { issueOpts.Assignees = o.AddAssignees issueOptsDirty = true } @@ -101,21 +90,8 @@ func EditIssue(ctx *context.TeaContext, client *gitea.Client, opts EditIssueOpti return nil, err } - if rmLabelOpts != nil { - // NOTE: as of 1.17, there is no API to remove multiple labels at once. - for _, id := range rmLabelOpts.Labels { - _, err := client.DeleteIssueLabel(ctx.Owner, ctx.Repo, opts.Index, id) - if err != nil { - return nil, fmt.Errorf("could not remove labels: %s", err) - } - } - } - - if addLabelOpts != nil { - _, _, err := client.AddIssueLabels(ctx.Owner, ctx.Repo, opts.Index, *addLabelOpts) - if err != nil { - return nil, fmt.Errorf("could not add labels: %s", err) - } + if err := ApplyLabelChanges(client, ctx.Owner, ctx.Repo, opts.Index, addLabelOpts, rmLabelOpts); err != nil { + return nil, err } var issue *gitea.Issue diff --git a/modules/task/labels.go b/modules/task/labels.go index c0897cd..8d3c12e 100644 --- a/modules/task/labels.go +++ b/modules/task/labels.go @@ -4,6 +4,8 @@ package task import ( + "fmt" + "code.gitea.io/sdk/gitea" "code.gitea.io/tea/modules/utils" ) @@ -24,3 +26,68 @@ func ResolveLabelNames(client *gitea.Client, owner, repo string, labelNames []st } return labelIDs, nil } + +// ResolveLabelOpts resolves label names to IssueLabelsOption. Returns nil if names is empty. +func ResolveLabelOpts(client *gitea.Client, owner, repo string, names []string) (*gitea.IssueLabelsOption, error) { + if len(names) == 0 { + return nil, nil + } + ids, err := ResolveLabelNames(client, owner, repo, names) + if err != nil { + return nil, err + } + return &gitea.IssueLabelsOption{Labels: ids}, nil +} + +// ApplyLabelChanges adds and removes labels on an issue or pull request. +func ApplyLabelChanges(client *gitea.Client, owner, repo string, index int64, add, rm *gitea.IssueLabelsOption) error { + if rm != nil { + // NOTE: as of 1.17, there is no API to remove multiple labels at once. + for _, id := range rm.Labels { + _, err := client.DeleteIssueLabel(owner, repo, index, id) + if err != nil { + return fmt.Errorf("could not remove labels: %s", err) + } + } + } + if add != nil { + _, _, err := client.AddIssueLabels(owner, repo, index, *add) + if err != nil { + return fmt.Errorf("could not add labels: %s", err) + } + } + return nil +} + +// ApplyReviewerChanges adds and removes reviewers on a pull request. +func ApplyReviewerChanges(client *gitea.Client, owner, repo string, index int64, add, rm []string) error { + if len(rm) != 0 { + _, err := client.DeleteReviewRequests(owner, repo, index, gitea.PullReviewRequestOptions{ + Reviewers: rm, + }) + if err != nil { + return fmt.Errorf("could not remove reviewers: %w", err) + } + } + if len(add) != 0 { + _, err := client.CreateReviewRequests(owner, repo, index, gitea.PullReviewRequestOptions{ + Reviewers: add, + }) + if err != nil { + return fmt.Errorf("could not add reviewers: %w", err) + } + } + return nil +} + +// ResolveMilestoneID resolves a milestone name to its ID. Returns 0 for empty name. +func ResolveMilestoneID(client *gitea.Client, owner, repo, name string) (int64, error) { + if name == "" { + return 0, nil + } + ms, _, err := client.GetMilestoneByName(owner, repo, name) + if err != nil { + return 0, fmt.Errorf("could not resolve milestone '%s': %w", name, err) + } + return ms.ID, nil +} diff --git a/modules/task/pull_edit.go b/modules/task/pull_edit.go new file mode 100644 index 0000000..0d73cfd --- /dev/null +++ b/modules/task/pull_edit.go @@ -0,0 +1,79 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package task + +import ( + "fmt" + + "code.gitea.io/sdk/gitea" + "code.gitea.io/tea/modules/context" +) + +// EditPull edits a pull request and returns the updated pull request. +func EditPull(ctx *context.TeaContext, client *gitea.Client, opts EditIssueOption) (*gitea.PullRequest, error) { + if client == nil { + client = ctx.Login.Client() + } + + addLabelOpts, err := ResolveLabelOpts(client, ctx.Owner, ctx.Repo, opts.AddLabels) + if err != nil { + return nil, err + } + rmLabelOpts, err := ResolveLabelOpts(client, ctx.Owner, ctx.Repo, opts.RemoveLabels) + if err != nil { + return nil, err + } + + prOpts := gitea.EditPullRequestOption{} + var prOptsDirty bool + if opts.Title != nil { + prOpts.Title = *opts.Title + prOptsDirty = true + } + if opts.Body != nil { + prOpts.Body = opts.Body + prOptsDirty = true + } + if opts.Milestone != nil { + id, err := ResolveMilestoneID(client, ctx.Owner, ctx.Repo, *opts.Milestone) + if err != nil { + return nil, err + } + prOpts.Milestone = id + prOptsDirty = true + } + if opts.Deadline != nil { + prOpts.Deadline = opts.Deadline + prOptsDirty = true + if opts.Deadline.IsZero() { + prOpts.RemoveDeadline = gitea.OptionalBool(true) + } + } + if len(opts.AddAssignees) != 0 { + prOpts.Assignees = opts.AddAssignees + prOptsDirty = true + } + + if err := ApplyLabelChanges(client, ctx.Owner, ctx.Repo, opts.Index, addLabelOpts, rmLabelOpts); err != nil { + return nil, err + } + + if err := ApplyReviewerChanges(client, ctx.Owner, ctx.Repo, opts.Index, opts.AddReviewers, opts.RemoveReviewers); err != nil { + return nil, err + } + + var pr *gitea.PullRequest + if prOptsDirty { + pr, _, err = client.EditPullRequest(ctx.Owner, ctx.Repo, opts.Index, prOpts) + if err != nil { + return nil, fmt.Errorf("could not edit pull request: %s", err) + } + } else { + pr, _, err = client.GetPullRequest(ctx.Owner, ctx.Repo, opts.Index) + if err != nil { + return nil, fmt.Errorf("could not get pull request: %s", err) + } + } + return pr, nil +}