Files
gitea-tea/internal/app/comments.go
T
bircni 1f4b5f868f refactor(prompts): fold Asker into Prompter and share delete confirmations
FormPrompter is the only production prompter and implements the rich
prompt API, so Ask, AskText and ConfirmDefault become the Prompter
interface and the plain Input/Confirm fallbacks go away.

Confirmations that default to no derive their non-terminal wording
from the prompt, and single deletes share deleteConfirmed for the
prompt, the request and the done line.

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

439 lines
17 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"
)
// 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: pullNumberArguments, example: "13"}
}
return commentNouns{group: "issue", noun: "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)
}
// 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) {
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)
}
// 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
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 {
name := commentName(t, comment)
return a.deleteConfirmed(cmd, name, 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 " + 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 {
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 {
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 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)
}