Merge branch 'trunk' into trunk

This commit is contained in:
Barak Amar 2025-05-01 22:16:55 +03:00 committed by GitHub
commit 232c024987
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 730 additions and 15 deletions

View file

@ -309,7 +309,7 @@ jobs:
rpmsign --addsign dist/*.rpm
- name: Attest release artifacts
if: inputs.environment == 'production'
uses: actions/attest-build-provenance@bd77c077858b8d561b7a36cbe48ef4cc642ca39d # v2.2.2
uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # v2.3.0
with:
subject-path: "dist/gh_*"
- name: Run createrepo

View file

@ -44,6 +44,10 @@ type CommentCreateInput struct {
SubjectId string
}
type CommentDeleteInput struct {
CommentId string
}
type CommentUpdateInput struct {
Body string
CommentId string
@ -99,6 +103,27 @@ func CommentUpdate(client *Client, repoHost string, params CommentUpdateInput) (
return mutation.UpdateIssueComment.IssueComment.URL, nil
}
func CommentDelete(client *Client, repoHost string, params CommentDeleteInput) error {
var mutation struct {
DeleteIssueComment struct {
ClientMutationID string
} `graphql:"deleteIssueComment(input: $input)"`
}
variables := map[string]interface{}{
"input": githubv4.DeleteIssueCommentInput{
ID: githubv4.ID(params.CommentId),
},
}
err := client.Mutate(repoHost, "CommentDelete", &mutation, variables)
if err != nil {
return err
}
return nil
}
func (c Comment) Identifier() string {
return c.ID
}

2
go.mod
View file

@ -17,7 +17,7 @@ require (
github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24
github.com/cli/oauth v1.1.1
github.com/cli/safeexec v1.0.1
github.com/cpuguy83/go-md2man/v2 v2.0.6
github.com/cpuguy83/go-md2man/v2 v2.0.7
github.com/creack/pty v1.1.24
github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7
github.com/distribution/reference v0.6.0

3
go.sum
View file

@ -150,8 +150,9 @@ github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUo
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4=
github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8=
github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU=
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=

View file

@ -76,6 +76,27 @@ func TestAccessiblePrompter(t *testing.T) {
assert.Equal(t, []int{0, 1}, multiSelectValue)
})
t.Run("MultiSelect - default values are respected by being pre-selected", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAccessiblePrompter(t, console)
go func() {
// Wait for prompt to appear
_, err := console.ExpectString("Select a number")
require.NoError(t, err)
// Don't select anything because the default should be selected.
// This confirms selections
_, err = console.SendLine("0")
require.NoError(t, err)
}()
multiSelectValue, err := p.MultiSelect("Select a number", []string{"2"}, []string{"1", "2", "3"})
require.NoError(t, err)
assert.Equal(t, []int{1}, multiSelectValue)
})
t.Run("Input", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAccessiblePrompter(t, console)

View file

@ -2,6 +2,7 @@ package prompter
import (
"fmt"
"slices"
"strings"
"github.com/AlecAivazis/survey/v2"
@ -100,6 +101,14 @@ func (p *accessiblePrompter) MultiSelect(prompt string, defaults []string, optio
var result []int
formOptions := make([]huh.Option[int], len(options))
for i, o := range options {
// If this option is in the defaults slice,
// let's add its index to the result slice and huh
// will treat it as a default selection.
// TODO: does an invalid default value constitute a panic?
if slices.Contains(defaults, o) {
result = append(result, i)
}
formOptions[i] = huh.NewOption(o, i)
}

View file

@ -0,0 +1,142 @@
package accessibility
import (
"fmt"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/browser"
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/spf13/cobra"
)
const (
webURL = "https://accessibility.github.com/conformance/cli/"
)
type AccessibilityOptions struct {
IO *iostreams.IOStreams
Browser browser.Browser
Web bool
}
func NewCmdAccessibility(f *cmdutil.Factory) *cobra.Command {
opts := AccessibilityOptions{
IO: f.IOStreams,
Browser: f.Browser,
}
cmd := &cobra.Command{
Use: "accessibility",
Aliases: []string{"a11y"},
Short: "Learn about GitHub CLI's accessibility experiences",
Long: longDescription(opts.IO),
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
if opts.Web {
if opts.IO.IsStdoutTTY() {
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(webURL))
}
return opts.Browser.Browse(webURL)
}
return cmd.Help()
},
Example: heredoc.Doc(`
# Open the GitHub Accessibility site in your browser
$ gh accessibility --web
# Display color using customizable, 4-bit accessible colors
$ gh config set accessible_colors enabled
# Use input prompts without redrawing the screen
$ gh config set accessible_prompter enabled
# Disable motion-based spinners for progress indicators in favor of text
$ gh config set spinner disabled
`),
}
cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open the GitHub Accessibility site in your browser")
cmdutil.DisableAuthCheck(cmd)
return cmd
}
func longDescription(io *iostreams.IOStreams) string {
cs := io.ColorScheme()
title := cs.Bold("Learn about GitHub CLI's accessibility experiences")
color := cs.Bold("Customizable and contrasting colors")
prompter := cs.Bold("Non-interactive user input prompting")
spinner := cs.Bold("Text-based spinners")
feedback := cs.Bold("Join the conversation")
return heredoc.Docf(`
%[2]s
As the home for all developers, we want every developer to feel welcome in our
community and be empowered to contribute to the future of global software
development with everything GitHub has to offer including the GitHub CLI.
%[3]s
Text interfaces often use color for various purposes, but insufficient contrast
or customizability can leave some users unable to benefit.
For a more accessible experience, the GitHub CLI can use color palettes
based on terminal background appearance and limit colors to 4-bit ANSI color
palettes, which users can customize within terminal preferences.
With this new experience, the GitHub CLI provides multiple options to address
color usage:
1. The GitHub CLI will use 4-bit color palette for increased color contrast based
on dark and light backgrounds including rendering Markdown based on the
GitHub Primer design system.
To enable this experience, use one of the following methods:
- Run %[1]sgh config set accessible_colors enabled%[1]s
- Set %[1]sGH_ACCESSIBLE_COLORS=enabled%[1]s environment variable
2. The GitHub CLI will display issue and pull request labels' custom RGB colors
in terminals with true color support.
To enable this experience, use one of the following methods:
- Run %[1]sgh config set color_labels enabled%[1]s
- Set %[1]sGH_COLOR_LABELS=enabled%[1]s environment variable
%[4]s
Interactive text user interfaces manipulate the terminal cursor to redraw parts
of the screen, which can be difficult for speech synthesizers or braille displays
to accurately detect and read.
For a more accessible experience, the GitHub CLI can provide a similar experience using
non-interactive prompts for user input.
To enable this experience, use one of the following methods:
- Run %[1]sgh config set accessible_prompter enabled%[1]s
- Set %[1]sGH_ACCESSIBLE_PROMPTER=enabled%[1]s environment variable
%[5]s
Motion-based spinners communicate in-progress activity by manipulating the
terminal cursor to create a spinning effect, which may cause discomfort to users
with motion sensitivity or miscommunicate information to speech synthesizers.
For a more accessible experience, this interactivity can be disabled in favor
of text-based progress indicators.
To enable this experience, use one of the following methods:
- Run %[1]sgh config set spinner disabled%[1]s
- Set %[1]sGH_SPINNER_DISABLED=yes%[1]s environment variable
%[6]s
We invite you to join us in improving GitHub CLI accessibility by sharing your
feedback and ideas through GitHub Accessibility feedback channels:
%[7]s
`, "`", title, color, prompter, spinner, feedback, webURL)
}

View file

@ -18,6 +18,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) e
InteractiveEditSurvey: prShared.CommentableInteractiveEditSurvey(f.Config, f.IOStreams),
ConfirmSubmitSurvey: prShared.CommentableConfirmSubmitSurvey(f.Prompter),
ConfirmCreateIfNoneSurvey: prShared.CommentableInteractiveCreateIfNoneSurvey(f.Prompter),
ConfirmDeleteLastComment: prShared.CommentableConfirmDeleteLastComment(f.Prompter),
OpenInBrowser: f.Browser.Browse,
}
@ -63,7 +64,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) e
}
fields := []string{"id", "url"}
if opts.EditLast {
if opts.EditLast || opts.DeleteLast {
fields = append(fields, "comments")
}
@ -96,7 +97,9 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) e
cmd.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read body text from `file` (use \"-\" to read from standard input)")
cmd.Flags().BoolP("editor", "e", false, "Skip prompts and open the text editor to write the body in")
cmd.Flags().BoolP("web", "w", false, "Open the web browser to write the comment")
cmd.Flags().BoolVar(&opts.EditLast, "edit-last", false, "Edit the last comment of the same author")
cmd.Flags().BoolVar(&opts.EditLast, "edit-last", false, "Edit the last comment of the current user")
cmd.Flags().BoolVar(&opts.DeleteLast, "delete-last", false, "Delete the last comment of the current user")
cmd.Flags().BoolVar(&opts.DeleteLastConfirmed, "yes", false, "Skip the delete confirmation prompt when --delete-last is provided")
cmd.Flags().BoolVar(&opts.CreateIfNone, "create-if-none", false, "Create a new comment if no comments are found. Can be used only with --edit-last")
return cmd

View file

@ -2,6 +2,7 @@ package comment
import (
"bytes"
"errors"
"fmt"
"net/http"
"os"
@ -31,11 +32,13 @@ func TestNewCmdComment(t *testing.T) {
stdin string
output shared.CommentableOptions
wantsErr bool
isTTY bool
}{
{
name: "no arguments",
input: "",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
@ -46,6 +49,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: 0,
Body: "",
},
isTTY: true,
wantsErr: false,
},
{
@ -56,6 +60,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: 0,
Body: "",
},
isTTY: true,
wantsErr: false,
},
{
@ -66,6 +71,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: shared.InputTypeInline,
Body: "test",
},
isTTY: true,
wantsErr: false,
},
{
@ -77,6 +83,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: shared.InputTypeInline,
Body: "this is on standard input",
},
isTTY: true,
wantsErr: false,
},
{
@ -87,6 +94,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: shared.InputTypeInline,
Body: "a body from file",
},
isTTY: true,
wantsErr: false,
},
{
@ -97,6 +105,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: shared.InputTypeEditor,
Body: "",
},
isTTY: true,
wantsErr: false,
},
{
@ -107,6 +116,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: shared.InputTypeWeb,
Body: "",
},
isTTY: true,
wantsErr: false,
},
{
@ -118,6 +128,7 @@ func TestNewCmdComment(t *testing.T) {
Body: "",
EditLast: true,
},
isTTY: true,
wantsErr: false,
},
{
@ -130,42 +141,110 @@ func TestNewCmdComment(t *testing.T) {
EditLast: true,
CreateIfNone: true,
},
isTTY: true,
wantsErr: false,
},
{
name: "delete last flag non-interactive",
input: "1 --delete-last",
isTTY: false,
wantsErr: true,
},
{
name: "delete last flag and pre-confirmation non-interactive",
input: "1 --delete-last --yes",
output: shared.CommentableOptions{
DeleteLast: true,
DeleteLastConfirmed: true,
},
isTTY: false,
wantsErr: false,
},
{
name: "delete last flag interactive",
input: "1 --delete-last",
output: shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
},
isTTY: true,
wantsErr: false,
},
{
name: "delete last flag and pre-confirmation interactive",
input: "1 --delete-last --yes",
output: shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
DeleteLastConfirmed: true,
},
isTTY: true,
wantsErr: false,
},
{
name: "delete last flag and pre-confirmation with web flag",
input: "1 --delete-last --yes --web",
isTTY: true,
wantsErr: true,
},
{
name: "delete last flag and pre-confirmation with editor flag",
input: "1 --delete-last --yes --editor",
isTTY: true,
wantsErr: true,
},
{
name: "delete last flag and pre-confirmation with body flag",
input: "1 --delete-last --yes --body",
isTTY: true,
wantsErr: true,
},
{
name: "delete pre-confirmation without delete last flag",
input: "1 --yes",
isTTY: true,
wantsErr: true,
},
{
name: "body and body-file flags",
input: "1 --body 'test' --body-file 'test-file.txt'",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
name: "editor and web flags",
input: "1 --editor --web",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
name: "editor and body flags",
input: "1 --editor --body test",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
name: "web and body flags",
input: "1 --web --body test",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
name: "editor, web, and body flags",
input: "1 --editor --web --body test",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
name: "create-if-none flag without edit-last",
input: "1 --create-if-none",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
}
@ -173,9 +252,10 @@ func TestNewCmdComment(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, stdin, _, _ := iostreams.Test()
ios.SetStdoutTTY(true)
ios.SetStdinTTY(true)
ios.SetStderrTTY(true)
isTTY := tt.isTTY
ios.SetStdoutTTY(isTTY)
ios.SetStdinTTY(isTTY)
ios.SetStderrTTY(isTTY)
if tt.stdin != "" {
_, _ = stdin.WriteString(tt.stdin)
@ -211,6 +291,8 @@ func TestNewCmdComment(t *testing.T) {
assert.Equal(t, tt.output.Interactive, gotOpts.Interactive)
assert.Equal(t, tt.output.InputType, gotOpts.InputType)
assert.Equal(t, tt.output.Body, gotOpts.Body)
assert.Equal(t, tt.output.DeleteLast, gotOpts.DeleteLast)
assert.Equal(t, tt.output.DeleteLastConfirmed, gotOpts.DeleteLastConfirmed)
})
}
}
@ -220,6 +302,7 @@ func Test_commentRun(t *testing.T) {
name string
input *shared.CommentableOptions
emptyComments bool
comments api.Comments
httpStubs func(*testing.T, *httpmock.Registry)
stdout string
stderr string
@ -255,6 +338,7 @@ func Test_commentRun(t *testing.T) {
},
emptyComments: true,
wantsErr: true,
stdout: "no comments found for current user",
},
{
name: "updating last comment with interactive editor succeeds if there are comments",
@ -331,6 +415,7 @@ func Test_commentRun(t *testing.T) {
},
emptyComments: true,
wantsErr: true,
stdout: "no comments found for current user",
},
{
name: "creating new comment with non-interactive editor succeeds",
@ -358,6 +443,7 @@ func Test_commentRun(t *testing.T) {
},
emptyComments: true,
wantsErr: true,
stdout: "no comments found for current user",
},
{
name: "updating last comment with non-interactive editor succeeds if there are comments",
@ -433,6 +519,117 @@ func Test_commentRun(t *testing.T) {
},
stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n",
},
{
name: "deleting last comment non-interactively without any comment",
input: &shared.CommentableOptions{
Interactive: false,
DeleteLast: true,
},
emptyComments: true,
wantsErr: true,
stdout: "no comments found for current user",
},
{
name: "deleting last comment interactively without any comment",
input: &shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
},
emptyComments: true,
wantsErr: true,
stdout: "no comments found for current user",
},
{
name: "deleting last comment non-interactively and pre-confirmed",
input: &shared.CommentableOptions{
Interactive: false,
DeleteLast: true,
DeleteLastConfirmed: true,
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentDelete(t, reg)
},
stderr: "Comment deleted\n",
},
{
name: "deleting last comment interactively and pre-confirmed",
input: &shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
DeleteLastConfirmed: true,
},
comments: api.Comments{Nodes: []api.Comment{
{ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-111", ViewerDidAuthor: true, Body: "comment body"},
}},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentDelete(t, reg)
},
stderr: "Comment deleted\n",
},
{
name: "deleting last comment interactively and confirmed",
input: &shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
ConfirmDeleteLastComment: func(body string) (bool, error) {
if body != "comment body" {
return false, errors.New("unexpected comment body")
}
return true, nil
},
},
comments: api.Comments{Nodes: []api.Comment{
{ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-111", ViewerDidAuthor: true, Body: "comment body"},
}},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentDelete(t, reg)
},
stdout: "! Deleted comments cannot be recovered.\n",
stderr: "Comment deleted\n",
},
{
name: "deleting last comment interactively and confirmation declined",
input: &shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
ConfirmDeleteLastComment: func(body string) (bool, error) {
if body != "comment body" {
return false, errors.New("unexpected comment body")
}
return true, nil
},
},
comments: api.Comments{Nodes: []api.Comment{
{ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-111", ViewerDidAuthor: true, Body: "comment body"},
}},
wantsErr: true,
stdout: "deletion not confirmed",
},
{
name: "deleting last comment interactively and confirmed with long comment body",
input: &shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
ConfirmDeleteLastComment: func(body string) (bool, error) {
if body != "Lorem ipsum dolor sit amet, consectet lo..." {
return false, errors.New("unexpected comment body")
}
return true, nil
},
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentDelete(t, reg)
},
comments: api.Comments{Nodes: []api.Comment{
{ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-111", ViewerDidAuthor: true, Body: "Lorem ipsum dolor sit amet, consectet lorem ipsum again"},
}},
wantsErr: false,
stdout: "! Deleted comments cannot be recovered.\n",
stderr: "Comment deleted\n",
},
}
for _, tt := range tests {
ios, _, stdout, stderr := iostreams.Test()
@ -458,6 +655,8 @@ func Test_commentRun(t *testing.T) {
if tt.emptyComments {
comments.Nodes = []api.Comment{}
} else if len(tt.comments.Nodes) > 0 {
comments = tt.comments
}
tt.input.RetrieveCommentable = func() (shared.Commentable, ghrepo.Interface, error) {
@ -472,6 +671,7 @@ func Test_commentRun(t *testing.T) {
err := shared.CommentableRun(tt.input)
if tt.wantsErr {
assert.Error(t, err)
assert.Equal(t, tt.stderr, stderr.String())
return
}
assert.NoError(t, err)
@ -508,3 +708,15 @@ func mockCommentUpdate(t *testing.T, reg *httpmock.Registry) {
}),
)
}
func mockCommentDelete(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`mutation CommentDelete\b`),
httpmock.GraphQLMutation(`
{ "data": { "deleteIssueComment": {} } }`,
func(inputs map[string]interface{}) {
assert.Equal(t, "id1", inputs["id"])
},
),
)
}

View file

@ -16,6 +16,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*shared.CommentableOptions) err
InteractiveEditSurvey: shared.CommentableInteractiveEditSurvey(f.Config, f.IOStreams),
ConfirmSubmitSurvey: shared.CommentableConfirmSubmitSurvey(f.Prompter),
ConfirmCreateIfNoneSurvey: shared.CommentableInteractiveCreateIfNoneSurvey(f.Prompter),
ConfirmDeleteLastComment: shared.CommentableConfirmDeleteLastComment(f.Prompter),
OpenInBrowser: f.Browser.Browse,
}
@ -43,7 +44,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*shared.CommentableOptions) err
selector = args[0]
}
fields := []string{"id", "url"}
if opts.EditLast {
if opts.EditLast || opts.DeleteLast {
fields = append(fields, "comments")
}
finder := shared.NewFinder(f)
@ -75,7 +76,9 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*shared.CommentableOptions) err
cmd.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read body text from `file` (use \"-\" to read from standard input)")
cmd.Flags().BoolP("editor", "e", false, "Skip prompts and open the text editor to write the body in")
cmd.Flags().BoolP("web", "w", false, "Open the web browser to write the comment")
cmd.Flags().BoolVar(&opts.EditLast, "edit-last", false, "Edit the last comment of the same author")
cmd.Flags().BoolVar(&opts.EditLast, "edit-last", false, "Edit the last comment of the current user")
cmd.Flags().BoolVar(&opts.DeleteLast, "delete-last", false, "Delete the last comment of the current user")
cmd.Flags().BoolVar(&opts.DeleteLastConfirmed, "yes", false, "Skip the delete confirmation prompt when --delete-last is provided")
cmd.Flags().BoolVar(&opts.CreateIfNone, "create-if-none", false, "Create a new comment if no comments are found. Can be used only with --edit-last")
return cmd

View file

@ -2,6 +2,7 @@ package comment
import (
"bytes"
"errors"
"fmt"
"net/http"
"os"
@ -31,6 +32,7 @@ func TestNewCmdComment(t *testing.T) {
stdin string
output shared.CommentableOptions
wantsErr bool
isTTY bool
}{
{
name: "no arguments",
@ -40,12 +42,14 @@ func TestNewCmdComment(t *testing.T) {
InputType: 0,
Body: "",
},
isTTY: true,
wantsErr: false,
},
{
name: "two arguments",
input: "1 2",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
@ -56,6 +60,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: 0,
Body: "",
},
isTTY: true,
wantsErr: false,
},
{
@ -66,6 +71,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: 0,
Body: "",
},
isTTY: true,
wantsErr: false,
},
{
@ -76,6 +82,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: 0,
Body: "",
},
isTTY: true,
wantsErr: false,
},
{
@ -86,6 +93,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: shared.InputTypeInline,
Body: "test",
},
isTTY: true,
wantsErr: false,
},
{
@ -97,6 +105,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: shared.InputTypeInline,
Body: "this is on standard input",
},
isTTY: true,
wantsErr: false,
},
{
@ -107,6 +116,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: shared.InputTypeInline,
Body: "a body from file",
},
isTTY: true,
wantsErr: false,
},
{
@ -117,6 +127,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: shared.InputTypeEditor,
Body: "",
},
isTTY: true,
wantsErr: false,
},
{
@ -127,6 +138,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: shared.InputTypeWeb,
Body: "",
},
isTTY: true,
wantsErr: false,
},
{
@ -138,6 +150,7 @@ func TestNewCmdComment(t *testing.T) {
Body: "",
EditLast: true,
},
isTTY: true,
wantsErr: false,
},
{
@ -150,42 +163,110 @@ func TestNewCmdComment(t *testing.T) {
EditLast: true,
CreateIfNone: true,
},
isTTY: true,
wantsErr: false,
},
{
name: "delete last flag non-interactive",
input: "1 --delete-last",
isTTY: false,
wantsErr: true,
},
{
name: "delete last flag and pre-confirmation non-interactive",
input: "1 --delete-last --yes",
output: shared.CommentableOptions{
DeleteLast: true,
DeleteLastConfirmed: true,
},
isTTY: false,
wantsErr: false,
},
{
name: "delete last flag interactive",
input: "1 --delete-last",
output: shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
},
isTTY: true,
wantsErr: false,
},
{
name: "delete last flag and pre-confirmation interactive",
input: "1 --delete-last --yes",
output: shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
DeleteLastConfirmed: true,
},
isTTY: true,
wantsErr: false,
},
{
name: "delete last flag and pre-confirmation with web flag",
input: "1 --delete-last --yes --web",
isTTY: true,
wantsErr: true,
},
{
name: "delete last flag and pre-confirmation with editor flag",
input: "1 --delete-last --yes --editor",
isTTY: true,
wantsErr: true,
},
{
name: "delete last flag and pre-confirmation with body flag",
input: "1 --delete-last --yes --body",
isTTY: true,
wantsErr: true,
},
{
name: "delete pre-confirmation without delete last flag",
input: "1 --yes",
isTTY: true,
wantsErr: true,
},
{
name: "body and body-file flags",
input: "1 --body 'test' --body-file 'test-file.txt'",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
name: "editor and web flags",
input: "1 --editor --web",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
name: "editor and body flags",
input: "1 --editor --body test",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
name: "web and body flags",
input: "1 --web --body test",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
name: "editor, web, and body flags",
input: "1 --editor --web --body test",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
name: "create-if-none flag without edit-last",
input: "1 --create-if-none",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
}
@ -193,9 +274,10 @@ func TestNewCmdComment(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, stdin, _, _ := iostreams.Test()
ios.SetStdoutTTY(true)
ios.SetStdinTTY(true)
ios.SetStderrTTY(true)
isTTY := tt.isTTY
ios.SetStdoutTTY(isTTY)
ios.SetStdinTTY(isTTY)
ios.SetStderrTTY(isTTY)
if tt.stdin != "" {
_, _ = stdin.WriteString(tt.stdin)
@ -231,6 +313,8 @@ func TestNewCmdComment(t *testing.T) {
assert.Equal(t, tt.output.Interactive, gotOpts.Interactive)
assert.Equal(t, tt.output.InputType, gotOpts.InputType)
assert.Equal(t, tt.output.Body, gotOpts.Body)
assert.Equal(t, tt.output.DeleteLast, gotOpts.DeleteLast)
assert.Equal(t, tt.output.DeleteLastConfirmed, gotOpts.DeleteLastConfirmed)
})
}
}
@ -240,6 +324,7 @@ func Test_commentRun(t *testing.T) {
name string
input *shared.CommentableOptions
emptyComments bool
comments api.Comments
httpStubs func(*testing.T, *httpmock.Registry)
stdout string
stderr string
@ -274,6 +359,7 @@ func Test_commentRun(t *testing.T) {
},
emptyComments: true,
wantsErr: true,
stdout: "no comments found for current user",
},
{
name: "updating last comment with interactive editor succeeds if there are comments",
@ -350,6 +436,7 @@ func Test_commentRun(t *testing.T) {
},
emptyComments: true,
wantsErr: true,
stdout: "no comments found for current user",
},
{
name: "creating new comment with non-interactive editor succeeds",
@ -377,6 +464,7 @@ func Test_commentRun(t *testing.T) {
},
emptyComments: true,
wantsErr: true,
stdout: "no comments found for current user",
},
{
name: "updating last comment with non-interactive editor succeeds if there are comments",
@ -451,6 +539,117 @@ func Test_commentRun(t *testing.T) {
},
stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
},
{
name: "deleting last comment non-interactively without any comment",
input: &shared.CommentableOptions{
Interactive: false,
DeleteLast: true,
},
emptyComments: true,
wantsErr: true,
stdout: "no comments found for current user",
},
{
name: "deleting last comment interactively without any comment",
input: &shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
},
emptyComments: true,
wantsErr: true,
stdout: "no comments found for current user",
},
{
name: "deleting last comment non-interactively and pre-confirmed",
input: &shared.CommentableOptions{
Interactive: false,
DeleteLast: true,
DeleteLastConfirmed: true,
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentDelete(t, reg)
},
stderr: "Comment deleted\n",
},
{
name: "deleting last comment interactively and pre-confirmed",
input: &shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
DeleteLastConfirmed: true,
},
comments: api.Comments{Nodes: []api.Comment{
{ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-111", ViewerDidAuthor: true, Body: "comment body"},
}},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentDelete(t, reg)
},
stderr: "Comment deleted\n",
},
{
name: "deleting last comment interactively and confirmed",
input: &shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
ConfirmDeleteLastComment: func(body string) (bool, error) {
if body != "comment body" {
return false, errors.New("unexpected comment body")
}
return true, nil
},
},
comments: api.Comments{Nodes: []api.Comment{
{ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-111", ViewerDidAuthor: true, Body: "comment body"},
}},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentDelete(t, reg)
},
stdout: "! Deleted comments cannot be recovered.\n",
stderr: "Comment deleted\n",
},
{
name: "deleting last comment interactively and confirmation declined",
input: &shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
ConfirmDeleteLastComment: func(body string) (bool, error) {
if body != "comment body" {
return false, errors.New("unexpected comment body")
}
return true, nil
},
},
comments: api.Comments{Nodes: []api.Comment{
{ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-111", ViewerDidAuthor: true, Body: "comment body"},
}},
wantsErr: true,
stdout: "deletion not confirmed",
},
{
name: "deleting last comment interactively and confirmed with long comment body",
input: &shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
ConfirmDeleteLastComment: func(body string) (bool, error) {
if body != "Lorem ipsum dolor sit amet, consectet lo..." {
return false, errors.New("unexpected comment body")
}
return true, nil
},
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentDelete(t, reg)
},
comments: api.Comments{Nodes: []api.Comment{
{ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-111", ViewerDidAuthor: true, Body: "Lorem ipsum dolor sit amet, consectet lorem ipsum again"},
}},
wantsErr: false,
stdout: "! Deleted comments cannot be recovered.\n",
stderr: "Comment deleted\n",
},
}
for _, tt := range tests {
ios, _, stdout, stderr := iostreams.Test()
@ -475,6 +674,8 @@ func Test_commentRun(t *testing.T) {
}}
if tt.emptyComments {
comments.Nodes = []api.Comment{}
} else if len(tt.comments.Nodes) > 0 {
comments = tt.comments
}
tt.input.RetrieveCommentable = func() (shared.Commentable, ghrepo.Interface, error) {
@ -489,6 +690,7 @@ func Test_commentRun(t *testing.T) {
err := shared.CommentableRun(tt.input)
if tt.wantsErr {
assert.Error(t, err)
assert.Equal(t, tt.stderr, stderr.String())
return
}
assert.NoError(t, err)
@ -524,3 +726,15 @@ func mockCommentUpdate(t *testing.T, reg *httpmock.Registry) {
}),
)
}
func mockCommentDelete(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`mutation CommentDelete\b`),
httpmock.GraphQLMutation(`
{ "data": { "deleteIssueComment": {} } }`,
func(inputs map[string]interface{}) {
assert.Equal(t, "id1", inputs["id"])
},
),
)
}

View file

@ -18,6 +18,7 @@ import (
)
var errNoUserComments = errors.New("no comments found for current user")
var errDeleteNotConfirmed = errors.New("deletion not confirmed")
type InputType int
@ -41,11 +42,14 @@ type CommentableOptions struct {
InteractiveEditSurvey func(string) (string, error)
ConfirmSubmitSurvey func() (bool, error)
ConfirmCreateIfNoneSurvey func() (bool, error)
ConfirmDeleteLastComment func(string) (bool, error)
OpenInBrowser func(string) error
Interactive bool
InputType InputType
Body string
EditLast bool
DeleteLast bool
DeleteLastConfirmed bool
CreateIfNone bool
Quiet bool
Host string
@ -74,6 +78,21 @@ func CommentablePreRun(cmd *cobra.Command, opts *CommentableOptions) error {
return cmdutil.FlagErrorf("`--create-if-none` can only be used with `--edit-last`")
}
if opts.DeleteLastConfirmed && !opts.DeleteLast {
return cmdutil.FlagErrorf("`--yes` should only be used with `--delete-last`")
}
if opts.DeleteLast {
if inputFlags > 0 {
return cmdutil.FlagErrorf("should not provide comment body when using `--delete-last`")
}
if opts.IO.CanPrompt() || opts.DeleteLastConfirmed {
opts.Interactive = opts.IO.CanPrompt()
return nil
}
return cmdutil.FlagErrorf("should provide `--yes` to confirm deletion in non-interactive mode")
}
if inputFlags == 0 {
if !opts.IO.CanPrompt() {
return cmdutil.FlagErrorf("flags required when not running interactively")
@ -92,6 +111,9 @@ func CommentableRun(opts *CommentableOptions) error {
return err
}
opts.Host = repo.RepoHost()
if opts.DeleteLast {
return deleteComment(commentable, opts)
}
// Create new comment, bail before complexities of updating the last comment
if !opts.EditLast {
@ -236,6 +258,53 @@ func updateComment(commentable Commentable, opts *CommentableOptions) error {
return nil
}
func deleteComment(commentable Commentable, opts *CommentableOptions) error {
comments := commentable.CurrentUserComments()
if len(comments) == 0 {
return errNoUserComments
}
lastComment := comments[len(comments)-1]
cs := opts.IO.ColorScheme()
if opts.Interactive && !opts.DeleteLastConfirmed {
// This is not an ideal way of truncating a random string that may
// contain emojis or other kind of wide chars.
truncated := lastComment.Body
if len(lastComment.Body) > 40 {
truncated = lastComment.Body[:40] + "..."
}
fmt.Fprintf(opts.IO.Out, "%s Deleted comments cannot be recovered.\n", cs.WarningIcon())
ok, err := opts.ConfirmDeleteLastComment(truncated)
if err != nil {
return err
}
if !ok {
return errDeleteNotConfirmed
}
}
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
params := api.CommentDeleteInput{CommentId: lastComment.Identifier()}
deletionErr := api.CommentDelete(apiClient, opts.Host, params)
if deletionErr != nil {
return deletionErr
}
if !opts.Quiet {
fmt.Fprintln(opts.IO.ErrOut, "Comment deleted")
}
return nil
}
func CommentableConfirmSubmitSurvey(p Prompt) func() (bool, error) {
return func() (bool, error) {
return p.Confirm("Submit?", true)
@ -271,6 +340,12 @@ func CommentableEditSurvey(cf func() (gh.Config, error), io *iostreams.IOStreams
}
}
func CommentableConfirmDeleteLastComment(p Prompt) func(string) (bool, error) {
return func(body string) (bool, error) {
return p.Confirm(fmt.Sprintf("Delete the comment: %q?", body), true)
}
}
func waitForEnter(r io.Reader) error {
scanner := bufio.NewScanner(r)
scanner.Scan()

View file

@ -109,8 +109,6 @@ func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, _ []string) {
return
}
namePadding := 12
type helpEntry struct {
Title string
Body string
@ -135,6 +133,12 @@ func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, _ []string) {
helpEntries = append(helpEntries, helpEntry{"ALIASES", strings.Join(BuildAliasList(command, command.Aliases), ", ") + "\n"})
}
// Statically calculated padding for non-extension commands,
// longest is `gh accessibility` with 13 characters + 1 space.
//
// Should consider novel way to calculate this in the future [AF]
namePadding := 14
for _, g := range GroupedCommands(command) {
var names []string
for _, c := range g.Commands {
@ -148,6 +152,9 @@ func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, _ []string) {
if isRootCmd(command) {
var helpTopics []string
if c := findCommand(command, "accessibility"); c != nil {
helpTopics = append(helpTopics, rpad(c.Name()+":", namePadding)+c.Short)
}
if c := findCommand(command, "actions"); c != nil {
helpTopics = append(helpTopics, rpad(c.Name()+":", namePadding)+c.Short)
}
@ -183,6 +190,7 @@ func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, _ []string) {
Use %[1]sgh <command> <subcommand> --help%[1]s for more information about a command.
Read the manual at https://cli.github.com/manual
Learn about exit codes using %[1]sgh help exit-codes%[1]s
Learn about accessibility experiences using %[1]sgh help accessibility%[1]s
`, "`")})
out := f.IOStreams.Out

View file

@ -6,6 +6,7 @@ import (
"strings"
"github.com/MakeNowJust/heredoc"
accessibilityCmd "github.com/cli/cli/v2/pkg/cmd/accessibility"
actionsCmd "github.com/cli/cli/v2/pkg/cmd/actions"
aliasCmd "github.com/cli/cli/v2/pkg/cmd/alias"
"github.com/cli/cli/v2/pkg/cmd/alias/shared"
@ -122,6 +123,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command,
// Child commands
cmd.AddCommand(versionCmd.NewCmdVersion(f, version, buildDate))
cmd.AddCommand(accessibilityCmd.NewCmdAccessibility(f))
cmd.AddCommand(actionsCmd.NewCmdActions(f))
cmd.AddCommand(aliasCmd.NewCmdAlias(f))
cmd.AddCommand(authCmd.NewCmdAuth(f))