Merge remote-tracking branch 'origin' into auth-with-ssh

This commit is contained in:
Mislav Marohnić 2021-02-17 15:29:36 +01:00
commit 4cd43cc8ef
25 changed files with 2402 additions and 227 deletions

View file

@ -24,44 +24,76 @@ type IssuesAndTotalCount struct {
}
type Issue struct {
ID string
Number int
Title string
URL string
State string
Closed bool
Body string
CreatedAt time.Time
UpdatedAt time.Time
Comments Comments
Author Author
Assignees struct {
Nodes []struct {
Login string
}
TotalCount int
ID string
Number int
Title string
URL string
State string
Closed bool
Body string
CreatedAt time.Time
UpdatedAt time.Time
Comments Comments
Author Author
Assignees Assignees
Labels Labels
ProjectCards ProjectCards
Milestone Milestone
ReactionGroups ReactionGroups
}
type Assignees struct {
Nodes []struct {
Login string
}
Labels struct {
Nodes []struct {
TotalCount int
}
func (a Assignees) Logins() []string {
logins := make([]string, len(a.Nodes))
for i, a := range a.Nodes {
logins[i] = a.Login
}
return logins
}
type Labels struct {
Nodes []struct {
Name string
}
TotalCount int
}
func (l Labels) Names() []string {
names := make([]string, len(l.Nodes))
for i, l := range l.Nodes {
names[i] = l.Name
}
return names
}
type ProjectCards struct {
Nodes []struct {
Project struct {
Name string
}
TotalCount int
}
ProjectCards struct {
Nodes []struct {
Project struct {
Name string
}
Column struct {
Name string
}
Column struct {
Name string
}
TotalCount int
}
Milestone struct {
Title string
TotalCount int
}
func (p ProjectCards) ProjectNames() []string {
names := make([]string, len(p.Nodes))
for i, c := range p.Nodes {
names[i] = c.Project.Name
}
ReactionGroups ReactionGroups
return names
}
type Milestone struct {
Title string
}
type IssuesDisabledError struct {
@ -488,6 +520,20 @@ func IssueDelete(client *Client, repo ghrepo.Interface, issue Issue) error {
return err
}
func IssueUpdate(client *Client, repo ghrepo.Interface, params githubv4.UpdateIssueInput) error {
var mutation struct {
UpdateIssue struct {
Issue struct {
ID string
}
} `graphql:"updateIssue(input: $input)"`
}
variables := map[string]interface{}{"input": params}
gql := graphQLClient(client.http, repo.RepoHost())
err := gql.MutateNamed(context.Background(), "IssueUpdate", &mutation, variables)
return err
}
// milestoneNodeIdToDatabaseId extracts the REST Database ID from the GraphQL Node ID
// This conversion is necessary since the GraphQL API requires the use of the milestone's database ID
// for querying the related issues.

View file

@ -16,10 +16,11 @@ import (
)
type PullRequestsPayload struct {
ViewerCreated PullRequestAndTotalCount
ReviewRequested PullRequestAndTotalCount
CurrentPR *PullRequest
DefaultBranch string
ViewerCreated PullRequestAndTotalCount
ReviewRequested PullRequestAndTotalCount
CurrentPR *PullRequest
DefaultBranch string
StrictProtection bool
}
type PullRequestAndTotalCount struct {
@ -28,16 +29,17 @@ type PullRequestAndTotalCount struct {
}
type PullRequest struct {
ID string
Number int
Title string
State string
Closed bool
URL string
BaseRefName string
HeadRefName string
Body string
Mergeable string
ID string
Number int
Title string
State string
Closed bool
URL string
BaseRefName string
HeadRefName string
Body string
Mergeable string
MergeStateStatus string
Author struct {
Login string
@ -80,45 +82,33 @@ type PullRequest struct {
}
}
}
ReviewRequests struct {
Nodes []struct {
RequestedReviewer struct {
TypeName string `json:"__typename"`
Login string
Name string
}
}
TotalCount int
}
Assignees struct {
Nodes []struct {
Login string
}
TotalCount int
}
Labels struct {
Nodes []struct {
Name string
}
TotalCount int
}
ProjectCards struct {
Nodes []struct {
Project struct {
Name string
}
Column struct {
Name string
}
}
TotalCount int
}
Milestone struct {
Title string
}
Assignees Assignees
Labels Labels
ProjectCards ProjectCards
Milestone Milestone
Comments Comments
ReactionGroups ReactionGroups
Reviews PullRequestReviews
ReviewRequests ReviewRequests
}
type ReviewRequests struct {
Nodes []struct {
RequestedReviewer struct {
TypeName string `json:"__typename"`
Login string
Name string
}
}
TotalCount int
}
func (r ReviewRequests) Logins() []string {
logins := make([]string, len(r.Nodes))
for i, a := range r.Nodes {
logins[i] = a.RequestedReviewer.Login
}
return logins
}
type NotFoundError struct {
@ -301,7 +291,10 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu
type response struct {
Repository struct {
DefaultBranchRef struct {
Name string
Name string
BranchProtectionRule struct {
RequiresStrictStatusChecks bool
}
}
PullRequests edges
PullRequest *PullRequest
@ -353,6 +346,7 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu
state
url
headRefName
mergeStateStatus
headRepositoryOwner {
login
}
@ -369,7 +363,12 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu
queryPrefix := `
query PullRequestStatus($owner: String!, $repo: String!, $headRefName: String!, $viewerQuery: String!, $reviewerQuery: String!, $per_page: Int = 10) {
repository(owner: $owner, name: $repo) {
defaultBranchRef { name }
defaultBranchRef {
name
branchProtectionRule {
requiresStrictStatusChecks
}
}
pullRequests(headRefName: $headRefName, first: $per_page, orderBy: { field: CREATED_AT, direction: DESC }) {
totalCount
edges {
@ -384,7 +383,12 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu
queryPrefix = `
query PullRequestStatus($owner: String!, $repo: String!, $number: Int!, $viewerQuery: String!, $reviewerQuery: String!, $per_page: Int = 10) {
repository(owner: $owner, name: $repo) {
defaultBranchRef { name }
defaultBranchRef {
name
branchProtectionRule {
requiresStrictStatusChecks
}
}
pullRequest(number: $number) {
...prWithReviews
}
@ -471,8 +475,9 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu
PullRequests: reviewRequested,
TotalCount: resp.ReviewRequested.TotalCount,
},
CurrentPR: currentPR,
DefaultBranch: resp.Repository.DefaultBranchRef.Name,
CurrentPR: currentPR,
DefaultBranch: resp.Repository.DefaultBranchRef.Name,
StrictProtection: resp.Repository.DefaultBranchRef.BranchProtectionRule.RequiresStrictStatusChecks,
}
return &payload, nil
@ -814,6 +819,7 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter
reviewParams["teamIds"] = ids
}
//TODO: How much work to extract this into own method and use for create and edit?
if len(reviewParams) > 0 {
reviewQuery := `
mutation PullRequestCreateRequestReviews($input: RequestReviewsInput!) {
@ -833,6 +839,34 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter
return pr, nil
}
func UpdatePullRequest(client *Client, repo ghrepo.Interface, params githubv4.UpdatePullRequestInput) error {
var mutation struct {
UpdatePullRequest struct {
PullRequest struct {
ID string
}
} `graphql:"updatePullRequest(input: $input)"`
}
variables := map[string]interface{}{"input": params}
gql := graphQLClient(client.http, repo.RepoHost())
err := gql.MutateNamed(context.Background(), "PullRequestUpdate", &mutation, variables)
return err
}
func UpdatePullRequestReviews(client *Client, repo ghrepo.Interface, params githubv4.RequestReviewsInput) error {
var mutation struct {
RequestReviews struct {
PullRequest struct {
ID string
}
} `graphql:"requestReviews(input: $input)"`
}
variables := map[string]interface{}{"input": params}
gql := graphQLClient(client.http, repo.RepoHost())
err := gql.MutateNamed(context.Background(), "PullRequestUpdateRequestReviews", &mutation, variables)
return err
}
func isBlank(v interface{}) bool {
switch vv := v.(type) {
case string:

View file

@ -4,7 +4,6 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
@ -497,7 +496,7 @@ func (m *RepoMetadataResult) MilestoneToID(title string) (string, error) {
return m.ID, nil
}
}
return "", errors.New("not found")
return "", fmt.Errorf("'%s' not found", title)
}
func (m *RepoMetadataResult) Merge(m2 *RepoMetadataResult) {

View file

@ -8,7 +8,9 @@ import (
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"time"
surveyCore "github.com/AlecAivazis/survey/v2/core"
"github.com/cli/cli/api"
@ -161,13 +163,16 @@ func main() {
newRelease := <-updateMessageChan
if newRelease != nil {
msg := fmt.Sprintf("%s %s → %s\n%s",
ghExe, _ := os.Executable()
fmt.Fprintf(stderr, "\n\n%s %s → %s\n",
ansi.Color("A new release of gh is available:", "yellow"),
ansi.Color(buildVersion, "cyan"),
ansi.Color(newRelease.Version, "cyan"),
ansi.Color(newRelease.Version, "cyan"))
if suggestBrewUpgrade(newRelease, ghExe) {
fmt.Fprintf(stderr, "To upgrade, run: %s\n", "brew update && brew upgrade gh")
}
fmt.Fprintf(stderr, "%s\n\n",
ansi.Color(newRelease.URL, "yellow"))
fmt.Fprintf(stderr, "\n\n%s\n\n", msg)
}
}
@ -259,3 +264,24 @@ func apiVerboseLog() api.ClientOption {
colorize := utils.IsTerminal(os.Stderr)
return api.VerboseLog(colorable.NewColorable(os.Stderr), logTraffic, colorize)
}
// Suggest to `brew upgrade gh` only if gh was found under homebrew prefix and when the release was
// published over 24h ago, allowing homebrew-core ample time to merge the formula bump.
func suggestBrewUpgrade(rel *update.ReleaseInfo, ghBinary string) bool {
if rel.PublishedAt.IsZero() || time.Since(rel.PublishedAt) < time.Duration(time.Hour*24) {
return false
}
brewExe, err := safeexec.LookPath("brew")
if err != nil {
return false
}
brewPrefixBytes, err := exec.Command(brewExe, "--prefix").Output()
if err != nil {
return false
}
brewBinPrefix := filepath.Join(strings.TrimSpace(string(brewPrefixBytes)), "bin") + string(filepath.Separator)
return strings.HasPrefix(ghBinary, brewBinPrefix)
}

View file

@ -18,8 +18,9 @@ var gitDescribeSuffixRE = regexp.MustCompile(`\d+-\d+-g[a-f0-9]{8}$`)
// ReleaseInfo stores information about a release
type ReleaseInfo struct {
Version string `json:"tag_name"`
URL string `json:"html_url"`
Version string `json:"tag_name"`
URL string `json:"html_url"`
PublishedAt time.Time `json:"published_at"`
}
type StateEntry struct {

View file

@ -55,44 +55,57 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
cmd := &cobra.Command{
Use: "api <endpoint>",
Short: "Make an authenticated GitHub API request",
Long: `Makes an authenticated HTTP request to the GitHub API and prints the response.
Long: heredoc.Docf(`
Makes an authenticated HTTP request to the GitHub API and prints the response.
The endpoint argument should either be a path of a GitHub API v3 endpoint, or
"graphql" to access the GitHub API v4.
The endpoint argument should either be a path of a GitHub API v3 endpoint, or
"graphql" to access the GitHub API v4.
Placeholder values ":owner", ":repo", and ":branch" in the endpoint argument will
get replaced with values from the repository of the current directory.
Placeholder values ":owner", ":repo", and ":branch" in the endpoint argument will
get replaced with values from the repository of the current directory.
The default HTTP request method is "GET" normally and "POST" if any parameters
were added. Override the method with '--method'.
The default HTTP request method is "GET" normally and "POST" if any parameters
were added. Override the method with %[1]s--method%[1]s.
Pass one or more '--raw-field' values in "key=value" format to add
JSON-encoded string parameters to the POST body.
Pass one or more %[1]s--raw-field%[1]s values in "key=value" format to add
JSON-encoded string parameters to the POST body.
The '--field' flag behaves like '--raw-field' with magic type conversion based
on the format of the value:
The %[1]s--field%[1]s flag behaves like %[1]s--raw-field%[1]s with magic type conversion based
on the format of the value:
- literal values "true", "false", "null", and integer numbers get converted to
appropriate JSON types;
- placeholder values ":owner", ":repo", and ":branch" get populated with values
from the repository of the current directory;
- if the value starts with "@", the rest of the value is interpreted as a
filename to read the value from. Pass "-" to read from standard input.
- literal values "true", "false", "null", and integer numbers get converted to
appropriate JSON types;
- placeholder values ":owner", ":repo", and ":branch" get populated with values
from the repository of the current directory;
- if the value starts with "@", the rest of the value is interpreted as a
filename to read the value from. Pass "-" to read from standard input.
For GraphQL requests, all fields other than "query" and "operationName" are
interpreted as GraphQL variables.
For GraphQL requests, all fields other than "query" and "operationName" are
interpreted as GraphQL variables.
Raw request body may be passed from the outside via a file specified by '--input'.
Pass "-" to read from standard input. In this mode, parameters specified via
'--field' flags are serialized into URL query parameters.
Raw request body may be passed from the outside via a file specified by %[1]s--input%[1]s.
Pass "-" to read from standard input. In this mode, parameters specified via
%[1]s--field%[1]s flags are serialized into URL query parameters.
In '--paginate' mode, all pages of results will sequentially be requested until
there are no more pages of results. For GraphQL requests, this requires that the
original query accepts an '$endCursor: String' variable and that it fetches the
'pageInfo{ hasNextPage, endCursor }' set of fields from a collection.`,
In %[1]s--paginate%[1]s mode, all pages of results will sequentially be requested until
there are no more pages of results. For GraphQL requests, this requires that the
original query accepts an %[1]s$endCursor: String%[1]s variable and that it fetches the
%[1]spageInfo{ hasNextPage, endCursor }%[1]s set of fields from a collection.
`, "`"),
Example: heredoc.Doc(`
# list releases in the current repository
$ gh api repos/:owner/:repo/releases
# post an issue comment
$ gh api repos/:owner/:repo/issues/123/comments -f body='Hi from CLI'
# add parameters to a GET request
$ gh api -X GET search/issues -f q='repo:cli/cli is:open remote'
# set a custom HTTP header
$ gh api -H 'Accept: application/vnd.github.XYZ-preview+json' ...
# list releases with GraphQL
$ gh api graphql -F owner=':owner' -F name=':repo' -f query='
query($name: String!, $owner: String!) {
repository(owner: $owner, name: $name) {
@ -103,6 +116,7 @@ original query accepts an '$endCursor: String' variable and that it fetches the
}
'
# list all repositories for a user
$ gh api graphql --paginate -f query='
query($endCursor: String) {
viewer {
@ -119,9 +133,11 @@ original query accepts an '$endCursor: String' variable and that it fetches the
`),
Annotations: map[string]string{
"help:environment": heredoc.Doc(`
GH_TOKEN, GITHUB_TOKEN (in order of precedence): an authentication token for github.com API requests.
GH_TOKEN, GITHUB_TOKEN (in order of precedence): an authentication token for
github.com API requests.
GH_ENTERPRISE_TOKEN, GITHUB_ENTERPRISE_TOKEN (in order of precedence): an authentication token for API requests to GitHub Enterprise.
GH_ENTERPRISE_TOKEN, GITHUB_ENTERPRISE_TOKEN (in order of precedence): an
authentication token for API requests to GitHub Enterprise.
GH_HOST: make the request to a GitHub host other than github.com.
`),
@ -153,12 +169,12 @@ original query accepts an '$endCursor: String' variable and that it fetches the
cmd.Flags().StringVar(&opts.Hostname, "hostname", "", "The GitHub hostname for the request (default \"github.com\")")
cmd.Flags().StringVarP(&opts.RequestMethod, "method", "X", "GET", "The HTTP method for the request")
cmd.Flags().StringArrayVarP(&opts.MagicFields, "field", "F", nil, "Add a parameter of inferred type")
cmd.Flags().StringArrayVarP(&opts.RawFields, "raw-field", "f", nil, "Add a string parameter")
cmd.Flags().StringArrayVarP(&opts.RequestHeaders, "header", "H", nil, "Add an additional HTTP request header")
cmd.Flags().StringArrayVarP(&opts.MagicFields, "field", "F", nil, "Add a typed parameter in `key=value` format")
cmd.Flags().StringArrayVarP(&opts.RawFields, "raw-field", "f", nil, "Add a string parameter in `key=value` format")
cmd.Flags().StringArrayVarP(&opts.RequestHeaders, "header", "H", nil, "Add a HTTP request header in `key:value` format")
cmd.Flags().BoolVarP(&opts.ShowResponseHeaders, "include", "i", false, "Include HTTP response headers in the output")
cmd.Flags().BoolVar(&opts.Paginate, "paginate", false, "Make additional HTTP requests to fetch all pages of results")
cmd.Flags().StringVar(&opts.RequestInputFile, "input", "", "The file to use as body for the HTTP request")
cmd.Flags().StringVar(&opts.RequestInputFile, "input", "", "The `file` to use as body for the HTTP request")
cmd.Flags().BoolVar(&opts.Silent, "silent", false, "Do not print the response body")
return cmd
}

View file

@ -87,6 +87,8 @@ func NewHTTPClient(io *iostreams.IOStreams, cfg config.Config, appVersion string
api.AddHeaderFunc("Accept", func(req *http.Request) (string, error) {
// antiope-preview: Checks
accept := "application/vnd.github.antiope-preview+json"
// introduced for #2952: pr branch up to date status
accept += ", application/vnd.github.merge-info-preview+json"
if ghinstance.IsEnterprise(req.URL.Hostname()) {
// shadow-cat-preview: Draft pull requests
accept += ", application/vnd.github.shadow-cat-preview"

View file

@ -27,7 +27,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
}
cmd := &cobra.Command{
Use: "delete {<gist ID> | <gist URL>}",
Use: "delete {<id> | <url>}",
Short: "Delete a gist",
Args: cmdutil.MinimumArgs(1, "cannot delete: gist argument required"),
RunE: func(c *cobra.Command, args []string) error {

View file

@ -47,7 +47,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
}
cmd := &cobra.Command{
Use: "edit {<gist ID> | <gist URL>}",
Use: "edit {<id> | <url>}",
Short: "Edit one of your gists",
Args: cmdutil.MinimumArgs(1, "cannot edit: gist argument required"),
RunE: func(c *cobra.Command, args []string) error {
@ -60,7 +60,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
return editRun(&opts)
},
}
cmd.Flags().StringVarP(&opts.Filename, "filename", "f", "", "a specific file to edit")
cmd.Flags().StringVarP(&opts.Filename, "filename", "f", "", "Select a file to edit")
return cmd
}

View file

@ -19,10 +19,11 @@ type ViewOptions struct {
IO *iostreams.IOStreams
HttpClient func() (*http.Client, error)
Selector string
Filename string
Raw bool
Web bool
Selector string
Filename string
Raw bool
Web bool
ListFiles bool
}
func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command {
@ -32,7 +33,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
}
cmd := &cobra.Command{
Use: "view {<gist id> | <gist url>}",
Use: "view {<id> | <url>}",
Short: "View a gist",
Args: cmdutil.MinimumArgs(1, "cannot view: gist argument required"),
RunE: func(cmd *cobra.Command, args []string) error {
@ -49,9 +50,10 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
},
}
cmd.Flags().BoolVarP(&opts.Raw, "raw", "r", false, "do not try and render markdown")
cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "open gist in browser")
cmd.Flags().StringVarP(&opts.Filename, "filename", "f", "", "display a single file of the gist")
cmd.Flags().BoolVarP(&opts.Raw, "raw", "r", false, "Print raw instead of rendered gist contents")
cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open gist in the browser")
cmd.Flags().BoolVarP(&opts.ListFiles, "files", "", false, "List file names from the gist")
cmd.Flags().StringVarP(&opts.Filename, "filename", "f", "", "Display a single file from the gist")
return cmd
}
@ -89,48 +91,71 @@ func viewRun(opts *ViewOptions) error {
return err
}
cs := opts.IO.ColorScheme()
if gist.Description != "" {
fmt.Fprintf(opts.IO.Out, "%s\n", cs.Bold(gist.Description))
theme := opts.IO.DetectTerminalTheme()
markdownStyle := markdown.GetStyle(theme)
if err := opts.IO.StartPager(); err != nil {
fmt.Fprintf(opts.IO.ErrOut, "starting pager failed: %v\n", err)
}
defer opts.IO.StopPager()
render := func(gf *shared.GistFile) error {
if strings.Contains(gf.Type, "markdown") && !opts.Raw {
rendered, err := markdown.Render(gf.Content, markdownStyle, "")
if err != nil {
return err
}
_, err = fmt.Fprint(opts.IO.Out, rendered)
return err
}
if _, err := fmt.Fprint(opts.IO.Out, gf.Content); err != nil {
return err
}
if !strings.HasSuffix(gf.Content, "\n") {
_, err := fmt.Fprint(opts.IO.Out, "\n")
return err
}
return nil
}
if opts.Filename != "" {
gistFile, ok := gist.Files[opts.Filename]
if !ok {
return fmt.Errorf("gist has no such file %q", opts.Filename)
return fmt.Errorf("gist has no such file: %q", opts.Filename)
}
return render(gistFile)
}
gist.Files = map[string]*shared.GistFile{
opts.Filename: gistFile,
}
cs := opts.IO.ColorScheme()
if gist.Description != "" && !opts.ListFiles {
fmt.Fprintf(opts.IO.Out, "%s\n\n", cs.Bold(gist.Description))
}
showFilenames := len(gist.Files) > 1
filenames := make([]string, 0, len(gist.Files))
for fn := range gist.Files {
filenames = append(filenames, fn)
}
sort.Strings(filenames)
outs := []string{} // to ensure consistent ordering
for filename, gistFile := range gist.Files {
out := ""
if showFilenames {
out += fmt.Sprintf("%s\n\n", cs.Gray(filename))
if opts.ListFiles {
for _, fn := range filenames {
fmt.Fprintln(opts.IO.Out, fn)
}
content := gistFile.Content
if strings.Contains(gistFile.Type, "markdown") && !opts.Raw {
style := markdown.GetStyle(opts.IO.DetectTerminalTheme())
rendered, err := markdown.Render(gistFile.Content, style, "")
if err == nil {
content = rendered
}
}
out += fmt.Sprintf("%s\n\n", content)
outs = append(outs, out)
return nil
}
sort.Strings(outs)
for _, out := range outs {
fmt.Fprint(opts.IO.Out, out)
for i, fn := range filenames {
if showFilenames {
fmt.Fprintf(opts.IO.Out, "%s\n\n", cs.Gray(fn))
}
if err := render(gist.Files[fn]); err != nil {
return err
}
if i < len(filenames)-1 {
fmt.Fprint(opts.IO.Out, "\n")
}
}
return nil

View file

@ -25,16 +25,18 @@ func TestNewCmdView(t *testing.T) {
tty: true,
cli: "123",
wants: ViewOptions{
Raw: false,
Selector: "123",
Raw: false,
Selector: "123",
ListFiles: false,
},
},
{
name: "nontty no arguments",
cli: "123",
wants: ViewOptions{
Raw: true,
Selector: "123",
Raw: true,
Selector: "123",
ListFiles: false,
},
},
{
@ -42,9 +44,20 @@ func TestNewCmdView(t *testing.T) {
cli: "-fcool.txt 123",
tty: true,
wants: ViewOptions{
Raw: false,
Selector: "123",
Filename: "cool.txt",
Raw: false,
Selector: "123",
Filename: "cool.txt",
ListFiles: false,
},
},
{
name: "files passed",
cli: "--files 123",
tty: true,
wants: ViewOptions{
Raw: false,
Selector: "123",
ListFiles: true,
},
},
}
@ -92,14 +105,16 @@ func Test_viewRun(t *testing.T) {
{
name: "no such gist",
opts: &ViewOptions{
Selector: "1234",
Selector: "1234",
ListFiles: false,
},
wantErr: true,
},
{
name: "one file",
opts: &ViewOptions{
Selector: "1234",
Selector: "1234",
ListFiles: false,
},
gist: &shared.Gist{
Files: map[string]*shared.GistFile{
@ -109,13 +124,14 @@ func Test_viewRun(t *testing.T) {
},
},
},
wantOut: "bwhiizzzbwhuiiizzzz\n\n",
wantOut: "bwhiizzzbwhuiiizzzz\n",
},
{
name: "filename selected",
opts: &ViewOptions{
Selector: "1234",
Filename: "cicada.txt",
Selector: "1234",
Filename: "cicada.txt",
ListFiles: false,
},
gist: &shared.Gist{
Files: map[string]*shared.GistFile{
@ -129,12 +145,35 @@ func Test_viewRun(t *testing.T) {
},
},
},
wantOut: "bwhiizzzbwhuiiizzzz\n\n",
wantOut: "bwhiizzzbwhuiiizzzz\n",
},
{
name: "filename selected, raw",
opts: &ViewOptions{
Selector: "1234",
Filename: "cicada.txt",
Raw: true,
ListFiles: false,
},
gist: &shared.Gist{
Files: map[string]*shared.GistFile{
"cicada.txt": {
Content: "bwhiizzzbwhuiiizzzz",
Type: "text/plain",
},
"foo.md": {
Content: "# foo",
Type: "application/markdown",
},
},
},
wantOut: "bwhiizzzbwhuiiizzzz\n",
},
{
name: "multiple files, no description",
opts: &ViewOptions{
Selector: "1234",
Selector: "1234",
ListFiles: false,
},
gist: &shared.Gist{
Files: map[string]*shared.GistFile{
@ -148,12 +187,33 @@ func Test_viewRun(t *testing.T) {
},
},
},
wantOut: "cicada.txt\n\nbwhiizzzbwhuiiizzzz\n\nfoo.md\n\n\n # foo \n\n\n\n",
wantOut: "cicada.txt\n\nbwhiizzzbwhuiiizzzz\n\nfoo.md\n\n\n # foo \n\n",
},
{
name: "multiple files, trailing newlines",
opts: &ViewOptions{
Selector: "1234",
ListFiles: false,
},
gist: &shared.Gist{
Files: map[string]*shared.GistFile{
"cicada.txt": {
Content: "bwhiizzzbwhuiiizzzz\n",
Type: "text/plain",
},
"foo.txt": {
Content: "bar\n",
Type: "text/plain",
},
},
},
wantOut: "cicada.txt\n\nbwhiizzzbwhuiiizzzz\n\nfoo.txt\n\nbar\n",
},
{
name: "multiple files, description",
opts: &ViewOptions{
Selector: "1234",
Selector: "1234",
ListFiles: false,
},
gist: &shared.Gist{
Description: "some files",
@ -168,13 +228,14 @@ func Test_viewRun(t *testing.T) {
},
},
},
wantOut: "some files\ncicada.txt\n\nbwhiizzzbwhuiiizzzz\n\nfoo.md\n\n\n \n • foo \n\n\n\n",
wantOut: "some files\n\ncicada.txt\n\nbwhiizzzbwhuiiizzzz\n\nfoo.md\n\n\n \n • foo \n\n",
},
{
name: "raw",
name: "multiple files, raw",
opts: &ViewOptions{
Selector: "1234",
Raw: true,
Selector: "1234",
Raw: true,
ListFiles: false,
},
gist: &shared.Gist{
Description: "some files",
@ -189,7 +250,47 @@ func Test_viewRun(t *testing.T) {
},
},
},
wantOut: "some files\ncicada.txt\n\nbwhiizzzbwhuiiizzzz\n\nfoo.md\n\n- foo\n\n",
wantOut: "some files\n\ncicada.txt\n\nbwhiizzzbwhuiiizzzz\n\nfoo.md\n\n- foo\n",
},
{
name: "one file, list files",
opts: &ViewOptions{
Selector: "1234",
Raw: false,
ListFiles: true,
},
gist: &shared.Gist{
Description: "some files",
Files: map[string]*shared.GistFile{
"cicada.txt": {
Content: "bwhiizzzbwhuiiizzzz",
Type: "text/plain",
},
},
},
wantOut: "cicada.txt\n",
},
{
name: "multiple file, list files",
opts: &ViewOptions{
Selector: "1234",
Raw: false,
ListFiles: true,
},
gist: &shared.Gist{
Description: "some files",
Files: map[string]*shared.GistFile{
"cicada.txt": {
Content: "bwhiizzzbwhuiiizzzz",
Type: "text/plain",
},
"foo.md": {
Content: "- foo",
Type: "application/markdown",
},
},
},
wantOut: "cicada.txt\nfoo.md\n",
},
}

227
pkg/cmd/issue/edit/edit.go Normal file
View file

@ -0,0 +1,227 @@
package edit
import (
"errors"
"fmt"
"net/http"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghrepo"
shared "github.com/cli/cli/pkg/cmd/issue/shared"
prShared "github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/shurcooL/githubv4"
"github.com/spf13/cobra"
)
type EditOptions struct {
HttpClient func() (*http.Client, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
DetermineEditor func() (string, error)
FieldsToEditSurvey func(*prShared.Editable) error
EditFieldsSurvey func(*prShared.Editable, string) error
FetchOptions func(*api.Client, ghrepo.Interface, *prShared.Editable) error
SelectorArg string
Interactive bool
prShared.Editable
}
func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Command {
opts := &EditOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
DetermineEditor: func() (string, error) { return cmdutil.DetermineEditor(f.Config) },
FieldsToEditSurvey: prShared.FieldsToEditSurvey,
EditFieldsSurvey: prShared.EditFieldsSurvey,
FetchOptions: prShared.FetchOptions,
}
cmd := &cobra.Command{
Use: "edit {<number> | <url>}",
Short: "Edit an issue",
Example: heredoc.Doc(`
$ gh issue edit 23 --title "I found a bug" --body "Nothing works"
$ gh issue edit 23 --add-label "bug,help wanted" --remove-label "core"
$ gh issue edit 23 --add-assignee @me --remove-assignee monalisa,hubot
$ gh issue edit 23 --add-project "Roadmap" --remove-project v1,v2
$ gh issue edit 23 --milestone "Version 1"
`),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
opts.SelectorArg = args[0]
flags := cmd.Flags()
if flags.Changed("title") {
opts.Editable.Title.Edited = true
}
if flags.Changed("body") {
opts.Editable.Body.Edited = true
}
if flags.Changed("add-assignee") || flags.Changed("remove-assignee") {
opts.Editable.Assignees.Edited = true
}
if flags.Changed("add-label") || flags.Changed("remove-label") {
opts.Editable.Labels.Edited = true
}
if flags.Changed("add-project") || flags.Changed("remove-project") {
opts.Editable.Projects.Edited = true
}
if flags.Changed("milestone") {
opts.Editable.Milestone.Edited = true
}
if !opts.Editable.Dirty() {
opts.Interactive = true
}
if opts.Interactive && !opts.IO.CanPrompt() {
return &cmdutil.FlagError{Err: errors.New("field to edit flag required when not running interactively")}
}
if runF != nil {
return runF(opts)
}
return editRun(opts)
},
}
cmd.Flags().StringVarP(&opts.Editable.Title.Value, "title", "t", "", "Set the new title.")
cmd.Flags().StringVarP(&opts.Editable.Body.Value, "body", "b", "", "Set the new body.")
cmd.Flags().StringSliceVar(&opts.Editable.Assignees.Add, "add-assignee", nil, "Add assigned users by their `login`. Use \"@me\" to assign yourself.")
cmd.Flags().StringSliceVar(&opts.Editable.Assignees.Remove, "remove-assignee", nil, "Remove assigned users by their `login`. Use \"@me\" to unassign yourself.")
cmd.Flags().StringSliceVar(&opts.Editable.Labels.Add, "add-label", nil, "Add labels by `name`")
cmd.Flags().StringSliceVar(&opts.Editable.Labels.Remove, "remove-label", nil, "Remove labels by `name`")
cmd.Flags().StringSliceVar(&opts.Editable.Projects.Add, "add-project", nil, "Add the issue to projects by `name`")
cmd.Flags().StringSliceVar(&opts.Editable.Projects.Remove, "remove-project", nil, "Remove the issue from projects by `name`")
cmd.Flags().StringVarP(&opts.Editable.Milestone.Value, "milestone", "m", "", "Edit the milestone the issue belongs to by `name`")
return cmd
}
func editRun(opts *EditOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
issue, repo, err := shared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg)
if err != nil {
return err
}
editable := opts.Editable
editable.Title.Default = issue.Title
editable.Body.Default = issue.Body
editable.Assignees.Default = issue.Assignees.Logins()
editable.Labels.Default = issue.Labels.Names()
editable.Projects.Default = issue.ProjectCards.ProjectNames()
editable.Milestone.Default = issue.Milestone.Title
if opts.Interactive {
err = opts.FieldsToEditSurvey(&editable)
if err != nil {
return err
}
}
opts.IO.StartProgressIndicator()
err = opts.FetchOptions(apiClient, repo, &editable)
opts.IO.StopProgressIndicator()
if err != nil {
return err
}
if opts.Interactive {
editorCommand, err := opts.DetermineEditor()
if err != nil {
return err
}
err = opts.EditFieldsSurvey(&editable, editorCommand)
if err != nil {
return err
}
}
opts.IO.StartProgressIndicator()
err = updateIssue(apiClient, repo, issue.ID, editable)
opts.IO.StopProgressIndicator()
if err != nil {
return err
}
fmt.Fprintln(opts.IO.Out, issue.URL)
return nil
}
func updateIssue(client *api.Client, repo ghrepo.Interface, id string, options prShared.Editable) error {
var err error
params := githubv4.UpdateIssueInput{
ID: id,
Title: ghString(options.TitleValue()),
Body: ghString(options.BodyValue()),
}
assigneeIds, err := options.AssigneeIds(client, repo)
if err != nil {
return err
}
params.AssigneeIDs = ghIds(assigneeIds)
labelIds, err := options.LabelIds()
if err != nil {
return err
}
params.LabelIDs = ghIds(labelIds)
projectIds, err := options.ProjectIds()
if err != nil {
return err
}
params.ProjectIDs = ghIds(projectIds)
milestoneId, err := options.MilestoneId()
if err != nil {
return err
}
params.MilestoneID = ghId(milestoneId)
return api.IssueUpdate(client, repo, params)
}
func ghIds(s *[]string) *[]githubv4.ID {
if s == nil {
return nil
}
ids := make([]githubv4.ID, len(*s))
for i, v := range *s {
ids[i] = v
}
return &ids
}
func ghId(s *string) *githubv4.ID {
if s == nil {
return nil
}
if *s == "" {
r := githubv4.ID(nil)
return &r
}
r := githubv4.ID(*s)
return &r
}
func ghString(s *string) *githubv4.String {
if s == nil {
return nil
}
r := githubv4.String(*s)
return &r
}

View file

@ -0,0 +1,395 @@
package edit
import (
"bytes"
"net/http"
"testing"
"github.com/cli/cli/internal/ghrepo"
prShared "github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
)
func TestNewCmdEdit(t *testing.T) {
tests := []struct {
name string
input string
output EditOptions
wantsErr bool
}{
{
name: "no argument",
input: "",
output: EditOptions{},
wantsErr: true,
},
{
name: "issue number argument",
input: "23",
output: EditOptions{
SelectorArg: "23",
Interactive: true,
},
wantsErr: false,
},
{
name: "title flag",
input: "23 --title test",
output: EditOptions{
SelectorArg: "23",
Editable: prShared.Editable{
Title: prShared.EditableString{
Value: "test",
Edited: true,
},
},
},
wantsErr: false,
},
{
name: "body flag",
input: "23 --body test",
output: EditOptions{
SelectorArg: "23",
Editable: prShared.Editable{
Body: prShared.EditableString{
Value: "test",
Edited: true,
},
},
},
wantsErr: false,
},
{
name: "add-assignee flag",
input: "23 --add-assignee monalisa,hubot",
output: EditOptions{
SelectorArg: "23",
Editable: prShared.Editable{
Assignees: prShared.EditableSlice{
Add: []string{"monalisa", "hubot"},
Edited: true,
},
},
},
wantsErr: false,
},
{
name: "remove-assignee flag",
input: "23 --remove-assignee monalisa,hubot",
output: EditOptions{
SelectorArg: "23",
Editable: prShared.Editable{
Assignees: prShared.EditableSlice{
Remove: []string{"monalisa", "hubot"},
Edited: true,
},
},
},
wantsErr: false,
},
{
name: "add-label flag",
input: "23 --add-label feature,TODO,bug",
output: EditOptions{
SelectorArg: "23",
Editable: prShared.Editable{
Labels: prShared.EditableSlice{
Add: []string{"feature", "TODO", "bug"},
Edited: true,
},
},
},
wantsErr: false,
},
{
name: "remove-label flag",
input: "23 --remove-label feature,TODO,bug",
output: EditOptions{
SelectorArg: "23",
Editable: prShared.Editable{
Labels: prShared.EditableSlice{
Remove: []string{"feature", "TODO", "bug"},
Edited: true,
},
},
},
wantsErr: false,
},
{
name: "add-project flag",
input: "23 --add-project Cleanup,Roadmap",
output: EditOptions{
SelectorArg: "23",
Editable: prShared.Editable{
Projects: prShared.EditableSlice{
Add: []string{"Cleanup", "Roadmap"},
Edited: true,
},
},
},
wantsErr: false,
},
{
name: "remove-project flag",
input: "23 --remove-project Cleanup,Roadmap",
output: EditOptions{
SelectorArg: "23",
Editable: prShared.Editable{
Projects: prShared.EditableSlice{
Remove: []string{"Cleanup", "Roadmap"},
Edited: true,
},
},
},
wantsErr: false,
},
{
name: "milestone flag",
input: "23 --milestone GA",
output: EditOptions{
SelectorArg: "23",
Editable: prShared.Editable{
Milestone: prShared.EditableString{
Value: "GA",
Edited: true,
},
},
},
wantsErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, _, _, _ := iostreams.Test()
io.SetStdoutTTY(true)
io.SetStdinTTY(true)
io.SetStderrTTY(true)
f := &cmdutil.Factory{
IOStreams: io,
}
argv, err := shlex.Split(tt.input)
assert.NoError(t, err)
var gotOpts *EditOptions
cmd := NewCmdEdit(f, func(opts *EditOptions) error {
gotOpts = opts
return nil
})
cmd.Flags().BoolP("help", "x", false, "")
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
_, err = cmd.ExecuteC()
if tt.wantsErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.output.SelectorArg, gotOpts.SelectorArg)
assert.Equal(t, tt.output.Interactive, gotOpts.Interactive)
assert.Equal(t, tt.output.Editable, gotOpts.Editable)
})
}
}
func Test_editRun(t *testing.T) {
tests := []struct {
name string
input *EditOptions
httpStubs func(*testing.T, *httpmock.Registry)
stdout string
stderr string
}{
{
name: "non-interactive",
input: &EditOptions{
SelectorArg: "123",
Interactive: false,
Editable: prShared.Editable{
Title: prShared.EditableString{
Value: "new title",
Edited: true,
},
Body: prShared.EditableString{
Value: "new body",
Edited: true,
},
Assignees: prShared.EditableSlice{
Add: []string{"monalisa", "hubot"},
Remove: []string{"octocat"},
Edited: true,
},
Labels: prShared.EditableSlice{
Add: []string{"feature", "TODO", "bug"},
Remove: []string{"docs"},
Edited: true,
},
Projects: prShared.EditableSlice{
Add: []string{"Cleanup", "Roadmap"},
Remove: []string{"Features"},
Edited: true,
},
Milestone: prShared.EditableString{
Value: "GA",
Edited: true,
},
},
FetchOptions: prShared.FetchOptions,
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockIssueGet(t, reg)
mockRepoMetadata(t, reg)
mockIssueUpdate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/issue/123\n",
},
{
name: "interactive",
input: &EditOptions{
SelectorArg: "123",
Interactive: true,
FieldsToEditSurvey: func(eo *prShared.Editable) error {
eo.Title.Edited = true
eo.Body.Edited = true
eo.Assignees.Edited = true
eo.Labels.Edited = true
eo.Projects.Edited = true
eo.Milestone.Edited = true
return nil
},
EditFieldsSurvey: func(eo *prShared.Editable, _ string) error {
eo.Title.Value = "new title"
eo.Body.Value = "new body"
eo.Assignees.Value = []string{"monalisa", "hubot"}
eo.Labels.Value = []string{"feature", "TODO", "bug"}
eo.Projects.Value = []string{"Cleanup", "Roadmap"}
eo.Milestone.Value = "GA"
return nil
},
FetchOptions: prShared.FetchOptions,
DetermineEditor: func() (string, error) { return "vim", nil },
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockIssueGet(t, reg)
mockRepoMetadata(t, reg)
mockIssueUpdate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/issue/123\n",
},
}
for _, tt := range tests {
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(true)
io.SetStdinTTY(true)
io.SetStderrTTY(true)
reg := &httpmock.Registry{}
defer reg.Verify(t)
tt.httpStubs(t, reg)
httpClient := func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }
baseRepo := func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }
tt.input.IO = io
tt.input.HttpClient = httpClient
tt.input.BaseRepo = baseRepo
t.Run(tt.name, func(t *testing.T) {
err := editRun(tt.input)
assert.NoError(t, err)
assert.Equal(t, tt.stdout, stdout.String())
assert.Equal(t, tt.stderr, stderr.String())
})
}
}
func mockIssueGet(_ *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "hasIssuesEnabled": true, "issue": {
"number": 123,
"url": "https://github.com/OWNER/REPO/issue/123"
} } } }`),
)
}
func mockRepoMetadata(_ *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query RepositoryAssignableUsers\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "assignableUsers": {
"nodes": [
{ "login": "hubot", "id": "HUBOTID" },
{ "login": "MonaLisa", "id": "MONAID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query RepositoryLabelList\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "labels": {
"nodes": [
{ "name": "feature", "id": "FEATUREID" },
{ "name": "TODO", "id": "TODOID" },
{ "name": "bug", "id": "BUGID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query RepositoryMilestoneList\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "milestones": {
"nodes": [
{ "title": "GA", "id": "GAID" },
{ "title": "Big One.oh", "id": "BIGONEID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query RepositoryProjectList\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "projects": {
"nodes": [
{ "name": "Cleanup", "id": "CLEANUPID" },
{ "name": "Roadmap", "id": "ROADMAPID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query OrganizationProjectList\b`),
httpmock.StringResponse(`
{ "data": { "organization": { "projects": {
"nodes": [
{ "name": "Triage", "id": "TRIAGEID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
}
func mockIssueUpdate(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`mutation IssueUpdate\b`),
httpmock.GraphQLMutation(`
{ "data": { "updateIssue": { "issue": {
"id": "123"
} } } }`,
func(inputs map[string]interface{}) {}),
)
}

View file

@ -6,6 +6,7 @@ import (
cmdComment "github.com/cli/cli/pkg/cmd/issue/comment"
cmdCreate "github.com/cli/cli/pkg/cmd/issue/create"
cmdDelete "github.com/cli/cli/pkg/cmd/issue/delete"
cmdEdit "github.com/cli/cli/pkg/cmd/issue/edit"
cmdList "github.com/cli/cli/pkg/cmd/issue/list"
cmdReopen "github.com/cli/cli/pkg/cmd/issue/reopen"
cmdStatus "github.com/cli/cli/pkg/cmd/issue/status"
@ -44,6 +45,7 @@ func NewCmdIssue(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(cmdView.NewCmdView(f, nil))
cmd.AddCommand(cmdComment.NewCmdComment(f, nil))
cmd.AddCommand(cmdDelete.NewCmdDelete(f, nil))
cmd.AddCommand(cmdEdit.NewCmdEdit(f, nil))
return cmd
}

View file

@ -84,20 +84,23 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
cmd := &cobra.Command{
Use: "create",
Short: "Create a pull request",
Long: heredoc.Doc(`
Long: heredoc.Docf(`
Create a pull request on GitHub.
When the current branch isn't fully pushed to a git remote, a prompt will ask where
to push the branch and offer an option to fork the base repository. Use '--head' to
to push the branch and offer an option to fork the base repository. Use %[1]s--head%[1]s to
explicitly skip any forking or pushing behavior.
A prompt will also ask for the title and the body of the pull request. Use '--title'
and '--body' to skip this, or use '--fill' to autofill these values from git commits.
A prompt will also ask for the title and the body of the pull request. Use %[1]s--title%[1]s
and %[1]s--body%[1]s to skip this, or use %[1]s--fill%[1]s to autofill these values from git commits.
By default users with write access to the base respository can add new commits to your branch.
If undesired, you may disable access of maintainers by using '--no-maintainer-edit'
You can always change this setting later via the web interface.
`),
Link an issue to the pull request by referencing the issue in the body of the pull
request. If the body text mentions %[1]sFixes #123%[1]s or %[1]sCloses #123%[1]s, the referenced issue
will automatically get closed when the pull request gets merged.
By default, users with write access to the base respository can push new commits to the
head branch of the pull request. Disable this with %[1]s--no-maintainer-edit%[1]s.
`, "`"),
Example: heredoc.Doc(`
$ gh pr create --title "The bug is fixed" --body "Everything works again"
$ gh pr create --reviewer monalisa,hubot --reviewer myorg/team-name
@ -113,21 +116,21 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
opts.MaintainerCanModify = !noMaintainerEdit
if !opts.IO.CanPrompt() && opts.RecoverFile != "" {
return &cmdutil.FlagError{Err: errors.New("--recover only supported when running interactively")}
return &cmdutil.FlagError{Err: errors.New("`--recover` only supported when running interactively")}
}
if !opts.IO.CanPrompt() && !opts.WebMode && !opts.TitleProvided && !opts.Autofill {
return &cmdutil.FlagError{Err: errors.New("--title or --fill required when not running interactively")}
return &cmdutil.FlagError{Err: errors.New("`--title` or `--fill` required when not running interactively")}
}
if opts.IsDraft && opts.WebMode {
return errors.New("the --draft flag is not supported with --web")
return errors.New("the `--draft` flag is not supported with `--web`")
}
if len(opts.Reviewers) > 0 && opts.WebMode {
return errors.New("the --reviewer flag is not supported with --web")
return errors.New("the `--reviewer` flag is not supported with `--web`")
}
if cmd.Flags().Changed("no-maintainer-edit") && opts.WebMode {
return errors.New("the --no-maintainer-edit flag is not supported with --web")
return errors.New("the `--no-maintainer-edit` flag is not supported with `--web`")
}
if runF != nil {

View file

@ -120,7 +120,7 @@ func TestPRCreate_nontty_insufficient_flags(t *testing.T) {
defer http.Verify(t)
output, err := runCommand(http, nil, "feature", false, "")
assert.EqualError(t, err, "--title or --fill required when not running interactively")
assert.EqualError(t, err, "`--title` or `--fill` required when not running interactively")
assert.Equal(t, "", output.String())
}

297
pkg/cmd/pr/edit/edit.go Normal file
View file

@ -0,0 +1,297 @@
package edit
import (
"errors"
"fmt"
"net/http"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/context"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
shared "github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/shurcooL/githubv4"
"github.com/spf13/cobra"
)
type EditOptions struct {
HttpClient func() (*http.Client, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
Remotes func() (context.Remotes, error)
Branch func() (string, error)
Surveyor Surveyor
Fetcher EditableOptionsFetcher
EditorRetriever EditorRetriever
SelectorArg string
Interactive bool
shared.Editable
}
func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Command {
opts := &EditOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
Remotes: f.Remotes,
Branch: f.Branch,
Surveyor: surveyor{},
Fetcher: fetcher{},
EditorRetriever: editorRetriever{config: f.Config},
}
cmd := &cobra.Command{
Use: "edit {<number> | <url>}",
Short: "Edit a pull request",
Example: heredoc.Doc(`
$ gh pr edit 23 --title "I found a bug" --body "Nothing works"
$ gh pr edit 23 --add-label "bug,help wanted" --remove-label "core"
$ gh pr edit 23 --add-reviewer monalisa,hubot --remove-reviewer myorg/team-name
$ gh pr edit 23 --add-assignee @me --remove-assignee monalisa,hubot
$ gh pr edit 23 --add-project "Roadmap" --remove-project v1,v2
$ gh pr edit 23 --milestone "Version 1"
`),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
opts.SelectorArg = args[0]
flags := cmd.Flags()
if flags.Changed("title") {
opts.Editable.Title.Edited = true
}
if flags.Changed("body") {
opts.Editable.Body.Edited = true
}
if flags.Changed("add-reviewer") || flags.Changed("remove-reviewer") {
opts.Editable.Reviewers.Edited = true
}
if flags.Changed("add-assignee") || flags.Changed("remove-assignee") {
opts.Editable.Assignees.Edited = true
}
if flags.Changed("add-label") || flags.Changed("remove-label") {
opts.Editable.Labels.Edited = true
}
if flags.Changed("add-project") || flags.Changed("remove-project") {
opts.Editable.Projects.Edited = true
}
if flags.Changed("milestone") {
opts.Editable.Milestone.Edited = true
}
if !opts.Editable.Dirty() {
opts.Interactive = true
}
if opts.Interactive && !opts.IO.CanPrompt() {
return &cmdutil.FlagError{Err: errors.New("--tile, --body, --reviewer, --assignee, --label, --project, or --milestone required when not running interactively")}
}
if runF != nil {
return runF(opts)
}
return editRun(opts)
},
}
cmd.Flags().StringVarP(&opts.Editable.Title.Value, "title", "t", "", "Set the new title.")
cmd.Flags().StringVarP(&opts.Editable.Body.Value, "body", "b", "", "Set the new body.")
cmd.Flags().StringSliceVar(&opts.Editable.Reviewers.Add, "add-reviewer", nil, "Add reviewers by their `login`.")
cmd.Flags().StringSliceVar(&opts.Editable.Reviewers.Remove, "remove-reviewer", nil, "Remove reviewers by their `login`.")
cmd.Flags().StringSliceVar(&opts.Editable.Assignees.Add, "add-assignee", nil, "Add assigned users by their `login`. Use \"@me\" to assign yourself.")
cmd.Flags().StringSliceVar(&opts.Editable.Assignees.Remove, "remove-assignee", nil, "Remove assigned users by their `login`. Use \"@me\" to unassign yourself.")
cmd.Flags().StringSliceVar(&opts.Editable.Labels.Add, "add-label", nil, "Add labels by `name`")
cmd.Flags().StringSliceVar(&opts.Editable.Labels.Remove, "remove-label", nil, "Remove labels by `name`")
cmd.Flags().StringSliceVar(&opts.Editable.Projects.Add, "add-project", nil, "Add the pull request to projects by `name`")
cmd.Flags().StringSliceVar(&opts.Editable.Projects.Remove, "remove-project", nil, "Remove the pull request from projects by `name`")
cmd.Flags().StringVarP(&opts.Editable.Milestone.Value, "milestone", "m", "", "Edit the milestone the pull request belongs to by `name`")
return cmd
}
func editRun(opts *EditOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
pr, repo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg)
if err != nil {
return err
}
editable := opts.Editable
editable.Reviewers.Allowed = true
editable.Title.Default = pr.Title
editable.Body.Default = pr.Body
editable.Reviewers.Default = pr.ReviewRequests.Logins()
editable.Assignees.Default = pr.Assignees.Logins()
editable.Labels.Default = pr.Labels.Names()
editable.Projects.Default = pr.ProjectCards.ProjectNames()
editable.Milestone.Default = pr.Milestone.Title
if opts.Interactive {
err = opts.Surveyor.FieldsToEdit(&editable)
if err != nil {
return err
}
}
opts.IO.StartProgressIndicator()
err = opts.Fetcher.EditableOptionsFetch(apiClient, repo, &editable)
opts.IO.StopProgressIndicator()
if err != nil {
return err
}
if opts.Interactive {
editorCommand, err := opts.EditorRetriever.Retrieve()
if err != nil {
return err
}
err = opts.Surveyor.EditFields(&editable, editorCommand)
if err != nil {
return err
}
}
opts.IO.StartProgressIndicator()
err = updatePullRequest(apiClient, repo, pr.ID, editable)
opts.IO.StopProgressIndicator()
if err != nil {
return err
}
fmt.Fprintln(opts.IO.Out, pr.URL)
return nil
}
func updatePullRequest(client *api.Client, repo ghrepo.Interface, id string, editable shared.Editable) error {
var err error
params := githubv4.UpdatePullRequestInput{
PullRequestID: id,
Title: ghString(editable.TitleValue()),
Body: ghString(editable.BodyValue()),
}
assigneeIds, err := editable.AssigneeIds(client, repo)
if err != nil {
return err
}
params.AssigneeIDs = ghIds(assigneeIds)
labelIds, err := editable.LabelIds()
if err != nil {
return err
}
params.LabelIDs = ghIds(labelIds)
projectIds, err := editable.ProjectIds()
if err != nil {
return err
}
params.ProjectIDs = ghIds(projectIds)
milestoneId, err := editable.MilestoneId()
if err != nil {
return err
}
params.MilestoneID = ghId(milestoneId)
err = api.UpdatePullRequest(client, repo, params)
if err != nil {
return err
}
return updatePullRequestReviews(client, repo, id, editable)
}
func updatePullRequestReviews(client *api.Client, repo ghrepo.Interface, id string, editable shared.Editable) error {
if !editable.Reviewers.Edited {
return nil
}
userIds, teamIds, err := editable.ReviewerIds()
if err != nil {
return err
}
union := githubv4.Boolean(false)
reviewsRequestParams := githubv4.RequestReviewsInput{
PullRequestID: id,
Union: &union,
UserIDs: ghIds(userIds),
TeamIDs: ghIds(teamIds),
}
return api.UpdatePullRequestReviews(client, repo, reviewsRequestParams)
}
type Surveyor interface {
FieldsToEdit(*shared.Editable) error
EditFields(*shared.Editable, string) error
}
type surveyor struct{}
func (s surveyor) FieldsToEdit(editable *shared.Editable) error {
return shared.FieldsToEditSurvey(editable)
}
func (s surveyor) EditFields(editable *shared.Editable, editorCmd string) error {
return shared.EditFieldsSurvey(editable, editorCmd)
}
type EditableOptionsFetcher interface {
EditableOptionsFetch(*api.Client, ghrepo.Interface, *shared.Editable) error
}
type fetcher struct{}
func (f fetcher) EditableOptionsFetch(client *api.Client, repo ghrepo.Interface, opts *shared.Editable) error {
return shared.FetchOptions(client, repo, opts)
}
type EditorRetriever interface {
Retrieve() (string, error)
}
type editorRetriever struct {
config func() (config.Config, error)
}
func (e editorRetriever) Retrieve() (string, error) {
return cmdutil.DetermineEditor(e.config)
}
func ghIds(s *[]string) *[]githubv4.ID {
if s == nil {
return nil
}
ids := make([]githubv4.ID, len(*s))
for i, v := range *s {
ids[i] = v
}
return &ids
}
func ghId(s *string) *githubv4.ID {
if s == nil {
return nil
}
if *s == "" {
r := githubv4.ID(nil)
return &r
}
r := githubv4.ID(*s)
return &r
}
func ghString(s *string) *githubv4.String {
if s == nil {
return nil
}
r := githubv4.String(*s)
return &r
}

View file

@ -0,0 +1,538 @@
package edit
import (
"bytes"
"net/http"
"testing"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghrepo"
shared "github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
)
func TestNewCmdEdit(t *testing.T) {
tests := []struct {
name string
input string
output EditOptions
wantsErr bool
}{
{
name: "no argument",
input: "",
output: EditOptions{},
wantsErr: true,
},
{
name: "pull request number argument",
input: "23",
output: EditOptions{
SelectorArg: "23",
Interactive: true,
},
wantsErr: false,
},
{
name: "title flag",
input: "23 --title test",
output: EditOptions{
SelectorArg: "23",
Editable: shared.Editable{
Title: shared.EditableString{
Value: "test",
Edited: true,
},
},
},
wantsErr: false,
},
{
name: "body flag",
input: "23 --body test",
output: EditOptions{
SelectorArg: "23",
Editable: shared.Editable{
Body: shared.EditableString{
Value: "test",
Edited: true,
},
},
},
wantsErr: false,
},
{
name: "add-reviewer flag",
input: "23 --add-reviewer monalisa,owner/core",
output: EditOptions{
SelectorArg: "23",
Editable: shared.Editable{
Reviewers: shared.EditableSlice{
Add: []string{"monalisa", "owner/core"},
Edited: true,
},
},
},
wantsErr: false,
},
{
name: "remove-reviewer flag",
input: "23 --remove-reviewer monalisa,owner/core",
output: EditOptions{
SelectorArg: "23",
Editable: shared.Editable{
Reviewers: shared.EditableSlice{
Remove: []string{"monalisa", "owner/core"},
Edited: true,
},
},
},
wantsErr: false,
},
{
name: "add-assignee flag",
input: "23 --add-assignee monalisa,hubot",
output: EditOptions{
SelectorArg: "23",
Editable: shared.Editable{
Assignees: shared.EditableSlice{
Add: []string{"monalisa", "hubot"},
Edited: true,
},
},
},
wantsErr: false,
},
{
name: "remove-assignee flag",
input: "23 --remove-assignee monalisa,hubot",
output: EditOptions{
SelectorArg: "23",
Editable: shared.Editable{
Assignees: shared.EditableSlice{
Remove: []string{"monalisa", "hubot"},
Edited: true,
},
},
},
wantsErr: false,
},
{
name: "add-label flag",
input: "23 --add-label feature,TODO,bug",
output: EditOptions{
SelectorArg: "23",
Editable: shared.Editable{
Labels: shared.EditableSlice{
Add: []string{"feature", "TODO", "bug"},
Edited: true,
},
},
},
wantsErr: false,
},
{
name: "remove-label flag",
input: "23 --remove-label feature,TODO,bug",
output: EditOptions{
SelectorArg: "23",
Editable: shared.Editable{
Labels: shared.EditableSlice{
Remove: []string{"feature", "TODO", "bug"},
Edited: true,
},
},
},
wantsErr: false,
},
{
name: "add-project flag",
input: "23 --add-project Cleanup,Roadmap",
output: EditOptions{
SelectorArg: "23",
Editable: shared.Editable{
Projects: shared.EditableSlice{
Add: []string{"Cleanup", "Roadmap"},
Edited: true,
},
},
},
wantsErr: false,
},
{
name: "remove-project flag",
input: "23 --remove-project Cleanup,Roadmap",
output: EditOptions{
SelectorArg: "23",
Editable: shared.Editable{
Projects: shared.EditableSlice{
Remove: []string{"Cleanup", "Roadmap"},
Edited: true,
},
},
},
wantsErr: false,
},
{
name: "milestone flag",
input: "23 --milestone GA",
output: EditOptions{
SelectorArg: "23",
Editable: shared.Editable{
Milestone: shared.EditableString{
Value: "GA",
Edited: true,
},
},
},
wantsErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, _, _, _ := iostreams.Test()
io.SetStdoutTTY(true)
io.SetStdinTTY(true)
io.SetStderrTTY(true)
f := &cmdutil.Factory{
IOStreams: io,
}
argv, err := shlex.Split(tt.input)
assert.NoError(t, err)
var gotOpts *EditOptions
cmd := NewCmdEdit(f, func(opts *EditOptions) error {
gotOpts = opts
return nil
})
cmd.Flags().BoolP("help", "x", false, "")
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
_, err = cmd.ExecuteC()
if tt.wantsErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.output.SelectorArg, gotOpts.SelectorArg)
assert.Equal(t, tt.output.Interactive, gotOpts.Interactive)
assert.Equal(t, tt.output.Editable, gotOpts.Editable)
})
}
}
func Test_editRun(t *testing.T) {
tests := []struct {
name string
input *EditOptions
httpStubs func(*testing.T, *httpmock.Registry)
stdout string
stderr string
}{
{
name: "non-interactive",
input: &EditOptions{
SelectorArg: "123",
Interactive: false,
Editable: shared.Editable{
Title: shared.EditableString{
Value: "new title",
Edited: true,
},
Body: shared.EditableString{
Value: "new body",
Edited: true,
},
Reviewers: shared.EditableSlice{
Add: []string{"OWNER/core", "OWNER/external", "monalisa", "hubot"},
Remove: []string{"dependabot"},
Edited: true,
},
Assignees: shared.EditableSlice{
Add: []string{"monalisa", "hubot"},
Remove: []string{"octocat"},
Edited: true,
},
Labels: shared.EditableSlice{
Add: []string{"feature", "TODO", "bug"},
Remove: []string{"docs"},
Edited: true,
},
Projects: shared.EditableSlice{
Add: []string{"Cleanup", "Roadmap"},
Remove: []string{"Features"},
Edited: true,
},
Milestone: shared.EditableString{
Value: "GA",
Edited: true,
},
},
Fetcher: testFetcher{},
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockPullRequestGet(t, reg)
mockRepoMetadata(t, reg, false)
mockPullRequestUpdate(t, reg)
mockPullRequestReviewersUpdate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/pull/123\n",
},
{
name: "non-interactive skip reviewers",
input: &EditOptions{
SelectorArg: "123",
Interactive: false,
Editable: shared.Editable{
Title: shared.EditableString{
Value: "new title",
Edited: true,
},
Body: shared.EditableString{
Value: "new body",
Edited: true,
},
Assignees: shared.EditableSlice{
Add: []string{"monalisa", "hubot"},
Remove: []string{"octocat"},
Edited: true,
},
Labels: shared.EditableSlice{
Value: []string{"feature", "TODO", "bug"},
Remove: []string{"docs"},
Edited: true,
},
Projects: shared.EditableSlice{
Value: []string{"Cleanup", "Roadmap"},
Remove: []string{"Features"},
Edited: true,
},
Milestone: shared.EditableString{
Value: "GA",
Edited: true,
},
},
Fetcher: testFetcher{},
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockPullRequestGet(t, reg)
mockRepoMetadata(t, reg, true)
mockPullRequestUpdate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/pull/123\n",
},
{
name: "interactive",
input: &EditOptions{
SelectorArg: "123",
Interactive: true,
Surveyor: testSurveyor{},
Fetcher: testFetcher{},
EditorRetriever: testEditorRetriever{},
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockPullRequestGet(t, reg)
mockRepoMetadata(t, reg, false)
mockPullRequestUpdate(t, reg)
mockPullRequestReviewersUpdate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/pull/123\n",
},
{
name: "interactive skip reviewers",
input: &EditOptions{
SelectorArg: "123",
Interactive: true,
Surveyor: testSurveyor{skipReviewers: true},
Fetcher: testFetcher{},
EditorRetriever: testEditorRetriever{},
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockPullRequestGet(t, reg)
mockRepoMetadata(t, reg, true)
mockPullRequestUpdate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/pull/123\n",
},
}
for _, tt := range tests {
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(true)
io.SetStdinTTY(true)
io.SetStderrTTY(true)
reg := &httpmock.Registry{}
defer reg.Verify(t)
tt.httpStubs(t, reg)
httpClient := func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }
baseRepo := func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }
tt.input.IO = io
tt.input.HttpClient = httpClient
tt.input.BaseRepo = baseRepo
t.Run(tt.name, func(t *testing.T) {
err := editRun(tt.input)
assert.NoError(t, err)
assert.Equal(t, tt.stdout, stdout.String())
assert.Equal(t, tt.stderr, stderr.String())
})
}
}
func mockPullRequestGet(_ *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"id": "456",
"number": 123,
"url": "https://github.com/OWNER/REPO/pull/123"
} } } }`),
)
}
func mockRepoMetadata(_ *testing.T, reg *httpmock.Registry, skipReviewers bool) {
reg.Register(
httpmock.GraphQL(`query RepositoryAssignableUsers\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "assignableUsers": {
"nodes": [
{ "login": "hubot", "id": "HUBOTID" },
{ "login": "MonaLisa", "id": "MONAID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query RepositoryLabelList\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "labels": {
"nodes": [
{ "name": "feature", "id": "FEATUREID" },
{ "name": "TODO", "id": "TODOID" },
{ "name": "bug", "id": "BUGID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query RepositoryMilestoneList\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "milestones": {
"nodes": [
{ "title": "GA", "id": "GAID" },
{ "title": "Big One.oh", "id": "BIGONEID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query RepositoryProjectList\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "projects": {
"nodes": [
{ "name": "Cleanup", "id": "CLEANUPID" },
{ "name": "Roadmap", "id": "ROADMAPID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query OrganizationProjectList\b`),
httpmock.StringResponse(`
{ "data": { "organization": { "projects": {
"nodes": [
{ "name": "Triage", "id": "TRIAGEID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
if !skipReviewers {
reg.Register(
httpmock.GraphQL(`query OrganizationTeamList\b`),
httpmock.StringResponse(`
{ "data": { "organization": { "teams": {
"nodes": [
{ "slug": "external", "id": "EXTERNALID" },
{ "slug": "core", "id": "COREID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
}
}
func mockPullRequestUpdate(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`mutation PullRequestUpdate\b`),
httpmock.GraphQLMutation(`
{ "data": { "updatePullRequest": { "pullRequest": {
"id": "456"
} } } }`,
func(inputs map[string]interface{}) {}),
)
}
func mockPullRequestReviewersUpdate(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`mutation PullRequestUpdateRequestReviews\b`),
httpmock.GraphQLMutation(`
{ "data": { "requestReviews": { "pullRequest": {
"id": "456"
} } } }`,
func(inputs map[string]interface{}) {}),
)
}
type testFetcher struct{}
type testSurveyor struct {
skipReviewers bool
}
type testEditorRetriever struct{}
func (f testFetcher) EditableOptionsFetch(client *api.Client, repo ghrepo.Interface, opts *shared.Editable) error {
return shared.FetchOptions(client, repo, opts)
}
func (s testSurveyor) FieldsToEdit(e *shared.Editable) error {
e.Title.Edited = true
e.Body.Edited = true
if !s.skipReviewers {
e.Reviewers.Edited = true
}
e.Assignees.Edited = true
e.Labels.Edited = true
e.Projects.Edited = true
e.Milestone.Edited = true
return nil
}
func (s testSurveyor) EditFields(e *shared.Editable, _ string) error {
e.Title.Value = "new title"
e.Body.Value = "new body"
if !s.skipReviewers {
e.Reviewers.Value = []string{"monalisa", "hubot", "OWNER/core", "OWNER/external"}
}
e.Assignees.Value = []string{"monalisa", "hubot"}
e.Labels.Value = []string{"feature", "TODO", "bug"}
e.Projects.Value = []string{"Cleanup", "Roadmap"}
e.Milestone.Value = "GA"
return nil
}
func (t testEditorRetriever) Retrieve() (string, error) {
return "vim", nil
}

View file

@ -8,6 +8,7 @@ import (
cmdComment "github.com/cli/cli/pkg/cmd/pr/comment"
cmdCreate "github.com/cli/cli/pkg/cmd/pr/create"
cmdDiff "github.com/cli/cli/pkg/cmd/pr/diff"
cmdEdit "github.com/cli/cli/pkg/cmd/pr/edit"
cmdList "github.com/cli/cli/pkg/cmd/pr/list"
cmdMerge "github.com/cli/cli/pkg/cmd/pr/merge"
cmdReady "github.com/cli/cli/pkg/cmd/pr/ready"
@ -55,6 +56,7 @@ func NewCmdPR(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(cmdView.NewCmdView(f, nil))
cmd.AddCommand(cmdChecks.NewCmdChecks(f, nil))
cmd.AddCommand(cmdComment.NewCmdComment(f, nil))
cmd.AddCommand(cmdEdit.NewCmdEdit(f, nil))
return cmd
}

View file

@ -0,0 +1,369 @@
package shared
import (
"fmt"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/set"
"github.com/cli/cli/pkg/surveyext"
)
type Editable struct {
Title EditableString
Body EditableString
Reviewers EditableSlice
Assignees EditableSlice
Labels EditableSlice
Projects EditableSlice
Milestone EditableString
Metadata api.RepoMetadataResult
}
type EditableString struct {
Value string
Default string
Options []string
Edited bool
}
type EditableSlice struct {
Value []string
Add []string
Remove []string
Default []string
Options []string
Edited bool
Allowed bool
}
func (e Editable) Dirty() bool {
return e.Title.Edited ||
e.Body.Edited ||
e.Reviewers.Edited ||
e.Assignees.Edited ||
e.Labels.Edited ||
e.Projects.Edited ||
e.Milestone.Edited
}
func (e Editable) TitleValue() *string {
if !e.Title.Edited {
return nil
}
return &e.Title.Value
}
func (e Editable) BodyValue() *string {
if !e.Body.Edited {
return nil
}
return &e.Body.Value
}
func (e Editable) ReviewerIds() (*[]string, *[]string, error) {
if !e.Reviewers.Edited {
return nil, nil, nil
}
if len(e.Reviewers.Add) != 0 || len(e.Reviewers.Remove) != 0 {
s := set.NewStringSet()
s.AddValues(e.Reviewers.Default)
s.AddValues(e.Reviewers.Add)
s.RemoveValues(e.Reviewers.Remove)
e.Reviewers.Value = s.ToSlice()
}
var userReviewers []string
var teamReviewers []string
for _, r := range e.Reviewers.Value {
if strings.ContainsRune(r, '/') {
teamReviewers = append(teamReviewers, r)
} else {
userReviewers = append(userReviewers, r)
}
}
userIds, err := e.Metadata.MembersToIDs(userReviewers)
if err != nil {
return nil, nil, err
}
teamIds, err := e.Metadata.TeamsToIDs(teamReviewers)
if err != nil {
return nil, nil, err
}
return &userIds, &teamIds, nil
}
func (e Editable) AssigneeIds(client *api.Client, repo ghrepo.Interface) (*[]string, error) {
if !e.Assignees.Edited {
return nil, nil
}
if len(e.Assignees.Add) != 0 || len(e.Assignees.Remove) != 0 {
meReplacer := NewMeReplacer(client, repo.RepoHost())
s := set.NewStringSet()
s.AddValues(e.Assignees.Default)
add, err := meReplacer.ReplaceSlice(e.Assignees.Add)
if err != nil {
return nil, err
}
s.AddValues(add)
remove, err := meReplacer.ReplaceSlice(e.Assignees.Remove)
if err != nil {
return nil, err
}
s.RemoveValues(remove)
e.Assignees.Value = s.ToSlice()
}
a, err := e.Metadata.MembersToIDs(e.Assignees.Value)
return &a, err
}
func (e Editable) LabelIds() (*[]string, error) {
if !e.Labels.Edited {
return nil, nil
}
if len(e.Labels.Add) != 0 || len(e.Labels.Remove) != 0 {
s := set.NewStringSet()
s.AddValues(e.Labels.Default)
s.AddValues(e.Labels.Add)
s.RemoveValues(e.Labels.Remove)
e.Labels.Value = s.ToSlice()
}
l, err := e.Metadata.LabelsToIDs(e.Labels.Value)
return &l, err
}
func (e Editable) ProjectIds() (*[]string, error) {
if !e.Projects.Edited {
return nil, nil
}
if len(e.Projects.Add) != 0 || len(e.Projects.Remove) != 0 {
s := set.NewStringSet()
s.AddValues(e.Projects.Default)
s.AddValues(e.Projects.Add)
s.RemoveValues(e.Projects.Remove)
e.Projects.Value = s.ToSlice()
}
p, err := e.Metadata.ProjectsToIDs(e.Projects.Value)
return &p, err
}
func (e Editable) MilestoneId() (*string, error) {
if !e.Milestone.Edited {
return nil, nil
}
if e.Milestone.Value == noMilestone || e.Milestone.Value == "" {
s := ""
return &s, nil
}
m, err := e.Metadata.MilestoneToID(e.Milestone.Value)
return &m, err
}
func EditFieldsSurvey(editable *Editable, editorCommand string) error {
var err error
if editable.Title.Edited {
editable.Title.Value, err = titleSurvey(editable.Title.Default)
if err != nil {
return err
}
}
if editable.Body.Edited {
editable.Body.Value, err = bodySurvey(editable.Body.Default, editorCommand)
if err != nil {
return err
}
}
if editable.Reviewers.Edited {
editable.Reviewers.Value, err = multiSelectSurvey("Reviewers", editable.Reviewers.Default, editable.Reviewers.Options)
if err != nil {
return err
}
}
if editable.Assignees.Edited {
editable.Assignees.Value, err = multiSelectSurvey("Assignees", editable.Assignees.Default, editable.Assignees.Options)
if err != nil {
return err
}
}
if editable.Labels.Edited {
editable.Labels.Value, err = multiSelectSurvey("Labels", editable.Labels.Default, editable.Labels.Options)
if err != nil {
return err
}
}
if editable.Projects.Edited {
editable.Projects.Value, err = multiSelectSurvey("Projects", editable.Projects.Default, editable.Projects.Options)
if err != nil {
return err
}
}
if editable.Milestone.Edited {
editable.Milestone.Value, err = milestoneSurvey(editable.Milestone.Default, editable.Milestone.Options)
if err != nil {
return err
}
}
confirm, err := confirmSurvey()
if err != nil {
return err
}
if !confirm {
return fmt.Errorf("Discarding...")
}
return nil
}
func FieldsToEditSurvey(editable *Editable) error {
contains := func(s []string, str string) bool {
for _, v := range s {
if v == str {
return true
}
}
return false
}
opts := []string{"Title", "Body"}
if editable.Reviewers.Allowed {
opts = append(opts, "Reviewers")
}
opts = append(opts, "Assignees", "Labels", "Projects", "Milestone")
results, err := multiSelectSurvey("What would you like to edit?", []string{}, opts)
if err != nil {
return err
}
if contains(results, "Title") {
editable.Title.Edited = true
}
if contains(results, "Body") {
editable.Body.Edited = true
}
if contains(results, "Reviewers") {
editable.Reviewers.Edited = true
}
if contains(results, "Assignees") {
editable.Assignees.Edited = true
}
if contains(results, "Labels") {
editable.Labels.Edited = true
}
if contains(results, "Projects") {
editable.Projects.Edited = true
}
if contains(results, "Milestone") {
editable.Milestone.Edited = true
}
return nil
}
func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable) error {
input := api.RepoMetadataInput{
Reviewers: editable.Reviewers.Edited,
Assignees: editable.Assignees.Edited,
Labels: editable.Labels.Edited,
Projects: editable.Projects.Edited,
Milestones: editable.Milestone.Edited,
}
metadata, err := api.RepoMetadata(client, repo, input)
if err != nil {
return err
}
var users []string
for _, u := range metadata.AssignableUsers {
users = append(users, u.Login)
}
var teams []string
for _, t := range metadata.Teams {
teams = append(teams, fmt.Sprintf("%s/%s", repo.RepoOwner(), t.Slug))
}
var labels []string
for _, l := range metadata.Labels {
labels = append(labels, l.Name)
}
var projects []string
for _, l := range metadata.Projects {
projects = append(projects, l.Name)
}
milestones := []string{noMilestone}
for _, m := range metadata.Milestones {
milestones = append(milestones, m.Title)
}
editable.Metadata = *metadata
editable.Reviewers.Options = append(users, teams...)
editable.Assignees.Options = users
editable.Labels.Options = labels
editable.Projects.Options = projects
editable.Milestone.Options = milestones
return nil
}
func titleSurvey(title string) (string, error) {
var result string
q := &survey.Input{
Message: "Title",
Default: title,
}
err := survey.AskOne(q, &result)
return result, err
}
func bodySurvey(body, editorCommand string) (string, error) {
var result string
q := &surveyext.GhEditor{
EditorCommand: editorCommand,
Editor: &survey.Editor{
Message: "Body",
FileName: "*.md",
Default: body,
HideDefault: true,
AppendDefault: true,
},
}
err := survey.AskOne(q, &result)
return result, err
}
func multiSelectSurvey(message string, defaults, options []string) ([]string, error) {
if len(options) == 0 {
return nil, nil
}
var results []string
q := &survey.MultiSelect{
Message: message,
Options: options,
Default: defaults,
}
err := survey.AskOne(q, &results)
return results, err
}
func milestoneSurvey(title string, opts []string) (string, error) {
if len(opts) == 0 {
return "", nil
}
var result string
q := &survey.Select{
Message: "Milestone",
Options: opts,
Default: title,
}
err := survey.AskOne(q, &result)
return result, err
}
func confirmSurvey() (bool, error) {
var result bool
q := &survey.Confirm{
Message: "Submit?",
Default: true,
}
err := survey.AskOne(q, &result)
return result, err
}

View file

@ -114,7 +114,7 @@ func statusRun(opts *StatusOptions) error {
currentPR = nil
}
if currentPR != nil {
printPrs(opts.IO, 1, *currentPR)
printPrs(opts.IO, 1, prPayload.StrictProtection, *currentPR)
} else if currentPRHeadRef == "" {
shared.PrintMessage(opts.IO, " There is no current branch")
} else {
@ -124,7 +124,7 @@ func statusRun(opts *StatusOptions) error {
shared.PrintHeader(opts.IO, "Created by you")
if prPayload.ViewerCreated.TotalCount > 0 {
printPrs(opts.IO, prPayload.ViewerCreated.TotalCount, prPayload.ViewerCreated.PullRequests...)
printPrs(opts.IO, prPayload.ViewerCreated.TotalCount, prPayload.StrictProtection, prPayload.ViewerCreated.PullRequests...)
} else {
shared.PrintMessage(opts.IO, " You have no open pull requests")
}
@ -132,7 +132,7 @@ func statusRun(opts *StatusOptions) error {
shared.PrintHeader(opts.IO, "Requesting a code review from you")
if prPayload.ReviewRequested.TotalCount > 0 {
printPrs(opts.IO, prPayload.ReviewRequested.TotalCount, prPayload.ReviewRequested.PullRequests...)
printPrs(opts.IO, prPayload.ReviewRequested.TotalCount, prPayload.StrictProtection, prPayload.ReviewRequested.PullRequests...)
} else {
shared.PrintMessage(opts.IO, " You have no pull requests to review")
}
@ -178,7 +178,7 @@ func prSelectorForCurrentBranch(baseRepo ghrepo.Interface, prHeadRef string, rem
return
}
func printPrs(io *iostreams.IOStreams, totalCount int, prs ...api.PullRequest) {
func printPrs(io *iostreams.IOStreams, totalCount int, strictProtection bool, prs ...api.PullRequest) {
w := io.Out
cs := io.ColorScheme()
@ -227,6 +227,19 @@ func printPrs(io *iostreams.IOStreams, totalCount int, prs ...api.PullRequest) {
} else if reviews.Approved {
fmt.Fprint(w, cs.Green("✓ Approved"))
}
// only check if the "up to date" setting is checked in repo settings
if strictProtection {
// add padding between reviews & merge status
fmt.Fprint(w, " ")
if pr.MergeStateStatus == "BEHIND" {
fmt.Fprint(w, cs.Yellow("- Not up to date"))
} else {
fmt.Fprint(w, cs.Green("✓ Up to date"))
}
}
} else {
fmt.Fprintf(w, " - %s", shared.StateTitleWithColor(cs, pr))
}

View file

@ -61,18 +61,30 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
var notesFile string
cmd := &cobra.Command{
DisableFlagsInUseLine: true,
Use: "create <tag> [<files>...]",
Short: "Create a new release",
Long: heredoc.Doc(`
Long: heredoc.Docf(`
Create a new GitHub Release for a repository.
A list of asset files may be given to upload to the new release. To define a
display label for an asset, append text starting with '#' after the file name.
`),
display label for an asset, append text starting with %[1]s#%[1]s after the file name.
If a matching git tag does not yet exist, one will automatically get created
from the latest state of the default branch. Use %[1]s--target%[1]s to override this.
To fetch the new tag locally after the release, do %[1]sgit fetch --tags origin%[1]s.
To create a release from an annotated git tag, first create one locally with
git, push the tag to GitHub, then run this command.
`, "`"),
Example: heredoc.Doc(`
# use release notes from a file
$ gh release create v1.2.3 -F changelog.md
# upload all tarballs in a directory as release assets
$ gh release create v1.2.3 ./dist/*.tgz
# upload a release asset with a display label
$ gh release create v1.2.3 '/path/to/asset.zip#My display label'
`),
@ -116,7 +128,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
cmd.Flags().BoolVarP(&opts.Draft, "draft", "d", false, "Save the release as a draft instead of publishing it")
cmd.Flags().BoolVarP(&opts.Prerelease, "prerelease", "p", false, "Mark the release as a prerelease")
cmd.Flags().StringVar(&opts.Target, "target", "", "Target `branch` or commit SHA (default: main branch)")
cmd.Flags().StringVar(&opts.Target, "target", "", "Target `branch` or full commit SHA (default: main branch)")
cmd.Flags().StringVarP(&opts.Name, "title", "t", "", "Release title")
cmd.Flags().StringVarP(&opts.Body, "notes", "n", "", "Release notes")
cmd.Flags().StringVarP(&notesFile, "notes-file", "F", "", "Read release notes from `file`")

View file

@ -49,10 +49,26 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
cmd := &cobra.Command{
Use: "create [<name>]",
Short: "Create a new repository",
Long: `Create a new GitHub repository.`,
Args: cobra.MaximumNArgs(1),
Long: heredoc.Docf(`
Create a new GitHub repository.
When the current directory is a local git repository, the new repository will be added
as the "origin" git remote. Otherwise, the command will prompt to clone the new
repository into a sub-directory.
To create a repository non-interactively, supply the following:
- the name argument;
- the %[1]s--confirm%[1]s flag;
- one of %[1]s--public%[1]s, %[1]s--private%[1]s, or %[1]s--internal%[1]s.
To toggle off %[1]s--enable-issues%[1]s or %[1]s--enable-wiki%[1]s, which are enabled
by default, use the %[1]s--enable-issues=false%[1]s syntax.
`, "`"),
Args: cobra.MaximumNArgs(1),
Example: heredoc.Doc(`
# create a repository under your account using the current directory name
$ git init my-project
$ cd my-project
$ gh repo create
# create a repository with a specific name
@ -60,12 +76,16 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
# create a repository in an organization
$ gh repo create cli/my-project
# disable issues and wiki
$ gh repo create --enable-issues=false --enable-wiki=false
`),
Annotations: map[string]string{
"help:arguments": heredoc.Doc(
`A repository can be supplied as an argument in any of the following formats:
- <OWNER/REPO>
- by URL, e.g. "https://github.com/OWNER/REPO"`),
"help:arguments": heredoc.Doc(`
A repository can be supplied as an argument in any of the following formats:
- "OWNER/REPO"
- by URL, e.g. "https://github.com/OWNER/REPO"
`),
},
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
@ -78,32 +98,31 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
}
if !opts.Internal && !opts.Private && !opts.Public {
return &cmdutil.FlagError{Err: errors.New("--public, --private, or --internal required when not running interactively")}
return &cmdutil.FlagError{Err: errors.New("`--public`, `--private`, or `--internal` required when not running interactively")}
}
}
if opts.Template != "" && (opts.Homepage != "" || opts.Team != "" || cmd.Flags().Changed("enable-issues") || cmd.Flags().Changed("enable-wiki")) {
return &cmdutil.FlagError{Err: errors.New("The `--template` option is not supported with `--homepage`, `--team`, `--enable-issues`, or `--enable-wiki`")}
}
if runF != nil {
return runF(opts)
}
if opts.Template != "" && (opts.Homepage != "" || opts.Team != "" || !opts.EnableIssues || !opts.EnableWiki) {
return &cmdutil.FlagError{Err: errors.New(`The '--template' option is not supported with '--homepage, --team, --enable-issues or --enable-wiki'`)}
}
return createRun(opts)
},
}
cmd.Flags().StringVarP(&opts.Description, "description", "d", "", "Description of repository")
cmd.Flags().StringVarP(&opts.Homepage, "homepage", "h", "", "Repository home page URL")
cmd.Flags().StringVarP(&opts.Team, "team", "t", "", "The name of the organization team to be granted access")
cmd.Flags().StringVarP(&opts.Template, "template", "p", "", "Make the new repository based on a template repository")
cmd.Flags().StringVarP(&opts.Description, "description", "d", "", "Description of the repository")
cmd.Flags().StringVarP(&opts.Homepage, "homepage", "h", "", "Repository home page `URL`")
cmd.Flags().StringVarP(&opts.Team, "team", "t", "", "The `name` of the organization team to be granted access")
cmd.Flags().StringVarP(&opts.Template, "template", "p", "", "Make the new repository based on a template `repository`")
cmd.Flags().BoolVar(&opts.EnableIssues, "enable-issues", true, "Enable issues in the new repository")
cmd.Flags().BoolVar(&opts.EnableWiki, "enable-wiki", true, "Enable wiki in the new repository")
cmd.Flags().BoolVar(&opts.Public, "public", false, "Make the new repository public")
cmd.Flags().BoolVar(&opts.Private, "private", false, "Make the new repository private")
cmd.Flags().BoolVar(&opts.Internal, "internal", false, "Make the new repository internal")
cmd.Flags().BoolVarP(&opts.ConfirmSubmit, "confirm", "y", false, "Confirm the submission directly")
cmd.Flags().BoolVarP(&opts.ConfirmSubmit, "confirm", "y", false, "Skip the confirmation prompt")
return cmd
}
@ -139,7 +158,7 @@ func createRun(opts *CreateOptions) error {
}
if enabledFlagCount > 1 {
return fmt.Errorf("expected exactly one of --public, --private, or --internal to be true")
return fmt.Errorf("expected exactly one of `--public`, `--private`, or `--internal` to be true")
} else if enabledFlagCount == 1 {
isVisibilityPassed = true
}

46
pkg/set/string_set.go Normal file
View file

@ -0,0 +1,46 @@
package set
var exists = struct{}{}
type stringSet struct {
m map[string]struct{}
}
func NewStringSet() *stringSet {
s := &stringSet{}
s.m = make(map[string]struct{})
return s
}
func (s *stringSet) Add(value string) {
s.m[value] = exists
}
func (s *stringSet) AddValues(values []string) {
for _, v := range values {
s.Add(v)
}
}
func (s *stringSet) Remove(value string) {
delete(s.m, value)
}
func (s *stringSet) RemoveValues(values []string) {
for _, v := range values {
s.Remove(v)
}
}
func (s *stringSet) Contains(value string) bool {
_, c := s.m[value]
return c
}
func (s *stringSet) ToSlice() []string {
r := make([]string, 0, len(s.m))
for k := range s.m {
r = append(r, k)
}
return r
}

View file

@ -165,6 +165,8 @@ func run(args ...string) error {
}
announce(args...)
cmd := exec.Command(exe, args[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}