feat(comment): comment on issues and pull requests the gh way

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 <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-12 00:16:09 +02:00
1 parent 0b6232087e
commit 10417049a1
7 files changed
+501 -130

No files matched your search

+5
View File
@@ -544,6 +544,9 @@ func commandOutputFields(cmd *cobra.Command) []string {
case "comment": case "comment":
return jsonFields[services.Comment]() return jsonFields[services.Comment]()
case "issue": case "issue":
if cmd.Name() == "comment" {
return jsonFields[services.Comment]()
}
return jsonFields[services.Issue]() return jsonFields[services.Issue]()
case "review": case "review":
if cmd.Name() == "request" || cmd.Name() == "cancel-request" { if cmd.Name() == "request" || cmd.Name() == "cancel-request" {
@@ -563,6 +566,8 @@ func commandOutputFields(cmd *cobra.Command) []string {
return jsonFields[services.Review]() return jsonFields[services.Review]()
case "clean": case "clean":
return jsonFields[git.CleanResult]() return jsonFields[git.CleanResult]()
case "comment":
return jsonFields[services.Comment]()
} }
return jsonFields[services.Pull]() return jsonFields[services.Pull]()
case "repo": case "repo":
-12
View File
@@ -424,11 +424,6 @@ const (
issue attachment list issue attachment list
issue attachment upload issue attachment upload
issue attachment view issue attachment view
issue comment create
issue comment delete
issue comment edit
issue comment list
issue comment view
label create label create
label delete label delete
label edit label edit
@@ -450,11 +445,6 @@ const (
pr attachment list pr attachment list
pr attachment upload pr attachment upload
pr attachment view pr attachment view
pr comment create
pr comment delete
pr comment edit
pr comment list
pr comment view
search issue search issue
search pr search pr
time create time create
@@ -464,12 +454,10 @@ const (
` `
longsToWrite = ` longsToWrite = `
issue attachment issue attachment
issue comment
label label
milestone milestone
milestone issue milestone issue
pr attachment pr attachment
pr comment
time time
` `
) )
+391 -112
View File
@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"time"
"gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services" "gitea.dev/tea/internal/services"
@@ -15,21 +16,43 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
func commentRecord(item services.Comment) terminal.Record { // commentTitle is the prompt for a comment body.
return terminal.Record{Heading: terminal.Row(strconv.FormatInt(item.ID, 10), item.Author), Lines: []string{item.Body, item.URL}} 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 // 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. // issue endpoints and would otherwise apply to the issue with that number.
func requirePull(cmd *cobra.Command, t target, n int64) error { func requirePull(cmd *cobra.Command, t target, n int64) error {
item, err := (services.Issues{Client: t.client, Repository: t.Repo}).View(cmd.Context(), n) _, err := discussion(cmd, t, n, pullRef)
if err != nil {
return err 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 && 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 item, err
return fmt.Errorf("#%d in %s is an issue, not a pull request; use %s %d", n, t.Repo, issueCommandPath(cmd), n)
}
return nil
} }
// issueCommandPath names the issue command that matches a pr command, such as "tea issue comment". // 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) return strings.Replace(cmd.CommandPath(), " pr ", " issue ", 1)
} }
func (a *application) commentCommand(kind refKind) *cobra.Command { // commentName names a comment in messages: "comment 81 on alice/x#1".
group := &cobra.Command{Use: "comment", Short: "Manage discussion comments"} func commentName(t target, comment services.Comment) string {
var limit, page int return fmt.Sprintf("comment %d on %s#%d", comment.ID, t.Repo, comment.IssueNumber)
list := &cobra.Command{Use: "list <number>", 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])
// 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 { if err != nil {
return err return err
} }
target, err := a.resolve(cmd.Context(), true) 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 {
words := commentWords(kind)
var flags commentFlags
var editLast, deleteLast bool
group := &cobra.Command{Use: "comment {<number> | <url>}", 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
}
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 {<number> | <url>}", 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 { if err != nil {
return err return err
} }
if kind == pullRef { if kind == pullRef {
if err := requirePull(cmd, target, n); err != nil { if err := requirePull(cmd, t, n); err != nil {
return err 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 { if err != nil {
return err return err
} }
return writeRecords(a, cmd, items, commentRecord) return a.writeComments(cmd, t, n, items)
}} }}
pageFlags(list, &limit, &page) pageFlags(list, &limit, &page)
group.AddCommand(list) list.ValidArgsFunction = a.completeDiscussions(kind)
for _, verb := range []string{"view", "create", "edit", "delete"} {
var body, file string view := &cobra.Command{Use: "view <comment-id>", 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 {
var useEditor bool id, err := number(args[0])
noun := "id" if err != nil {
if verb == "create" { return err
noun = "number"
} }
command := &cobra.Command{Use: verb + " <" + noun + ">", Short: titleCase(verb) + " a discussion comment", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) (runErr error) { t, err := a.resolve(cmd.Context(), true)
var draft terminal.Draft if err != nil {
defer finishDraft(&draft, &runErr) return err
if err := validateBodySources(cmd); err != nil { }
comment, err := a.comment(cmd, t, id, kind)
if err != nil {
return err
}
return writeView(a, cmd, comment, commentView(t, a.deps.Now()))
}}
var createFlags commentFlags
create := &cobra.Command{Use: "create {<number> | <url>}", 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 <comment-id>", 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 return err
} }
id, err := number(args[0]) id, err := number(args[0])
if err != nil { if err != nil {
return err return err
} }
prompted := false t, err := a.resolve(cmd.Context(), true)
if (verb == "create" || verb == "edit") && !useEditor {
body, err = a.readBody(cmd, body, file)
if err != nil { if err != nil {
return err return err
} }
if !cmd.Flags().Changed("body") && !cmd.Flags().Changed("body-file") { comment, err := a.comment(cmd, t, id, kind)
prompted, err = a.require(cmd, &body, "body")
if err != nil { if err != nil {
return err return err
} }
} return a.editComment(cmd, t, comment, editFlags)
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" { editFlags.register(edit)
command.Flags().StringVar(&body, "body", "", "Comment body")
command.Flags().StringVar(&file, "body-file", "", "Read body from file, or - for stdin") remove := &cobra.Command{Use: "delete <comment-id>", 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 {
editHelp := "Compose the comment in the configured editor" id, err := number(args[0])
if verb == "edit" { if err != nil {
editHelp = "Edit the current comment body in the configured editor" return err
} }
command.Flags().BoolVar(&useEditor, "editor", false, editHelp) t, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
} }
if verb == "delete" { comment, err := a.comment(cmd, t, id, kind)
command.Annotations = textOutput("comment deletion does not return a resource") if err != nil {
} return err
group.AddCommand(command)
} }
return a.deleteComment(cmd, t, comment)
}}
group.AddCommand(list, view, create, edit, remove)
return group 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)
}
+86 -5
View File
@@ -7,15 +7,22 @@ import (
"context" "context"
"net/http" "net/http"
"testing" "testing"
"time"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestCommentConfirmationNamesTheTarget(t *testing.T) { func TestCommentConfirmationNamesTheTarget(t *testing.T) {
var created []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method + " " + r.URL.Path { 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": 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": case "DELETE /api/v1/repos/alice/project/issues/comments/12":
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
default: default:
@@ -24,13 +31,22 @@ func TestCommentConfirmationNamesTheTarget(t *testing.T) {
} }
})) }))
defer server.Close() 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"} prompt := &scriptedPrompter{accept: true, input: "Prompted"}
deps.Prompter = prompt deps.Prompter = prompt
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true deps.Streams.InTTY, deps.Streams.ErrTTY, deps.Streams.OutTTY = true, true, true
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "comment", "create", "3", "-R", "alice/project"})) 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.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) { func TestPullCommentsAndAttachmentsRequirePullRequests(t *testing.T) {
@@ -85,3 +101,68 @@ func TestPullCommentsAndAttachmentsRequirePullRequests(t *testing.T) {
"POST /api/v1/repos/alice/project/issues/1/comments", "POST /api/v1/repos/alice/project/issues/1/comments",
}, changed) }, 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())
}
+4
View File
@@ -23,6 +23,10 @@ func TestCommentEditorDraftLifecycle(t *testing.T) {
t.Run(mode, func(t *testing.T) { t.Run(mode, func(t *testing.T) {
requests := 0 requests := 0
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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++ requests++
var options struct{ Body string } var options struct{ Body string }
require.NoError(t, json.NewDecoder(r.Body).Decode(&options)) require.NoError(t, json.NewDecoder(r.Body).Decode(&options))
+1 -1
View File
@@ -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{"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{"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) { 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") deps, out, _, pager := pagedDeps(t, server.URL, nil, "pager", "fake-pager", "color", "never", "hyperlinks", "never")
+14
View File
@@ -5,6 +5,8 @@ package services
import ( import (
"context" "context"
"path"
"strconv"
gitea "gitea.dev/sdk" gitea "gitea.dev/sdk"
"gitea.dev/tea/internal/transport" "gitea.dev/tea/internal/transport"
@@ -18,6 +20,8 @@ type Comment struct {
Author string `json:"author"` Author string `json:"author"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_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 reports a comment on a pull request rather than an issue.
Pull bool `json:"-"` Pull bool `json:"-"`
} }
@@ -26,9 +30,19 @@ func commentFrom(comment *gitea.Comment) Comment {
return Comment{ return Comment{
ID: comment.ID, Body: comment.Body, URL: comment.HTMLURL, Author: login(comment.Poster), ID: comment.ID, Body: comment.Body, URL: comment.HTMLURL, Author: login(comment.Poster),
CreatedAt: timestamp(comment.Created), UpdatedAt: timestamp(comment.Updated), Pull: comment.PRURL != "", 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. // Comments implements discussion comments under their owning repository resource.
type Comments struct { type Comments struct {
Client *transport.Client Client *transport.Client