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>
638 lines
24 KiB
Go
638 lines
24 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 pullArguments = `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"
|
|
|
|
Without an argument, the pull request of the current branch is selected.`
|
|
|
|
func pullArgumentsAnnotation(annotations map[string]string) map[string]string {
|
|
if annotations == nil {
|
|
annotations = map[string]string{}
|
|
}
|
|
annotations["tea.arguments"] = pullArguments
|
|
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
|
|
}
|
|
item, err := (services.Reviews{Client: t.client, Repository: t.Repo}).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 = 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,
|
|
}
|
|
if preferences.Editor != "" {
|
|
choices.ReviewDiff = func(context.Context) ([]terminal.ReviewCommentDraft, error) {
|
|
return a.collectDiffReviewComments(cmd, t, n)
|
|
}
|
|
}
|
|
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)
|
|
return err
|
|
}
|
|
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
|
|
}
|
|
|
|
func (a *application) collectDiffReviewComments(cmd *cobra.Command, t target, n int64) ([]terminal.ReviewCommentDraft, 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)
|
|
}
|
|
_ = os.Remove(path)
|
|
return comments, 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.Author + "'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
|
|
}
|
|
items, err := (services.Reviews{Client: t.client, Repository: t.Repo}).List(cmd.Context(), n, limit, page)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ref := fmt.Sprintf("%s#%d", t.Repo, n)
|
|
return writeTable(a, cmd, 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.Author},
|
|
reviewStateCell(item),
|
|
{Time: terminal.ParseTime(item.SubmittedAt)},
|
|
{Text: strconv.Itoa(item.CommentsCount)},
|
|
{Text: item.Body},
|
|
}
|
|
},
|
|
Empty: "no reviews on " + ref, Context: "reviews on " + ref,
|
|
}, listPage{Number: page, More: len(items) == limit})
|
|
}}
|
|
pageFlags(cmd, &limit, &page)
|
|
cmd.Example = `$ tea pr review list 13
|
|
$ tea pr review list --json id,author,state`
|
|
cmd.ValidArgsFunction = a.completePulls("all")
|
|
return cmd
|
|
}
|
|
|
|
func (a *application) pullReviewViewCommand() *cobra.Command {
|
|
cmd := &cobra.Command{Use: "view <number> <review-id>", Short: "View a review", Annotations: pullArgumentsAnnotation(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.Author
|
|
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.Author, 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 {
|
|
location := item.Path
|
|
if item.Line > 0 {
|
|
location = fmt.Sprintf("%s:%d", item.Path, item.Line)
|
|
}
|
|
return []terminal.Cell{{Text: strconv.FormatInt(item.ID, 10), Link: item.URL}, {Text: 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 = 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: pullArgumentsAnnotation(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)
|
|
}
|
|
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.Author
|
|
}), err
|
|
})(cmd, args, toComplete)
|
|
}
|
|
}
|