mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
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:
7 files changed
+514
-143
No files matched your search
@@ -544,6 +544,9 @@ func commandOutputFields(cmd *cobra.Command) []string {
|
|||||||
case "comment":
|
case "comment":
|
||||||
return jsonFields[services.Comment]()
|
return jsonFields[services.Comment]()
|
||||||
case "issue":
|
case "issue":
|
||||||
|
if cmd.Name() == "comment" {
|
||||||
|
return jsonFields[services.Comment]()
|
||||||
|
}
|
||||||
return jsonFields[services.Issue]()
|
return jsonFields[services.Issue]()
|
||||||
case "review":
|
case "review":
|
||||||
if cmd.Name() == "request" || cmd.Name() == "cancel-request" {
|
if cmd.Name() == "request" || cmd.Name() == "cancel-request" {
|
||||||
@@ -563,6 +566,8 @@ func commandOutputFields(cmd *cobra.Command) []string {
|
|||||||
return jsonFields[services.Review]()
|
return jsonFields[services.Review]()
|
||||||
case "clean":
|
case "clean":
|
||||||
return jsonFields[git.CleanResult]()
|
return jsonFields[git.CleanResult]()
|
||||||
|
case "comment":
|
||||||
|
return jsonFields[services.Comment]()
|
||||||
}
|
}
|
||||||
return jsonFields[services.Pull]()
|
return jsonFields[services.Pull]()
|
||||||
case "repo":
|
case "repo":
|
||||||
|
|||||||
@@ -424,11 +424,6 @@ const (
|
|||||||
issue attachment list
|
issue attachment list
|
||||||
issue attachment upload
|
issue attachment upload
|
||||||
issue attachment view
|
issue attachment view
|
||||||
issue comment create
|
|
||||||
issue comment delete
|
|
||||||
issue comment edit
|
|
||||||
issue comment list
|
|
||||||
issue comment view
|
|
||||||
label create
|
label create
|
||||||
label delete
|
label delete
|
||||||
label edit
|
label edit
|
||||||
@@ -450,11 +445,6 @@ const (
|
|||||||
pr attachment list
|
pr attachment list
|
||||||
pr attachment upload
|
pr attachment upload
|
||||||
pr attachment view
|
pr attachment view
|
||||||
pr comment create
|
|
||||||
pr comment delete
|
|
||||||
pr comment edit
|
|
||||||
pr comment list
|
|
||||||
pr comment view
|
|
||||||
search issue
|
search issue
|
||||||
search pr
|
search pr
|
||||||
time create
|
time create
|
||||||
@@ -464,12 +454,10 @@ const (
|
|||||||
`
|
`
|
||||||
longsToWrite = `
|
longsToWrite = `
|
||||||
issue attachment
|
issue attachment
|
||||||
issue comment
|
|
||||||
label
|
label
|
||||||
milestone
|
milestone
|
||||||
milestone issue
|
milestone issue
|
||||||
pr attachment
|
pr attachment
|
||||||
pr comment
|
|
||||||
time
|
time
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
+404
-125
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.dev/tea/internal/clierr"
|
"gitea.dev/tea/internal/clierr"
|
||||||
"gitea.dev/tea/internal/services"
|
"gitea.dev/tea/internal/services"
|
||||||
@@ -15,21 +16,43 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
func commentRecord(item services.Comment) terminal.Record {
|
// commentTitle is the prompt for a comment body.
|
||||||
return terminal.Record{Heading: terminal.Row(strconv.FormatInt(item.ID, 10), item.Author), Lines: []string{item.Body, item.URL}}
|
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
|
// 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.
|
// issue endpoints and would otherwise apply to the issue with that number.
|
||||||
func requirePull(cmd *cobra.Command, t target, n int64) error {
|
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)
|
item, err := (services.Issues{Client: t.client, Repository: t.Repo}).View(cmd.Context(), n)
|
||||||
if err != nil {
|
if err == nil && kind == pullRef && item.Kind != services.KindPull {
|
||||||
return err
|
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 item, err
|
||||||
return fmt.Errorf("#%d in %s is an issue, not a pull request; use %s %d", n, t.Repo, issueCommandPath(cmd), n)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// issueCommandPath names the issue command that matches a pr command, such as "tea issue comment".
|
// 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)
|
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 {
|
func (a *application) commentCommand(kind refKind) *cobra.Command {
|
||||||
group := &cobra.Command{Use: "comment", Short: "Manage discussion comments"}
|
words := commentWords(kind)
|
||||||
var limit, page int
|
var flags commentFlags
|
||||||
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 {
|
var editLast, deleteLast bool
|
||||||
n, err := number(args[0])
|
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 {
|
if err != nil {
|
||||||
return err
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if kind == pullRef {
|
if kind == pullRef {
|
||||||
if err := requirePull(cmd, target, n); err != nil {
|
if err := requirePull(cmd, t, n); err != nil {
|
||||||
return err
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return writeRecords(a, cmd, items, commentRecord)
|
return a.writeComments(cmd, t, n, items)
|
||||||
}}
|
}}
|
||||||
pageFlags(list, &limit, &page)
|
pageFlags(list, &limit, &page)
|
||||||
group.AddCommand(list)
|
list.ValidArgsFunction = a.completeDiscussions(kind)
|
||||||
for _, verb := range []string{"view", "create", "edit", "delete"} {
|
|
||||||
var body, file string
|
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 {
|
||||||
var useEditor bool
|
id, err := number(args[0])
|
||||||
noun := "id"
|
if err != nil {
|
||||||
if verb == "create" {
|
return err
|
||||||
noun = "number"
|
|
||||||
}
|
}
|
||||||
command := &cobra.Command{Use: verb + " <" + noun + ">", Short: titleCase(verb) + " a discussion comment", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) (runErr error) {
|
t, err := a.resolve(cmd.Context(), true)
|
||||||
var draft terminal.Draft
|
if err != nil {
|
||||||
defer finishDraft(&draft, &runErr)
|
return err
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
if verb == "delete" {
|
comment, err := a.comment(cmd, t, id, kind)
|
||||||
command.Annotations = textOutput("comment deletion does not return a resource")
|
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
|
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)
|
||||||
|
}
|
||||||
@@ -7,15 +7,22 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCommentConfirmationNamesTheTarget(t *testing.T) {
|
func TestCommentConfirmationNamesTheTarget(t *testing.T) {
|
||||||
|
var created []string
|
||||||
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method + " " + r.URL.Path {
|
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":
|
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":
|
case "DELETE /api/v1/repos/alice/project/issues/comments/12":
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
default:
|
default:
|
||||||
@@ -24,13 +31,22 @@ func TestCommentConfirmationNamesTheTarget(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
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"}
|
prompt := &scriptedPrompter{accept: true, input: "Prompted"}
|
||||||
deps.Prompter = prompt
|
deps.Prompter = prompt
|
||||||
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
|
deps.Streams.InTTY, deps.Streams.ErrTTY, deps.Streams.OutTTY = true, true, true
|
||||||
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "comment", "create", "3", "-R", "alice/project"}))
|
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.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) {
|
func TestPullCommentsAndAttachmentsRequirePullRequests(t *testing.T) {
|
||||||
@@ -85,3 +101,68 @@ func TestPullCommentsAndAttachmentsRequirePullRequests(t *testing.T) {
|
|||||||
"POST /api/v1/repos/alice/project/issues/1/comments",
|
"POST /api/v1/repos/alice/project/issues/1/comments",
|
||||||
}, changed)
|
}, 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())
|
||||||
|
}
|
||||||
@@ -23,6 +23,10 @@ func TestCommentEditorDraftLifecycle(t *testing.T) {
|
|||||||
t.Run(mode, func(t *testing.T) {
|
t.Run(mode, func(t *testing.T) {
|
||||||
requests := 0
|
requests := 0
|
||||||
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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++
|
requests++
|
||||||
var options struct{ Body string }
|
var options struct{ Body string }
|
||||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&options))
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&options))
|
||||||
|
|||||||
@@ -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{"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{"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) {
|
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")
|
deps, out, _, pager := pagedDeps(t, server.URL, nil, "pager", "fake-pager", "color", "never", "hyperlinks", "never")
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
gitea "gitea.dev/sdk"
|
gitea "gitea.dev/sdk"
|
||||||
"gitea.dev/tea/internal/transport"
|
"gitea.dev/tea/internal/transport"
|
||||||
@@ -18,6 +20,8 @@ type Comment struct {
|
|||||||
Author string `json:"author"`
|
Author string `json:"author"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
UpdatedAt string `json:"updated_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 reports a comment on a pull request rather than an issue.
|
||||||
Pull bool `json:"-"`
|
Pull bool `json:"-"`
|
||||||
}
|
}
|
||||||
@@ -26,9 +30,19 @@ func commentFrom(comment *gitea.Comment) Comment {
|
|||||||
return Comment{
|
return Comment{
|
||||||
ID: comment.ID, Body: comment.Body, URL: comment.HTMLURL, Author: login(comment.Poster),
|
ID: comment.ID, Body: comment.Body, URL: comment.HTMLURL, Author: login(comment.Poster),
|
||||||
CreatedAt: timestamp(comment.Created), UpdatedAt: timestamp(comment.Updated), Pull: comment.PRURL != "",
|
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.
|
// Comments implements discussion comments under their owning repository resource.
|
||||||
type Comments struct {
|
type Comments struct {
|
||||||
Client *transport.Client
|
Client *transport.Client
|
||||||
|
|||||||
Reference in new issue
Block a user