From 10417049a1aaca0ceef1ee3f9051926ac7806bfe Mon Sep 17 00:00:00 2001 From: bircni Date: Sun, 11 Oct 2026 23:44:30 +0200 Subject: [PATCH] feat(comment): comment on issues and pull requests the gh way MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tea issue comment 12 --body "LGTM" failed with "unknown flag: --body"; the comment had to be created with issue comment create. issue comment and pr comment now take the issue or pull request and -b/-F/-e directly, plus --edit-last and --delete-last for your last comment. The subcommands stay, create is also add, and a misspelled subcommand suggests the right one. Comments are written in the configured editor or a multi-line "Comment (markdown):" field, which edits start from the current text, without a confirmation afterwards. The new comment's URL goes to stdout with a ✓ line; deletes ask "Delete comment 81 on alice/x#1?". comment list shows each comment under a "#79 alice • about 2 hours ago" heading on a terminal and as key/value blocks when piped, and comment JSON gains issue_number. Signed-off-by: bircni --- internal/app/app.go | 5 + internal/app/app_test.go | 12 - internal/app/comments.go | 529 +++++++++++++++++++++++------- internal/app/comments_test.go | 91 ++++- internal/app/editor_test.go | 4 + internal/app/human_output_test.go | 2 +- internal/services/comments.go | 14 + 7 files changed, 514 insertions(+), 143 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 4c81a9b2..bc115157 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -544,6 +544,9 @@ func commandOutputFields(cmd *cobra.Command) []string { case "comment": return jsonFields[services.Comment]() case "issue": + if cmd.Name() == "comment" { + return jsonFields[services.Comment]() + } return jsonFields[services.Issue]() case "review": if cmd.Name() == "request" || cmd.Name() == "cancel-request" { @@ -563,6 +566,8 @@ func commandOutputFields(cmd *cobra.Command) []string { return jsonFields[services.Review]() case "clean": return jsonFields[git.CleanResult]() + case "comment": + return jsonFields[services.Comment]() } return jsonFields[services.Pull]() case "repo": diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 39162ef8..87cc0a3b 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -424,11 +424,6 @@ const ( issue attachment list issue attachment upload issue attachment view - issue comment create - issue comment delete - issue comment edit - issue comment list - issue comment view label create label delete label edit @@ -450,11 +445,6 @@ const ( pr attachment list pr attachment upload pr attachment view - pr comment create - pr comment delete - pr comment edit - pr comment list - pr comment view search issue search pr time create @@ -464,12 +454,10 @@ const ( ` longsToWrite = ` issue attachment - issue comment label milestone milestone issue pr attachment - pr comment time ` ) diff --git a/internal/app/comments.go b/internal/app/comments.go index 44df1b4b..982fc9f7 100644 --- a/internal/app/comments.go +++ b/internal/app/comments.go @@ -8,6 +8,7 @@ import ( "fmt" "strconv" "strings" + "time" "gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/services" @@ -15,21 +16,43 @@ import ( "github.com/spf13/cobra" ) -func commentRecord(item services.Comment) terminal.Record { - return terminal.Record{Heading: terminal.Row(strconv.FormatInt(item.ID, 10), item.Author), Lines: []string{item.Body, item.URL}} +// commentTitle is the prompt for a comment body. +const commentTitle = "Comment (markdown):" + +// commentNouns are the words for the commands of an issue or pull request comment group. +type commentNouns struct { + group, noun, arguments, example string } +func commentWords(kind refKind) commentNouns { + if kind == pullRef { + return commentNouns{group: "pr", noun: "pull request", arguments: pullArguments, example: "13"} + } + return commentNouns{group: "issue", noun: "issue", arguments: issueArguments, example: "12"} +} + +// pullArguments is the ARGUMENTS help of pr comment and pr attachment commands, which take pull +// request numbers like issue commands take issue numbers. +const pullArguments = `A pull request can be supplied as argument in any of the following formats: +- by number, e.g. "13" or "#13" +- by URL, e.g. "https://gitea.com/OWNER/REPO/pulls/13" +- by repository and number, e.g. "OWNER/REPO#13"` + // requirePull fails unless issue n is a pull request, for pr commands that Gitea serves through // issue endpoints and would otherwise apply to the issue with that number. func requirePull(cmd *cobra.Command, t target, n int64) error { + _, err := discussion(cmd, t, n, pullRef) + return err +} + +// discussion returns the issue or pull request n that a command of kind addresses: pr commands +// refuse issues, which Gitea would otherwise serve through the same endpoints. +func discussion(cmd *cobra.Command, t target, n int64, kind refKind) (services.Issue, error) { item, err := (services.Issues{Client: t.client, Repository: t.Repo}).View(cmd.Context(), n) - if err != nil { - return err + if err == nil && kind == pullRef && item.Kind != services.KindPull { + err = fmt.Errorf("#%d in %s is an issue, not a pull request; use %s %d", n, t.Repo, issueCommandPath(cmd), n) } - if item.Kind != services.KindPull { - return fmt.Errorf("#%d in %s is an issue, not a pull request; use %s %d", n, t.Repo, issueCommandPath(cmd), n) - } - return nil + return item, err } // issueCommandPath names the issue command that matches a pr command, such as "tea issue comment". @@ -37,141 +60,397 @@ func issueCommandPath(cmd *cobra.Command) string { return strings.Replace(cmd.CommandPath(), " pr ", " issue ", 1) } +// commentName names a comment in messages: "comment 81 on alice/x#1". +func commentName(t target, comment services.Comment) string { + return fmt.Sprintf("comment %d on %s#%d", comment.ID, t.Repo, comment.IssueNumber) +} + +// commentFlags are the body flags of commands that write a comment. +type commentFlags struct { + body, file string + useEditor bool +} + +func (f *commentFlags) register(cmd *cobra.Command) { + cmd.Flags().StringVarP(&f.body, "body", "b", "", "Comment `text`") + cmd.Flags().StringVarP(&f.file, "body-file", "F", "", "Read the comment from `file`, or - for stdin") + cmd.Flags().BoolVarP(&f.useEditor, "editor", "e", false, "Write the comment in the configured editor") +} + +// validate rejects conflicting or empty comment sources before any request. +func (f commentFlags) validate(cmd *cobra.Command) error { + if err := validateBodySources(cmd); err != nil { + return err + } + if cmd.Flags().Changed("body") && strings.TrimSpace(f.body) == "" { + return clierr.Wrap(clierr.Usage, errors.New("comment body cannot be empty")) + } + return nil +} + +// readComment returns the comment text: from --body or --body-file, else from the editor with --editor or +// when one is configured, else from a multi-line prompt. initial seeds the editor and the prompt. +func (a *application) readComment(cmd *cobra.Command, f commentFlags, initial string) (string, terminal.Draft, error) { + var draft terminal.Draft + var body string + var err error + switch { + case cmd.Flags().Changed("body") || cmd.Flags().Changed("body-file"): + body, err = a.readBody(cmd, f.body, f.file) + case f.useEditor: + draft, err = a.editDraft(cmd, initial) + body = draft.Body + default: + body, draft, err = a.promptComment(cmd, initial) + } + if err == nil && strings.TrimSpace(body) == "" { + err = clierr.Wrap(clierr.Usage, errors.New("comment body cannot be empty")) + } + return body, draft, err +} + +// promptComment asks for a comment in the configured editor, or in a multi-line field without one. +func (a *application) promptComment(cmd *cobra.Command, initial string) (string, terminal.Draft, error) { + snapshot, err := a.deps.Settings.Load() + if err != nil { + return "", terminal.Draft{}, err + } + if !a.policy(snapshot).CanPrompt(a.deps.Streams) { + return "", terminal.Draft{}, clierr.Wrap(clierr.Usage, errors.New("missing required input: supply --body, --body-file or --editor")) + } + if preferences, err := a.preferences(); err == nil && preferences.Editor != "" { + draft, err := a.editDraft(cmd, initial) + return draft.Body, draft, err + } + if asker, ok := a.deps.Prompter.(terminal.Asker); ok { + body, err := asker.AskText(cmd.Context(), commentTitle, initial) + return body, terminal.Draft{}, err + } + body, err := a.deps.Prompter.Input(cmd.Context(), commentTitle) + return body, terminal.Draft{}, err +} + +// commentOn creates a comment on n, reporting it like gh: its URL on stdout, or JSON. +func (a *application) commentOn(cmd *cobra.Command, t target, n int64, kind refKind, f commentFlags) (runErr error) { + item, err := discussion(cmd, t, n, kind) + if err != nil { + return err + } + body, draft, err := a.readComment(cmd, f, "") + defer finishDraft(&draft, &runErr) + if err != nil { + return err + } + comment, err := (services.Comments{Client: t.client, Repository: t.Repo}).Create(cmd.Context(), n, body) + if err != nil { + return err + } + if a.options.json != "" { + return a.writeJSON(cmd, comment) + } + a.done(cmd, "Commented on %s", refLabel(t.Repo, n, item.Title)) + return a.printURL(cmd, comment.URL) +} + +// lastComment returns the newest comment on n by the account tea acts as. +func lastComment(cmd *cobra.Command, t target, n int64) (services.Comment, error) { + items, err := (services.Comments{Client: t.client, Repository: t.Repo}).List(cmd.Context(), n) + if err != nil { + return services.Comment{}, err + } + for i := len(items) - 1; i >= 0; i-- { + if strings.EqualFold(items[i].Author, t.login()) { + return items[i], nil + } + } + return services.Comment{}, fmt.Errorf("%s has no comments on %s#%d", t.login(), t.Repo, n) +} + +// comment returns comment id, which a command of kind may change: pr commands refuse comments on +// issues. +func (a *application) comment(cmd *cobra.Command, t target, id int64, kind refKind) (services.Comment, error) { + current, err := (services.Comments{Client: t.client, Repository: t.Repo}).View(cmd.Context(), id) + if err == nil && kind == pullRef && !current.Pull { + err = fmt.Errorf("comment %d in %s is on an issue, not a pull request; use %s %d", id, t.Repo, issueCommandPath(cmd), id) + } + return current, err +} + +// editComment replaces the text of comment, prefilling the prompt or editor with it. +func (a *application) editComment(cmd *cobra.Command, t target, comment services.Comment, f commentFlags) (runErr error) { + body, draft, err := a.readComment(cmd, f, comment.Body) + defer finishDraft(&draft, &runErr) + if err != nil { + return err + } + edited, err := (services.Comments{Client: t.client, Repository: t.Repo}).Edit(cmd.Context(), comment.ID, body) + if err != nil { + return err + } + edited.IssueNumber = comment.IssueNumber + if a.options.json != "" { + return a.writeJSON(cmd, edited) + } + a.done(cmd, "Edited %s", commentName(t, edited)) + return nil +} + +// deleteComment deletes comment after a confirmation that defaults to no. +func (a *application) deleteComment(cmd *cobra.Command, t target, comment services.Comment) error { + name := commentName(t, comment) + if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: "Delete " + name + "?", NonTTY: "delete " + name}); err != nil { + return err + } + if err := (services.Comments{Client: t.client, Repository: t.Repo}).Delete(cmd.Context(), comment.ID); err != nil { + return err + } + a.done(cmd, "Deleted %s", name) + return nil +} + +// commentView shows one comment; now dates it on a terminal. +func commentView(t target, now time.Time) func(services.Comment) terminal.View { + return func(comment services.Comment) terminal.View { + return terminal.View{ + Title: fmt.Sprintf("Comment on %s#%d", t.Repo, comment.IssueNumber), ID: "#" + strconv.FormatInt(comment.ID, 10), + Meta: comment.Author + " commented " + terminal.RelativeTime(now, terminal.ParseTime(comment.CreatedAt)), + Fields: []terminal.Field{ + {Label: "ID", Value: strconv.FormatInt(comment.ID, 10), PipedOnly: true}, + {Label: "Author", Value: comment.Author, PipedOnly: true}, + {Label: "Created_at", Value: comment.CreatedAt, PipedOnly: true}, + }, + Body: comment.Body, Kind: "comment", URL: comment.URL, + } + } +} + +// writeComments shows comments as gh shows a conversation: on a terminal a heading line per comment, +// "#79 alice • about 2 hours ago (edited)", above its indented body; otherwise key/value blocks +// each followed by its body and "--". +func (a *application) writeComments(cmd *cobra.Command, t target, n int64, items []services.Comment) error { + if a.options.json != "" { + return a.writeJSON(cmd, items) + } + d := a.display() + if len(items) == 0 { + if d.TTY { + _, err := fmt.Fprintf(cmd.ErrOrStderr(), "no comments on %s#%d\n", t.Repo, n) + return err + } + return nil + } + var text strings.Builder + for i, comment := range items { + if !d.TTY { + text.WriteString(fmt.Sprintf("id:\t%d\nauthor:\t%s\ncreated_at:\t%s\nurl:\t%s\n--\n", comment.ID, terminal.SafeLine(comment.Author), comment.CreatedAt, terminal.SafeLine(comment.URL))) + text.WriteString(strings.TrimRight(terminal.SafeText(comment.Body), "\n") + "\n--\n") + continue + } + if i > 0 { + text.WriteString("\n") + } + heading := terminal.Cell{Text: "#" + strconv.FormatInt(comment.ID, 10), Link: comment.URL, Bold: true} + meta := terminal.SafeLine(comment.Author) + if created := terminal.ParseTime(comment.CreatedAt); !created.IsZero() { + meta += " • " + terminal.RelativeTime(d.Now, created) + } + if comment.UpdatedAt != "" && comment.UpdatedAt != comment.CreatedAt { + meta += " (edited)" + } + text.WriteString(terminal.Table(nil, [][]terminal.Cell{{heading, {Text: meta, Muted: true}}}, d)) + body := strings.TrimSpace(comment.Body) + if d.Markdown { + body = strings.TrimSpace(terminal.Markdown(body, d)) + } else { + body = terminal.SafeText(body) + } + for line := range strings.Lines(body) { + text.WriteString(" " + line) + } + text.WriteString("\n") + } + return a.writeHuman(cmd, text.String()) +} + func (a *application) commentCommand(kind refKind) *cobra.Command { - group := &cobra.Command{Use: "comment", Short: "Manage discussion comments"} - var limit, page int - list := &cobra.Command{Use: "list ", Short: "List comments on an issue or pull request", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { - n, err := number(args[0]) + words := commentWords(kind) + var flags commentFlags + var editLast, deleteLast bool + group := &cobra.Command{Use: "comment { | }", Short: "Add a comment to " + article(words.noun), Long: fmt.Sprintf(`Add a comment to %[1]s, or edit or delete your last comment on it. + +Without --body, --body-file or --editor, the configured editor opens, or a multi-line prompt +asks for the comment. The subcommands list, view, edit and delete comments by their ID.`, article(words.noun)), Example: fmt.Sprintf(`$ tea %[1]s comment %[2]s --body "Fixed in #14" +$ tea %[1]s comment %[2]s --body-file notes.md +# Change or remove your last comment +$ tea %[1]s comment %[2]s --edit-last --body "Fixed in #15" +$ tea %[1]s comment %[2]s --delete-last --yes +$ tea %[1]s comment list %[2]s`, words.group, words.example), Annotations: map[string]string{"tea.arguments": words.arguments, "tea.confirm": "true"}, Args: commentArgs(kind), RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + if localFlagsChanged(cmd) { + return clierr.Wrap(clierr.Usage, &argumentError{problem: "missing " + words.noun + " number", cmd: cmd}) + } + return cmd.Help() + } + if err := flags.validate(cmd); err != nil { + return err + } + if editLast && deleteLast { + return clierr.Wrap(clierr.Usage, errors.New("--edit-last and --delete-last cannot be combined")) + } + if deleteLast && (cmd.Flags().Changed("body") || cmd.Flags().Changed("body-file") || flags.useEditor) { + return clierr.Wrap(clierr.Usage, errors.New("--delete-last takes no comment text")) + } + t, n, err := a.resolveRef(cmd, args[0], kind) if err != nil { return err } - target, err := a.resolve(cmd.Context(), true) + if !editLast && !deleteLast { + return a.commentOn(cmd, t, n, kind, flags) + } + if _, err := discussion(cmd, t, n, kind); err != nil { + return err + } + last, err := lastComment(cmd, t, n) + if err != nil { + return err + } + if deleteLast { + return a.deleteComment(cmd, t, last) + } + return a.editComment(cmd, t, last, flags) + }} + group.SuggestionsMinimumDistance = 2 + flags.register(group) + group.Flags().BoolVar(&editLast, "edit-last", false, "Edit your last comment instead of adding one") + group.Flags().BoolVar(&deleteLast, "delete-last", false, "Delete your last comment") + group.ValidArgsFunction = a.completeDiscussions(kind) + + var limit, page int + list := &cobra.Command{Use: "list { | }", Short: "List the comments on " + article(words.noun), Example: fmt.Sprintf(`$ tea %[1]s comment list %[2]s +$ tea %[1]s comment list %[2]s --json id,author,body`, words.group, words.example), Annotations: map[string]string{"tea.arguments": words.arguments}, Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { + t, n, err := a.resolveRef(cmd, args[0], kind) if err != nil { return err } if kind == pullRef { - if err := requirePull(cmd, target, n); err != nil { + if err := requirePull(cmd, t, n); err != nil { return err } } - items, err := (services.Comments{Client: target.client, Repository: target.Repo}).Page(cmd.Context(), n, limit, page) + items, err := (services.Comments{Client: t.client, Repository: t.Repo}).Page(cmd.Context(), n, limit, page) if err != nil { return err } - return writeRecords(a, cmd, items, commentRecord) + return a.writeComments(cmd, t, n, items) }} pageFlags(list, &limit, &page) - group.AddCommand(list) - for _, verb := range []string{"view", "create", "edit", "delete"} { - var body, file string - var useEditor bool - noun := "id" - if verb == "create" { - noun = "number" + list.ValidArgsFunction = a.completeDiscussions(kind) + + view := &cobra.Command{Use: "view ", Short: "View a comment", Example: fmt.Sprintf("$ tea %s comment view 81", words.group), Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { + id, err := number(args[0]) + if err != nil { + return err } - command := &cobra.Command{Use: verb + " <" + noun + ">", Short: titleCase(verb) + " a discussion comment", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) (runErr error) { - var draft terminal.Draft - defer finishDraft(&draft, &runErr) - if err := validateBodySources(cmd); err != nil { - return err - } - id, err := number(args[0]) - if err != nil { - return err - } - prompted := false - if (verb == "create" || verb == "edit") && !useEditor { - body, err = a.readBody(cmd, body, file) - if err != nil { - return err - } - if !cmd.Flags().Changed("body") && !cmd.Flags().Changed("body-file") { - prompted, err = a.require(cmd, &body, "body") - if err != nil { - return err - } - } - if strings.TrimSpace(body) == "" { - return clierr.Wrap(clierr.Usage, errors.New("comment body cannot be empty")) - } - } - target, err := a.resolve(cmd.Context(), true) - if err != nil { - return err - } - service := services.Comments{Client: target.client, Repository: target.Repo} - if kind == pullRef && verb == "create" { - if err := requirePull(cmd, target, id); err != nil { - return err - } - } - if kind == pullRef && verb != "create" { - current, err := service.View(cmd.Context(), id) - if err != nil { - return err - } - if !current.Pull { - return fmt.Errorf("comment %d in %s is on an issue, not a pull request; use %s %d", id, target.Repo, issueCommandPath(cmd), id) - } - } - if verb == "create" || verb == "edit" { - if useEditor { - initial := "" - if verb == "edit" { - current, viewErr := service.View(cmd.Context(), id) - if viewErr != nil { - return viewErr - } - initial = current.Body - } - draft, err = a.editDraft(cmd, initial) - if err != nil { - return err - } - body = draft.Body - } - if strings.TrimSpace(body) == "" { - return clierr.Wrap(clierr.Usage, errors.New("comment body cannot be empty")) - } - } - if verb == "delete" || prompted { - description := fmt.Sprintf("%s comment %d on %s", titleCase(verb), id, target.Repo) - if verb == "create" { - description = fmt.Sprintf("Comment on %s#%d", target.Repo, id) - } - if err := a.confirm(cmd, description); err != nil { - return err - } - } - var item services.Comment - switch verb { - case "view": - item, err = service.View(cmd.Context(), id) - case "create": - item, err = service.Create(cmd.Context(), id, body) - case "edit": - item, err = service.Edit(cmd.Context(), id, body) - case "delete": - return service.Delete(cmd.Context(), id) - } - if err != nil { - return err - } - return writeRecord(a, cmd, item, commentRecord) - }} - if verb == "create" || verb == "edit" { - command.Flags().StringVar(&body, "body", "", "Comment body") - command.Flags().StringVar(&file, "body-file", "", "Read body from file, or - for stdin") - editHelp := "Compose the comment in the configured editor" - if verb == "edit" { - editHelp = "Edit the current comment body in the configured editor" - } - command.Flags().BoolVar(&useEditor, "editor", false, editHelp) + t, err := a.resolve(cmd.Context(), true) + if err != nil { + return err } - if verb == "delete" { - command.Annotations = textOutput("comment deletion does not return a resource") + comment, err := a.comment(cmd, t, id, kind) + if err != nil { + return err } - group.AddCommand(command) - } + return writeView(a, cmd, comment, commentView(t, a.deps.Now())) + }} + + var createFlags commentFlags + create := &cobra.Command{Use: "create { | }", Aliases: []string{"add"}, Short: "Add a comment to " + article(words.noun), Example: fmt.Sprintf(`$ tea %[1]s comment create %[2]s --body "Looks good" +$ tea %[1]s comment add %[2]s --editor`, words.group, words.example), Annotations: map[string]string{"tea.arguments": words.arguments}, Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { + if err := createFlags.validate(cmd); err != nil { + return err + } + t, n, err := a.resolveRef(cmd, args[0], kind) + if err != nil { + return err + } + return a.commentOn(cmd, t, n, kind, createFlags) + }} + createFlags.register(create) + create.ValidArgsFunction = a.completeDiscussions(kind) + + var editFlags commentFlags + edit := &cobra.Command{Use: "edit ", Short: "Edit a comment", Long: "Replace the text of a comment. Without --body or --body-file, the editor or the prompt starts\nfrom the current text.", Example: fmt.Sprintf(`$ tea %[1]s comment edit 81 --body "Fixed in #15" +$ tea %[1]s comment edit 81`, words.group), Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { + if err := editFlags.validate(cmd); err != nil { + return err + } + id, err := number(args[0]) + if err != nil { + return err + } + t, err := a.resolve(cmd.Context(), true) + if err != nil { + return err + } + comment, err := a.comment(cmd, t, id, kind) + if err != nil { + return err + } + return a.editComment(cmd, t, comment, editFlags) + }} + editFlags.register(edit) + + remove := &cobra.Command{Use: "delete ", Short: "Delete a comment", Example: fmt.Sprintf("$ tea %s comment delete 81 --yes", words.group), Annotations: map[string]string{"tea.text": "comment deletion does not return a resource", "tea.confirm": "true"}, Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { + id, err := number(args[0]) + if err != nil { + return err + } + t, err := a.resolve(cmd.Context(), true) + if err != nil { + return err + } + comment, err := a.comment(cmd, t, id, kind) + if err != nil { + return err + } + return a.deleteComment(cmd, t, comment) + }} + group.AddCommand(list, view, create, edit, remove) return group } + +// commentArgs accepts at most an issue or pull request, and reports a misspelled subcommand, such +// as "craete", as one. +func commentArgs(kind refKind) cobra.PositionalArgs { + return func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + if _, err := parseRef(args[0], kind); errors.Is(err, errNotRef) { + if suggestions := cmd.SuggestionsFor(args[0]); len(suggestions) > 0 { + return clierr.Wrap(clierr.Usage, fmt.Errorf("unknown command %q for %q; did you mean %s?", args[0], cmd.CommandPath(), strings.Join(suggestions, " or "))) + } + } + } + return usageArgs(cobra.MaximumNArgs(1))(cmd, args) + } +} + +// article returns noun with its indefinite article: "an issue", "a pull request". +func article(noun string) string { + if strings.ContainsRune("aeiou", rune(noun[0])) { + return "an " + noun + } + return "a " + noun +} + +// completeDiscussions completes the open issues or pull requests that a command of kind takes. +func (a *application) completeDiscussions(kind refKind) cobra.CompletionFunc { + if kind == pullRef { + complete := a.completePulls("open") + return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return complete(cmd, args, toComplete) + } + } + return a.completeIssueArgs("open", false) +} diff --git a/internal/app/comments_test.go b/internal/app/comments_test.go index 72844480..947b030b 100644 --- a/internal/app/comments_test.go +++ b/internal/app/comments_test.go @@ -7,15 +7,22 @@ import ( "context" "net/http" "testing" + "time" "github.com/stretchr/testify/require" ) func TestCommentConfirmationNamesTheTarget(t *testing.T) { + var created []string server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method + " " + r.URL.Path { + case "GET /api/v1/repos/alice/project/issues/3": + _, _ = w.Write([]byte(`{"number":3,"title":"Crash"}`)) case "POST /api/v1/repos/alice/project/issues/3/comments": - _, _ = w.Write([]byte(`{"id":12,"body":"Prompted"}`)) + created = append(created, payloadField(t, r, "body")) + _, _ = w.Write([]byte(`{"id":12,"body":"Prompted","html_url":"https://example.org/alice/project/issues/3#issuecomment-12"}`)) + case "GET /api/v1/repos/alice/project/issues/comments/12": + _, _ = w.Write([]byte(`{"id":12,"body":"Prompted","issue_url":"https://example.org/alice/project/issues/3"}`)) case "DELETE /api/v1/repos/alice/project/issues/comments/12": w.WriteHeader(http.StatusNoContent) default: @@ -24,13 +31,22 @@ func TestCommentConfirmationNamesTheTarget(t *testing.T) { } })) defer server.Close() - deps, _, _ := serverDeps(t, server.URL) + deps, out, diagnostics := serverDeps(t, server.URL) + deps.Getenv = serverEnv(server.URL, map[string]string{"NO_COLOR": "1"}) prompt := &scriptedPrompter{accept: true, input: "Prompted"} deps.Prompter = prompt - deps.Streams.InTTY, deps.Streams.ErrTTY = true, true - require.NoError(t, Execute(context.Background(), deps, []string{"issue", "comment", "create", "3", "-R", "alice/project"})) + deps.Streams.InTTY, deps.Streams.ErrTTY, deps.Streams.OutTTY = true, true, true + require.NoError(t, Execute(context.Background(), deps, []string{"issue", "comment", "3", "-R", "alice/project"})) + require.Equal(t, []string{"Prompted"}, created) + require.Equal(t, []string{commentTitle}, prompt.asked) + require.Equal(t, "https://example.org/alice/project/issues/3#issuecomment-12\n", out.String()) + require.Equal(t, "✓ Commented on alice/project#3 (Crash)\n", diagnostics.String()) + diagnostics.Reset() require.NoError(t, Execute(context.Background(), deps, []string{"issue", "comment", "delete", "12", "-R", "alice/project"})) - require.Equal(t, []string{"Comment on alice/project#3", "Delete comment 12 on alice/project"}, prompt.confirmations) + require.Equal(t, []string{"Delete comment 12 on alice/project#3?"}, prompt.confirmations) + require.Equal(t, "✓ Deleted comment 12 on alice/project#3\n", diagnostics.String()) + err := Execute(context.Background(), deps, []string{"issue", "comment", "delete", "12", "-R", "alice/project", "--no-input"}) + require.EqualError(t, err, "confirmation required to delete comment 12 on alice/project#3; rerun with --yes") } func TestPullCommentsAndAttachmentsRequirePullRequests(t *testing.T) { @@ -85,3 +101,68 @@ func TestPullCommentsAndAttachmentsRequirePullRequests(t *testing.T) { "POST /api/v1/repos/alice/project/issues/1/comments", }, changed) } + +func TestCommentGroupCommentsLikeGh(t *testing.T) { + var changed []string + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method + " " + r.URL.Path { + case "GET /api/v1/repos/alice/project/issues/1": + _, _ = w.Write([]byte(`{"number":1,"title":"Crash"}`)) + case "GET /api/v1/repos/alice/project/issues/13": + _, _ = w.Write([]byte(`{"number":13,"title":"Fix","pull_request":{}}`)) + case "GET /api/v1/repos/alice/project/issues/1/comments": + _, _ = w.Write([]byte(`[{"id":79,"body":"First **one**","user":{"login":"alice"},"created_at":"2026-10-11T18:00:00Z","updated_at":"2026-10-11T18:00:00Z","issue_url":"https://example.org/alice/project/issues/1"},` + + `{"id":80,"body":"Second\nline","user":{"login":"bob"},"created_at":"2026-10-11T19:00:00Z","updated_at":"2026-10-11T19:30:00Z","issue_url":"https://example.org/alice/project/issues/1"},` + + `{"id":81,"body":"Third","user":{"login":"bob"},"created_at":"2026-10-11T19:10:00Z","updated_at":"2026-10-11T19:10:00Z","issue_url":"https://example.org/alice/project/issues/1"}]`)) + default: + changed = append(changed, r.Method+" "+r.URL.Path) + _, _ = w.Write([]byte(`{"id":90,"body":"x","html_url":"https://example.org/alice/project/issues/1#issuecomment-90","issue_url":"https://example.org/alice/project/issues/1"}`)) + } + })) + defer server.Close() + deps, out, diagnostics := serverDeps(t, server.URL) + deps.Now = func() time.Time { return time.Date(2026, 10, 11, 20, 0, 0, 0, time.UTC) } + run := func(args ...string) error { + out.Reset() + diagnostics.Reset() + return Execute(context.Background(), deps, append(args, "-R", "alice/project", "--no-input")) + } + require.NoError(t, run("issue", "comment", "1", "-b", "LGTM")) + require.Equal(t, "https://example.org/alice/project/issues/1#issuecomment-90\n", out.String()) + require.NoError(t, run("issue", "comment", "add", "#1", "--body", "LGTM")) + require.NoError(t, run("pr", "comment", "13", "-b", "Ship it")) + require.NoError(t, run("issue", "comment", "1", "--edit-last", "-b", "Edited")) + require.NoError(t, run("issue", "comment", "1", "--delete-last", "--yes")) + require.NoError(t, run("issue", "comment", "1", "-b", "x", "--json", "issue_number")) + require.JSONEq(t, `{"issue_number":1}`, out.String()) + require.Equal(t, []string{ + "POST /api/v1/repos/alice/project/issues/1/comments", + "POST /api/v1/repos/alice/project/issues/1/comments", + "POST /api/v1/repos/alice/project/issues/13/comments", + "PATCH /api/v1/repos/alice/project/issues/comments/79", + "DELETE /api/v1/repos/alice/project/issues/comments/79", + "POST /api/v1/repos/alice/project/issues/1/comments", + }, changed) + for _, test := range []struct { + args []string + want string + }{ + {[]string{"issue", "comment", "craete", "1"}, `unknown command "craete" for "tea issue comment"; did you mean create?`}, + {[]string{"issue", "comment", "-b", "x"}, "missing issue number"}, + {[]string{"issue", "comment", "1", "--edit-last", "--delete-last"}, "--edit-last and --delete-last cannot be combined"}, + {[]string{"issue", "comment", "1"}, "missing required input: supply --body, --body-file or --editor"}, + {[]string{"pr", "comment", "1", "-b", "x"}, "#1 in alice/project is an issue, not a pull request; use tea issue comment 1"}, + } { + require.ErrorContains(t, run(test.args...), test.want, test.args) + } + require.NoError(t, run("issue", "comment", "list", "1")) + require.Equal(t, "id:\t79\nauthor:\talice\ncreated_at:\t2026-10-11T18:00:00Z\nurl:\t\n--\nFirst **one**\n--\n"+ + "id:\t80\nauthor:\tbob\ncreated_at:\t2026-10-11T19:00:00Z\nurl:\t\n--\nSecond\nline\n--\n"+ + "id:\t81\nauthor:\tbob\ncreated_at:\t2026-10-11T19:10:00Z\nurl:\t\n--\nThird\n--\n", out.String()) + deps.Streams.OutTTY = true + deps.Getenv = serverEnv(server.URL, map[string]string{"NO_COLOR": "1", "TEA_MARKDOWN": "raw"}) + require.NoError(t, run("issue", "comment", "list", "1")) + require.Equal(t, "#79 alice • about 2 hours ago\n First **one**\n\n"+ + "#80 bob • about 1 hour ago (edited)\n Second\n line\n\n"+ + "#81 bob • about 50 minutes ago\n Third\n", out.String()) +} diff --git a/internal/app/editor_test.go b/internal/app/editor_test.go index 76f76ba6..d5eb14cb 100644 --- a/internal/app/editor_test.go +++ b/internal/app/editor_test.go @@ -23,6 +23,10 @@ func TestCommentEditorDraftLifecycle(t *testing.T) { t.Run(mode, func(t *testing.T) { requests := 0 server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(`{"number":3,"title":"Crash"}`)) + return + } requests++ var options struct{ Body string } require.NoError(t, json.NewDecoder(r.Body).Decode(&options)) diff --git a/internal/app/human_output_test.go b/internal/app/human_output_test.go index bd6d38a4..a03dc8b3 100644 --- a/internal/app/human_output_test.go +++ b/internal/app/human_output_test.go @@ -174,7 +174,7 @@ func TestMultiItemHumanOutputPagesOnce(t *testing.T) { }{ {[]string{"branch", "list"}, "Showing 2 of 2 branches in alice/project\n\nNAME COMMIT PROTECTED UPDATED MESSAGE\nmain (default) abc\ndev def\n"}, {[]string{"milestone", "issue", "list", "3"}, "1 Issue 1 closed issue\n2 Issue 2 closed issue\n"}, - {[]string{"issue", "comment", "list", "5"}, "1 alice\none\n\n2 bob\ntwo\n\n"}, + {[]string{"issue", "comment", "list", "5"}, "#1 alice\n one\n\n#2 bob\n two\n"}, } { t.Run(tc.args[0]+" "+tc.args[1], func(t *testing.T) { deps, out, _, pager := pagedDeps(t, server.URL, nil, "pager", "fake-pager", "color", "never", "hyperlinks", "never") diff --git a/internal/services/comments.go b/internal/services/comments.go index 03f80ef6..6351c23e 100644 --- a/internal/services/comments.go +++ b/internal/services/comments.go @@ -5,6 +5,8 @@ package services import ( "context" + "path" + "strconv" gitea "gitea.dev/sdk" "gitea.dev/tea/internal/transport" @@ -18,6 +20,8 @@ type Comment struct { Author string `json:"author"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` + // IssueNumber is the issue or pull request the comment is on. + IssueNumber int64 `json:"issue_number"` // Pull reports a comment on a pull request rather than an issue. Pull bool `json:"-"` } @@ -26,9 +30,19 @@ func commentFrom(comment *gitea.Comment) Comment { return Comment{ ID: comment.ID, Body: comment.Body, URL: comment.HTMLURL, Author: login(comment.Poster), CreatedAt: timestamp(comment.Created), UpdatedAt: timestamp(comment.Updated), Pull: comment.PRURL != "", + IssueNumber: discussionNumber(comment.IssueURL + comment.PRURL), } } +// discussionNumber reads the number from the web URL of an issue or pull request, or returns 0. +func discussionNumber(url string) int64 { + n, err := strconv.ParseInt(path.Base(url), 10, 64) + if err != nil { + return 0 + } + return n +} + // Comments implements discussion comments under their owning repository resource. type Comments struct { Client *transport.Client