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