From d1b9b7735eb470f3a3543e591d8f8fcfb89e5c6f Mon Sep 17 00:00:00 2001 From: Tyler Date: Thu, 28 May 2026 17:45:44 +0000 Subject: [PATCH 1/4] fix(comment): don't block on stdin when body is given positionally (#1011) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## The bug `tea comment "body"` hangs forever in any non-TTY context — CI pipelines, subshells, agent harnesses, scripts — unless the user explicitly redirects stdin with `< /dev/null`. ### Reproduction (released `tea` 0.14.1) ``` # Plain positional body — hangs forever $ tea comment --repo owner/repo 1 "body text" # Heredoc body — hangs forever $ tea comment --repo owner/repo 1 "$(cat <<'EOM' body text EOM )" # Workaround that works (but shouldn't be necessary) $ tea comment --repo owner/repo 1 "body text" < /dev/null ``` Verified on `gitea.com` (server 1.26.0+dev) with `tea 0.14.1`. Both hanging cases hit a 30s timeout in testing; in normal shell use they hang indefinitely. ## Root cause `cmd/comment.go:58-65`: ```go body := strings.Join(ctx.Args().Tail(), " ") if interact.IsStdinPiped() { if bodyStdin, err := io.ReadAll(ctx.Reader); err != nil { return err } else if len(bodyStdin) != 0 { body = strings.Join([]string{body, string(bodyStdin)}, "\n\n") } } ``` `interact.IsStdinPiped()` is implemented as `!term.IsTerminal(os.Stdin)` — true for *any* non-TTY stdin, not just for piped data. When tea enters this branch in a subshell where stdin is open but no producer ever writes to it (the typical case for shell scripts and automation), `io.ReadAll` blocks waiting for an EOF that never arrives. ## Fix Only consume stdin when the user did **not** supply a positional body. If they passed a body via args, that's their content — ignore stdin entirely. ```go if len(body) == 0 && interact.IsStdinPiped() { // ... read stdin ... body = string(bodyStdin) } ``` ## Behavior matrix | Invocation | Before | After | |---|---|---| | `tea comment "body"` (TTY) | works | works | | `tea comment "body"` (non-TTY, no redirect) | **hangs forever** | **works** | | `tea comment "body" < /dev/null` | works | works | | `echo body \| tea comment ` | works | works | | `tea comment "prefix"` + piped stdin | "prefix" + stdin concatenated | positional body wins, stdin ignored | | `tea comment ` (TTY, no body, no pipe) | opens editor | opens editor | The one behavior change is the prefix-plus-stdin case (5th row). I couldn't find anyone relying on that pattern and it wasn't documented; defaulting to "positional body wins" matches the principle of least surprise. ## Verification Confirmed each row against `dinsmoor/tea-testing` issue #1 on `gitea.com` with the patched binary. Previously-hanging invocations now post in <0.5s. Piped-stdin path unchanged. --- This patch was authored interactively with an AI assistant, driven and reviewed by a human (Tyler / @dinsmoor) every step. *pull request created by Tyler's lovingly wrangled demon machine <3* --------- Co-authored-by: Lunny Xiao Reviewed-on: https://gitea.com/gitea/tea/pulls/1011 Reviewed-by: Lunny Xiao Co-authored-by: Tyler Co-committed-by: Tyler --- cmd/comment.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cmd/comment.go b/cmd/comment.go index cbd6f8d7..48e63665 100644 --- a/cmd/comment.go +++ b/cmd/comment.go @@ -56,12 +56,16 @@ func runAddComment(requestCtx stdctx.Context, cmd *cli.Command) error { } body := strings.Join(ctx.Args().Tail(), " ") - if interact.IsStdinPiped() { + // Only consume stdin if no positional body was given. interact.IsStdinPiped() + // is true for any non-TTY stdin (CI, subshells, agent harnesses) — not just + // piped data — so reading unconditionally would block forever in those + // contexts when the body is supplied via args. + if len(body) == 0 && interact.IsStdinPiped() { // custom solution until https://github.com/AlecAivazis/survey/issues/328 is fixed if bodyStdin, err := io.ReadAll(ctx.Reader); err != nil { return err } else if len(bodyStdin) != 0 { - body = strings.Join([]string{body, string(bodyStdin)}, "\n\n") + body = string(bodyStdin) } } else if len(body) == 0 { if err := huh.NewForm( From 18274f1ebc166bc27b31b89c634e376ee22121d0 Mon Sep 17 00:00:00 2001 From: Tyler Date: Thu, 28 May 2026 18:29:11 +0000 Subject: [PATCH 2/4] fix(pulls): restore standard fork-flow PR creation (#1010) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1009. ## The standard fork-flow The textbook workflow for opening a PR with any git CLI (gh, glab, hub, tea, etc.) is: 1. Fork `upstream/repo` on the server. 2. Clone the fork locally; add `upstream` as a second remote. 3. Branch off, commit, push the branch to your fork. 4. Tell the tool: "open a PR on `upstream/repo`, source is `fork:branch`". In tea that's: ``` tea pulls create --repo upstream/repo --head fork:branch --base main ``` This is the flow tea supported before #850 and the flow this repo's own contribution model assumes. ## What broke #850 ("Enable git worktree support and improve pr create error handling", Nov 2025) correctly addressed worktree detection by adding `LocalRepo: true` to `runPullsCreate`'s `CtxRequirement`. But that requirement is checked before `--repo` is interpreted, and a slug-style `--repo gitea/tea` doesn't satisfy `LocalRepo` — only a path does. Result: the standard fork-flow invocation above started failing with: ``` Error: local repository required: execute from a repo dir, or specify a path with --repo ``` — which is misleading, because the user IS in a repo dir and `--repo` IS set. ## The fix Only require `LocalRepo` when the command genuinely needs the working tree: - **Interactive mode** (no flags) — uses tree info for prompts. - **`--head` omitted** — defaults head from the current branch. Otherwise the command runs with just `RemoteRepo`, and `task.CreatePull` takes the explicit `--repo` and `--head` as given. The #850 worktree fix is preserved for the in-tree path. ## Behavior table | Invocation | Before | After | |---|---|---| | `tea pulls create` (interactive) | works | works (unchanged) | | `tea pulls create --head my-branch --base main` (in-tree, same repo) | works | works (unchanged) | | `tea pulls create --repo upstream/repo --head fork:branch --base main` (fork-flow) | **fails with misleading error** | **works** | | `tea pulls create --repo upstream/repo --base main` (no `--head`, no working tree) | fails | still fails with the same error (correctly — head can't be defaulted without a working tree or explicit flag) | ## Verification Smoke-tested by opening a PR from `/tmp` (deliberately not a git repo): ``` cd /tmp tea-dev pulls create --login gitea.com --repo dinsmoor/tea-testing \ --head dinsmoor:xfork-smoke --base main \ --title "xfork smoke test" --description "..." → https://gitea.com/dinsmoor/tea-testing/pulls/4 ``` And in fact, **this very PR was opened using the patched binary** invoked from `/tmp`, since the released `tea` still has the bug. --- This patch was authored interactively with an AI assistant, driven and reviewed by a human (Tyler / @dinsmoor) every step. *pull request created by Tyler's lovingly wrangled demon machine <3* --------- Co-authored-by: Lunny Xiao Reviewed-on: https://gitea.com/gitea/tea/pulls/1010 Reviewed-by: Lunny Xiao Co-authored-by: Tyler Co-committed-by: Tyler --- cmd/pulls/create.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/cmd/pulls/create.go b/cmd/pulls/create.go index fb1abb34..8bf59dbb 100644 --- a/cmd/pulls/create.go +++ b/cmd/pulls/create.go @@ -54,10 +54,16 @@ func runPullsCreate(requestCtx stdctx.Context, cmd *cli.Command) error { if err != nil { return err } - if err := ctx.Ensure(context.CtxRequirement{ - LocalRepo: true, - RemoteRepo: true, - }); err != nil { + // Interactive mode and head-branch defaulting both need a local repo. + // When --head is given explicitly the user can target a cross-fork PR + // from outside a working tree (e.g. with --repo /); + // task.CreatePull only consults ctx.LocalRepo when head is empty. + needsLocalRepo := ctx.IsInteractiveMode() || len(ctx.String("head")) == 0 + requirement := context.CtxRequirement{RemoteRepo: true} + if needsLocalRepo { + requirement.LocalRepo = true + } + if err := ctx.Ensure(requirement); err != nil { return err } From 5fa24b9a65bcfd90c97572c40139459c480bb19e Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 31 May 2026 02:50:32 +0000 Subject: [PATCH 3/4] 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) + } + } +} From 09fc09c2f724b01c5922a1ccf4ea35cd4304ea2b Mon Sep 17 00:00:00 2001 From: dinsmoor <204368+dinsmoor@noreply.gitea.com> Date: Sun, 31 May 2026 22:20:48 +0000 Subject: [PATCH 4/4] feat(comments): add list/edit/delete subcommands to tea comment (#1015) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Today `tea comment` can only *add* a comment. Editing or deleting requires falling back to `tea api`. This came up while I was iterating on PRs in this same repo earlier today and had to correct a couple of comments by hand. Every comparable forge CLI (gh, glab, etc.) exposes these operations as first-class commands. ## What Restructures `tea comment` from a single-action command into a parent with four subcommands. The parent's default action remains the existing "add" behavior, so the historical shorthand keeps working. | Command | Purpose | |---|---| | `tea comment add []` | Add a comment (explicit subcommand) | | `tea comment list ` | Tabular listing including comment IDs | | `tea comment edit []` | Replace the body of one comment | | `tea comment delete [...]` | Delete one or more comments | | `tea comment []` | Unchanged — still routes to `add` | The `list` command exists specifically so users can discover the IDs that `edit` and `delete` accept. ## Backward compatibility The whole point of routing the parent's default `Action` through `add` is to preserve every existing invocation. `tea comment 1 "body"` still does what it did before. No flag or arg names change. ## Input forms (for add and edit) Same pattern as the original `tea comment`: 1. Positional body (`tea comment edit "new body"`) — wins if present. 2. Piped stdin if no positional body is given. 3. External `$EDITOR` (pre-populated with the current body, on `edit`) if neither. This matches the stdin-handling fix in #1011 — positional body wins over a non-TTY stdin so the command doesn't hang in CI/subshells. ## Verification All four subcommands were exercised live against `https://gitea.com/dinsmoor/tea-testing` issue #1. The test artifacts and a summary log are visible on that issue right now. Specifically: - The annotated summary comment lists every operation tested and the comment IDs each one acted on. - Comments 1197162 (legacy add), 1197163 (subcommand add, later edited), 1197164 (stdin add) are still there to be inspected. - Comment 1197166 was created and then deleted; its absence from `tea comment list` output is evidence that delete works. ## New files - `cmd/comments/add.go` — extracted from the old `cmd/comment.go` - `cmd/comments/list.go` - `cmd/comments/edit.go` - `cmd/comments/delete.go` - `modules/print/comment.go` — adds `CommentsList` helper for the tabular output `cmd/comment.go` is rewritten as a thin parent that wires these together. ## Open questions for the reviewer - **Naming**: should the top-level command be `comments` (plural) or stay `comment` (singular)? I kept it singular with `comments` as an alias to match the existing user-visible name. - **Delete confirmation**: I did not add a confirmation prompt — `delete` just deletes. Some projects gate this behind `--yes` / interactive `[y/N]`. I'd rather follow whatever convention the maintainers prefer. - **Output format on list**: currently uses the existing `print.tableWithHeader` helper, matching `tea organizations list` etc. Other tea listings support `--output json` / `--output csv` via the shared `--output` flag, which works here automatically through the same helper. --- This patch was authored interactively with an AI assistant, driven and reviewed by a human (Tyler / @dinsmoor) every step. *pull request created by Tyler's lovingly wrangled demon machine <3* Reviewed-on: https://gitea.com/gitea/tea/pulls/1015 Reviewed-by: Lunny Xiao Co-authored-by: dinsmoor <204368+dinsmoor@noreply.gitea.com> Co-committed-by: dinsmoor <204368+dinsmoor@noreply.gitea.com> --- cmd/cmd.go | 2 +- cmd/comment.go | 109 +++++---------------------- cmd/comments/add.go | 97 ++++++++++++++++++++++++ cmd/comments/delete.go | 55 ++++++++++++++ cmd/comments/edit.go | 102 +++++++++++++++++++++++++ cmd/comments/list.go | 62 +++++++++++++++ docs/CLI.md | 56 +++++++++++++- modules/print/comment.go | 50 ++++++++++++ tests/integration/comments_test.go | 117 +++++++++++++++++++++++++++++ 9 files changed, 558 insertions(+), 92 deletions(-) create mode 100644 cmd/comments/add.go create mode 100644 cmd/comments/delete.go create mode 100644 cmd/comments/edit.go create mode 100644 cmd/comments/list.go create mode 100644 tests/integration/comments_test.go diff --git a/cmd/cmd.go b/cmd/cmd.go index b0506a2f..a2306d9c 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -41,7 +41,7 @@ func App() *cli.Command { &CmdActions, &CmdWiki, &CmdWebhooks, - &CmdAddComment, + &CmdComments, &CmdOpen, &CmdNotifications, diff --git a/cmd/comment.go b/cmd/comment.go index 48e63665..1434c70b 100644 --- a/cmd/comment.go +++ b/cmd/comment.go @@ -4,97 +4,28 @@ package cmd import ( - stdctx "context" - "errors" - "fmt" - "io" - "strings" + "gitea.dev/tea/cmd/comments" - gitea "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/config" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/interact" - "gitea.dev/tea/modules/print" - "gitea.dev/tea/modules/theme" - "gitea.dev/tea/modules/utils" - - "charm.land/huh/v2" "github.com/urfave/cli/v3" ) -// CmdAddComment is the main command to operate with notifications -var CmdAddComment = cli.Command{ - Name: "comment", - Aliases: []string{"c"}, - Category: catEntities, - Usage: "Add a comment to an issue / pr", - Description: "Add a comment to an issue / pr", - ArgsUsage: " []", - Action: runAddComment, - Flags: flags.AllDefaultFlags, -} - -func runAddComment(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 - } - - args := ctx.Args() - if args.Len() == 0 { - return fmt.Errorf("please specify issue / pr index") - } - - idx, err := utils.ArgToIndex(ctx.Args().First()) - if err != nil { - return err - } - - body := strings.Join(ctx.Args().Tail(), " ") - // Only consume stdin if no positional body was given. interact.IsStdinPiped() - // is true for any non-TTY stdin (CI, subshells, agent harnesses) — not just - // piped data — so reading unconditionally would block forever in those - // contexts when the body is supplied via args. - if len(body) == 0 && interact.IsStdinPiped() { - // custom solution until https://github.com/AlecAivazis/survey/issues/328 is fixed - if bodyStdin, err := io.ReadAll(ctx.Reader); err != nil { - return err - } else if len(bodyStdin) != 0 { - body = string(bodyStdin) - } - } else if len(body) == 0 { - if err := huh.NewForm( - huh.NewGroup( - huh.NewText(). - Title("Comment(markdown):"). - ExternalEditor(config.GetPreferences().Editor). - EditorExtension("md"). - Value(&body), - ), - ).WithTheme(theme.GetTheme()). - Run(); err != nil { - return err - } - } - - if len(body) == 0 { - return errors.New("no comment content provided") - } - - client := ctx.Login.Client() - comment, _, err := client.Issues.CreateIssueComment(requestCtx, ctx.Owner, ctx.Repo, idx, gitea.CreateIssueCommentOption{ - Body: body, - }) - if err != nil { - return err - } - - print.Comment(comment) - - return nil +// CmdComments is the top-level command for managing comments on issues and pull requests. +var CmdComments = cli.Command{ + Name: "comments", + Aliases: []string{"comment", "c"}, + Category: catEntities, + Usage: "Manage comments on issues and pull requests", + Description: `Manage comments on issues and pull requests. + +When invoked with an issue/PR index and an optional body, behaves like 'tea comments add' +(this preserves the historical 'tea comment ""' shorthand).`, + ArgsUsage: " []", + Action: comments.RunCommentsAdd, + Flags: comments.CmdCommentsAdd.Flags, + Commands: []*cli.Command{ + &comments.CmdCommentsAdd, + &comments.CmdCommentsList, + &comments.CmdCommentsEdit, + &comments.CmdCommentsDelete, + }, } diff --git a/cmd/comments/add.go b/cmd/comments/add.go new file mode 100644 index 00000000..aff2d04a --- /dev/null +++ b/cmd/comments/add.go @@ -0,0 +1,97 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package comments + +import ( + stdctx "context" + "errors" + "fmt" + "io" + "strings" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/config" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/interact" + "gitea.dev/tea/modules/print" + "gitea.dev/tea/modules/theme" + "gitea.dev/tea/modules/utils" + + "charm.land/huh/v2" + "github.com/urfave/cli/v3" +) + +// CmdCommentsAdd adds a comment to an issue or pull request. +var CmdCommentsAdd = cli.Command{ + Name: "add", + Aliases: []string{"a"}, + Usage: "Add a comment to an issue or pull request", + Description: "Add a comment to an issue or pull request.", + ArgsUsage: " []", + Action: RunCommentsAdd, + Flags: flags.AllDefaultFlags, +} + +// RunCommentsAdd creates a new comment. +func RunCommentsAdd(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 + } + + if ctx.Args().Len() == 0 { + return fmt.Errorf("please specify issue / pr index") + } + + idx, err := utils.ArgToIndex(ctx.Args().First()) + if err != nil { + return err + } + + body := strings.Join(ctx.Args().Tail(), " ") + // Only consume stdin if no positional body was given. interact.IsStdinPiped() + // is true for any non-TTY stdin (CI, subshells, agent harnesses) — not just + // piped data — so reading unconditionally would block forever in those + // contexts when the body is supplied via args. + if len(body) == 0 && interact.IsStdinPiped() { + if bodyStdin, err := io.ReadAll(ctx.Reader); err != nil { + return err + } else if len(bodyStdin) != 0 { + body = string(bodyStdin) + } + } else if len(body) == 0 { + if err := huh.NewForm( + huh.NewGroup( + huh.NewText(). + Title("Comment(markdown):"). + ExternalEditor(config.GetPreferences().Editor). + EditorExtension("md"). + Value(&body), + ), + ).WithTheme(theme.GetTheme()). + Run(); err != nil { + return err + } + } + + if len(body) == 0 { + return errors.New("no comment content provided") + } + + client := ctx.Login.Client() + comment, _, err := client.Issues.CreateIssueComment(requestCtx, ctx.Owner, ctx.Repo, idx, gitea.CreateIssueCommentOption{ + Body: body, + }) + if err != nil { + return err + } + + print.Comment(comment) + return nil +} diff --git a/cmd/comments/delete.go b/cmd/comments/delete.go new file mode 100644 index 00000000..b4d94a80 --- /dev/null +++ b/cmd/comments/delete.go @@ -0,0 +1,55 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package comments + +import ( + stdctx "context" + "fmt" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/utils" + + "github.com/urfave/cli/v3" +) + +// CmdCommentsDelete deletes one or more comments by ID. +var CmdCommentsDelete = cli.Command{ + Name: "delete", + Aliases: []string{"rm"}, + Usage: "Delete one or more comments by ID", + Description: "Delete one or more comments by their comment ID. Use 'tea comments list ' to find IDs.", + ArgsUsage: " [...]", + Action: RunCommentsDelete, + Flags: flags.AllDefaultFlags, +} + +// RunCommentsDelete removes one or more comments. +func RunCommentsDelete(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 + } + + if ctx.Args().Len() == 0 { + return fmt.Errorf("please specify at least one comment id") + } + + ids, err := utils.ArgsToIndices(ctx.Args().Slice()) + if err != nil { + return fmt.Errorf("invalid comment id: %s", err) + } + + client := ctx.Login.Client() + for _, id := range ids { + if _, err := client.Issues.DeleteIssueComment(requestCtx, ctx.Owner, ctx.Repo, id); err != nil { + return fmt.Errorf("could not delete comment %d: %s", id, err) + } + fmt.Printf("Deleted comment %d\n", id) + } + return nil +} diff --git a/cmd/comments/edit.go b/cmd/comments/edit.go new file mode 100644 index 00000000..bf5a8763 --- /dev/null +++ b/cmd/comments/edit.go @@ -0,0 +1,102 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package comments + +import ( + stdctx "context" + "errors" + "fmt" + "io" + "strings" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/config" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/interact" + "gitea.dev/tea/modules/print" + "gitea.dev/tea/modules/theme" + "gitea.dev/tea/modules/utils" + + "charm.land/huh/v2" + "github.com/urfave/cli/v3" +) + +// CmdCommentsEdit edits an existing comment. +var CmdCommentsEdit = cli.Command{ + Name: "edit", + Aliases: []string{"e"}, + Usage: "Edit the body of an existing comment", + Description: `Edit the body of an existing comment by its comment ID. Use 'tea comments list ' to find IDs. + +The new body can be supplied as a positional argument, piped on stdin, or (if neither is given and stdin is a terminal) entered in your $EDITOR.`, + ArgsUsage: " []", + Action: RunCommentsEdit, + Flags: flags.AllDefaultFlags, +} + +// RunCommentsEdit updates the body of an existing comment. +func RunCommentsEdit(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 + } + + if ctx.Args().Len() == 0 { + return fmt.Errorf("please specify comment id") + } + + id, err := utils.ArgToIndex(ctx.Args().First()) + if err != nil { + return fmt.Errorf("invalid comment id %q: %s", ctx.Args().First(), err) + } + + body := strings.Join(ctx.Args().Tail(), " ") + if len(body) == 0 && interact.IsStdinPiped() { + if bodyStdin, err := io.ReadAll(ctx.Reader); err != nil { + return err + } else if len(bodyStdin) != 0 { + body = string(bodyStdin) + } + } else if len(body) == 0 { + // Fetch current body to pre-populate the editor. + client := ctx.Login.Client() + current, _, fetchErr := client.Issues.GetIssueComment(requestCtx, ctx.Owner, ctx.Repo, id) + if fetchErr != nil { + return fmt.Errorf("could not fetch comment %d: %s", id, fetchErr) + } + body = current.Body + if err := huh.NewForm( + huh.NewGroup( + huh.NewText(). + Title("Comment(markdown):"). + ExternalEditor(config.GetPreferences().Editor). + EditorExtension("md"). + Value(&body), + ), + ).WithTheme(theme.GetTheme()). + Run(); err != nil { + return err + } + } + + if len(body) == 0 { + return errors.New("no comment content provided") + } + + client := ctx.Login.Client() + comment, _, err := client.Issues.EditIssueComment(requestCtx, ctx.Owner, ctx.Repo, id, gitea.EditIssueCommentOption{ + Body: body, + }) + if err != nil { + return err + } + + print.Comment(comment) + return nil +} diff --git a/cmd/comments/list.go b/cmd/comments/list.go new file mode 100644 index 00000000..4fe9d97d --- /dev/null +++ b/cmd/comments/list.go @@ -0,0 +1,62 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package comments + +import ( + stdctx "context" + "fmt" + + gitea "gitea.dev/sdk" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/context" + "gitea.dev/tea/modules/print" + "gitea.dev/tea/modules/utils" + + "github.com/urfave/cli/v3" +) + +// CmdCommentsList lists comments on an issue or pull request. +var CmdCommentsList = cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List comments on an issue or pull request", + Description: "List comments on an issue or pull request. Comment IDs returned here are the IDs accepted by 'tea comments edit' and 'tea comments delete'.", + ArgsUsage: "", + Action: RunCommentsList, + Flags: append([]cli.Flag{ + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, + }, flags.AllDefaultFlags...), +} + +// RunCommentsList lists comments on the given issue/PR. +func RunCommentsList(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 + } + + if ctx.Args().Len() == 0 { + return fmt.Errorf("please specify issue / pr index") + } + + idx, err := utils.ArgToIndex(ctx.Args().First()) + if err != nil { + return err + } + + client := ctx.Login.Client() + comments, _, err := client.Issues.ListIssueComments(requestCtx, ctx.Owner, ctx.Repo, idx, gitea.ListIssueCommentOptions{ + ListOptions: flags.GetListOptions(cmd), + }) + if err != nil { + return err + } + + return print.CommentsList(comments, ctx.Output) +} diff --git a/docs/CLI.md b/docs/CLI.md index ddcb6d02..2b1a2020 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1903,9 +1903,61 @@ Update a webhook **--url**="": webhook URL -## comment, c +## comments, comment, c -Add a comment to an issue / pr +Manage comments on issues and pull requests + +**--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 + +### add, a + +Add a comment to an issue or 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 + +### list, ls + +List comments on an issue or pull request + +**--limit, --lm**="": specify limit of items per page (default: 30) + +**--login, -l**="": Use a different Gitea Login. Optional + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + +**--page, -p**="": specify page (default: 1) + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + +### edit, e + +Edit the body of an existing comment + +**--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 + +### delete, rm + +Delete one or more comments by ID **--login, -l**="": Use a different Gitea Login. Optional diff --git a/modules/print/comment.go b/modules/print/comment.go index 0f42e295..3d2cda32 100644 --- a/modules/print/comment.go +++ b/modules/print/comment.go @@ -29,6 +29,56 @@ func Comments(comments []*gitea.Comment) { ), baseURL) } +// CommentsList prints comments in tabular form, including IDs so they can be +// passed to 'tea comments edit' / 'tea comments delete'. +func CommentsList(comments []*gitea.Comment, output string) error { + if len(comments) == 0 { + fmt.Println("No comments found") + return nil + } + + t := tableWithHeader( + "ID", + "Author", + "Created", + "Updated", + "Body", + ) + + for _, c := range comments { + updated := "" + if c.Updated.After(c.Created) { + updated = FormatTime(c.Updated, false) + } + t.addRow( + fmt.Sprintf("%d", c.ID), + "@"+c.Poster.UserName, + FormatTime(c.Created, false), + updated, + summarizeBody(c.Body), + ) + } + + return t.print(output) +} + +func summarizeBody(body string) string { + const max = 80 + var b strings.Builder + for _, r := range body { + if r == '\n' || r == '\r' { + b.WriteByte(' ') + } else { + b.WriteRune(r) + } + } + s := b.String() + if len(s) > max { + return s[:max-1] + "…" + } + return s +} + // Comment renders a comment to stdout func Comment(c *gitea.Comment) { _ = outputMarkdown(formatComment(c), getRepoURL(c.HTMLURL)) diff --git a/tests/integration/comments_test.go b/tests/integration/comments_test.go new file mode 100644 index 00000000..65c99537 --- /dev/null +++ b/tests/integration/comments_test.go @@ -0,0 +1,117 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "fmt" + "strconv" + "strings" + "testing" + "time" + + "gitea.dev/sdk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCommentsCommandLifecycle exercises `tea comments add`, `list`, `edit`, +// and `delete` end-to-end against a live Gitea instance. It creates a throwaway +// repo with one issue, then drives the comment subcommands and verifies state +// via the SDK after each step. +func TestCommentsCommandLifecycle(t *testing.T) { + login := createIntegrationLogin(t) + client := login.Client() + ctx := context.Background() + + repoName := fmt.Sprintf("tea-comments-integration-%d", time.Now().UnixNano()) + _, _, err := client.CreateRepo(ctx, gitea.CreateRepoOption{Name: repoName, AutoInit: true}) + require.NoError(t, err) + t.Cleanup(func() { + if _, delErr := client.DeleteRepo(ctx, login.User, repoName); delErr != nil { + t.Logf("failed to delete integration test repo %q: %v", repoName, delErr) + } + }) + + repoSlug := fmt.Sprintf("%s/%s", login.User, repoName) + + issue, _, err := client.Issues.CreateIssue(ctx, login.User, repoName, gitea.CreateIssueOption{ + Title: "comment test issue", + Body: "scratch issue for tea comment integration tests", + }) + require.NoError(t, err) + issueIdx := strconv.FormatInt(issue.Index, 10) + + // add via positional body + addOutput := runTeaCommand(t, + "comments", "add", + "--login", login.Name, + "--repo", repoSlug, + issueIdx, "first comment", + ) + assert.Contains(t, addOutput, "first comment") + + // add via the historical shorthand (parent command, no "add" subcommand) + runTeaCommand(t, + "comments", + "--login", login.Name, + "--repo", repoSlug, + issueIdx, "second comment", + ) + + // list should now show both, in tabular form with IDs + listOutput := runTeaCommand(t, + "comments", "list", + "--login", login.Name, + "--repo", repoSlug, + issueIdx, + ) + assert.Contains(t, listOutput, "first comment") + assert.Contains(t, listOutput, "second comment") + + // pull the real IDs from the server so edit/delete have something to act on + apiComments, _, err := client.Issues.ListIssueComments(ctx, login.User, repoName, issue.Index, gitea.ListIssueCommentOptions{}) + require.NoError(t, err) + require.Len(t, apiComments, 2) + + var firstID, secondID int64 + for _, c := range apiComments { + switch { + case strings.Contains(c.Body, "first comment"): + firstID = c.ID + case strings.Contains(c.Body, "second comment"): + secondID = c.ID + } + } + require.NotZero(t, firstID, "first comment id not found in listing") + require.NotZero(t, secondID, "second comment id not found in listing") + + // edit the first comment via positional body + editOutput := runTeaCommand(t, + "comments", "edit", + "--login", login.Name, + "--repo", repoSlug, + strconv.FormatInt(firstID, 10), "first comment (edited)", + ) + assert.Contains(t, editOutput, "first comment (edited)") + + edited, _, err := client.Issues.GetIssueComment(ctx, login.User, repoName, firstID) + require.NoError(t, err) + assert.Equal(t, "first comment (edited)", edited.Body) + + // delete both comments in one batch + deleteOutput := runTeaCommand(t, + "comments", "delete", + "--login", login.Name, + "--repo", repoSlug, + strconv.FormatInt(firstID, 10), + strconv.FormatInt(secondID, 10), + ) + assert.Contains(t, deleteOutput, fmt.Sprintf("Deleted comment %d", firstID)) + assert.Contains(t, deleteOutput, fmt.Sprintf("Deleted comment %d", secondID)) + + remaining, _, err := client.Issues.ListIssueComments(ctx, login.User, repoName, issue.Index, gitea.ListIssueCommentOptions{}) + require.NoError(t, err) + assert.Empty(t, remaining, "expected all comments to be deleted") +}