mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
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>
401 lines
16 KiB
Go
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"))
|
|
}
|