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