Merge remote-tracking branch 'origin' into auth-with-ssh
This commit is contained in:
commit
4cd43cc8ef
25 changed files with 2402 additions and 227 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
227
pkg/cmd/issue/edit/edit.go
Normal 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
|
||||
}
|
||||
395
pkg/cmd/issue/edit/edit_test.go
Normal file
395
pkg/cmd/issue/edit/edit_test.go
Normal 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{}) {}),
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
297
pkg/cmd/pr/edit/edit.go
Normal 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
|
||||
}
|
||||
538
pkg/cmd/pr/edit/edit_test.go
Normal file
538
pkg/cmd/pr/edit/edit_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
369
pkg/cmd/pr/shared/editable.go
Normal file
369
pkg/cmd/pr/shared/editable.go
Normal 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
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(¬esFile, "notes-file", "F", "", "Read release notes from `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
46
pkg/set/string_set.go
Normal 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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue