From 5fa24b9a65bcfd90c97572c40139459c480bb19e Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 31 May 2026 02:50:32 +0000 Subject: [PATCH] feat(pulls): add --draft to create and --draft/--ready to edit (#1008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #979. An alternative to the approach in #980 — this one is purely client-side title-mangling, no SDK changes needed. Gitea already treats any PR with a `WIP:` or `[WIP]` title prefix (case-insensitive) as a draft. This patch wires three flags around that behavior: - `tea pulls create --draft` — prepend `WIP: ` to the title at creation time - `tea pulls edit --draft ` — add `WIP: ` to an existing PR's title - `tea pulls edit --ready ` — strip any recognized draft prefix All three are idempotent. `--draft` and `--ready` on edit are mutually exclusive. If the user also passes `--title` on edit, the toggle applies to the supplied title; otherwise the current title is fetched from the server first. Why this approach over a server-payload-based one: Gitea's draft state is *defined* as the title-prefix convention (see the Gitea source for `HasWIPPrefix`). Modeling it server-side would either duplicate or fight that. A small string helper covers it without needing the SDK to add a `Draft` field. Verified against `gitea.com` (1.26.0+dev) with a throwaway repo: - create with `--draft` → server reports `draft: true` ✓ - `edit --ready` strips → `draft: false` ✓ - `edit --draft` adds back → `draft: true` ✓ - second `edit --draft` is idempotent ✓ - `edit --draft --ready` errors ✓ Unit tests for the prefix detection live in `modules/utils/draft_test.go`. --- This patch was authored interactively with an AI assistant, driven and reviewed by a human (Tyler / @dinsmoor) every step. Reproduction, design decisions, and the choice not to follow #980's payload approach were mine — happy to discuss any of it. *pull request created by Tyler's lovingly wrangled demon machine <3* --------- Co-authored-by: Lunny Xiao Reviewed-on: https://gitea.com/gitea/tea/pulls/1008 Reviewed-by: Lunny Xiao Co-authored-by: Tyler Co-committed-by: Tyler --- cmd/pulls/create.go | 9 +++++++ cmd/pulls/edit.go | 47 +++++++++++++++++++++++++++++++++++++ docs/CLI.md | 6 +++++ modules/utils/draft.go | 31 ++++++++++++++++++++++++ modules/utils/draft_test.go | 34 +++++++++++++++++++++++++++ 5 files changed, 127 insertions(+) create mode 100644 modules/utils/draft.go create mode 100644 modules/utils/draft_test.go diff --git a/cmd/pulls/create.go b/cmd/pulls/create.go index 8bf59dbb..fc3e7c12 100644 --- a/cmd/pulls/create.go +++ b/cmd/pulls/create.go @@ -13,6 +13,7 @@ import ( "gitea.dev/tea/modules/context" "gitea.dev/tea/modules/interact" "gitea.dev/tea/modules/task" + "gitea.dev/tea/modules/utils" ) // CmdPullsCreate creates a pull request @@ -46,6 +47,10 @@ var CmdPullsCreate = cli.Command{ Name: "topic", Usage: "Topic name for agit flow pull request", }, + &cli.BoolFlag{ + Name: "draft", + Usage: "Create as a draft (prepends \"WIP: \" to the title; Gitea treats WIP-prefixed PRs as drafts)", + }, }, flags.IssuePRCreateFlags...), } @@ -81,6 +86,10 @@ func runPullsCreate(requestCtx stdctx.Context, cmd *cli.Command) error { return err } + if ctx.Bool("draft") { + opts.Title = utils.AddDraftPrefix(opts.Title) + } + if ctx.Bool("agit") { return task.CreateAgitFlowPull( requestCtx, diff --git a/cmd/pulls/edit.go b/cmd/pulls/edit.go index 272646e2..64604e7c 100644 --- a/cmd/pulls/edit.go +++ b/cmd/pulls/edit.go @@ -18,6 +18,42 @@ import ( "github.com/urfave/cli/v3" ) +// applyDraftFlag mutates opts.Title according to --draft / --ready. +// If a flag is set but --title isn't, it fetches the current title from the server. +// Returns an error if both --draft and --ready are set, or on a server error. +func applyDraftFlag(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea.Client, idx int64, opts *task.EditIssueOption) error { + draft := ctx.Bool("draft") + ready := ctx.Bool("ready") + if !draft && !ready { + return nil + } + if draft && ready { + return fmt.Errorf("--draft and --ready are mutually exclusive") + } + + var current string + if opts.Title != nil { + current = *opts.Title + } else { + pr, _, err := client.PullRequests.GetPullRequest(requestCtx, ctx.Owner, ctx.Repo, idx) + if err != nil { + return fmt.Errorf("could not fetch pull request #%d: %s", idx, err) + } + current = pr.Title + } + + var next string + if draft { + next = utils.AddDraftPrefix(current) + } else { + next = utils.StripDraftPrefix(current) + } + if next != current || opts.Title != nil { + opts.Title = &next + } + return nil +} + // CmdPullsEdit is the subcommand of pulls to edit pull requests var CmdPullsEdit = cli.Command{ Name: "edit", @@ -37,6 +73,14 @@ use an empty string (eg. --milestone "").`, Name: "remove-reviewers", Usage: "Comma-separated list of usernames to remove from reviewers", }, + &cli.BoolFlag{ + Name: "draft", + Usage: "Mark as draft by prepending \"WIP: \" to the title (idempotent)", + }, + &cli.BoolFlag{ + Name: "ready", + Usage: "Mark as ready for review by stripping any leading \"WIP: \" or \"[WIP]\" prefix", + }, ), } @@ -72,6 +116,9 @@ func runPullsEdit(requestCtx stdctx.Context, cmd *cli.Command) error { client := ctx.Login.Client() for _, opts.Index = range indices { + if err := applyDraftFlag(requestCtx, ctx, client, opts.Index, opts); err != nil { + return err + } pr, err := task.EditPull(requestCtx, ctx, client, *opts) if err != nil { return err diff --git a/docs/CLI.md b/docs/CLI.md index d1e183c0..ddcb6d02 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -375,6 +375,8 @@ Create a pull-request **--description, -d**="": +**--draft**: Create as a draft (prepends "WIP: " to the title; Gitea treats WIP-prefixed PRs as drafts) + **--head**="": Branch name of the PR source (default is current one). To specify a different head repo, use : **--labels, -L**="": Comma-separated list of labels to assign @@ -431,10 +433,14 @@ Edit one or more pull requests **--description, -d**="": +**--draft**: Mark as draft by prepending "WIP: " to the title (idempotent) + **--login, -l**="": Use a different Gitea Login. Optional **--milestone, -m**="": Milestone to assign +**--ready**: Mark as ready for review by stripping any leading "WIP: " or "[WIP]" prefix + **--referenced-version, -v**="": commit-hash or tag name to assign **--remote, -R**="": Discover Gitea login from remote. Optional diff --git a/modules/utils/draft.go b/modules/utils/draft.go new file mode 100644 index 00000000..1d2041e5 --- /dev/null +++ b/modules/utils/draft.go @@ -0,0 +1,31 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package utils + +import "regexp" + +// draftPrefixRe matches the prefixes Gitea recognizes as marking a pull +// request as a draft: a leading "WIP:" or "[WIP]" (case-insensitive), +// followed by any whitespace. +var draftPrefixRe = regexp.MustCompile(`(?i)^(wip:\s*|\[wip\]\s*)`) + +// HasDraftPrefix reports whether title already starts with a draft marker. +func HasDraftPrefix(title string) bool { + return draftPrefixRe.MatchString(title) +} + +// AddDraftPrefix returns title with a "WIP: " prefix, or title unchanged +// if it already carries a recognized draft prefix. +func AddDraftPrefix(title string) string { + if HasDraftPrefix(title) { + return title + } + return "WIP: " + title +} + +// StripDraftPrefix returns title with any recognized leading draft prefix +// removed, or title unchanged if no prefix is present. +func StripDraftPrefix(title string) string { + return draftPrefixRe.ReplaceAllString(title, "") +} diff --git a/modules/utils/draft_test.go b/modules/utils/draft_test.go new file mode 100644 index 00000000..6bc937f1 --- /dev/null +++ b/modules/utils/draft_test.go @@ -0,0 +1,34 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package utils + +import "testing" + +func TestDraftPrefix(t *testing.T) { + cases := []struct { + in string + has bool + stripped string + withDraft string + }{ + {"plain title", false, "plain title", "WIP: plain title"}, + {"WIP: already", true, "already", "WIP: already"}, + {"wip: lowercase", true, "lowercase", "wip: lowercase"}, + {"[WIP] bracketed", true, "bracketed", "[WIP] bracketed"}, + {"[wip] extra space", true, "extra space", "[wip] extra space"}, + {"Draft: not recognized", false, "Draft: not recognized", "WIP: Draft: not recognized"}, + {"", false, "", "WIP: "}, + } + for _, c := range cases { + if got := HasDraftPrefix(c.in); got != c.has { + t.Errorf("HasDraftPrefix(%q) = %v, want %v", c.in, got, c.has) + } + if got := StripDraftPrefix(c.in); got != c.stripped { + t.Errorf("StripDraftPrefix(%q) = %q, want %q", c.in, got, c.stripped) + } + if got := AddDraftPrefix(c.in); got != c.withDraft { + t.Errorf("AddDraftPrefix(%q) = %q, want %q", c.in, got, c.withDraft) + } + } +}