mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
404 lines
16 KiB
Go
404 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: pipedTime(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), pipedTime(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 {
|
|
// Glamour already indents every line by its margin, which TrimSpace would strip from the first.
|
|
text.WriteString(strings.Trim(terminal.Markdown(body, d), "\n"))
|
|
} else {
|
|
for line := range strings.Lines(terminal.SafeText(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"))
|
|
}
|
|
if deleteLast && a.options.json != "" {
|
|
return clierr.Wrap(clierr.Usage, errors.New("comment deletion does not return a resource; --json and --jq are not supported"))
|
|
}
|
|
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"))
|
|
}
|