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
+514 -143

No files matched your search

+5
View File
@@ -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":
-12
View File
@@ -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
`
)
+404 -125
View File
@@ -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 <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])
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
}
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 {<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 {
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 <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 {
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 {<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
}
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 <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 {
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)
}
+86 -5
View File
@@ -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())
}
+4
View File
@@ -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))
+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{"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")
+14
View File
@@ -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