Files
gitea-tea/internal/app/comments.go
T
bircni 2826726557 refactor(issue,pr): trim help, restating comments and single-use helpers
Cut issue, pr, label, milestone and comment examples to at most three, drop
doc comments that only restate the name, fold the comment and attachment
argument lookups into one closure each, and inline label helpers with one
caller.

Signed-off-by: bircni <bircni@icloud.com>
2026-10-12 20:48:00 +02:00

401 lines
16 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
const commentTitle = "Comment (markdown):"
// commentNouns are the words for the commands of an issue or pull request comment group.
type commentNouns struct {
group, arguments, example string
}
func commentWords(kind refKind) commentNouns {
if kind == pullRef {
return commentNouns{group: "pr", arguments: pullNumberArguments, example: "13"}
}
return commentNouns{group: "issue", arguments: issueArguments, example: "12"}
}
// pullNumberArguments is the ARGUMENTS help of pr comment and pr attachment commands, which take pull
// request numbers like issue commands take issue numbers.
const pullNumberArguments = `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 && 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)
}
return item, err
}
// issueCommandPath names the issue command that matches a pr command, such as "tea issue comment".
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)
}
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) {
if !a.canPrompt() {
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
}
body, err := a.deps.Prompter.AskText(cmd.Context(), commentTitle, initial)
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
}
a.done(cmd, "Commented on %s", refLabel(t.Repo, n, item.Title))
return a.created(cmd, comment, comment.URL)
}
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 the comment that the ID argument names, which a command of kind may change: pr
// commands refuse comments on issues.
func (a *application) comment(cmd *cobra.Command, arg string, kind refKind) (target, services.Comment, error) {
id, err := number(arg)
if err != nil {
return target{}, services.Comment{}, err
}
t, err := a.resolve(cmd.Context(), true)
if err != nil {
return t, services.Comment{}, err
}
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 t, 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
a.done(cmd, "Edited %s", commentName(t, edited))
return a.jsonResult(cmd, edited)
}
// deleteComment deletes comment after a confirmation that defaults to no.
func (a *application) deleteComment(cmd *cobra.Command, t target, comment services.Comment) error {
return a.deleteConfirmed(cmd, commentName(t, comment), func() error {
return (services.Comments{Client: t.client, Repository: t.Repo}).Delete(cmd.Context(), comment.ID)
})
}
// 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 " + kind.article(), 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.`, kind.article()), Example: fmt.Sprintf(`$ tea %[1]s comment %[2]s --body "Fixed in #14"
$ tea %[1]s comment %[2]s --edit-last --body "Fixed in #15"
$ 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 " + kind.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 " + kind.article(), 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, t, n); err != nil {
return err
}
}
items, err := (services.Comments{Client: t.client, Repository: t.Repo}).Page(cmd.Context(), n, limit, page)
if err != nil {
return err
}
return a.writeComments(cmd, t, n, items)
}}
pageFlags(list, &limit, &page)
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 {
t, comment, err := a.comment(cmd, args[0], 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 " + kind.article(), 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
}
t, comment, err := a.comment(cmd, args[0], 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 {
t, comment, err := a.comment(cmd, args[0], 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)
}
}
func (a *application) completeDiscussions(kind refKind) cobra.CompletionFunc {
if kind == pullRef {
return firstArg(a.completePulls("open"))
}
return firstArg(a.completeIssues("open"))
}