Files
gitea-tea/internal/app/pull_review.go
T

672 lines
26 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
const pullArgumentFormats = `A pull request can be supplied as argument in any of the following formats:
- by number, such as "13" or "#13"
- by URL, such as "https://gitea.com/OWNER/REPO/pulls/13"
- by the name of its head branch, such as "feature/login" or "bob:feature/login"`
const pullArguments = pullArgumentFormats + `
Without an argument, the pull request of the current branch is selected.`
// pullArgumentsAnnotation documents a pull request argument that may be left out.
func pullArgumentsAnnotation(annotations map[string]string) map[string]string {
return argumentsAnnotation(annotations, pullArguments)
}
// pullArgumentRequiredAnnotation documents a pull request argument that has no default.
func pullArgumentRequiredAnnotation(annotations map[string]string) map[string]string {
return argumentsAnnotation(annotations, pullArgumentFormats)
}
func argumentsAnnotation(annotations map[string]string, text string) map[string]string {
if annotations == nil {
annotations = map[string]string{}
}
annotations["tea.arguments"] = text
return annotations
}
// reviewStates maps the review types --event accepts onto the API's review states.
var reviewStates = map[string]string{
"approve": "APPROVED", "approved": "APPROVED",
"request-changes": "REQUEST_CHANGES", "request_changes": "REQUEST_CHANGES",
"comment": "COMMENT", "pending": "PENDING",
}
type reviewType struct {
approve, requestChanges, comment, pending bool
// event is the hidden spelling of the type that tea 0.x and scripts use.
event string
}
// register adds -a, -r, -c, --pending when pending is set, and the hidden --event to cmd.
func (r *reviewType) register(cmd *cobra.Command, pending bool) {
flags := cmd.Flags()
flags.BoolVarP(&r.approve, "approve", "a", false, "Approve the pull request")
flags.BoolVarP(&r.requestChanges, "request-changes", "r", false, "Request changes (requires a body)")
flags.BoolVarP(&r.comment, "comment", "c", false, "Comment without approving (requires a body or inline comments)")
values := []string{"approve", "approved", "request-changes", "request_changes", "comment"}
if pending {
flags.BoolVar(&r.pending, "pending", false, "Save the review as pending, to submit later with tea pr review submit")
values = append(values, "pending")
}
flags.StringVar(&r.event, "event", "", "Review type")
enumValues(cmd, "event", values...)
_ = flags.MarkHidden("event")
}
// state returns the API state of the chosen review type, or "" when none was chosen.
func (r reviewType) state() (string, error) {
var states []string
for state, chosen := range map[string]bool{"APPROVED": r.approve, "REQUEST_CHANGES": r.requestChanges, "COMMENT": r.comment, "PENDING": r.pending} {
if chosen {
states = append(states, state)
}
}
if r.event != "" {
states = append(states, reviewStates[strings.ToLower(r.event)])
}
switch len(states) {
case 0:
return "", nil
case 1:
return states[0], nil
}
return "", clierr.Wrap(clierr.Usage, errors.New("choose one review type: --approve, --request-changes or --comment"))
}
// errReviewType asks for a review type where tea cannot prompt for one.
func errReviewType(cmd *cobra.Command) error {
return clierr.Wrap(clierr.Usage, &argumentError{problem: "choose a review type: --approve, --request-changes, or --comment", cmd: cmd})
}
// reviewVerbs reports a submitted review by its state: "Approved pull request alice/x#1 (Add one)".
var reviewVerbs = map[string]string{
"APPROVED": "Approved", "REQUEST_CHANGES": "Requested changes on", "COMMENT": "Commented on", "PENDING": "Saved pending review on",
}
// reviewNouns name a review by its state in prompts: "Submit approval for #1?".
var reviewNouns = map[string]string{
"APPROVED": "approval", "REQUEST_CHANGES": "change request", "COMMENT": "comment", "PENDING": "pending review",
}
// reviewWords are review types people try as a subcommand, with the flag that chooses them.
var reviewWords = map[string]string{
"approve": "approve", "lgtm": "approve", "comment": "comment", "reject": "request-changes", "request-changes": "request-changes",
}
// reviewArgs accepts one pull request and points review types given as a subcommand, as in
// "tea pr review approve 13", at their flags.
func reviewArgs(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return nil
}
if flag, ok := reviewWords[strings.ToLower(args[0])]; ok {
pull := "<number>"
if len(args) > 1 {
pull = args[1]
}
return clierr.Wrap(clierr.Usage, fmt.Errorf("unknown review command %q; to %s run: tea pr review %s --%s (see tea pr review --help)",
args[0], strings.ReplaceAll(flag, "-", " "), pull, flag))
}
if len(args) > 1 {
if _, err := parseRef(args[0], pullRef); errors.Is(err, errNotRef) {
err := fmt.Errorf("unknown command %q for %q", args[0], cmd.CommandPath())
if suggestions := cmd.SuggestionsFor(args[0]); len(suggestions) > 0 {
err = fmt.Errorf("%w; did you mean %s?", err, strings.Join(suggestions, " or "))
}
return clierr.Wrap(clierr.Usage, err)
}
}
return usageArgs(cobra.MaximumNArgs(1))(cmd, args)
}
const reviewExamples = `# Approve the pull request of the current branch
$ tea pr review --approve
# Request changes on #13
$ tea pr review 13 -r -b "Please hash the password"
# Start a pending review with inline comments, then submit it
$ tea pr review 13 --pending --comments-file inline.json
$ tea pr review submit 13 --approve`
func (a *application) pullReviewCommand() *cobra.Command {
review := a.reviewCreateCommand("review [<number> | <url> | <branch>]", "Review a pull request or manage its reviews")
review.Long = `Submit a review on a pull request, or manage existing reviews with the commands below.
Choose the review type with --approve, --request-changes or --comment. On a terminal, leaving
the type out opens an interactive review that also offers to annotate the diff in your editor.`
review.Example = reviewExamples
review.SuggestionsMinimumDistance = 2
create := a.reviewCreateCommand("create [<number> | <url> | <branch>]", `Submit a review (same as "tea pr review")`)
create.Example = `$ tea pr review create 13 --approve
$ tea pr review create --comment -b "Looks good overall"`
review.AddCommand(create, a.pullReviewListCommand(), a.pullReviewViewCommand(), a.pullReviewCommentsCommand(), a.pullReviewReplyCommand())
for _, resolve := range []bool{true, false} {
review.AddCommand(a.pullReviewResolveCommand(resolve))
}
review.AddCommand(a.pullReviewRequestCommand(true), a.pullReviewRequestCommand(false))
review.AddCommand(a.pullReviewSubmitCommand(), a.pullReviewDeleteCommand(), a.pullReviewDismissCommand(true), a.pullReviewDismissCommand(false))
return review
}
func (a *application) reviewCreateCommand(use, short string) *cobra.Command {
var kind reviewType
var body, file, commentsFile, commit string
var useEditor bool
cmd := &cobra.Command{Use: use, Short: short, Annotations: pullArgumentsAnnotation(nil), Args: reviewArgs, RunE: func(cmd *cobra.Command, args []string) (runErr error) {
var draft terminal.Draft
defer finishDraft(&draft, &runErr)
if err := validateBodySources(cmd); err != nil {
return err
}
state, err := kind.state()
if err != nil {
return err
}
comments, err := readInlineComments(commentsFile)
if err != nil {
return err
}
if state == "" {
if localFlagsChanged(cmd) || !a.canPrompt() || a.deps.ReviewForm == nil {
return errReviewType(cmd)
}
return a.reviewInteractively(cmd, args)
}
// A comment needs a body or inline comments, and a change request a body.
needsBody := state == "REQUEST_CHANGES" || (state != "APPROVED" && len(comments) == 0)
missingBody := clierr.Wrap(clierr.Usage, fmt.Errorf("a %s needs a body; pass --body, --body-file or --editor", reviewNouns[state]))
explicit := cmd.Flags().Changed("body") || cmd.Flags().Changed("body-file")
if body, err = a.readBody(cmd, body, file); err != nil {
return err
}
if needsBody && strings.TrimSpace(body) == "" && (explicit || (!useEditor && !a.canPrompt())) {
return missingBody
}
t, n, err := a.pullSelector(cmd, args)
if err != nil {
return err
}
pull, err := (services.Pulls{Client: t.client, Repository: t.Repo}).View(cmd.Context(), n)
if err != nil {
return err
}
if err := ownReview(pull, t, state); err != nil {
return err
}
switch {
case useEditor:
draft, err = a.editDraft(cmd, "")
case needsBody && !explicit:
draft, err = a.bodyEditor(cmd)(cmd.Context(), "")
default:
draft.Body = body
}
if err != nil {
return err
}
if body = draft.Body; needsBody && strings.TrimSpace(body) == "" {
return missingBody
}
service := services.Reviews{Client: t.client, Repository: t.Repo}
if strings.TrimSpace(body) == "" && !explicit && !useEditor {
// Gitea adds a new review to the caller's oldest pending review and replaces its body,
// so keep the pending body when none was given, as tea pr review submit does.
pending, err := service.Pending(cmd.Context(), n, t.login())
if err != nil {
return err
}
if len(pending) > 0 {
body = pending[0].Body
}
}
item, err := service.Create(cmd.Context(), n, services.ReviewOptions{Event: state, Body: body, Commit: commit, Comments: comments})
if err != nil {
return err
}
return a.reportReview(cmd, t, pull, item)
}}
kind.register(cmd, true)
cmd.Flags().StringVarP(&body, "body", "b", "", "Review body")
cmd.Flags().StringVarP(&file, "body-file", "F", "", "Read the body from `file`, or - for stdin")
cmd.Flags().BoolVarP(&useEditor, "editor", "e", false, "Write the body in your editor")
cmd.Flags().StringVar(&commentsFile, "comments-file", "", "Read inline comments from a JSON `file`: an array of path, body, and new_position or old_position")
cmd.Flags().StringVar(&commit, "commit", "", "Commit `sha` to review (default: the head of the pull request)")
cmd.ValidArgsFunction = firstArg(a.completePulls("open"))
return cmd
}
// ownReview refuses to approve or request changes on one's own pull request before any body is
// written, as Gitea would after.
func ownReview(pull services.Pull, t target, state string) error {
if (state == "APPROVED" || state == "REQUEST_CHANGES") && t.login() != "" && strings.EqualFold(pull.Author, t.login()) {
return clierr.Wrap(clierr.Usage, fmt.Errorf("you can't approve or request changes on your own pull request #%d; use --comment", pull.Number))
}
return nil
}
// reportReview reports a submitted review, then prints its URL, or the review as JSON.
func (a *application) reportReview(cmd *cobra.Command, t target, pull services.Pull, item services.Review) error {
a.done(cmd, "%s pull request %s", reviewVerbs[item.State], refLabel(t.Repo, pull.Number, pull.Title))
if a.options.json == "" && item.URL == "" {
return nil
}
return a.created(cmd, item, item.URL)
}
func readInlineComments(path string) ([]services.InlineComment, error) {
if path == "" {
return nil, nil
}
if path == "-" {
return nil, clierr.Wrap(clierr.Usage, errors.New("--comments-file requires a file; use --body-file - for stdin"))
}
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
decoder := json.NewDecoder(file)
decoder.DisallowUnknownFields()
var comments []services.InlineComment
if err := decoder.Decode(&comments); err != nil {
return nil, clierr.Wrap(clierr.Usage, fmt.Errorf("invalid inline comments: %w", err))
}
if comments == nil {
return nil, clierr.Wrap(clierr.Usage, errors.New("inline comments must be a JSON array"))
}
if err := decoder.Decode(new(any)); err != io.EOF {
return nil, clierr.Wrap(clierr.Usage, errors.New("inline comments file must contain exactly one JSON array"))
}
for _, comment := range comments {
if err := comment.Validate(); err != nil {
return nil, clierr.Wrap(clierr.Usage, err)
}
}
return comments, nil
}
func (a *application) reviewInteractively(cmd *cobra.Command, args []string) error {
t, n, err := a.pullSelector(cmd, args)
if err != nil {
return err
}
pull, err := (services.Pulls{Client: t.client, Repository: t.Repo}).View(cmd.Context(), n)
if err != nil {
return err
}
preferences, err := a.preferences()
if err != nil {
return err
}
choices := terminal.ReviewFormChoices{
Header: fmt.Sprintf("Reviewing #%d %q (%s → %s) in %s", n, pull.Title, pullHead(pull, t.Repo), pull.Base, t.Repo),
Author: t.login() != "" && strings.EqualFold(pull.Author, t.login()),
Editor: preferences.Editor,
}
// The annotated diff stays until the review is created, so declining or a failure loses no comment.
var diffPath string
if preferences.Editor != "" {
choices.ReviewDiff = func(context.Context) ([]terminal.ReviewCommentDraft, error) {
comments, path, err := a.collectDiffReviewComments(cmd, t, n)
diffPath = path
return comments, err
}
}
values, err := a.deps.ReviewForm.CreateReview(cmd.Context(), choices)
if err == nil {
err = a.confirmAction(cmd, terminal.Confirmation{Prompt: fmt.Sprintf("Submit %s for #%d?", reviewNouns[values.Event], n), DefaultYes: true})
}
var item services.Review
if err == nil {
opts := services.ReviewOptions{Event: values.Event, Body: values.Body}
for _, comment := range values.Comments {
opts.Comments = append(opts.Comments, services.InlineComment(comment))
}
item, err = (services.Reviews{Client: t.client, Repository: t.Repo}).Create(cmd.Context(), n, opts)
}
if err != nil {
draft := keepDraft(values.Body)
finishDraft(&draft, &err)
if diffPath != "" {
err = fmt.Errorf("%w; the annotated diff is kept at %s", err, diffPath)
}
return err
}
if diffPath != "" {
_ = os.Remove(diffPath)
}
return a.reportReview(cmd, t, pull, item)
}
// keepDraft saves typed text that a canceled or failed command did not submit, so that finishDraft
// reports where it is. Empty text, or text that cannot be saved, has no draft.
func keepDraft(body string) terminal.Draft {
if strings.TrimSpace(body) == "" {
return terminal.Draft{}
}
file, err := os.CreateTemp("", "tea-draft-*.md")
if err != nil {
return terminal.Draft{}
}
_, writeErr := file.WriteString(body)
if err := errors.Join(writeErr, file.Close()); err != nil {
_ = os.Remove(file.Name())
return terminal.Draft{}
}
return terminal.Draft{Path: file.Name(), Body: body}
}
// pullHead names the head branch of a pull request, as OWNER:BRANCH when it is in a fork of repo.
func pullHead(item services.Pull, repo string) string {
if owner, _, ok := strings.Cut(item.HeadRepo, "/"); ok && !strings.EqualFold(item.HeadRepo, repo) {
return owner + ":" + item.Head
}
return item.Head
}
// collectDiffReviewComments returns the comments annotated into the diff of pull request n and the
// file holding it, which the caller removes once the review is created.
func (a *application) collectDiffReviewComments(cmd *cobra.Command, t target, n int64) ([]terminal.ReviewCommentDraft, string, error) {
diff, err := (services.Pulls{Client: t.client, Repository: t.Repo}).Diff(cmd.Context(), n)
if err != nil {
return nil, "", err
}
defer diff.Close()
path, err := saveDiffReview("", n, t.Repo, diff)
if err != nil {
return nil, "", err
}
comments, err := a.reviewDiffFile(cmd, path)
if err != nil {
return nil, "", fmt.Errorf("%w; the annotated diff is kept at %s", err, path)
}
return comments, path, nil
}
// reviewDiffFile lets the user annotate the diff at path and returns the validated comments.
func (a *application) reviewDiffFile(cmd *cobra.Command, path string) ([]terminal.ReviewCommentDraft, error) {
if err := a.editPath(cmd, path); err != nil {
return nil, err
}
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("couldn't load diff: %w", err)
}
defer file.Close()
comments, err := parseDiffComments(file)
if err != nil {
return nil, err
}
for _, comment := range comments {
if err := services.InlineComment(comment).Validate(); err != nil {
return nil, err
}
}
return comments, nil
}
// reviewStateCell words a review's state for people, colored like gh: Approved, Changes requested,
// Commented, Pending or Requested, with "(dismissed)" for dismissed reviews.
func reviewStateCell(item services.Review) terminal.Cell {
words := map[string]terminal.Cell{
"APPROVED": {Text: "Approved", Color: "green"}, "REQUEST_CHANGES": {Text: "Changes requested", Color: "red"},
"COMMENT": {Text: "Commented"}, "PENDING": {Text: "Pending", Color: "yellow"}, "REQUEST_REVIEW": {Text: "Requested", Muted: true},
}
cell, ok := words[item.State]
if !ok {
cell = terminal.Cell{Text: item.State}
}
if item.Dismissed {
cell = terminal.Cell{Text: cell.Text + " (dismissed)", Muted: true}
}
return cell
}
// reviewPossessive names a review by its author and state in feedback: "bob's approval".
func reviewPossessive(item services.Review) string {
noun := "review"
switch item.State {
case "APPROVED":
noun = "approval"
case "REQUEST_CHANGES":
noun = "change request"
}
return item.Reviewer() + "'s " + noun
}
// pullAndID resolves the pull request and the ID that review subcommands take as their two arguments.
func (a *application) pullAndID(cmd *cobra.Command, args []string) (target, int64, int64, error) {
id, err := number(args[1])
if err != nil {
return target{}, 0, 0, err
}
t, n, err := a.pullSelector(cmd, args[:1])
return t, n, id, err
}
func (a *application) pullReviewListCommand() *cobra.Command {
var limit, page int
cmd := &cobra.Command{Use: "list [<number> | <url> | <branch>]", Short: "List reviews on a pull request", Annotations: pullArgumentsAnnotation(nil), Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error {
t, n, err := a.pullSelector(cmd, args)
if err != nil {
return err
}
window, err := (services.Reviews{Client: t.client, Repository: t.Repo}).ListPage(cmd.Context(), n, limit, page)
if err != nil {
return err
}
ref := fmt.Sprintf("%s#%d", t.Repo, n)
return writeTable(a, cmd, window.Items, listSpec[services.Review]{
Columns: []terminal.Column{{Header: "ID"}, {Header: "Reviewer"}, {Header: "State"}, {Header: "Submitted"}, {Header: "Comments"}, {Header: "Body", Flex: true}},
Row: func(item services.Review) []terminal.Cell {
return []terminal.Cell{
{Text: strconv.FormatInt(item.ID, 10), Link: item.URL},
{Text: item.Reviewer()},
reviewStateCell(item),
{Time: terminal.ParseTime(item.SubmittedAt)},
{Text: strconv.Itoa(item.CommentsCount)},
{Text: item.Body},
}
},
Empty: "no reviews on " + ref, Context: "reviews on " + ref,
}, servicePage(window, page))
}}
pageFlags(cmd, &limit, &page)
cmd.Example = `$ tea pr review list 13
$ tea pr review list --json id,author,state`
cmd.ValidArgsFunction = firstArg(a.completePulls("all"))
return cmd
}
func (a *application) pullReviewViewCommand() *cobra.Command {
cmd := &cobra.Command{Use: "view <number> <review-id>", Short: "View a review", Annotations: pullArgumentRequiredAnnotation(nil), Args: argsExact(2), RunE: func(cmd *cobra.Command, args []string) error {
t, n, id, err := a.pullAndID(cmd, args)
if err != nil {
return err
}
item, err := (services.Reviews{Client: t.client, Repository: t.Repo}).View(cmd.Context(), n, id)
if err != nil {
return err
}
now := a.deps.Now()
return writeView(a, cmd, item, func(item services.Review) terminal.View {
meta := item.Reviewer()
if submitted := terminal.ParseTime(item.SubmittedAt); !submitted.IsZero() {
meta += " • " + terminal.RelativeTime(now, submitted)
}
return terminal.View{
Title: fmt.Sprintf("Review on %s#%d", t.Repo, n), ID: strconv.FormatInt(item.ID, 10), Badges: []terminal.Cell{reviewStateCell(item)}, Meta: meta,
Fields: []terminal.Field{
{Label: "Reviewer", Value: item.Reviewer(), PipedOnly: true},
{Label: "State", Value: item.State, PipedOnly: true},
{Label: "Inline comments", Value: strconv.Itoa(item.CommentsCount)},
{Label: "Commit", Value: item.Commit},
},
Body: item.Body, Kind: "review", URL: item.URL,
}
})
}}
cmd.Example = "$ tea pr review view 13 6"
cmd.ValidArgsFunction = a.completeReviewArgs(false)
return cmd
}
func (a *application) pullReviewCommentsCommand() *cobra.Command {
var limit, page int
var reviewID int64
cmd := &cobra.Command{Use: "comments [<number> | <url> | <branch>]", Short: "List inline review comments", Annotations: pullArgumentsAnnotation(nil), Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error {
if reviewID < 0 {
return clierr.Wrap(clierr.Usage, errors.New("--review must be a positive review ID"))
}
t, n, err := a.pullSelector(cmd, args)
if err != nil {
return err
}
items, err := (services.Reviews{Client: t.client, Repository: t.Repo}).Comments(cmd.Context(), n, reviewID, limit, page)
if err != nil {
return err
}
ref := fmt.Sprintf("%s#%d", t.Repo, n)
return writeTable(a, cmd, items, listSpec[services.ReviewComment]{
Columns: []terminal.Column{{Header: "ID"}, {Header: "File"}, {Header: "Author"}, {Header: "Resolved"}, {Header: "Body", Flex: true}},
Row: func(item services.ReviewComment) []terminal.Cell {
return []terminal.Cell{{Text: strconv.FormatInt(item.ID, 10), Link: item.URL}, {Text: item.Location()}, {Text: item.Author}, {Text: item.Resolver}, {Text: item.Body}}
},
Empty: "no inline review comments on " + ref, Context: "inline review comments on " + ref,
}, listPage{Number: page, More: len(items) == limit})
}}
pageFlags(cmd, &limit, &page)
cmd.Flags().Int64Var(&reviewID, "review", 0, "Limit comments to one review `ID`")
cmd.Example = `$ tea pr review comments 13
$ tea pr review comments 13 --review 6`
cmd.ValidArgsFunction = firstArg(a.completePulls("all"))
return cmd
}
func (a *application) pullReviewResolveCommand(resolve bool) *cobra.Command {
verb, short, done := "resolve", "Mark an inline conversation as resolved", "Resolved"
if !resolve {
verb, short, done = "unresolve", "Reopen a resolved inline conversation", "Reopened"
}
cmd := &cobra.Command{Use: verb + " <comment-id>", Short: short, Annotations: textOutput("conversation state changes do not return a resource"), 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
}
if err := (services.Reviews{Client: t.client, Repository: t.Repo}).Resolve(cmd.Context(), id, resolve); err != nil {
return err
}
a.done(cmd, "%s conversation of review comment %d in %s", done, id, t.Repo)
return nil
}}
cmd.Example = fmt.Sprintf("# Find the comment IDs with: tea pr review comments 13\n$ tea pr review %s 36", verb)
return cmd
}
func (a *application) pullReviewReplyCommand() *cobra.Command {
var body, file string
var useEditor bool
cmd := &cobra.Command{Use: "reply <number> <comment-id>", Short: "Reply to an inline review comment", Annotations: pullArgumentRequiredAnnotation(nil), Args: argsExact(2), RunE: func(cmd *cobra.Command, args []string) (runErr error) {
var draft terminal.Draft
defer finishDraft(&draft, &runErr)
if err := validateBodySources(cmd); err != nil {
return err
}
t, n, id, err := a.pullAndID(cmd, args)
if err != nil {
return err
}
if useEditor {
draft, err = a.editDraft(cmd, "")
body = draft.Body
} else {
body, err = a.readBody(cmd, body, file)
}
if err != nil {
return err
}
if !useEditor && !cmd.Flags().Changed("body") && !cmd.Flags().Changed("body-file") {
if !a.canPrompt() {
return clierr.Wrap(clierr.Usage, errors.New("a reply needs a body; pass --body, --body-file or --editor"))
}
if draft, err = a.bodyEditor(cmd)(cmd.Context(), ""); err != nil {
return err
}
body = draft.Body
}
if strings.TrimSpace(body) == "" {
return clierr.Wrap(clierr.Usage, errors.New("reply body cannot be empty"))
}
item, err := (services.Reviews{Client: t.client, Repository: t.Repo}).Reply(cmd.Context(), n, id, body)
if err != nil {
return err
}
a.done(cmd, "Replied to review comment %d on %s#%d", id, t.Repo, n)
return a.created(cmd, item, item.URL)
}}
cmd.Flags().StringVarP(&body, "body", "b", "", "Reply body")
cmd.Flags().StringVarP(&file, "body-file", "F", "", "Read the body from `file`, or - for stdin")
cmd.Flags().BoolVarP(&useEditor, "editor", "e", false, "Write the reply in your editor")
cmd.Example = `$ tea pr review reply 13 36 -b "Done, the password is hashed now"`
cmd.ValidArgsFunction = a.completeReviewArgs(true)
return cmd
}
// completeReviewArgs completes an open pull request, then the IDs of its reviews, or of its inline
// review comments when inline is set.
func (a *application) completeReviewArgs(inline bool) cobra.CompletionFunc {
pulls := a.completePulls("open")
return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
if len(args) == 0 {
return pulls(cmd, args, toComplete)
}
if len(args) > 1 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
n, err := number(args[0])
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
service := services.Reviews{Client: t.client, Repository: t.Repo}
if inline {
items, err := service.Comments(ctx, n, 0, completionLimit, 1)
return describe(items, func(item services.ReviewComment) (string, string) {
return strconv.FormatInt(item.ID, 10), item.Author + " on " + item.Path + ": " + item.Body
}), err
}
items, err := service.List(ctx, n, completionLimit, 1)
return describe(items, func(item services.Review) (string, string) {
return strconv.FormatInt(item.ID, 10), reviewStateCell(item).Text + " by " + item.Reviewer()
}), err
})(cmd, args, toComplete)
}
}