From 4ef8f458e2e030fa3b6715e5c77dda90c584006a Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 17 Oct 2026 12:52:36 +1100 Subject: [PATCH] Add --base to agentpr pr edit to retarget a PR --- README.md | 3 ++- cmd/agentpr/main.go | 18 ++++++++++++++---- cmd/agentpr/main_test.go | 25 ++++++++++++++++++++++--- internal/agent/client_test.go | 4 +++- internal/agent/gitea.go | 6 ++++-- 5 files changed, 45 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 549bc3a..105ac05 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,10 @@ agentpr pr create --repo unkin/argocd-apps \ # Comment on a PR agentpr pr comment --repo unkin/argocd-apps --pr 42 --body "Rebased, CI green." -# Edit a PR's title and/or body; an omitted flag is left unchanged +# Edit a PR's title, body and/or base branch; an omitted flag is left unchanged agentpr pr edit --repo unkin/argocd-apps --pr 42 --body "Adds the ServiceAccount ..." agentpr pr edit --repo unkin/argocd-apps --pr 42 --title "Add woodpecker SA" +agentpr pr edit --repo unkin/argocd-apps --pr 42 --base benvin/parent-change # retarget a stacked PR # prints: # # File an issue (--body optional) diff --git a/cmd/agentpr/main.go b/cmd/agentpr/main.go index 2bd4703..02d26f7 100644 --- a/cmd/agentpr/main.go +++ b/cmd/agentpr/main.go @@ -5,7 +5,7 @@ // // agentpr pr create --repo owner/repo --base main --head feature --title T --body B // agentpr pr comment --repo owner/repo --pr 12 --body "..." -// agentpr pr edit --repo owner/repo --pr 12 --title T --body B +// agentpr pr edit --repo owner/repo --pr 12 --title T --body B --base B // agentpr issue create --repo owner/repo --title T --body B // agentpr issue comment --repo owner/repo --issue 12 --body "..." // agentpr issue edit --repo owner/repo --issue 12 --title T --body B @@ -169,11 +169,11 @@ func newCommentCmd(numFlag, noun, short string) *cobra.Command { } func newPREditCmd() *cobra.Command { - var repo, title, body string + var repo, title, body, base string var pr int cmd := &cobra.Command{ Use: "edit", - Short: "Edit a pull request's title and/or body", + Short: "Edit a pull request's title, body and/or base branch", SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { owner, name, err := agent.ParseRepo(repo) @@ -187,6 +187,12 @@ func newPREditCmd() *cobra.Command { if err != nil { return err } + if cmd.Flags().Changed("base") { + if base == "" { + return fmt.Errorf("--base cannot be empty") + } + opts.Base = &base + } c, err := client() if err != nil { return err @@ -204,6 +210,7 @@ func newPREditCmd() *cobra.Command { f.IntVar(&pr, "pr", 0, "PR number (required)") f.StringVar(&title, "title", "", "New PR title (unchanged when omitted)") f.StringVar(&body, "body", "", "New PR body (unchanged when omitted)") + f.StringVar(&base, "base", "", "New base branch (unchanged when omitted)") _ = cmd.MarkFlagRequired("repo") _ = cmd.MarkFlagRequired("pr") return cmd @@ -225,7 +232,10 @@ func editOptions(cmd *cobra.Command, title, body string) (agent.EditOptions, err if cmd.Flags().Changed("body") { opts.Body = &body } - if opts.Title == nil && opts.Body == nil { + if opts.Title == nil && opts.Body == nil && !cmd.Flags().Changed("base") { + if cmd.Flags().Lookup("base") != nil { + return opts, fmt.Errorf("at least one of --title, --body or --base is required") + } return opts, fmt.Errorf("at least one of --title or --body is required") } return opts, nil diff --git a/cmd/agentpr/main_test.go b/cmd/agentpr/main_test.go index 749b586..6543fc4 100644 --- a/cmd/agentpr/main_test.go +++ b/cmd/agentpr/main_test.go @@ -20,7 +20,7 @@ func TestExecuteBadRepoErrors(t *testing.T) { } } -// `pr edit` with neither --title nor --body has nothing to send; it must fail +// `pr edit` with none of --title, --body or --base has nothing to send; it must fail // with a usage error before any Vault/Gitea call, so this stays hermetic. func TestPREditRequiresTitleOrBody(t *testing.T) { cmd := newRootCmd() @@ -29,13 +29,32 @@ func TestPREditRequiresTitleOrBody(t *testing.T) { cmd.SetErr(io.Discard) err := cmd.Execute() if err == nil { - t.Fatal("Execute() = nil, want an error when neither --title nor --body is given") + t.Fatal("Execute() = nil, want an error when no edit flag is given") } - if !strings.Contains(err.Error(), "--title or --body") { + if !strings.Contains(err.Error(), "--title, --body or --base") { t.Errorf("error = %q, want it to name the missing flags", err) } } +// An empty --base names no branch to retarget to; it must fail before any +// Vault/Gitea call rather than be sent. +func TestPREditRejectsEmptyBase(t *testing.T) { + err := execute("pr", "edit", "--repo", "unkin/repo", "--pr", "7", "--base", "") + if err == nil { + t.Fatal("Execute() = nil, want an error for an empty --base") + } + if !strings.Contains(err.Error(), "--base cannot be empty") { + t.Errorf("error = %q, want it to reject the empty base", err) + } +} + +// issue edit has no --base, so its usage error must not suggest one. +func TestIssueEditHasNoBaseFlag(t *testing.T) { + if err := execute("issue", "edit", "--repo", "unkin/repo", "--issue", "12", "--base", "x"); err == nil { + t.Fatal("Execute() = nil, want an unknown-flag error for issue edit --base") + } +} + // Gitea silently ignores an empty title, so `pr edit --title ""` would report // success while changing nothing; it must fail before any Vault/Gitea call. func TestPREditRejectsEmptyTitle(t *testing.T) { diff --git a/internal/agent/client_test.go b/internal/agent/client_test.go index 696abc7..dd77402 100644 --- a/internal/agent/client_test.go +++ b/internal/agent/client_test.go @@ -102,7 +102,7 @@ func TestCreatePRRequestBody(t *testing.T) { // An edit must send only the fields it was given: Gitea overwrites whatever // key it receives, so an omitted --title arriving as "" would blank the title. func TestEditPRSendsOnlySuppliedFields(t *testing.T) { - title, body, empty := "new title", "new body", "" + title, body, base, empty := "new title", "new body", "benvin/parent", "" tests := []struct { name string opts EditOptions @@ -112,6 +112,8 @@ func TestEditPRSendsOnlySuppliedFields(t *testing.T) { {"title only", EditOptions{Title: &title}, map[string]any{"title": "new title"}}, {"both", EditOptions{Title: &title, Body: &body}, map[string]any{"title": "new title", "body": "new body"}}, {"explicit empty body is sent", EditOptions{Body: &empty}, map[string]any{"body": ""}}, + {"base only", EditOptions{Base: &base}, map[string]any{"base": "benvin/parent"}}, + {"base with title", EditOptions{Title: &title, Base: &base}, map[string]any{"title": "new title", "base": "benvin/parent"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/agent/gitea.go b/internal/agent/gitea.go index 46a7dd6..e063d23 100644 --- a/internal/agent/gitea.go +++ b/internal/agent/gitea.go @@ -283,13 +283,15 @@ func (c *GiteaClient) CreatePR(repoPath string, opts CreatePROptions) (PullReque // entirely, leaving that field as it is. The two fields are not symmetric: // Gitea only applies a title when it is non-empty, so Title can be set but // never cleared and a "" title is a silent no-op, while a pointer to "" Body -// really does blank the body. +// really does blank the body. Base retargets a pull request and is never set +// for an issue. type EditOptions struct { Title *string `json:"title,omitempty"` Body *string `json:"body,omitempty"` + Base *string `json:"base,omitempty"` } -// EditPR updates a pull request's title and/or body +// EditPR updates a pull request's title, body and/or base branch // (PATCH /api/v1/repos/{owner}/{repo}/pulls/{index}). func (c *GiteaClient) EditPR(repoPath string, number int, opts EditOptions) (PullRequest, error) { var pr PullRequest