Merge branch 'trunk' into feature-macos-pkg-installer

This commit is contained in:
Paul 2023-09-04 12:59:46 +02:00 committed by GitHub
commit 53c83fdd82
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
159 changed files with 7230 additions and 3250 deletions

View file

@ -23,7 +23,7 @@ Please avoid:
## Building the project
Prerequisites:
- Go 1.16+
- Go 1.21+
Build with:
* Unix-like systems: `make`

View file

@ -1,6 +1,6 @@
name: Deployment
concurrency:
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name }}
cancel-in-progress: true
@ -17,7 +17,7 @@ on:
default: production
type: environment
go_version:
default: "1.19"
default: "1.21"
type: string
platforms:
default: "linux,macos,windows"
@ -61,7 +61,7 @@ jobs:
dist/*.tar.gz
dist/*.rpm
dist/*.deb
macos:
runs-on: macos-latest
environment: ${{ inputs.environment }}
@ -132,7 +132,7 @@ jobs:
dist/*.tar.gz
dist/*.zip
dist/*.pkg
windows:
runs-on: windows-latest
environment: ${{ inputs.environment }}
@ -354,6 +354,7 @@ jobs:
if: inputs.environment == 'production' && !contains(inputs.tag_name, '-')
with:
formula-name: gh
formula-path: Formula/g/gh.rb
tag-name: ${{ inputs.tag_name }}
push-to: cli/homebrew-core
env:

View file

@ -13,10 +13,10 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Set up Go 1.19
- name: Set up Go 1.21
uses: actions/setup-go@v4
with:
go-version: 1.19
go-version: 1.21
- name: Check out code
uses: actions/checkout@v3

View file

@ -19,10 +19,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.19
- name: Set up Go 1.21
uses: actions/setup-go@v4
with:
go-version: 1.19
go-version: 1.21
- name: Check out code
uses: actions/checkout@v3
@ -40,7 +40,7 @@ jobs:
go mod verify
go mod download
LINT_VERSION=1.50.1
LINT_VERSION=1.54.1
curl -fsSL https://github.com/golangci/golangci-lint/releases/download/v${LINT_VERSION}/golangci-lint-${LINT_VERSION}-linux-amd64.tar.gz | \
tar xz --strip-components 1 --wildcards \*/golangci-lint
mkdir -p bin && mv golangci-lint bin/

View file

@ -23,6 +23,7 @@ type HTTPClientOptions struct {
EnableCache bool
Log io.Writer
LogColorize bool
LogVerboseHTTP bool
SkipAcceptHeaders bool
}
@ -35,10 +36,15 @@ func NewHTTPClient(opts HTTPClientOptions) (*http.Client, error) {
LogIgnoreEnv: true,
}
if debugEnabled, debugValue := utils.IsDebugEnabled(); debugEnabled {
debugEnabled, debugValue := utils.IsDebugEnabled()
if strings.Contains(debugValue, "api") {
opts.LogVerboseHTTP = true
}
if opts.LogVerboseHTTP || debugEnabled {
clientOpts.Log = opts.Log
clientOpts.LogColorize = opts.LogColorize
clientOpts.LogVerboseHTTP = strings.Contains(debugValue, "api")
clientOpts.LogVerboseHTTP = opts.LogVerboseHTTP
}
headers := map[string]string{
@ -63,8 +69,6 @@ func NewHTTPClient(opts HTTPClientOptions) (*http.Client, error) {
client.Transport = AddAuthTokenHeader(client.Transport, opts.Config)
}
client.Transport = AddASCIISanitizer(client.Transport)
return client, nil
}
@ -91,9 +95,17 @@ func AddAuthTokenHeader(rt http.RoundTripper, cfg tokenGetter) http.RoundTripper
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
// If the header is already set in the request, don't overwrite it.
if req.Header.Get(authorization) == "" {
hostname := ghinstance.NormalizeHostname(getHost(req))
if token, _ := cfg.Token(hostname); token != "" {
req.Header.Set(authorization, fmt.Sprintf("token %s", token))
var redirectHostnameChange bool
if req.Response != nil && req.Response.Request != nil {
redirectHostnameChange = getHost(req) != getHost(req.Response.Request)
}
// Only set header if an initial request or redirect request to the same host as the initial request.
// If the host has changed during a redirect do not add the authentication token header.
if !redirectHostnameChange {
hostname := ghinstance.NormalizeHostname(getHost(req))
if token, _ := cfg.Token(hostname); token != "" {
req.Header.Set(authorization, fmt.Sprintf("token %s", token))
}
}
}
return rt.RoundTrip(req)
@ -128,5 +140,5 @@ func getHost(r *http.Request) string {
if r.Host != "" {
return r.Host
}
return r.URL.Hostname()
return r.URL.Host
}

View file

@ -1,11 +1,13 @@
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"regexp"
"strings"
"testing"
"github.com/MakeNowJust/heredoc"
@ -16,16 +18,14 @@ import (
func TestNewHTTPClient(t *testing.T) {
type args struct {
config tokenGetter
appVersion string
setAccept bool
config tokenGetter
appVersion string
setAccept bool
logVerboseHTTP bool
}
tests := []struct {
name string
args args
envDebug string
setGhDebug bool
envGhDebug string
host string
wantHeader map[string]string
wantStderr string
@ -33,9 +33,10 @@ func TestNewHTTPClient(t *testing.T) {
{
name: "github.com with Accept header",
args: args{
config: tinyConfig{"github.com:oauth_token": "MYTOKEN"},
appVersion: "v1.2.3",
setAccept: true,
config: tinyConfig{"github.com:oauth_token": "MYTOKEN"},
appVersion: "v1.2.3",
setAccept: true,
logVerboseHTTP: false,
},
host: "github.com",
wantHeader: map[string]string{
@ -48,9 +49,10 @@ func TestNewHTTPClient(t *testing.T) {
{
name: "github.com no Accept header",
args: args{
config: tinyConfig{"github.com:oauth_token": "MYTOKEN"},
appVersion: "v1.2.3",
setAccept: false,
config: tinyConfig{"github.com:oauth_token": "MYTOKEN"},
appVersion: "v1.2.3",
setAccept: false,
logVerboseHTTP: false,
},
host: "github.com",
wantHeader: map[string]string{
@ -63,9 +65,10 @@ func TestNewHTTPClient(t *testing.T) {
{
name: "github.com no authentication token",
args: args{
config: tinyConfig{"example.com:oauth_token": "MYTOKEN"},
appVersion: "v1.2.3",
setAccept: true,
config: tinyConfig{"example.com:oauth_token": "MYTOKEN"},
appVersion: "v1.2.3",
setAccept: true,
logVerboseHTTP: false,
},
host: "github.com",
wantHeader: map[string]string{
@ -78,45 +81,12 @@ func TestNewHTTPClient(t *testing.T) {
{
name: "github.com in verbose mode",
args: args{
config: tinyConfig{"github.com:oauth_token": "MYTOKEN"},
appVersion: "v1.2.3",
setAccept: true,
config: tinyConfig{"github.com:oauth_token": "MYTOKEN"},
appVersion: "v1.2.3",
setAccept: true,
logVerboseHTTP: true,
},
host: "github.com",
envDebug: "api",
setGhDebug: false,
wantHeader: map[string]string{
"authorization": "token MYTOKEN",
"user-agent": "GitHub CLI v1.2.3",
"accept": "application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview",
},
wantStderr: heredoc.Doc(`
* Request at <time>
* Request to http://<host>:<port>
> GET / HTTP/1.1
> Host: github.com
> Accept: application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview
> Authorization: token
> Content-Type: application/json; charset=utf-8
> Time-Zone: <timezone>
> User-Agent: GitHub CLI v1.2.3
< HTTP/1.1 204 No Content
< Date: <time>
* Request took <duration>
`),
},
{
name: "github.com in verbose mode",
args: args{
config: tinyConfig{"github.com:oauth_token": "MYTOKEN"},
appVersion: "v1.2.3",
setAccept: true,
},
host: "github.com",
envGhDebug: "api",
setGhDebug: true,
host: "github.com",
wantHeader: map[string]string{
"authorization": "token MYTOKEN",
"user-agent": "GitHub CLI v1.2.3",
@ -165,19 +135,13 @@ func TestNewHTTPClient(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("DEBUG", tt.envDebug)
if tt.setGhDebug {
t.Setenv("GH_DEBUG", tt.envGhDebug)
} else {
os.Unsetenv("GH_DEBUG")
}
ios, _, _, stderr := iostreams.Test()
client, err := NewHTTPClient(HTTPClientOptions{
AppVersion: tt.args.appVersion,
Config: tt.args.config,
Log: ios.ErrOut,
SkipAcceptHeaders: !tt.args.setAccept,
LogVerboseHTTP: tt.args.logVerboseHTTP,
})
require.NoError(t, err)
@ -200,6 +164,112 @@ func TestNewHTTPClient(t *testing.T) {
}
}
func TestHTTPClientRedirectAuthenticationHeaderHandling(t *testing.T) {
var request *http.Request
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
request = r
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
var redirectRequest *http.Request
redirectServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
redirectRequest = r
http.Redirect(w, r, server.URL, http.StatusFound)
}))
defer redirectServer.Close()
client, err := NewHTTPClient(HTTPClientOptions{
Config: tinyConfig{
fmt.Sprintf("%s:oauth_token", strings.TrimPrefix(redirectServer.URL, "http://")): "REDIRECT-TOKEN",
fmt.Sprintf("%s:oauth_token", strings.TrimPrefix(server.URL, "http://")): "TOKEN",
},
})
require.NoError(t, err)
req, err := http.NewRequest("GET", redirectServer.URL, nil)
require.NoError(t, err)
res, err := client.Do(req)
require.NoError(t, err)
assert.Equal(t, "token REDIRECT-TOKEN", redirectRequest.Header.Get(authorization))
assert.Equal(t, "", request.Header.Get(authorization))
assert.Equal(t, 204, res.StatusCode)
}
func TestHTTPClientSanitizeJSONControlCharactersC0(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
issue := Issue{
Title: "\u001B[31mRed Title\u001B[0m",
Body: "1\u0001 2\u0002 3\u0003 4\u0004 5\u0005 6\u0006 7\u0007 8\u0008 9\t A\r\n B\u000b C\u000c D\r\n E\u000e F\u000f",
Author: Author{
ID: "1",
Name: "10\u0010 11\u0011 12\u0012 13\u0013 14\u0014 15\u0015 16\u0016 17\u0017 18\u0018 19\u0019 1A\u001a 1B\u001b 1C\u001c 1D\u001d 1E\u001e 1F\u001f",
Login: "monalisa \\u00\u001b",
},
ActiveLockReason: "Escaped \u001B \\u001B \\\u001B \\\\u001B",
}
responseData, _ := json.Marshal(issue)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
fmt.Fprint(w, string(responseData))
}))
defer ts.Close()
client, err := NewHTTPClient(HTTPClientOptions{})
require.NoError(t, err)
req, err := http.NewRequest("GET", ts.URL, nil)
require.NoError(t, err)
res, err := client.Do(req)
require.NoError(t, err)
body, err := io.ReadAll(res.Body)
res.Body.Close()
require.NoError(t, err)
var issue Issue
err = json.Unmarshal(body, &issue)
require.NoError(t, err)
assert.Equal(t, "^[[31mRed Title^[[0m", issue.Title)
assert.Equal(t, "1^A 2^B 3^C 4^D 5^E 6^F 7^G 8^H 9\t A\r\n B\v C^L D\r\n E^N F^O", issue.Body)
assert.Equal(t, "10^P 11^Q 12^R 13^S 14^T 15^U 16^V 17^W 18^X 19^Y 1A^Z 1B^[ 1C^\\ 1D^] 1E^^ 1F^_", issue.Author.Name)
assert.Equal(t, "monalisa \\u00^[", issue.Author.Login)
assert.Equal(t, "Escaped ^[ \\^[ \\^[ \\\\^[", issue.ActiveLockReason)
}
func TestHTTPClientSanitizeControlCharactersC1(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
issue := Issue{
Title: "\xC2\x9B[31mRed Title\xC2\x9B[0m",
Body: "80\xC2\x80 81\xC2\x81 82\xC2\x82 83\xC2\x83 84\xC2\x84 85\xC2\x85 86\xC2\x86 87\xC2\x87 88\xC2\x88 89\xC2\x89 8A\xC2\x8A 8B\xC2\x8B 8C\xC2\x8C 8D\xC2\x8D 8E\xC2\x8E 8F\xC2\x8F",
Author: Author{
ID: "1",
Name: "90\xC2\x90 91\xC2\x91 92\xC2\x92 93\xC2\x93 94\xC2\x94 95\xC2\x95 96\xC2\x96 97\xC2\x97 98\xC2\x98 99\xC2\x99 9A\xC2\x9A 9B\xC2\x9B 9C\xC2\x9C 9D\xC2\x9D 9E\xC2\x9E 9F\xC2\x9F",
Login: "monalisa\xC2\xA1",
},
}
responseData, _ := json.Marshal(issue)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
fmt.Fprint(w, string(responseData))
}))
defer ts.Close()
client, err := NewHTTPClient(HTTPClientOptions{})
require.NoError(t, err)
req, err := http.NewRequest("GET", ts.URL, nil)
require.NoError(t, err)
res, err := client.Do(req)
require.NoError(t, err)
body, err := io.ReadAll(res.Body)
res.Body.Close()
require.NoError(t, err)
var issue Issue
err = json.Unmarshal(body, &issue)
require.NoError(t, err)
assert.Equal(t, "^[[31mRed Title^[[0m", issue.Title)
assert.Equal(t, "80^@ 81^A 82^B 83^C 84^D 85^E 86^F 87^G 88^H 89^I 8A^J 8B^K 8C^L 8D^M 8E^N 8F^O", issue.Body)
assert.Equal(t, "90^P 91^Q 92^R 93^S 94^T 95^U 96^V 97^W 98^X 99^Y 9A^Z 9B^[ 9C^\\ 9D^] 9E^^ 9F^_", issue.Author.Name)
assert.Equal(t, "monalisa¡", issue.Author.Login)
}
type tinyConfig map[string]string
func (c tinyConfig) Token(host string) (string, string) {

View file

@ -4,63 +4,16 @@ import (
"fmt"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/shurcooL/githubv4"
)
type LinkedBranch struct {
ID string
BranchName string
RepoUrl string
URL string
}
// method to return url of linked branch, adds the branch name to the end of the repo url
func (b *LinkedBranch) Url() string {
return fmt.Sprintf("%s/tree/%s", b.RepoUrl, b.BranchName)
}
func nameParam(params map[string]interface{}) string {
if params["name"] != "" {
return "name: $name,"
}
return ""
}
func nameArg(params map[string]interface{}) string {
if params["name"] != "" {
return "$name: String, "
}
return ""
}
func CreateBranchIssueReference(client *Client, repo *Repository, params map[string]interface{}) (*LinkedBranch, error) {
query := fmt.Sprintf(`
mutation CreateLinkedBranch($issueId: ID!, $oid: GitObjectID!, %[1]s$repositoryId: ID) {
createLinkedBranch(input: {
issueId: $issueId,
%[2]s
oid: $oid,
repositoryId: $repositoryId
}) {
linkedBranch {
id
ref {
name
}
}
}
}`, nameArg(params), nameParam(params))
inputParams := map[string]interface{}{
"repositoryId": repo.ID,
}
for key, val := range params {
switch key {
case "issueId", "name", "oid":
inputParams[key] = val
}
}
result := struct {
func CreateLinkedBranch(client *Client, host string, repoID, issueID, branchID, branchName string) (string, error) {
var mutation struct {
CreateLinkedBranch struct {
LinkedBranch struct {
ID string
@ -68,90 +21,76 @@ func CreateBranchIssueReference(client *Client, repo *Repository, params map[str
Name string
}
}
}
}{}
if err := client.GraphQL(repo.RepoHost(), query, inputParams, &result); err != nil {
return nil, err
} `graphql:"createLinkedBranch(input: $input)"`
}
ref := LinkedBranch{
ID: result.CreateLinkedBranch.LinkedBranch.ID,
BranchName: result.CreateLinkedBranch.LinkedBranch.Ref.Name,
input := githubv4.CreateLinkedBranchInput{
IssueID: githubv4.ID(issueID),
Oid: githubv4.GitObjectID(branchID),
}
if repoID != "" {
repo := githubv4.ID(repoID)
input.RepositoryID = &repo
}
if branchName != "" {
name := githubv4.String(branchName)
input.Name = &name
}
variables := map[string]interface{}{
"input": input,
}
return &ref, nil
err := client.Mutate(host, "CreateLinkedBranch", &mutation, variables)
if err != nil {
return "", err
}
return mutation.CreateLinkedBranch.LinkedBranch.Ref.Name, nil
}
func ListLinkedBranches(client *Client, repo ghrepo.Interface, issueNumber int) ([]LinkedBranch, error) {
query := `
query BranchIssueReferenceListLinkedBranches($repositoryName: String!, $repositoryOwner: String!, $issueNumber: Int!) {
repository(name: $repositoryName, owner: $repositoryOwner) {
issue(number: $issueNumber) {
linkedBranches(first: 30) {
edges {
node {
ref {
name
repository {
url
}
}
}
}
}
}
}
}
`
variables := map[string]interface{}{
"repositoryName": repo.RepoName(),
"repositoryOwner": repo.RepoOwner(),
"issueNumber": issueNumber,
}
result := struct {
var query struct {
Repository struct {
Issue struct {
LinkedBranches struct {
Edges []struct {
Node struct {
Ref struct {
Name string
Repository struct {
NameWithOwner string
Url string
}
Nodes []struct {
Ref struct {
Name string
Repository struct {
Url string
}
}
}
}
}
}
}{}
} `graphql:"linkedBranches(first: 30)"`
} `graphql:"issue(number: $number)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}
if err := client.GraphQL(repo.RepoHost(), query, variables, &result); err != nil {
variables := map[string]interface{}{
"number": githubv4.Int(issueNumber),
"owner": githubv4.String(repo.RepoOwner()),
"name": githubv4.String(repo.RepoName()),
}
if err := client.Query(repo.RepoHost(), "ListLinkedBranches", &query, variables); err != nil {
return []LinkedBranch{}, err
}
var branchNames []LinkedBranch
for _, edge := range result.Repository.Issue.LinkedBranches.Edges {
for _, node := range query.Repository.Issue.LinkedBranches.Nodes {
branch := LinkedBranch{
BranchName: edge.Node.Ref.Name,
RepoUrl: edge.Node.Ref.Repository.Url,
BranchName: node.Ref.Name,
URL: fmt.Sprintf("%s/tree/%s", node.Ref.Repository.Url, node.Ref.Name),
}
branchNames = append(branchNames, branch)
}
return branchNames, nil
}
// introspects the schema to see if we expose the LinkedBranch type
func CheckLinkedBranchFeature(client *Client, host string) (err error) {
var featureDetection struct {
func CheckLinkedBranchFeature(client *Client, host string) error {
var query struct {
Name struct {
Fields []struct {
Name string
@ -159,43 +98,21 @@ func CheckLinkedBranchFeature(client *Client, host string) (err error) {
} `graphql:"LinkedBranch: __type(name: \"LinkedBranch\")"`
}
if err := client.Query(host, "LinkedBranch_fields", &featureDetection, nil); err != nil {
if err := client.Query(host, "LinkedBranchFeature", &query, nil); err != nil {
return err
}
if len(featureDetection.Name.Fields) == 0 {
if len(query.Name.Fields) == 0 {
return fmt.Errorf("the `gh issue develop` command is not currently available")
}
return nil
}
// This fetches the oids for the repo's default branch (`main`, etc) and the name the user might have provided in one shot.
func FindBaseOid(client *Client, repo *Repository, ref string) (string, string, error) {
query := `
query BranchIssueReferenceFindBaseOid($repositoryName: String!, $repositoryOwner: String!, $ref: String!) {
repository(name: $repositoryName, owner: $repositoryOwner) {
defaultBranchRef {
target {
oid
}
}
ref(qualifiedName: $ref) {
target {
oid
}
}
}
}`
variables := map[string]interface{}{
"repositoryName": repo.Name,
"repositoryOwner": repo.RepoOwner(),
"ref": ref,
}
result := struct {
func FindRepoBranchID(client *Client, repo ghrepo.Interface, ref string) (string, string, error) {
var query struct {
Repository struct {
Id string
DefaultBranchRef struct {
Target struct {
Oid string
@ -205,13 +122,24 @@ func FindBaseOid(client *Client, repo *Repository, ref string) (string, string,
Target struct {
Oid string
}
}
}
}{}
} `graphql:"ref(qualifiedName: $ref)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}
if err := client.GraphQL(repo.RepoHost(), query, variables, &result); err != nil {
variables := map[string]interface{}{
"ref": githubv4.String(ref),
"owner": githubv4.String(repo.RepoOwner()),
"name": githubv4.String(repo.RepoName()),
}
if err := client.Query(repo.RepoHost(), "FindRepoBranchID", &query, variables); err != nil {
return "", "", err
}
return result.Repository.Ref.Target.Oid, result.Repository.DefaultBranchRef.Target.Oid, nil
branchID := query.Repository.Ref.Target.Oid
if branchID == "" {
branchID = query.Repository.DefaultBranchRef.Target.Oid
}
return query.Repository.Id, branchID, nil
}

View file

@ -1370,3 +1370,21 @@ func GetRepoIDs(client *Client, host string, repositories []ghrepo.Interface) ([
}
return result, nil
}
func RepoExists(client *Client, repo ghrepo.Interface) (bool, error) {
path := fmt.Sprintf("%srepos/%s/%s", ghinstance.RESTPrefix(repo.RepoHost()), repo.RepoOwner(), repo.RepoName())
resp, err := client.HTTP().Head(path)
if err != nil {
return false, err
}
switch resp.StatusCode {
case 200:
return true, nil
case 404:
return false, nil
default:
return false, ghAPI.HandleHTTPError(resp)
}
}

View file

@ -8,6 +8,7 @@ import (
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/stretchr/testify/assert"
)
func TestGitHubRepo_notFound(t *testing.T) {
@ -467,3 +468,72 @@ func TestDisplayName(t *testing.T) {
}
}
}
func TestRepoExists(t *testing.T) {
tests := []struct {
name string
httpStub func(*httpmock.Registry)
repo ghrepo.Interface
existCheck bool
wantErrMsg string
}{
{
name: "repo exists",
httpStub: func(r *httpmock.Registry) {
r.Register(
httpmock.REST("HEAD", "repos/OWNER/REPO"),
httpmock.StringResponse("{}"),
)
},
repo: ghrepo.New("OWNER", "REPO"),
existCheck: true,
wantErrMsg: "",
},
{
name: "repo does not exists",
httpStub: func(r *httpmock.Registry) {
r.Register(
httpmock.REST("HEAD", "repos/OWNER/REPO"),
httpmock.StatusStringResponse(404, "Not Found"),
)
},
repo: ghrepo.New("OWNER", "REPO"),
existCheck: false,
wantErrMsg: "",
},
{
name: "http error",
httpStub: func(r *httpmock.Registry) {
r.Register(
httpmock.REST("HEAD", "repos/OWNER/REPO"),
httpmock.StatusStringResponse(500, "Internal Server Error"),
)
},
repo: ghrepo.New("OWNER", "REPO"),
existCheck: false,
wantErrMsg: "HTTP 500 (https://api.github.com/repos/OWNER/REPO)",
},
}
for _, tt := range tests {
reg := &httpmock.Registry{}
if tt.httpStub != nil {
tt.httpStub(reg)
}
client := newTestClient(reg)
t.Run(tt.name, func(t *testing.T) {
exist, err := RepoExists(client, ghrepo.New("OWNER", "REPO"))
if tt.wantErrMsg != "" {
assert.Equal(t, tt.wantErrMsg, err.Error())
} else {
assert.NoError(t, err)
}
if exist != tt.existCheck {
t.Errorf("RepoExists() returns %v, expected %v", exist, tt.existCheck)
return
}
})
}
}

View file

@ -205,11 +205,15 @@ func StatusCheckRollupGraphQLWithoutCountByState(after string) string {
}`), afterClause)
}
func RequiredStatusCheckRollupGraphQL(prID, after string) string {
func RequiredStatusCheckRollupGraphQL(prID, after string, includeEvent bool) string {
var afterClause string
if after != "" {
afterClause = ",after:" + after
}
eventField := "event,"
if !includeEvent {
eventField = ""
}
return fmt.Sprintf(shortenQuery(`
statusCheckRollup: commits(last: 1) {
nodes {
@ -228,7 +232,7 @@ func RequiredStatusCheckRollupGraphQL(prID, after string) string {
},
...on CheckRun {
name,
checkSuite{workflowRun{event,workflow{name}}},
checkSuite{workflowRun{%[3]sworkflow{name}}},
status,
conclusion,
startedAt,
@ -242,7 +246,7 @@ func RequiredStatusCheckRollupGraphQL(prID, after string) string {
}
}
}
}`), afterClause, prID)
}`), afterClause, prID, eventField)
}
var IssueFields = []string{

View file

@ -1,223 +0,0 @@
package api
import (
"bytes"
"io"
"net/http"
"regexp"
"strings"
"golang.org/x/text/transform"
)
var jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`)
// GitHub servers do not sanitize their API output for terminal display
// and leave in unescaped ASCII control characters.
// C0 control characters are represented in their unicode code point form ranging from \u0000 to \u001F.
// C1 control characters are represented in two bytes, the first being 0xC2 and the second ranging from 0x80 to 0x9F.
// These control characters will be interpreted by the terminal, this behaviour can be
// used maliciously as an attack vector, especially the control characters \u001B and \u009B.
// This function wraps JSON response bodies in a ReadCloser that transforms C0 and C1
// control characters to their caret notations respectively so that the terminal will not
// interpret them.
func AddASCIISanitizer(rt http.RoundTripper) http.RoundTripper {
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
res, err := rt.RoundTrip(req)
if err != nil || !jsonTypeRE.MatchString(res.Header.Get("Content-Type")) {
return res, err
}
res.Body = sanitizedReadCloser(res.Body)
return res, err
}}
}
func sanitizedReadCloser(rc io.ReadCloser) io.ReadCloser {
return struct {
io.Reader
io.Closer
}{
Reader: transform.NewReader(rc, &sanitizer{}),
Closer: rc,
}
}
// Sanitizer implements transform.Transformer interface.
type sanitizer struct {
addEscape bool
}
// Transform uses a sliding window algorithm to detect C0 and C1
// ASCII control sequences as they are read and replaces them
// with equivalent inert characters. Characters that are not part
// of a control sequence are not modified.
func (t *sanitizer) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) {
lSrc := len(src)
lDst := len(dst)
for nSrc < lSrc-6 && nDst < lDst {
window := src[nSrc : nSrc+6]
// Replace C1 Control Characters
if repl, found := mapC1ToCaret(window[:2]); found {
if len(repl)+nDst > lDst {
err = transform.ErrShortDst
return
}
for j := 0; j < len(repl); j++ {
dst[nDst] = repl[j]
nDst++
}
nSrc += 2
continue
}
// Replace C0 Control Characters
if repl, found := mapC0ToCaret(window); found {
if t.addEscape {
repl = append([]byte{'\\'}, repl...)
}
if len(repl)+nDst > lDst {
err = transform.ErrShortDst
return
}
for j := 0; j < len(repl); j++ {
dst[nDst] = repl[j]
nDst++
}
t.addEscape = false
nSrc += 6
continue
}
if window[0] == '\\' {
t.addEscape = !t.addEscape
} else {
t.addEscape = false
}
dst[nDst] = src[nSrc]
nDst++
nSrc++
}
if !atEOF {
err = transform.ErrShortSrc
return
}
remaining := lSrc - nSrc
if remaining+nDst > lDst {
err = transform.ErrShortDst
return
}
for j := 0; j < remaining; j++ {
dst[nDst] = src[nSrc]
nDst++
nSrc++
}
return
}
func (t *sanitizer) Reset() {
t.addEscape = false
}
// mapC0ToCaret maps C0 control sequences to caret notation.
func mapC0ToCaret(b []byte) ([]byte, bool) {
if len(b) != 6 {
return b, false
}
if !bytes.HasPrefix(b, []byte(`\u00`)) {
return b, false
}
m := map[string]string{
`\u0000`: `^@`,
`\u0001`: `^A`,
`\u0002`: `^B`,
`\u0003`: `^C`,
`\u0004`: `^D`,
`\u0005`: `^E`,
`\u0006`: `^F`,
`\u0007`: `^G`,
`\u0008`: `^H`,
`\u0009`: `^I`,
`\u000a`: `^J`,
`\u000b`: `^K`,
`\u000c`: `^L`,
`\u000d`: `^M`,
`\u000e`: `^N`,
`\u000f`: `^O`,
`\u0010`: `^P`,
`\u0011`: `^Q`,
`\u0012`: `^R`,
`\u0013`: `^S`,
`\u0014`: `^T`,
`\u0015`: `^U`,
`\u0016`: `^V`,
`\u0017`: `^W`,
`\u0018`: `^X`,
`\u0019`: `^Y`,
`\u001a`: `^Z`,
`\u001b`: `^[`,
`\u001c`: `^\\`,
`\u001d`: `^]`,
`\u001e`: `^^`,
`\u001f`: `^_`,
}
if c, ok := m[strings.ToLower(string(b))]; ok {
return []byte(c), true
}
return b, false
}
// mapC1ToCaret maps C1 control sequences to caret notation.
// C1 control sequences are two bytes long where the first byte is 0xC2.
func mapC1ToCaret(b []byte) ([]byte, bool) {
if len(b) != 2 {
return b, false
}
if b[0] != 0xC2 {
return b, false
}
m := map[byte]string{
128: `^@`,
129: `^A`,
130: `^B`,
131: `^C`,
132: `^D`,
133: `^E`,
134: `^F`,
135: `^G`,
136: `^H`,
137: `^I`,
138: `^J`,
139: `^K`,
140: `^L`,
141: `^M`,
142: `^N`,
143: `^O`,
144: `^P`,
145: `^Q`,
146: `^R`,
147: `^S`,
148: `^T`,
149: `^U`,
150: `^V`,
151: `^W`,
152: `^X`,
153: `^Y`,
154: `^Z`,
155: `^[`,
156: `^\\`,
157: `^]`,
158: `^^`,
159: `^_`,
}
if c, ok := m[b[1]]; ok {
return []byte(c), true
}
return b, false
}

View file

@ -1,93 +0,0 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
"testing/iotest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHTTPClientSanitizeASCIIControlCharactersC0(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
issue := Issue{
Title: "\u001B[31mRed Title\u001B[0m",
Body: "1\u0001 2\u0002 3\u0003 4\u0004 5\u0005 6\u0006 7\u0007 8\u0008 9\t A\r\n B\u000b C\u000c D\r\n E\u000e F\u000f",
Author: Author{
ID: "1",
Name: "10\u0010 11\u0011 12\u0012 13\u0013 14\u0014 15\u0015 16\u0016 17\u0017 18\u0018 19\u0019 1A\u001a 1B\u001b 1C\u001c 1D\u001d 1E\u001e 1F\u001f",
Login: "monalisa \\u00\u001b",
},
ActiveLockReason: "Escaped \u001B \\u001B \\\u001B \\\\u001B",
}
responseData, _ := json.Marshal(issue)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
fmt.Fprint(w, string(responseData))
}))
defer ts.Close()
client, err := NewHTTPClient(HTTPClientOptions{})
require.NoError(t, err)
req, err := http.NewRequest("GET", ts.URL, nil)
require.NoError(t, err)
res, err := client.Do(req)
require.NoError(t, err)
body, err := io.ReadAll(res.Body)
res.Body.Close()
require.NoError(t, err)
var issue Issue
err = json.Unmarshal(body, &issue)
require.NoError(t, err)
assert.Equal(t, "^[[31mRed Title^[[0m", issue.Title)
assert.Equal(t, "1^A 2^B 3^C 4^D 5^E 6^F 7^G 8^H 9\t A\r\n B^K C^L D\r\n E^N F^O", issue.Body)
assert.Equal(t, "10^P 11^Q 12^R 13^S 14^T 15^U 16^V 17^W 18^X 19^Y 1A^Z 1B^[ 1C^\\ 1D^] 1E^^ 1F^_", issue.Author.Name)
assert.Equal(t, "monalisa \\u00^[", issue.Author.Login)
assert.Equal(t, "Escaped ^[ \\^[ \\^[ \\\\^[", issue.ActiveLockReason)
}
func TestHTTPClientSanitizeASCIIControlCharactersC1(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
issue := Issue{
Title: "\xC2\x9B[31mRed Title\xC2\x9B[0m",
Body: "80\xC2\x80 81\xC2\x81 82\xC2\x82 83\xC2\x83 84\xC2\x84 85\xC2\x85 86\xC2\x86 87\xC2\x87 88\xC2\x88 89\xC2\x89 8A\xC2\x8A 8B\xC2\x8B 8C\xC2\x8C 8D\xC2\x8D 8E\xC2\x8E 8F\xC2\x8F",
Author: Author{
ID: "1",
Name: "90\xC2\x90 91\xC2\x91 92\xC2\x92 93\xC2\x93 94\xC2\x94 95\xC2\x95 96\xC2\x96 97\xC2\x97 98\xC2\x98 99\xC2\x99 9A\xC2\x9A 9B\xC2\x9B 9C\xC2\x9C 9D\xC2\x9D 9E\xC2\x9E 9F\xC2\x9F",
Login: "monalisa\xC2\xA1",
},
}
responseData, _ := json.Marshal(issue)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
fmt.Fprint(w, string(responseData))
}))
defer ts.Close()
client, err := NewHTTPClient(HTTPClientOptions{})
require.NoError(t, err)
req, err := http.NewRequest("GET", ts.URL, nil)
require.NoError(t, err)
res, err := client.Do(req)
require.NoError(t, err)
body, err := io.ReadAll(res.Body)
res.Body.Close()
require.NoError(t, err)
var issue Issue
err = json.Unmarshal(body, &issue)
require.NoError(t, err)
assert.Equal(t, "^[[31mRed Title^[[0m", issue.Title)
assert.Equal(t, "80^@ 81^A 82^B 83^C 84^D 85^E 86^F 87^G 88^H 89^I 8A^J 8B^K 8C^L 8D^M 8E^N 8F^O", issue.Body)
assert.Equal(t, "90^P 91^Q 92^R 93^S 94^T 95^U 96^V 97^W 98^X 99^Y 9A^Z 9B^[ 9C^\\ 9D^] 9E^^ 9F^_", issue.Author.Name)
assert.Equal(t, "monalisa¡", issue.Author.Login)
}
func TestSanitizedReadCloser(t *testing.T) {
data := []byte(`the quick brown fox\njumped over the lazy dog\t`)
rc := sanitizedReadCloser(io.NopCloser(bytes.NewReader(data)))
assert.NoError(t, iotest.TestReader(rc, data))
}

View file

@ -34,10 +34,11 @@ var updaterEnabled = ""
type exitCode int
const (
exitOK exitCode = 0
exitError exitCode = 1
exitCancel exitCode = 2
exitAuth exitCode = 4
exitOK exitCode = 0
exitError exitCode = 1
exitCancel exitCode = 2
exitAuth exitCode = 4
exitPending exitCode = 8
)
func main() {
@ -113,6 +114,8 @@ func mainRun() exitCode {
var authError *root.AuthError
if err == cmdutil.SilentError {
return exitError
} else if err == cmdutil.PendingError {
return exitPending
} else if cmdutil.IsUserCancellation(err) {
if errors.Is(err, terminal.InterruptErr) {
// ensure the next shell prompt will start on its own line

View file

@ -197,10 +197,10 @@ kiss b github-cli && kiss i github-cli
### Nix/NixOS
Nix/NixOS users can install from [nixpkgs](https://search.nixos.org/packages?show=gitAndTools.gh&query=gh&from=0&size=30&sort=relevance&channel=20.03#disabled):
Nix/NixOS users can install from [nixpkgs](https://search.nixos.org/packages?query=gh&sort=relevance&show=gh):
```bash
nix-env -iA nixos.gitAndTools.gh
nix-env -iA nixos.gh
```
### openSUSE Tumbleweed

View file

@ -1,6 +1,6 @@
# Installation from source
1. Verify that you have Go 1.19+ installed
1. Verify that you have Go 1.21+ installed
```sh
$ go version

27
go.mod
View file

@ -1,15 +1,15 @@
module github.com/cli/cli/v2
go 1.19
go 1.21
require (
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/MakeNowJust/heredoc v1.0.0
github.com/briandowns/spinner v1.18.1
github.com/cenkalti/backoff/v4 v4.2.1
github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da
github.com/charmbracelet/glamour v0.6.0
github.com/charmbracelet/lipgloss v0.5.0
github.com/cli/go-gh/v2 v2.0.1
github.com/cli/go-gh/v2 v2.3.0
github.com/cli/oauth v1.0.1
github.com/cli/safeexec v1.0.1
github.com/cpuguy83/go-md2man/v2 v2.0.2
@ -30,16 +30,16 @@ require (
github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38
github.com/opentracing/opentracing-go v1.1.0
github.com/rivo/tview v0.0.0-20221029100920-c4a7e501810d
github.com/shurcooL/githubv4 v0.0.0-20230424031643-6cea62ecd5a9
github.com/shurcooL/githubv4 v0.0.0-20230704064427-599ae7bbf278
github.com/sourcegraph/jsonrpc2 v0.1.0
github.com/spf13/cobra v1.6.1
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.8.4
github.com/zalando/go-keyring v0.2.3
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519
golang.org/x/crypto v0.12.0
golang.org/x/sync v0.1.0
golang.org/x/term v0.7.0
golang.org/x/text v0.9.0
golang.org/x/term v0.11.0
golang.org/x/text v0.12.0
google.golang.org/grpc v1.53.0
google.golang.org/protobuf v1.28.1
gopkg.in/h2non/gock.v1 v1.1.2
@ -49,6 +49,7 @@ require (
require (
github.com/alecthomas/chroma v0.10.0 // indirect
github.com/alessio/shellescape v1.4.1 // indirect
github.com/aymanbagabas/go-osc52 v1.0.3 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/cli/browser v1.2.0 // indirect
github.com/cli/shurcooL-graphql v0.0.3 // indirect
@ -68,20 +69,20 @@ require (
github.com/kr/text v0.2.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/microcosm-cc/bluemonday v1.0.20 // indirect
github.com/microcosm-cc/bluemonday v1.0.21 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.12.0 // indirect
github.com/muesli/termenv v0.13.0 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29 // indirect
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e // indirect
github.com/yuin/goldmark v1.4.13 // indirect
github.com/yuin/goldmark v1.5.2 // indirect
github.com/yuin/goldmark-emoji v1.0.1 // indirect
golang.org/x/net v0.9.0 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/net v0.14.0 // indirect
golang.org/x/sys v0.11.0 // indirect
google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
)

55
go.sum
View file

@ -8,14 +8,16 @@ github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbf
github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0=
github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
github.com/aymanbagabas/go-osc52 v1.0.3 h1:DTwqENW7X9arYimJrPeGZcV0ln14sGMt3pHZspWD+Mg=
github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/briandowns/spinner v1.18.1 h1:yhQmQtM1zsqFsouh09Bk/jCjd50pC3EOGsh28gLVvwY=
github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU=
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da h1:FGz53GWQRiKQ/5xUsoCCkewSQIC7u81Scaxx2nUy3nM=
github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da/go.mod h1:HXz79SMFnF9arKxqeoHWxmo1BhplAH7wehlRhKQIL94=
github.com/charmbracelet/glamour v0.6.0 h1:wi8fse3Y7nfcabbbDuwolqTqMQPMnVPeZhDM273bISc=
github.com/charmbracelet/glamour v0.6.0/go.mod h1:taqWV4swIMMbWALc0m7AfE9JkPSU8om2538k9ITBxOc=
github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8=
github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs=
github.com/cli/browser v1.0.0/go.mod h1:IEWkHYbLjkhtjwwWlwTHW2lGxeS5gezEQBMLTwDHf5Q=
@ -23,8 +25,8 @@ github.com/cli/browser v1.2.0 h1:yvU7e9qf97kZqGFX6n2zJPHsmSObY9ske+iCvKelvXg=
github.com/cli/browser v1.2.0/go.mod h1:xFFnXLVcAyW9ni0cuo6NnrbCP75JxJ0RO7VtCBiH/oI=
github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03 h1:3f4uHLfWx4/WlnMPXGai03eoWAI+oGHJwr+5OXfxCr8=
github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
github.com/cli/go-gh/v2 v2.0.1 h1:W4L7C5xT9CwkcqTsUzBhFhRKGpek9oRtdDLku2Hku+E=
github.com/cli/go-gh/v2 v2.0.1/go.mod h1:zWab1jRnJ0Ug8qRjsZHFk/Oq51ZWuhSxRL2FDUDgQWk=
github.com/cli/go-gh/v2 v2.3.0 h1:FAQAP4PaWSAJf4VSxFEIYDQ1oBIs+bKB4GXQAiRr2sQ=
github.com/cli/go-gh/v2 v2.3.0/go.mod h1:6WBUuf7LUVAc+eXYYX/nYTYURRc6M03K9cJNwBKvwT0=
github.com/cli/oauth v1.0.1 h1:pXnTFl/qUegXHK531Dv0LNjW4mLx626eS42gnzfXJPA=
github.com/cli/oauth v1.0.1/go.mod h1:qd/FX8ZBD6n1sVNQO3aIdRxeu5LGw9WhKnYhIIoC2A4=
github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
@ -91,6 +93,7 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
@ -112,16 +115,14 @@ github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/microcosm-cc/bluemonday v1.0.19/go.mod h1:QNzV2UbLK2/53oIIwTOyLUSABMkjZ4tqiyC1g/DyqxE=
github.com/microcosm-cc/bluemonday v1.0.20 h1:flpzsq4KU3QIYAYGV/szUat7H+GPOXR0B2JU5A1Wp8Y=
github.com/microcosm-cc/bluemonday v1.0.20/go.mod h1:yfBmMi8mxvaZut3Yytv+jTXRY8mxyjJ0/kQBTElld50=
github.com/microcosm-cc/bluemonday v1.0.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg=
github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM=
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.11.0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/muesli/termenv v0.12.0 h1:KuQRUE3PgxRFWhq4gHvZtPSLCGDqM5q/cYr1pZ39ytc=
github.com/muesli/termenv v0.12.0/go.mod h1:WCCv32tusQ/EEZ5S8oUIIrC/nIuBcxCVqlN4Xfkv+7A=
github.com/muesli/termenv v0.13.0 h1:wK20DRpJdDX8b7Ek2QfhvqhRQFZ237RGRO0RQ/Iqdy0=
github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc=
github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38 h1:0FrBxrkJ0hVembTb/e4EU5Ml6vLcOusAqymmYISg5Uo=
github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38/go.mod h1:saF2fIVw4banK0H4+/EuqfFLpRnoy5S+ECwTOCcRcSU=
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4=
@ -140,10 +141,10 @@ github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shurcooL/githubv4 v0.0.0-20230424031643-6cea62ecd5a9 h1:nCBaIs5/R0HFP5+aPW/SzFUF8z0oKuCXmuDmHWaxzjY=
github.com/shurcooL/githubv4 v0.0.0-20230424031643-6cea62ecd5a9/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo=
github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29 h1:B1PEwpArrNp4dkQrfxh/abbBAOZBVp0ds+fBEOUOqOc=
github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg=
github.com/shurcooL/githubv4 v0.0.0-20230704064427-599ae7bbf278 h1:kdEGVAV4sO46DPtb8k793jiecUEhaX9ixoIBt41HEGU=
github.com/shurcooL/githubv4 v0.0.0-20230704064427-599ae7bbf278/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8=
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0=
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE=
github.com/sourcegraph/jsonrpc2 v0.1.0 h1:ohJHjZ+PcaLxDUjqk2NC3tIGsVa5bXThe1ZheSXOjuk=
github.com/sourcegraph/jsonrpc2 v0.1.0/go.mod h1:ZafdZgk/axhT1cvZAPOhw+95nz2I/Ra5qMlU4gTRwIo=
github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA=
@ -164,9 +165,9 @@ github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e h1:Buzhfgf
github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.4/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg=
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.5.2 h1:ALmeCk/px5FSm1MAcFBAsVKZjDuMVj8Tm7FFIlMJnqU=
github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os=
github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ=
github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms=
@ -175,13 +176,14 @@ golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220923203811-8be639271d50/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/oauth2 v0.4.0 h1:NF0gk8LVPg1Ml7SSbGyySuoxdsXitj7TvgvuRxIMc/M=
golang.org/x/oauth2 v0.4.0/go.mod h1:RznEsdpjGAINPTOF0UH/t+xJ75L18YO3Ho6Pyn+uRec=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -196,36 +198,37 @@ golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.7.0 h1:BEvjmm5fURWqcfbSKTdpkDXYBrUS1c0m8agp14W48vQ=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.11.0 h1:F9tnn/DA/Im8nCwm+fX+1/eBwi4qFjRT++MhtVC4ZX0=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f h1:BWUVssLB0HVOSY78gIdvk1dTVYtT1y8SBWtPYuTJ/6w=
google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
google.golang.org/grpc v1.53.0 h1:LAv2ds7cmFV/XTS3XG1NneeENYrXGmorPxsBbptIjNc=

View file

@ -1,13 +1,12 @@
package api
// For descriptions of service interfaces, see:
// - https://online.visualstudio.com/api/swagger (for visualstudio.com)
// - https://docs.github.com/en/rest/reference/repos (for api.github.com)
// - https://github.com/github/github/blob/master/app/api/codespaces.rb (for vscs_internal)
// TODO(adonovan): replace the last link with a public doc URL when available.
// TODO(adonovan): a possible reorganization would be to split this
// file into three internal packages, one per backend service, and to
// file into two internal packages, one per backend service, and to
// rename api.API to github.Client:
//
// - github.GetUser(github.Client)
@ -20,7 +19,6 @@ package api
// - codespaces.GetToken(Client, login, name)
// - codespaces.List(Client, user)
// - codespaces.Start(Client, token, codespace)
// - visualstudio.GetRegionLocation(http.Client) // no dependency on github
//
// This would make the meaning of each operation clearer.
@ -34,6 +32,7 @@ import (
"io"
"net/http"
"net/url"
"os"
"reflect"
"regexp"
"strconv"
@ -42,13 +41,14 @@ import (
"github.com/cenkalti/backoff/v4"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/opentracing/opentracing-go"
)
const (
githubServer = "https://github.com"
githubAPI = "https://api.github.com"
vscsAPI = "https://online.visualstudio.com"
defaultAPIURL = "https://api.github.com"
defaultServerURL = "https://github.com"
)
const (
@ -60,31 +60,40 @@ const (
// API is the interface to the codespace service.
type API struct {
client httpClient
vscsAPI string
client func() (*http.Client, error)
githubAPI string
githubServer string
retryBackoff time.Duration
}
type httpClient interface {
Do(req *http.Request) (*http.Response, error)
}
// New creates a new API client connecting to the configured endpoints with the HTTP client.
func New(serverURL, apiURL, vscsURL string, httpClient httpClient) *API {
if serverURL == "" {
serverURL = githubServer
}
func New(f *cmdutil.Factory) *API {
apiURL := os.Getenv("GITHUB_API_URL")
if apiURL == "" {
apiURL = githubAPI
cfg, err := f.Config()
if err != nil {
// fallback to the default api endpoint
apiURL = defaultAPIURL
} else {
host, _ := cfg.Authentication().DefaultHost()
apiURL = ghinstance.RESTPrefix(host)
}
}
if vscsURL == "" {
vscsURL = vscsAPI
serverURL := os.Getenv("GITHUB_SERVER_URL")
if serverURL == "" {
cfg, err := f.Config()
if err != nil {
// fallback to the default server endpoint
serverURL = defaultServerURL
} else {
host, _ := cfg.Authentication().DefaultHost()
serverURL = ghinstance.HostPrefix(host)
}
}
return &API{
client: httpClient,
vscsAPI: strings.TrimSuffix(vscsURL, "/"),
client: f.HttpClient,
githubAPI: strings.TrimSuffix(apiURL, "/"),
githubServer: strings.TrimSuffix(serverURL, "/"),
retryBackoff: 100 * time.Millisecond,
@ -97,6 +106,11 @@ type User struct {
Type string `json:"type"`
}
// ServerURL returns the server url (not the API url), such as https://github.com
func (a *API) ServerURL() string {
return a.githubServer
}
// GetUser returns the user associated with the given token.
func (a *API) GetUser(ctx context.Context) (*User, error) {
req, err := http.NewRequest(http.MethodGet, a.githubAPI+"/user", nil)
@ -1119,7 +1133,13 @@ func (a *API) do(ctx context.Context, req *http.Request, spanName string) (*http
span, ctx := opentracing.StartSpanFromContext(ctx, spanName)
defer span.Finish()
req = req.WithContext(ctx)
return a.client.Do(req)
httpClient, err := a.client()
if err != nil {
return nil, err
}
return httpClient.Do(req)
}
// setHeaders sets the required headers for the API.
@ -1139,6 +1159,6 @@ func (a *API) withRetry(f func() (*http.Response, error)) (*http.Response, error
if resp.StatusCode < 500 {
return resp, nil
}
return nil, errors.New("retry")
return nil, fmt.Errorf("received response with status code %d", resp.StatusCode)
}, backoff.WithMaxRetries(bo, 3))
}

View file

@ -3,12 +3,16 @@ package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"testing"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/pkg/cmdutil"
)
func generateCodespaceList(start int, end int) []*Codespace {
@ -126,13 +130,187 @@ func createFakeCreateEndpointServer(t *testing.T, wantStatus int) *httptest.Serv
}))
}
func createHttpClient() (*http.Client, error) {
return &http.Client{}, nil
}
func TestNew_APIURL_dotcomConfig(t *testing.T) {
t.Setenv("GITHUB_API_URL", "")
t.Setenv("GITHUB_SERVER_URL", "https://github.com")
cfg := &config.ConfigMock{
AuthenticationFunc: func() *config.AuthConfig {
return &config.AuthConfig{}
},
}
f := &cmdutil.Factory{
Config: func() (config.Config, error) {
return cfg, nil
},
}
api := New(f)
if api.githubAPI != "https://api.github.com" {
t.Fatalf("expected https://api.github.com, got %s", api.githubAPI)
}
if len(cfg.AuthenticationCalls()) != 1 {
t.Fatalf("API url was not pulled from the config")
}
}
func TestNew_APIURL_customConfig(t *testing.T) {
t.Setenv("GITHUB_API_URL", "")
t.Setenv("GITHUB_SERVER_URL", "https://github.mycompany.com")
cfg := &config.ConfigMock{
AuthenticationFunc: func() *config.AuthConfig {
authCfg := &config.AuthConfig{}
authCfg.SetDefaultHost("github.mycompany.com", "GH_HOST")
return authCfg
},
}
f := &cmdutil.Factory{
Config: func() (config.Config, error) {
return cfg, nil
},
}
api := New(f)
if api.githubAPI != "https://github.mycompany.com/api/v3" {
t.Fatalf("expected https://github.mycompany.com/api/v3, got %s", api.githubAPI)
}
if len(cfg.AuthenticationCalls()) != 1 {
t.Fatalf("API url was not pulled from the config")
}
}
func TestNew_APIURL_env(t *testing.T) {
t.Setenv("GITHUB_API_URL", "https://api.mycompany.com")
t.Setenv("GITHUB_SERVER_URL", "https://mycompany.com")
cfg := &config.ConfigMock{
AuthenticationFunc: func() *config.AuthConfig {
return &config.AuthConfig{}
},
}
f := &cmdutil.Factory{
Config: func() (config.Config, error) {
return cfg, nil
},
}
api := New(f)
if api.githubAPI != "https://api.mycompany.com" {
t.Fatalf("expected https://api.mycompany.com, got %s", api.githubAPI)
}
if len(cfg.AuthenticationCalls()) != 0 {
t.Fatalf("Configuration was checked instead of using the GITHUB_API_URL environment variable")
}
}
func TestNew_APIURL_dotcomFallback(t *testing.T) {
t.Setenv("GITHUB_API_URL", "")
f := &cmdutil.Factory{
Config: func() (config.Config, error) {
return nil, errors.New("Failed to load")
},
}
api := New(f)
if api.githubAPI != "https://api.github.com" {
t.Fatalf("expected https://api.github.com, got %s", api.githubAPI)
}
}
func TestNew_ServerURL_dotcomConfig(t *testing.T) {
t.Setenv("GITHUB_SERVER_URL", "")
t.Setenv("GITHUB_API_URL", "https://api.github.com")
cfg := &config.ConfigMock{
AuthenticationFunc: func() *config.AuthConfig {
return &config.AuthConfig{}
},
}
f := &cmdutil.Factory{
Config: func() (config.Config, error) {
return cfg, nil
},
}
api := New(f)
if api.githubServer != "https://github.com" {
t.Fatalf("expected https://github.com, got %s", api.githubServer)
}
if len(cfg.AuthenticationCalls()) != 1 {
t.Fatalf("Server url was not pulled from the config")
}
}
func TestNew_ServerURL_customConfig(t *testing.T) {
t.Setenv("GITHUB_SERVER_URL", "")
t.Setenv("GITHUB_API_URL", "https://github.mycompany.com/api/v3")
cfg := &config.ConfigMock{
AuthenticationFunc: func() *config.AuthConfig {
authCfg := &config.AuthConfig{}
authCfg.SetDefaultHost("github.mycompany.com", "GH_HOST")
return authCfg
},
}
f := &cmdutil.Factory{
Config: func() (config.Config, error) {
return cfg, nil
},
}
api := New(f)
if api.githubServer != "https://github.mycompany.com" {
t.Fatalf("expected https://github.mycompany.com, got %s", api.githubServer)
}
if len(cfg.AuthenticationCalls()) != 1 {
t.Fatalf("Server url was not pulled from the config")
}
}
func TestNew_ServerURL_env(t *testing.T) {
t.Setenv("GITHUB_SERVER_URL", "https://mycompany.com")
t.Setenv("GITHUB_API_URL", "https://api.mycompany.com")
cfg := &config.ConfigMock{
AuthenticationFunc: func() *config.AuthConfig {
return &config.AuthConfig{}
},
}
f := &cmdutil.Factory{
Config: func() (config.Config, error) {
return cfg, nil
},
}
api := New(f)
if api.githubServer != "https://mycompany.com" {
t.Fatalf("expected https://mycompany.com, got %s", api.githubServer)
}
if len(cfg.AuthenticationCalls()) != 0 {
t.Fatalf("Configuration was checked instead of using the GITHUB_SERVER_URL environment variable")
}
}
func TestNew_ServerURL_dotcomFallback(t *testing.T) {
t.Setenv("GITHUB_SERVER_URL", "")
f := &cmdutil.Factory{
Config: func() (config.Config, error) {
return nil, errors.New("Failed to load")
},
}
api := New(f)
if api.githubServer != "https://github.com" {
t.Fatalf("expected https://github.com, got %s", api.githubServer)
}
}
func TestCreateCodespaces(t *testing.T) {
svr := createFakeCreateEndpointServer(t, http.StatusCreated)
defer svr.Close()
api := API{
githubAPI: svr.URL,
client: &http.Client{},
client: createHttpClient,
}
ctx := context.TODO()
@ -161,7 +339,7 @@ func TestCreateCodespaces_displayName(t *testing.T) {
api := API{
githubAPI: svr.URL,
client: &http.Client{},
client: createHttpClient,
}
retentionPeriod := 0
@ -186,7 +364,7 @@ func TestCreateCodespaces_Pending(t *testing.T) {
api := API{
githubAPI: svr.URL,
client: &http.Client{},
client: createHttpClient,
retryBackoff: 0,
}
@ -213,7 +391,7 @@ func TestListCodespaces_limited(t *testing.T) {
api := API{
githubAPI: svr.URL,
client: &http.Client{},
client: createHttpClient,
}
ctx := context.TODO()
codespaces, err := api.ListCodespaces(ctx, ListCodespacesOptions{Limit: 200})
@ -238,7 +416,7 @@ func TestListCodespaces_unlimited(t *testing.T) {
api := API{
githubAPI: svr.URL,
client: &http.Client{},
client: createHttpClient,
}
ctx := context.TODO()
codespaces, err := api.ListCodespaces(ctx, ListCodespacesOptions{})
@ -329,7 +507,7 @@ func runRepoSearchTest(t *testing.T, searchText, wantQueryText, wantSort, wantMa
api := API{
githubAPI: svr.URL,
client: &http.Client{},
client: createHttpClient,
}
ctx := context.Background()
@ -374,7 +552,7 @@ func TestRetries(t *testing.T) {
t.Cleanup(srv.Close)
a := &API{
githubAPI: srv.URL,
client: &http.Client{},
client: createHttpClient,
}
cs, err := a.GetCodespace(context.Background(), "test", false)
if err != nil {
@ -562,7 +740,7 @@ func TestAPI_EditCodespace(t *testing.T) {
defer svr.Close()
a := &API{
client: &http.Client{},
client: createHttpClient,
githubAPI: svr.URL,
}
got, err := a.EditCodespace(tt.args.ctx, tt.args.codespaceName, tt.args.params)
@ -602,7 +780,7 @@ func TestAPI_EditCodespacePendingOperation(t *testing.T) {
defer svr.Close()
a := &API{
client: &http.Client{},
client: createHttpClient,
githubAPI: svr.URL,
}

View file

@ -35,6 +35,14 @@ type logger interface {
Printf(f string, v ...interface{})
}
type TimeoutError struct {
message string
}
func (e *TimeoutError) Error() string {
return e.message
}
// ConnectToLiveshare waits for a Codespace to become running,
// and connects to it using a Live Share session.
func ConnectToLiveshare(ctx context.Context, progress progressIndicator, sessionLogger logger, apiClient apiClient, codespace *api.Codespace) (*liveshare.Session, error) {
@ -63,15 +71,15 @@ func ConnectToLiveshare(ctx context.Context, progress progressIndicator, session
return nil
}
return errors.New("codespace not ready yet")
return &TimeoutError{message: "codespace not ready yet"}
}, backoff.WithContext(expBackoff, ctx))
if err != nil {
var permErr *backoff.PermanentError
if errors.As(err, &permErr) {
return nil, err
var timeoutErr *TimeoutError
if errors.As(err, &timeoutErr) {
return nil, errors.New("timed out while waiting for the codespace to start")
}
return nil, errors.New("timed out while waiting for the codespace to start")
return nil, err
}
}

View file

@ -5,6 +5,7 @@ import (
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghinstance"
"golang.org/x/sync/errgroup"
)
type Detector interface {
@ -27,11 +28,13 @@ type PullRequestFeatures struct {
// the checkRunCount, checkRunCountsByState, statusContextCount and stausContextCountsByState
// fields on the StatusCheckRollupContextConnection
CheckRunAndStatusContextCounts bool
CheckRunEvent bool
}
var allPullRequestFeatures = PullRequestFeatures{
MergeQueue: true,
CheckRunAndStatusContextCounts: true,
CheckRunEvent: true,
}
type RepositoryFeatures struct {
@ -111,8 +114,26 @@ func (d *detector) PullRequestFeatures() (PullRequestFeatures, error) {
} `graphql:"StatusCheckRollupContextConnection: __type(name: \"StatusCheckRollupContextConnection\")"`
}
// Break feature detection down into two separate queries because the platform
// only supports two `__type` expressions in one query.
var pullRequestFeatureDetection2 struct {
WorkflowRun struct {
Fields []struct {
Name string
} `graphql:"fields(includeDeprecated: true)"`
} `graphql:"WorkflowRun: __type(name: \"WorkflowRun\")"`
}
gql := api.NewClientFromHTTP(d.httpClient)
if err := gql.Query(d.host, "PullRequest_fields", &pullRequestFeatureDetection, nil); err != nil {
var wg errgroup.Group
wg.Go(func() error {
return gql.Query(d.host, "PullRequest_fields", &pullRequestFeatureDetection, nil)
})
wg.Go(func() error {
return gql.Query(d.host, "PullRequest_fields2", &pullRequestFeatureDetection2, nil)
})
if err := wg.Wait(); err != nil {
return PullRequestFeatures{}, err
}
@ -132,6 +153,12 @@ func (d *detector) PullRequestFeatures() (PullRequestFeatures, error) {
}
}
for _, field := range pullRequestFeatureDetection2.WorkflowRun.Fields {
if field.Name == "event" {
features.CheckRunEvent = true
}
}
return features, nil
}

View file

@ -82,7 +82,7 @@ func TestPullRequestFeatures(t *testing.T) {
wantErr bool
}{
{
name: "github.com with merge queue and status check counts by state",
name: "github.com with all features",
hostname: "github.com",
queryResponse: map[string]string{
`query PullRequest_fields\b`: heredoc.Doc(`
@ -104,10 +104,21 @@ func TestPullRequestFeatures(t *testing.T) {
}
}
}`),
`query PullRequest_fields2\b`: heredoc.Doc(`
{
"data": {
"WorkflowRun": {
"fields": [
{"name": "event"}
]
}
}
}`),
},
wantFeatures: PullRequestFeatures{
MergeQueue: true,
CheckRunAndStatusContextCounts: true,
CheckRunEvent: true,
},
wantErr: false,
},
@ -131,15 +142,26 @@ func TestPullRequestFeatures(t *testing.T) {
}
}
}`),
`query PullRequest_fields2\b`: heredoc.Doc(`
{
"data": {
"WorkflowRun": {
"fields": [
{"name": "event"}
]
}
}
}`),
},
wantFeatures: PullRequestFeatures{
MergeQueue: false,
CheckRunAndStatusContextCounts: true,
CheckRunEvent: true,
},
wantErr: false,
},
{
name: "GHE with merge queue and status check counts by state",
name: "GHE with all features",
hostname: "git.my.org",
queryResponse: map[string]string{
`query PullRequest_fields\b`: heredoc.Doc(`
@ -161,15 +183,26 @@ func TestPullRequestFeatures(t *testing.T) {
}
}
}`),
`query PullRequest_fields2\b`: heredoc.Doc(`
{
"data": {
"WorkflowRun": {
"fields": [
{"name": "event"}
]
}
}
}`),
},
wantFeatures: PullRequestFeatures{
MergeQueue: true,
CheckRunAndStatusContextCounts: true,
CheckRunEvent: true,
},
wantErr: false,
},
{
name: "GHE without merge queue and status check counts by state",
name: "GHE with no features",
hostname: "git.my.org",
queryResponse: map[string]string{
`query PullRequest_fields\b`: heredoc.Doc(`
@ -183,10 +216,19 @@ func TestPullRequestFeatures(t *testing.T) {
}
}
}`),
`query PullRequest_fields2\b`: heredoc.Doc(`
{
"data": {
"WorkflowRun": {
"fields": []
}
}
}`),
},
wantFeatures: PullRequestFeatures{
MergeQueue: false,
CheckRunAndStatusContextCounts: false,
CheckRunEvent: false,
},
wantErr: false,
},

View file

@ -2,132 +2,74 @@ package prompter
import (
"fmt"
"io"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/pkg/surveyext"
ghPrompter "github.com/cli/go-gh/v2/pkg/prompter"
)
//go:generate moq -rm -out prompter_mock.go . Prompter
type Prompter interface {
// generic prompts from go-gh
Select(string, string, []string) (int, error)
MultiSelect(string, []string, []string) ([]int, error)
MultiSelect(prompt string, defaults []string, options []string) ([]int, error)
Input(string, string) (string, error)
InputHostname() (string, error)
Password(string) (string, error)
AuthToken() (string, error)
Confirm(string, bool) (bool, error)
// gh specific prompts
AuthToken() (string, error)
ConfirmDeletion(string) error
InputHostname() (string, error)
MarkdownEditor(string, string, bool) (string, error)
}
type fileWriter interface {
io.Writer
Fd() uintptr
}
type fileReader interface {
io.Reader
Fd() uintptr
}
func New(editorCmd string, stdin fileReader, stdout fileWriter, stderr io.Writer) Prompter {
func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWriter, stderr ghPrompter.FileWriter) Prompter {
return &surveyPrompter{
editorCmd: editorCmd,
prompter: ghPrompter.New(stdin, stdout, stderr),
stdin: stdin,
stdout: stdout,
stderr: stderr,
editorCmd: editorCmd,
}
}
type surveyPrompter struct {
prompter *ghPrompter.Prompter
stdin ghPrompter.FileReader
stdout ghPrompter.FileWriter
stderr ghPrompter.FileWriter
editorCmd string
stdin fileReader
stdout fileWriter
stderr io.Writer
}
// LatinMatchingFilter returns whether the value matches the input filter.
// The strings are compared normalized in case.
// The filter's diactritics are kept as-is, but the value's are normalized,
// so that a missing diactritic in the filter still returns a result.
func LatinMatchingFilter(filter, value string, index int) bool {
filter = strings.ToLower(filter)
value = strings.ToLower(value)
// include this option if it matches.
return strings.Contains(value, filter) || strings.Contains(text.RemoveDiacritics(value), filter)
func (p *surveyPrompter) Select(prompt, defaultValue string, options []string) (int, error) {
return p.prompter.Select(prompt, defaultValue, options)
}
func (p *surveyPrompter) Select(message, defaultValue string, options []string) (result int, err error) {
q := &survey.Select{
Message: message,
Options: options,
PageSize: 20,
Filter: LatinMatchingFilter,
}
if defaultValue != "" {
// in some situations, defaultValue ends up not being a valid option; do
// not set default in that case as it will make survey panic
for _, o := range options {
if o == defaultValue {
q.Default = defaultValue
break
}
}
}
err = p.ask(q, &result)
return
func (p *surveyPrompter) MultiSelect(prompt string, defaultValues, options []string) ([]int, error) {
return p.prompter.MultiSelect(prompt, defaultValues, options)
}
func (p *surveyPrompter) MultiSelect(message string, defaultValues, options []string) (result []int, err error) {
q := &survey.MultiSelect{
Message: message,
Options: options,
PageSize: 20,
Filter: LatinMatchingFilter,
}
if len(defaultValues) > 0 {
// TODO I don't actually know that this is needed, just being extra cautious
validatedDefault := []string{}
for _, x := range defaultValues {
for _, y := range options {
if x == y {
validatedDefault = append(validatedDefault, x)
}
}
}
q.Default = validatedDefault
}
err = p.ask(q, &result)
return
func (p *surveyPrompter) Input(prompt, defaultValue string) (string, error) {
return p.prompter.Input(prompt, defaultValue)
}
func (p *surveyPrompter) ask(q survey.Prompt, response interface{}, opts ...survey.AskOpt) error {
opts = append(opts, survey.WithStdio(p.stdin, p.stdout, p.stderr))
err := survey.AskOne(q, response, opts...)
if err == nil {
return nil
}
return fmt.Errorf("could not prompt: %w", err)
func (p *surveyPrompter) Password(prompt string) (string, error) {
return p.prompter.Password(prompt)
}
func (p *surveyPrompter) Input(prompt, defaultValue string) (result string, err error) {
err = p.ask(&survey.Input{
Message: prompt,
Default: defaultValue,
}, &result)
func (p *surveyPrompter) Confirm(prompt string, defaultValue bool) (bool, error) {
return p.prompter.Confirm(prompt, defaultValue)
}
return
func (p *surveyPrompter) AuthToken() (string, error) {
var result string
err := p.ask(&survey.Password{
Message: "Paste your authentication token:",
}, &result, survey.WithValidator(survey.Required))
return result, err
}
func (p *surveyPrompter) ConfirmDeletion(requiredValue string) error {
@ -146,54 +88,38 @@ func (p *surveyPrompter) ConfirmDeletion(requiredValue string) error {
}))
}
func (p *surveyPrompter) InputHostname() (result string, err error) {
err = p.ask(
func (p *surveyPrompter) InputHostname() (string, error) {
var result string
err := p.ask(
&survey.Input{
Message: "GHE hostname:",
}, &result, survey.WithValidator(func(v interface{}) error {
return ghinstance.HostnameValidator(v.(string))
}))
return
return result, err
}
func (p *surveyPrompter) Password(prompt string) (result string, err error) {
err = p.ask(&survey.Password{
Message: prompt,
}, &result)
return
}
func (p *surveyPrompter) Confirm(prompt string, defaultValue bool) (result bool, err error) {
err = p.ask(&survey.Confirm{
Message: prompt,
Default: defaultValue,
}, &result)
return
}
func (p *surveyPrompter) MarkdownEditor(message, defaultValue string, blankAllowed bool) (result string, err error) {
err = p.ask(&surveyext.GhEditor{
func (p *surveyPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) {
var result string
err := p.ask(&surveyext.GhEditor{
BlankAllowed: blankAllowed,
EditorCommand: p.editorCmd,
Editor: &survey.Editor{
Message: message,
Message: prompt,
Default: defaultValue,
FileName: "*.md",
HideDefault: true,
AppendDefault: true,
},
}, &result)
return
return result, err
}
func (p *surveyPrompter) AuthToken() (result string, err error) {
err = p.ask(&survey.Password{
Message: "Paste your authentication token:",
}, &result, survey.WithValidator(survey.Required))
return
func (p *surveyPrompter) ask(q survey.Prompt, response interface{}, opts ...survey.AskOpt) error {
opts = append(opts, survey.WithStdio(p.stdin, p.stdout, p.stderr))
err := survey.AskOne(q, response, opts...)
if err == nil {
return nil
}
return fmt.Errorf("could not prompt: %w", err)
}

View file

@ -1,78 +0,0 @@
package prompter
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestFilterDiacritics(t *testing.T) {
tests := []struct {
name string
filter string
value string
want bool
}{
{
name: "exact match no diacritics",
filter: "Mikelis",
value: "Mikelis",
want: true,
},
{
name: "exact match no diacritics",
filter: "Mikelis",
value: "Mikelis",
want: true,
},
{
name: "exact match diacritics",
filter: "Miķelis",
value: "Miķelis",
want: true,
},
{
name: "partial match diacritics",
filter: "Miķe",
value: "Miķelis",
want: true,
},
{
name: "exact match diacritics in value",
filter: "Mikelis",
value: "Miķelis",
want: true,
},
{
name: "partial match diacritics in filter",
filter: "Miķe",
value: "Miķelis",
want: true,
},
{
name: "no match when removing diacritics in filter",
filter: "Mielis",
value: "Mikelis",
want: false,
},
{
name: "no match when removing diacritics in value",
filter: "Mikelis",
value: "Mielis",
want: false,
},
{
name: "no match diacritics in filter",
filter: "Miķelis",
value: "Mikelis",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, LatinMatchingFilter(tt.filter, tt.value, 0), tt.want)
})
}
}

View file

@ -5,10 +5,132 @@ import (
"strings"
"testing"
ghPrompter "github.com/cli/go-gh/v2/pkg/prompter"
"github.com/stretchr/testify/assert"
)
// Test helpers
func NewMockPrompter(t *testing.T) *MockPrompter {
m := &MockPrompter{
t: t,
PrompterMock: *ghPrompter.NewMock(t),
authTokenStubs: []authTokenStub{},
confirmDeletionStubs: []confirmDeletionStub{},
inputHostnameStubs: []inputHostnameStub{},
markdownEditorStubs: []markdownEditorStub{},
}
t.Cleanup(m.Verify)
return m
}
type MockPrompter struct {
t *testing.T
ghPrompter.PrompterMock
authTokenStubs []authTokenStub
confirmDeletionStubs []confirmDeletionStub
inputHostnameStubs []inputHostnameStub
markdownEditorStubs []markdownEditorStub
}
type authTokenStub struct {
fn func() (string, error)
}
type confirmDeletionStub struct {
prompt string
fn func(string) error
}
type inputHostnameStub struct {
fn func() (string, error)
}
type markdownEditorStub struct {
prompt string
fn func(string, string, bool) (string, error)
}
func (m *MockPrompter) AuthToken() (string, error) {
var s authTokenStub
if len(m.authTokenStubs) == 0 {
return "", NoSuchPromptErr("AuthToken")
}
s = m.authTokenStubs[0]
m.authTokenStubs = m.authTokenStubs[1:len(m.authTokenStubs)]
return s.fn()
}
func (m *MockPrompter) ConfirmDeletion(prompt string) error {
var s confirmDeletionStub
if len(m.confirmDeletionStubs) == 0 {
return NoSuchPromptErr("ConfirmDeletion")
}
s = m.confirmDeletionStubs[0]
m.confirmDeletionStubs = m.confirmDeletionStubs[1:len(m.confirmDeletionStubs)]
return s.fn(prompt)
}
func (m *MockPrompter) InputHostname() (string, error) {
var s inputHostnameStub
if len(m.inputHostnameStubs) == 0 {
return "", NoSuchPromptErr("InputHostname")
}
s = m.inputHostnameStubs[0]
m.inputHostnameStubs = m.inputHostnameStubs[1:len(m.inputHostnameStubs)]
return s.fn()
}
func (m *MockPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) {
var s markdownEditorStub
if len(m.markdownEditorStubs) == 0 {
return "", NoSuchPromptErr(prompt)
}
s = m.markdownEditorStubs[0]
m.markdownEditorStubs = m.markdownEditorStubs[1:len(m.markdownEditorStubs)]
if s.prompt != prompt {
return "", NoSuchPromptErr(prompt)
}
return s.fn(prompt, defaultValue, blankAllowed)
}
func (m *MockPrompter) RegisterAuthToken(stub func() (string, error)) {
m.authTokenStubs = append(m.authTokenStubs, authTokenStub{fn: stub})
}
func (m *MockPrompter) RegisterConfirmDeletion(prompt string, stub func(string) error) {
m.confirmDeletionStubs = append(m.confirmDeletionStubs, confirmDeletionStub{prompt: prompt, fn: stub})
}
func (m *MockPrompter) RegisterInputHostname(stub func() (string, error)) {
m.inputHostnameStubs = append(m.inputHostnameStubs, inputHostnameStub{fn: stub})
}
func (m *MockPrompter) RegisterMarkdownEditor(prompt string, stub func(string, string, bool) (string, error)) {
m.markdownEditorStubs = append(m.markdownEditorStubs, markdownEditorStub{prompt: prompt, fn: stub})
}
func (m *MockPrompter) Verify() {
errs := []string{}
if len(m.authTokenStubs) > 0 {
errs = append(errs, "AuthToken")
}
if len(m.confirmDeletionStubs) > 0 {
errs = append(errs, "ConfirmDeletion")
}
if len(m.inputHostnameStubs) > 0 {
errs = append(errs, "inputHostname")
}
if len(m.markdownEditorStubs) > 0 {
errs = append(errs, "markdownEditorStubs")
}
if len(errs) > 0 {
m.t.Helper()
m.t.Errorf("%d unmatched calls to %s", len(errs), strings.Join(errs, ","))
}
}
func AssertOptions(t *testing.T, expected, actual []string) {
assert.Equal(t, expected, actual)
}
func IndexFor(options []string, answer string) (int, error) {
for ix, a := range options {
@ -19,267 +141,10 @@ func IndexFor(options []string, answer string) (int, error) {
return -1, NoSuchAnswerErr(answer)
}
func AssertOptions(t *testing.T, expected, actual []string) {
assert.Equal(t, expected, actual)
func NoSuchPromptErr(prompt string) error {
return fmt.Errorf("no such prompt '%s'", prompt)
}
func NoSuchAnswerErr(answer string) error {
return fmt.Errorf("no such answer '%s'", answer)
}
func NoSuchPromptErr(prompt string) error {
return fmt.Errorf("no such prompt '%s'", prompt)
}
type SelectStub struct {
Prompt string
ExpectedOpts []string
Fn func(string, string, []string) (int, error)
}
type InputStub struct {
Prompt string
Fn func(string, string) (string, error)
}
type ConfirmStub struct {
Prompt string
Fn func(string, bool) (bool, error)
}
type MultiSelectStub struct {
Prompt string
ExpectedOpts []string
Fn func(string, []string, []string) ([]int, error)
}
type InputHostnameStub struct {
Fn func() (string, error)
}
type PasswordStub struct {
Prompt string
Fn func(string) (string, error)
}
type AuthTokenStub struct {
Fn func() (string, error)
}
type ConfirmDeletionStub struct {
Prompt string
Fn func(string) error
}
type MockPrompter struct {
PrompterMock
t *testing.T
SelectStubs []SelectStub
InputStubs []InputStub
ConfirmStubs []ConfirmStub
MultiSelectStubs []MultiSelectStub
InputHostnameStubs []InputHostnameStub
PasswordStubs []PasswordStub
AuthTokenStubs []AuthTokenStub
ConfirmDeletionStubs []ConfirmDeletionStub
}
func NewMockPrompter(t *testing.T) *MockPrompter {
m := &MockPrompter{
t: t,
SelectStubs: []SelectStub{},
InputStubs: []InputStub{},
ConfirmStubs: []ConfirmStub{},
MultiSelectStubs: []MultiSelectStub{},
InputHostnameStubs: []InputHostnameStub{},
PasswordStubs: []PasswordStub{},
AuthTokenStubs: []AuthTokenStub{},
ConfirmDeletionStubs: []ConfirmDeletionStub{},
}
t.Cleanup(m.Verify)
m.SelectFunc = func(p, d string, opts []string) (int, error) {
var s SelectStub
if len(m.SelectStubs) > 0 {
s = m.SelectStubs[0]
m.SelectStubs = m.SelectStubs[1:len(m.SelectStubs)]
} else {
return -1, NoSuchPromptErr(p)
}
if s.Prompt != p {
return -1, NoSuchPromptErr(p)
}
AssertOptions(m.t, s.ExpectedOpts, opts)
return s.Fn(p, d, opts)
}
m.MultiSelectFunc = func(p string, d, opts []string) ([]int, error) {
var s MultiSelectStub
if len(m.MultiSelectStubs) > 0 {
s = m.MultiSelectStubs[0]
m.MultiSelectStubs = m.MultiSelectStubs[1:len(m.MultiSelectStubs)]
} else {
return []int{}, NoSuchPromptErr(p)
}
if s.Prompt != p {
return []int{}, NoSuchPromptErr(p)
}
AssertOptions(m.t, s.ExpectedOpts, opts)
return s.Fn(p, d, opts)
}
m.InputFunc = func(p, d string) (string, error) {
var s InputStub
if len(m.InputStubs) > 0 {
s = m.InputStubs[0]
m.InputStubs = m.InputStubs[1:len(m.InputStubs)]
} else {
return "", NoSuchPromptErr(p)
}
if s.Prompt != p {
return "", NoSuchPromptErr(p)
}
return s.Fn(p, d)
}
m.ConfirmFunc = func(p string, d bool) (bool, error) {
var s ConfirmStub
if len(m.ConfirmStubs) > 0 {
s = m.ConfirmStubs[0]
m.ConfirmStubs = m.ConfirmStubs[1:len(m.ConfirmStubs)]
} else {
return false, NoSuchPromptErr(p)
}
if s.Prompt != p {
return false, NoSuchPromptErr(p)
}
return s.Fn(p, d)
}
m.InputHostnameFunc = func() (string, error) {
var s InputHostnameStub
if len(m.InputHostnameStubs) > 0 {
s = m.InputHostnameStubs[0]
m.InputHostnameStubs = m.InputHostnameStubs[1:len(m.InputHostnameStubs)]
} else {
return "", NoSuchPromptErr("InputHostname")
}
return s.Fn()
}
m.PasswordFunc = func(p string) (string, error) {
var s PasswordStub
if len(m.PasswordStubs) > 0 {
s = m.PasswordStubs[0]
m.PasswordStubs = m.PasswordStubs[1:len(m.PasswordStubs)]
} else {
return "", NoSuchPromptErr(p)
}
if s.Prompt != p {
return "", NoSuchPromptErr(p)
}
return s.Fn(p)
}
m.AuthTokenFunc = func() (string, error) {
var s AuthTokenStub
if len(m.AuthTokenStubs) > 0 {
s = m.AuthTokenStubs[0]
m.AuthTokenStubs = m.AuthTokenStubs[1:len(m.AuthTokenStubs)]
} else {
return "", NoSuchPromptErr("AuthToken")
}
return s.Fn()
}
m.ConfirmDeletionFunc = func(p string) error {
var s ConfirmDeletionStub
if len(m.ConfirmDeletionStubs) > 0 {
s = m.ConfirmDeletionStubs[0]
m.ConfirmDeletionStubs = m.ConfirmDeletionStubs[1:len(m.ConfirmDeletionStubs)]
} else {
return NoSuchPromptErr("ConfirmDeletion")
}
return s.Fn(p)
}
// TODO MarkdownEditor(string, string, bool) (string, error)
return m
}
func (m *MockPrompter) RegisterSelect(prompt string, opts []string, stub func(_, _ string, _ []string) (int, error)) {
m.SelectStubs = append(m.SelectStubs, SelectStub{
Prompt: prompt,
ExpectedOpts: opts,
Fn: stub})
}
func (m *MockPrompter) RegisterMultiSelect(prompt string, d, opts []string, stub func(_ string, _, _ []string) ([]int, error)) {
m.MultiSelectStubs = append(m.MultiSelectStubs, MultiSelectStub{
Prompt: prompt,
ExpectedOpts: opts,
Fn: stub})
}
func (m *MockPrompter) RegisterInput(prompt string, stub func(_, _ string) (string, error)) {
m.InputStubs = append(m.InputStubs, InputStub{Prompt: prompt, Fn: stub})
}
func (m *MockPrompter) RegisterConfirm(prompt string, stub func(_ string, _ bool) (bool, error)) {
m.ConfirmStubs = append(m.ConfirmStubs, ConfirmStub{Prompt: prompt, Fn: stub})
}
func (m *MockPrompter) RegisterInputHostname(stub func() (string, error)) {
m.InputHostnameStubs = append(m.InputHostnameStubs, InputHostnameStub{Fn: stub})
}
func (m *MockPrompter) RegisterPassword(prompt string, stub func(string) (string, error)) {
m.PasswordStubs = append(m.PasswordStubs, PasswordStub{Fn: stub})
}
func (m *MockPrompter) RegisterConfirmDeletion(prompt string, stub func(string) error) {
m.ConfirmDeletionStubs = append(m.ConfirmDeletionStubs, ConfirmDeletionStub{Prompt: prompt, Fn: stub})
}
func (m *MockPrompter) Verify() {
errs := []string{}
if len(m.SelectStubs) > 0 {
errs = append(errs, "Select")
}
if len(m.InputStubs) > 0 {
errs = append(errs, "Input")
}
if len(m.ConfirmStubs) > 0 {
errs = append(errs, "Confirm")
}
// TODO other prompt types
if len(errs) > 0 {
m.t.Helper()
m.t.Errorf("%d unmatched calls to %s", len(errs), strings.Join(errs, ","))
}
}

View file

@ -6,14 +6,10 @@ import (
"regexp"
"strings"
"time"
"unicode"
"github.com/cli/go-gh/v2/pkg/text"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"golang.org/x/text/runes"
"golang.org/x/text/transform"
"golang.org/x/text/unicode/norm"
)
var whitespaceRE = regexp.MustCompile(`\s+`)
@ -79,16 +75,5 @@ func DisplayURL(urlStr string) string {
// RemoveDiacritics returns the input value without "diacritics", or accent marks
func RemoveDiacritics(value string) string {
// Mn = "Mark, nonspacing" unicode character category
removeMnTransfomer := runes.Remove(runes.In(unicode.Mn))
// 1/ Decompose the text into characters and diacritical marks,
// 2/ Remove the diacriticals marks
// 3/ Recompose the text
t := transform.Chain(norm.NFD, removeMnTransfomer, norm.NFC)
normalized, _, err := transform.String(t, value)
if err != nil {
return value
}
return normalized
return text.RemoveDiacritics(value)
}

View file

@ -54,57 +54,3 @@ func TestFuzzyAgoAbbr(t *testing.T) {
assert.Equal(t, expected, fuzzy)
}
}
func TestRemoveDiacritics(t *testing.T) {
tests := [][]string{
// no diacritics
{"e", "e"},
{"و", "و"},
{"И", "И"},
{"ж", "ж"},
{"私", "私"},
{"万", "万"},
// diacritics test sets
{"à", "a"},
{"é", "e"},
{"è", "e"},
{"ô", "o"},
{"ᾳ", "α"},
{"εͅ", "ε"},
{"ῃ", "η"},
{"ιͅ", "ι"},
{"ؤ", "و"},
{"ā", "a"},
{"č", "c"},
{"ģ", "g"},
{"ķ", "k"},
{"ņ", "n"},
{"š", "s"},
{"ž", "z"},
{"ŵ", "w"},
{"ŷ", "y"},
{"ä", "a"},
{"ÿ", "y"},
{"á", "a"},
{"ẁ", "w"},
{"ỳ", "y"},
{"ō", "o"},
// full words
{"Miķelis", "Mikelis"},
{"François", "Francois"},
{"žluťoučký", "zlutoucky"},
{"învățătorița", "invatatorita"},
{"Kękę przy łóżku", "Keke przy łozku"},
}
for _, tt := range tests {
t.Run(RemoveDiacritics(tt[0]), func(t *testing.T) {
assert.Equal(t, tt[1], RemoveDiacritics(tt[0]))
})
}
}

View file

@ -26,6 +26,7 @@ func actionsExplainer(cs *iostreams.ColorScheme) string {
header := cs.Bold("Welcome to GitHub Actions on the command line.")
runHeader := cs.Bold("Interacting with workflow runs")
workflowHeader := cs.Bold("Interacting with workflow files")
cacheHeader := cs.Bold("Interacting with the Actions cache")
return heredoc.Docf(`
%s
@ -49,5 +50,12 @@ func actionsExplainer(cs *iostreams.ColorScheme) string {
gh workflow run: Trigger a workflow_dispatch run for a workflow file
To see more help, run 'gh help workflow <subcommand>'
`, header, runHeader, workflowHeader)
%s
gh cache list: List all the caches saved in Actions for a repository
gh cache delete: Delete one or all saved caches in Actions for a repository
To see more help, run 'gh help cache <subcommand>'
`, header, runHeader, workflowHeader, cacheHeader)
}

View file

@ -17,9 +17,10 @@ type SetOptions struct {
Config func() (config.Config, error)
IO *iostreams.IOStreams
Name string
Expansion string
IsShell bool
Name string
Expansion string
IsShell bool
OverwriteExisting bool
validAliasName func(string) bool
validAliasExpansion func(string) bool
@ -86,6 +87,7 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
}
cmd.Flags().BoolVarP(&opts.IsShell, "shell", "s", false, "Declare an alias to be passed through a shell interpreter")
cmd.Flags().BoolVar(&opts.OverwriteExisting, "clobber", false, "Overwrite existing aliases of the same name")
return cmd
}
@ -104,31 +106,39 @@ func setRun(opts *SetOptions) error {
return fmt.Errorf("did not understand expansion: %w", err)
}
isTerminal := opts.IO.IsStdoutTTY()
if isTerminal {
fmt.Fprintf(opts.IO.ErrOut, "- Adding alias for %s: %s\n", cs.Bold(opts.Name), cs.Bold(expansion))
}
if opts.IsShell && !strings.HasPrefix(expansion, "!") {
expansion = "!" + expansion
}
isTerminal := opts.IO.IsStdoutTTY()
if isTerminal {
fmt.Fprintf(opts.IO.ErrOut, "- Creating alias for %s: %s\n", cs.Bold(opts.Name), cs.Bold(expansion))
}
var existingAlias bool
if _, err := aliasCfg.Get(opts.Name); err == nil {
existingAlias = true
}
if !opts.validAliasName(opts.Name) {
return fmt.Errorf("could not create alias: %q is already a gh command, extension, or alias", opts.Name)
if !existingAlias {
return fmt.Errorf("%s Could not create alias %s: already a gh command or extension",
cs.FailureIcon(),
cs.Bold(opts.Name))
}
if existingAlias && !opts.OverwriteExisting {
return fmt.Errorf("%s Could not create alias %s: name already taken, use the --clobber flag to overwrite it",
cs.FailureIcon(),
cs.Bold(opts.Name),
)
}
}
if !opts.validAliasExpansion(expansion) {
return fmt.Errorf("could not create alias: %s does not correspond to a gh command, extension, or alias", expansion)
}
successMsg := fmt.Sprintf("%s Added alias.", cs.SuccessIcon())
if oldExpansion, err := aliasCfg.Get(opts.Name); err == nil {
successMsg = fmt.Sprintf("%s Changed alias %s from %s to %s",
cs.SuccessIcon(),
cs.Bold(opts.Name),
cs.Bold(oldExpansion),
cs.Bold(expansion),
)
return fmt.Errorf("%s Could not create alias %s: expansion does not correspond to a gh command, extension, or alias",
cs.FailureIcon(),
cs.Bold(opts.Name))
}
aliasCfg.Add(opts.Name, expansion)
@ -138,6 +148,13 @@ func setRun(opts *SetOptions) error {
return err
}
successMsg := fmt.Sprintf("%s Added alias %s", cs.SuccessIcon(), cs.Bold(opts.Name))
if existingAlias && opts.OverwriteExisting {
successMsg = fmt.Sprintf("%s Changed alias %s",
cs.WarningIcon(),
cs.Bold(opts.Name))
}
if isTerminal {
fmt.Fprintln(opts.IO.ErrOut, successMsg)
}

View file

@ -2,314 +2,313 @@ package set
import (
"bytes"
"io"
"fmt"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/pkg/cmd/alias/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/extensions"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/test"
"github.com/google/shlex"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func runCommand(cfg config.Config, isTTY bool, cli string, in string) (*test.CmdOut, error) {
ios, stdin, stdout, stderr := iostreams.Test()
ios.SetStdoutTTY(isTTY)
ios.SetStdinTTY(isTTY)
ios.SetStderrTTY(isTTY)
stdin.WriteString(in)
factory := &cmdutil.Factory{
IOStreams: ios,
Config: func() (config.Config, error) {
return cfg, nil
func TestNewCmdSet(t *testing.T) {
tests := []struct {
name string
input string
output SetOptions
wantErr bool
errMsg string
}{
{
name: "no arguments",
input: "",
wantErr: true,
errMsg: "accepts 2 arg(s), received 0",
},
ExtensionManager: &extensions.ExtensionManagerMock{
ListFunc: func() []extensions.Extension {
return []extensions.Extension{}
{
name: "only one argument",
input: "name",
wantErr: true,
errMsg: "accepts 2 arg(s), received 1",
},
{
name: "name and expansion",
input: "alias-name alias-expansion",
output: SetOptions{
Name: "alias-name",
Expansion: "alias-expansion",
},
},
{
name: "shell flag",
input: "alias-name alias-expansion --shell",
output: SetOptions{
Name: "alias-name",
Expansion: "alias-expansion",
IsShell: true,
},
},
{
name: "clobber flag",
input: "alias-name alias-expansion --clobber",
output: SetOptions{
Name: "alias-name",
Expansion: "alias-expansion",
OverwriteExisting: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, _, _, _ := iostreams.Test()
f := &cmdutil.Factory{
IOStreams: ios,
}
argv, err := shlex.Split(tt.input)
assert.NoError(t, err)
var gotOpts *SetOptions
cmd := NewCmdSet(f, func(opts *SetOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd := NewCmdSet(factory, nil)
// Create fake command structure for testing.
rootCmd := &cobra.Command{}
rootCmd.AddCommand(cmd)
prCmd := &cobra.Command{Use: "pr"}
prCmd.AddCommand(&cobra.Command{Use: "checkout"})
prCmd.AddCommand(&cobra.Command{Use: "status"})
rootCmd.AddCommand(prCmd)
issueCmd := &cobra.Command{Use: "issue"}
issueCmd.AddCommand(&cobra.Command{Use: "list"})
rootCmd.AddCommand(issueCmd)
apiCmd := &cobra.Command{Use: "api"}
apiCmd.AddCommand(&cobra.Command{Use: "graphql"})
rootCmd.AddCommand(apiCmd)
argv, err := shlex.Split("set " + cli)
if err != nil {
return nil, err
}
rootCmd.SetArgs(argv)
rootCmd.SetIn(stdin)
rootCmd.SetOut(io.Discard)
rootCmd.SetErr(io.Discard)
_, err = rootCmd.ExecuteC()
return &test.CmdOut{
OutBuf: stdout,
ErrBuf: stderr,
}, err
}
func TestAliasSet_gh_command(t *testing.T) {
cfg := config.NewFromString(``)
_, err := runCommand(cfg, true, "pr 'pr status'", "")
assert.EqualError(t, err, `could not create alias: "pr" is already a gh command, extension, or alias`)
}
func TestAliasSet_empty_aliases(t *testing.T) {
readConfigs := config.StubWriteConfig(t)
cfg := config.NewFromString(heredoc.Doc(`
aliases:
editor: vim
`))
output, err := runCommand(cfg, true, "co 'pr checkout'", "")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
mainBuf := bytes.Buffer{}
readConfigs(&mainBuf, io.Discard)
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), "Added alias")
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.String(), "")
expected := `aliases:
co: pr checkout
editor: vim
`
assert.Equal(t, expected, mainBuf.String())
}
func TestAliasSet_existing_alias(t *testing.T) {
_ = config.StubWriteConfig(t)
cfg := config.NewFromString(heredoc.Doc(`
aliases:
co: pr checkout
`))
output, err := runCommand(cfg, true, "co 'pr checkout -Rcool/repo'", "")
require.NoError(t, err)
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), "Changed alias.*co.*from.*pr checkout.*to.*pr checkout -Rcool/repo")
}
func TestAliasSet_space_args(t *testing.T) {
readConfigs := config.StubWriteConfig(t)
cfg := config.NewFromString(``)
output, err := runCommand(cfg, true, `il 'issue list -l "cool story"'`, "")
require.NoError(t, err)
mainBuf := bytes.Buffer{}
readConfigs(&mainBuf, io.Discard)
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), `Adding alias for.*il.*issue list -l "cool story"`)
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, mainBuf.String(), `il: issue list -l "cool story"`)
}
func TestAliasSet_arg_processing(t *testing.T) {
readConfigs := config.StubWriteConfig(t)
cases := []struct {
Cmd string
ExpectedOutputLine string
ExpectedConfigLine string
}{
{`il "issue list"`, "- Adding alias for.*il.*issue list", "il: issue list"},
{`iz 'issue list'`, "- Adding alias for.*iz.*issue list", "iz: issue list"},
{`ii 'issue list --author="$1" --label="$2"'`,
`- Adding alias for.*ii.*issue list --author="\$1" --label="\$2"`,
`ii: issue list --author="\$1" --label="\$2"`},
{`ix "issue list --author='\$1' --label='\$2'"`,
`- Adding alias for.*ix.*issue list --author='\$1' --label='\$2'`,
`ix: issue list --author='\$1' --label='\$2'`},
}
for _, c := range cases {
t.Run(c.Cmd, func(t *testing.T) {
cfg := config.NewFromString(``)
output, err := runCommand(cfg, true, c.Cmd, "")
if err != nil {
t.Fatalf("got unexpected error running %s: %s", c.Cmd, err)
_, err = cmd.ExecuteC()
if tt.wantErr {
assert.EqualError(t, err, tt.errMsg)
return
}
mainBuf := bytes.Buffer{}
readConfigs(&mainBuf, io.Discard)
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), c.ExpectedOutputLine)
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, mainBuf.String(), c.ExpectedConfigLine)
assert.NoError(t, err)
assert.Equal(t, tt.output.Name, gotOpts.Name)
assert.Equal(t, tt.output.Expansion, gotOpts.Expansion)
assert.Equal(t, tt.output.IsShell, gotOpts.IsShell)
assert.Equal(t, tt.output.OverwriteExisting, gotOpts.OverwriteExisting)
})
}
}
func TestAliasSet_init_alias_cfg(t *testing.T) {
readConfigs := config.StubWriteConfig(t)
cfg := config.NewFromString(heredoc.Doc(`
editor: vim
`))
output, err := runCommand(cfg, true, "diff 'pr diff'", "")
require.NoError(t, err)
mainBuf := bytes.Buffer{}
readConfigs(&mainBuf, io.Discard)
expected := `editor: vim
aliases:
diff: pr diff
`
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), "Adding alias for.*diff.*pr diff", "Added alias.")
assert.Equal(t, expected, mainBuf.String())
}
func TestAliasSet_existing_aliases(t *testing.T) {
readConfigs := config.StubWriteConfig(t)
cfg := config.NewFromString(heredoc.Doc(`
aliases:
foo: bar
`))
output, err := runCommand(cfg, true, "view 'pr view'", "")
require.NoError(t, err)
mainBuf := bytes.Buffer{}
readConfigs(&mainBuf, io.Discard)
expected := `aliases:
foo: bar
view: pr view
`
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), "Adding alias for.*view.*pr view", "Added alias.")
assert.Equal(t, expected, mainBuf.String())
}
func TestAliasSet_invalid_command(t *testing.T) {
cfg := config.NewFromString(``)
_, err := runCommand(cfg, true, "co 'pe checkout'", "")
assert.EqualError(t, err, "could not create alias: pe checkout does not correspond to a gh command, extension, or alias")
}
func TestShellAlias_flag(t *testing.T) {
readConfigs := config.StubWriteConfig(t)
cfg := config.NewFromString(``)
output, err := runCommand(cfg, true, "--shell igrep 'gh issue list | grep'", "")
if err != nil {
t.Fatalf("unexpected error: %s", err)
func TestSetRun(t *testing.T) {
tests := []struct {
name string
tty bool
opts *SetOptions
stdin string
wantExpansion string
wantStdout string
wantStderr string
wantErrMsg string
}{
{
name: "creates alias tty",
tty: true,
opts: &SetOptions{
Name: "foo",
Expansion: "bar",
},
wantExpansion: "bar",
wantStderr: "- Creating alias for foo: bar\n✓ Added alias foo\n",
},
{
name: "creates alias",
opts: &SetOptions{
Name: "foo",
Expansion: "bar",
},
wantExpansion: "bar",
},
{
name: "creates shell alias tty",
tty: true,
opts: &SetOptions{
Name: "igrep",
Expansion: "!gh issue list | grep",
},
wantExpansion: "!gh issue list | grep",
wantStderr: "- Creating alias for igrep: !gh issue list | grep\n✓ Added alias igrep\n",
},
{
name: "creates shell alias",
opts: &SetOptions{
Name: "igrep",
Expansion: "!gh issue list | grep",
},
wantExpansion: "!gh issue list | grep",
},
{
name: "creates shell alias using flag tty",
tty: true,
opts: &SetOptions{
Name: "igrep",
Expansion: "gh issue list | grep",
IsShell: true,
},
wantExpansion: "!gh issue list | grep",
wantStderr: "- Creating alias for igrep: !gh issue list | grep\n✓ Added alias igrep\n",
},
{
name: "creates shell alias using flag",
opts: &SetOptions{
Name: "igrep",
Expansion: "gh issue list | grep",
IsShell: true,
},
wantExpansion: "!gh issue list | grep",
},
{
name: "creates alias where expansion has args tty",
tty: true,
opts: &SetOptions{
Name: "foo",
Expansion: "bar baz --author='$1' --label='$2'",
},
wantExpansion: "bar baz --author='$1' --label='$2'",
wantStderr: "- Creating alias for foo: bar baz --author='$1' --label='$2'\n✓ Added alias foo\n",
},
{
name: "creates alias where expansion has args",
opts: &SetOptions{
Name: "foo",
Expansion: "bar baz --author='$1' --label='$2'",
},
wantExpansion: "bar baz --author='$1' --label='$2'",
},
{
name: "creates alias from stdin tty",
tty: true,
opts: &SetOptions{
Name: "foo",
Expansion: "-",
},
stdin: `bar baz --author="$1" --label="$2"`,
wantExpansion: `bar baz --author="$1" --label="$2"`,
wantStderr: "- Creating alias for foo: bar baz --author=\"$1\" --label=\"$2\"\n✓ Added alias foo\n",
},
{
name: "creates alias from stdin",
opts: &SetOptions{
Name: "foo",
Expansion: "-",
},
stdin: `bar baz --author="$1" --label="$2"`,
wantExpansion: `bar baz --author="$1" --label="$2"`,
},
{
name: "overwrites existing alias tty",
tty: true,
opts: &SetOptions{
Name: "co",
Expansion: "bar",
OverwriteExisting: true,
},
wantExpansion: "bar",
wantStderr: "- Creating alias for co: bar\n! Changed alias co\n",
},
{
name: "overwrites existing alias",
opts: &SetOptions{
Name: "co",
Expansion: "bar",
OverwriteExisting: true,
},
wantExpansion: "bar",
},
{
name: "fails when alias name is an existing alias tty",
tty: true,
opts: &SetOptions{
Name: "co",
Expansion: "bar",
},
wantExpansion: "pr checkout",
wantErrMsg: "X Could not create alias co: name already taken, use the --clobber flag to overwrite it",
wantStderr: "- Creating alias for co: bar\n",
},
{
name: "fails when alias name is an existing alias",
opts: &SetOptions{
Name: "co",
Expansion: "bar",
},
wantExpansion: "pr checkout",
wantErrMsg: "X Could not create alias co: name already taken, use the --clobber flag to overwrite it",
},
{
name: "fails when alias expansion is not an existing command tty",
tty: true,
opts: &SetOptions{
Name: "foo",
Expansion: "baz",
},
wantErrMsg: "X Could not create alias foo: expansion does not correspond to a gh command, extension, or alias",
wantStderr: "- Creating alias for foo: baz\n",
},
{
name: "fails when alias expansion is not an existing command",
opts: &SetOptions{
Name: "foo",
Expansion: "baz",
},
wantErrMsg: "X Could not create alias foo: expansion does not correspond to a gh command, extension, or alias",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rootCmd := &cobra.Command{}
barCmd := &cobra.Command{Use: "bar"}
barCmd.AddCommand(&cobra.Command{Use: "baz"})
rootCmd.AddCommand(barCmd)
coCmd := &cobra.Command{Use: "co"}
rootCmd.AddCommand(coCmd)
mainBuf := bytes.Buffer{}
readConfigs(&mainBuf, io.Discard)
tt.opts.validAliasName = shared.ValidAliasNameFunc(rootCmd)
tt.opts.validAliasExpansion = shared.ValidAliasExpansionFunc(rootCmd)
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), "Adding alias for.*igrep")
ios, stdin, stdout, stderr := iostreams.Test()
ios.SetStdinTTY(tt.tty)
ios.SetStdoutTTY(tt.tty)
ios.SetStderrTTY(tt.tty)
tt.opts.IO = ios
expected := `aliases:
igrep: '!gh issue list | grep'
`
assert.Equal(t, expected, mainBuf.String())
if tt.stdin != "" {
fmt.Fprint(stdin, tt.stdin)
}
cfg := config.NewBlankConfig()
cfg.WriteFunc = func() error {
return nil
}
tt.opts.Config = func() (config.Config, error) {
return cfg, nil
}
err := setRun(tt.opts)
if tt.wantErrMsg != "" {
assert.EqualError(t, err, tt.wantErrMsg)
writeCalls := cfg.WriteCalls()
assert.Equal(t, 0, len(writeCalls))
} else {
assert.NoError(t, err)
writeCalls := cfg.WriteCalls()
assert.Equal(t, 1, len(writeCalls))
}
ac := cfg.Aliases()
expansion, _ := ac.Get(tt.opts.Name)
assert.Equal(t, tt.wantExpansion, expansion)
assert.Equal(t, tt.wantStdout, stdout.String())
assert.Equal(t, tt.wantStderr, stderr.String())
})
}
}
func TestShellAlias_bang(t *testing.T) {
readConfigs := config.StubWriteConfig(t)
cfg := config.NewFromString(``)
output, err := runCommand(cfg, true, "igrep '!gh issue list | grep'", "")
require.NoError(t, err)
mainBuf := bytes.Buffer{}
readConfigs(&mainBuf, io.Discard)
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), "Adding alias for.*igrep")
expected := `aliases:
igrep: '!gh issue list | grep'
`
assert.Equal(t, expected, mainBuf.String())
}
func TestShellAlias_from_stdin(t *testing.T) {
readConfigs := config.StubWriteConfig(t)
cfg := config.NewFromString(``)
output, err := runCommand(cfg, true, "users -", `api graphql -F name="$1" -f query='
query ($name: String!) {
user(login: $name) {
name
}
}'`)
require.NoError(t, err)
mainBuf := bytes.Buffer{}
readConfigs(&mainBuf, io.Discard)
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), "Adding alias for.*users")
expected := `aliases:
users: |-
api graphql -F name="$1" -f query='
query ($name: String!) {
user(login: $name) {
name
}
}'
`
assert.Equal(t, expected, mainBuf.String())
}
func TestShellAlias_getExpansion(t *testing.T) {
func TestGetExpansion(t *testing.T) {
tests := []struct {
name string
want string

View file

@ -30,7 +30,12 @@ import (
)
type ApiOptions struct {
IO *iostreams.IOStreams
AppVersion string
BaseRepo func() (ghrepo.Interface, error)
Branch func() (string, error)
Config func() (config.Config, error)
HttpClient func() (*http.Client, error)
IO *iostreams.IOStreams
Hostname string
RequestMethod string
@ -47,20 +52,16 @@ type ApiOptions struct {
Template string
CacheTTL time.Duration
FilterOutput string
Config func() (config.Config, error)
HttpClient func() (*http.Client, error)
BaseRepo func() (ghrepo.Interface, error)
Branch func() (string, error)
Verbose bool
}
func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command {
opts := ApiOptions{
IO: f.IOStreams,
Config: f.Config,
HttpClient: f.HttpClient,
AppVersion: f.AppVersion,
BaseRepo: f.BaseRepo,
Branch: f.Branch,
Config: f.Config,
IO: f.IOStreams,
}
cmd := &cobra.Command{
@ -209,7 +210,8 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
}
if err := cmdutil.MutuallyExclusive(
"only one of `--template`, `--jq`, or `--silent` may be used",
"only one of `--template`, `--jq`, `--silent`, or `--verbose` may be used",
opts.Verbose,
opts.Silent,
opts.FilterOutput != "",
opts.Template != "",
@ -237,6 +239,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
cmd.Flags().StringVarP(&opts.Template, "template", "t", "", "Format JSON output using a Go template; see \"gh help formatting\"")
cmd.Flags().StringVarP(&opts.FilterOutput, "jq", "q", "", "Query to select values from the response using jq syntax")
cmd.Flags().DurationVar(&opts.CacheTTL, "cache", 0, "Cache the response, e.g. \"3600s\", \"60m\", \"1h\"")
cmd.Flags().BoolVar(&opts.Verbose, "verbose", false, "Include full HTTP request and response in the output")
return cmd
}
@ -283,12 +286,33 @@ func apiRun(opts *ApiOptions) error {
requestHeaders = append(requestHeaders, "Accept: "+previewNamesToMIMETypes(opts.Previews))
}
httpClient, err := opts.HttpClient()
cfg, err := opts.Config()
if err != nil {
return err
}
if opts.CacheTTL > 0 {
httpClient = api.NewCachedHTTPClient(httpClient, opts.CacheTTL)
if opts.HttpClient == nil {
opts.HttpClient = func() (*http.Client, error) {
log := opts.IO.ErrOut
if opts.Verbose {
log = opts.IO.Out
}
opts := api.HTTPClientOptions{
AppVersion: opts.AppVersion,
CacheTTL: opts.CacheTTL,
Config: cfg.Authentication(),
EnableCache: opts.CacheTTL > 0,
Log: log,
LogColorize: opts.IO.ColorEnabled(),
LogVerboseHTTP: opts.Verbose,
SkipAcceptHeaders: true,
}
return api.NewHTTPClient(opts)
}
}
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
if !opts.Silent {
@ -304,10 +328,10 @@ func apiRun(opts *ApiOptions) error {
if opts.Silent {
bodyWriter = io.Discard
}
cfg, err := opts.Config()
if err != nil {
return err
if opts.Verbose {
// httpClient handles output when verbose flag is specified.
bodyWriter = io.Discard
headersWriter = io.Discard
}
host, _ := cfg.Authentication().DefaultHost()

View file

@ -52,6 +52,7 @@ func Test_NewCmdApi(t *testing.T) {
CacheTTL: 0,
Template: "",
FilterOutput: "",
Verbose: false,
},
wantsErr: false,
},
@ -73,6 +74,7 @@ func Test_NewCmdApi(t *testing.T) {
CacheTTL: 0,
Template: "",
FilterOutput: "",
Verbose: false,
},
wantsErr: false,
},
@ -94,6 +96,7 @@ func Test_NewCmdApi(t *testing.T) {
CacheTTL: 0,
Template: "",
FilterOutput: "",
Verbose: false,
},
wantsErr: false,
},
@ -115,6 +118,7 @@ func Test_NewCmdApi(t *testing.T) {
CacheTTL: 0,
Template: "",
FilterOutput: "",
Verbose: false,
},
wantsErr: false,
},
@ -136,6 +140,7 @@ func Test_NewCmdApi(t *testing.T) {
CacheTTL: 0,
Template: "",
FilterOutput: "",
Verbose: false,
},
wantsErr: false,
},
@ -157,6 +162,7 @@ func Test_NewCmdApi(t *testing.T) {
CacheTTL: 0,
Template: "",
FilterOutput: "",
Verbose: false,
},
wantsErr: false,
},
@ -183,6 +189,7 @@ func Test_NewCmdApi(t *testing.T) {
CacheTTL: 0,
Template: "",
FilterOutput: "",
Verbose: false,
},
wantsErr: false,
},
@ -209,6 +216,7 @@ func Test_NewCmdApi(t *testing.T) {
CacheTTL: 0,
Template: "",
FilterOutput: "",
Verbose: false,
},
wantsErr: false,
},
@ -235,6 +243,7 @@ func Test_NewCmdApi(t *testing.T) {
CacheTTL: 0,
Template: "",
FilterOutput: "",
Verbose: false,
},
wantsErr: false,
},
@ -256,6 +265,7 @@ func Test_NewCmdApi(t *testing.T) {
CacheTTL: time.Minute * 5,
Template: "",
FilterOutput: "",
Verbose: false,
},
wantsErr: false,
},
@ -277,6 +287,7 @@ func Test_NewCmdApi(t *testing.T) {
CacheTTL: 0,
Template: "hello {{.name}}",
FilterOutput: "",
Verbose: false,
},
wantsErr: false,
},
@ -298,6 +309,7 @@ func Test_NewCmdApi(t *testing.T) {
CacheTTL: 0,
Template: "",
FilterOutput: ".name",
Verbose: false,
},
wantsErr: false,
},
@ -316,6 +328,28 @@ func Test_NewCmdApi(t *testing.T) {
cli: "user --jq .foo -t '{{.foo}}'",
wantsErr: true,
},
{
name: "with verbose",
cli: "user --verbose",
wants: ApiOptions{
Hostname: "",
RequestMethod: "GET",
RequestMethodPassed: false,
RequestPath: "user",
RequestInputFile: "",
RawFields: []string(nil),
MagicFields: []string(nil),
RequestHeaders: []string(nil),
ShowResponseHeaders: false,
Paginate: false,
Silent: false,
CacheTTL: 0,
Template: "",
FilterOutput: "",
Verbose: true,
},
wantsErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -352,6 +386,7 @@ func Test_NewCmdApi(t *testing.T) {
assert.Equal(t, tt.wants.CacheTTL, opts.CacheTTL)
assert.Equal(t, tt.wants.Template, opts.Template)
assert.Equal(t, tt.wants.FilterOutput, opts.FilterOutput)
assert.Equal(t, tt.wants.Verbose, opts.Verbose)
})
}
}

View file

@ -162,7 +162,7 @@ func Login(opts *LoginOptions) error {
fmt.Fprint(opts.IO.ErrOut, heredoc.Docf(`
Tip: you can generate a Personal Access Token here https://%s/settings/tokens
The minimum required scopes are %s.
`, hostname, scopesSentence(minimumScopes, ghinstance.IsEnterprise(hostname))))
`, hostname, scopesSentence(minimumScopes)))
var err error
authToken, err = opts.Prompter.AuthToken()
@ -216,14 +216,10 @@ func Login(opts *LoginOptions) error {
return nil
}
func scopesSentence(scopes []string, isEnterprise bool) string {
func scopesSentence(scopes []string) string {
quoted := make([]string, len(scopes))
for i, s := range scopes {
quoted[i] = fmt.Sprintf("'%s'", s)
if s == "workflow" && isEnterprise {
// remove when GHE 2.x reaches EOL
quoted[i] += " (GHE 3.0+)"
}
}
return strings.Join(quoted, ", ")
}

View file

@ -117,8 +117,7 @@ func TestLogin_ssh(t *testing.T) {
func Test_scopesSentence(t *testing.T) {
type args struct {
scopes []string
isEnterprise bool
scopes []string
}
tests := []struct {
name string
@ -128,39 +127,28 @@ func Test_scopesSentence(t *testing.T) {
{
name: "basic scopes",
args: args{
scopes: []string{"repo", "read:org"},
isEnterprise: false,
scopes: []string{"repo", "read:org"},
},
want: "'repo', 'read:org'",
},
{
name: "empty",
args: args{
scopes: []string(nil),
isEnterprise: false,
scopes: []string(nil),
},
want: "",
},
{
name: "workflow scope for dotcom",
name: "workflow scope",
args: args{
scopes: []string{"repo", "workflow"},
isEnterprise: false,
scopes: []string{"repo", "workflow"},
},
want: "'repo', 'workflow'",
},
{
name: "workflow scope for GHE",
args: args{
scopes: []string{"repo", "workflow"},
isEnterprise: true,
},
want: "'repo', 'workflow' (GHE 3.0+)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := scopesSentence(tt.args.scopes, tt.args.isEnterprise); got != tt.want {
if got := scopesSentence(tt.args.scopes); got != tt.want {
t.Errorf("scopesSentence() = %q, want %q", got, tt.want)
}
})

View file

@ -108,7 +108,7 @@ func statusRun(opts *StatusOptions) error {
scopesHeader, err := shared.GetScopes(httpClient, hostname, token)
if err != nil {
addMsg("%s %s: authentication failed", cs.Red("X"), hostname)
addMsg("- The %s token in %s is no longer valid.", cs.Bold(hostname), tokenSource)
addMsg("- The %s token in %s is invalid.", cs.Bold(hostname), tokenSource)
if tokenIsWriteable {
addMsg("- To re-authenticate, run: %s %s",
cs.Bold("gh auth login -h"), cs.Bold(hostname))

View file

@ -160,7 +160,7 @@ func Test_statusRun(t *testing.T) {
wantErrOut: heredoc.Doc(`
joel.miller
X joel.miller: authentication failed
- The joel.miller token in GH_CONFIG_DIR/hosts.yml is no longer valid.
- The joel.miller token in GH_CONFIG_DIR/hosts.yml is invalid.
- To re-authenticate, run: gh auth login -h joel.miller
- To forget about this host, run: gh auth logout -h joel.miller

View file

@ -178,7 +178,19 @@ func runBrowse(opts *BrowseOptions) error {
url := ghrepo.GenerateRepoURL(baseRepo, "%s", section)
if opts.NoBrowserFlag {
_, err := fmt.Fprintln(opts.IO.Out, url)
client, err := opts.HttpClient()
if err != nil {
return err
}
exist, err := api.RepoExists(api.NewClientFromHTTP(client), baseRepo)
if err != nil {
return err
}
if !exist {
return fmt.Errorf("%s doesn't exist", text.DisplayURL(url))
}
_, err = fmt.Fprintln(opts.IO.Out, url)
return err
}

View file

@ -232,6 +232,7 @@ func Test_runBrowse(t *testing.T) {
tests := []struct {
name string
opts BrowseOptions
httpStub func(*httpmock.Registry)
baseRepo ghrepo.Interface
defaultBranch string
expectedURL string
@ -432,6 +433,12 @@ func Test_runBrowse(t *testing.T) {
SelectorArg: "init.rb:6",
NoBrowserFlag: true,
},
httpStub: func(r *httpmock.Registry) {
r.Register(
httpmock.REST("HEAD", "repos/mislav/will_paginate"),
httpmock.StringResponse("{}"),
)
},
baseRepo: ghrepo.New("mislav", "will_paginate"),
wantsErr: false,
expectedURL: "https://github.com/mislav/will_paginate/blob/3-0-stable/init.rb?plain=1#L6",
@ -556,6 +563,10 @@ func Test_runBrowse(t *testing.T) {
reg.StubRepoInfoResponse(tt.baseRepo.RepoOwner(), tt.baseRepo.RepoName(), tt.defaultBranch)
}
if tt.httpStub != nil {
tt.httpStub(&reg)
}
opts := tt.opts
opts.IO = ios
opts.BaseRepo = func() (ghrepo.Interface, error) {

29
pkg/cmd/cache/cache.go vendored Normal file
View file

@ -0,0 +1,29 @@
package cache
import (
"github.com/MakeNowJust/heredoc"
cmdDelete "github.com/cli/cli/v2/pkg/cmd/cache/delete"
cmdList "github.com/cli/cli/v2/pkg/cmd/cache/list"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/spf13/cobra"
)
func NewCmdCache(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "cache <command>",
Short: "Manage Github Actions caches",
Long: "Work with Github Actions caches.",
Example: heredoc.Doc(`
$ gh cache list
$ gh cache delete --all
`),
GroupID: "actions",
}
cmdutil.EnableRepoOverride(cmd, f)
cmd.AddCommand(cmdList.NewCmdList(f, nil))
cmd.AddCommand(cmdDelete.NewCmdDelete(f, nil))
return cmd
}

155
pkg/cmd/cache/delete/delete.go vendored Normal file
View file

@ -0,0 +1,155 @@
package delete
import (
"errors"
"fmt"
"net/http"
"strconv"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/pkg/cmd/cache/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/spf13/cobra"
)
type DeleteOptions struct {
BaseRepo func() (ghrepo.Interface, error)
HttpClient func() (*http.Client, error)
IO *iostreams.IOStreams
DeleteAll bool
Identifier string
}
func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command {
opts := &DeleteOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
}
cmd := &cobra.Command{
Use: "delete [<cache-id>| <cache-key> | --all]",
Short: "Delete Github Actions caches",
Long: `
Delete Github Actions caches.
Deletion requires authorization with the "repo" scope.
`,
Example: heredoc.Doc(`
# Delete a cache by id
$ gh cache delete 1234
# Delete a cache by key
$ gh cache delete cache-key
# Delete a cache by id in a specific repo
$ gh cache delete 1234 --repo cli/cli
# Delete all caches
$ gh cache delete --all
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// support -R/--repo flag
opts.BaseRepo = f.BaseRepo
if err := cmdutil.MutuallyExclusive(
"specify only one of cache id, cache key, or --all",
opts.DeleteAll, len(args) > 0,
); err != nil {
return err
}
if !opts.DeleteAll && len(args) == 0 {
return cmdutil.FlagErrorf("must provide either cache id, cache key, or use --all")
}
if len(args) == 1 {
opts.Identifier = args[0]
}
if runF != nil {
return runF(opts)
}
return deleteRun(opts)
},
}
cmd.Flags().BoolVarP(&opts.DeleteAll, "all", "a", false, "Delete all caches")
return cmd
}
func deleteRun(opts *DeleteOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
return fmt.Errorf("failed to create http client: %w", err)
}
client := api.NewClientFromHTTP(httpClient)
repo, err := opts.BaseRepo()
if err != nil {
return fmt.Errorf("failed to determine base repo: %w", err)
}
var toDelete []string
if opts.DeleteAll {
caches, err := shared.GetCaches(client, repo, shared.GetCachesOptions{Limit: -1})
if err != nil {
return err
}
if len(caches.ActionsCaches) == 0 {
return fmt.Errorf("%s No caches to delete", opts.IO.ColorScheme().FailureIcon())
}
for _, cache := range caches.ActionsCaches {
toDelete = append(toDelete, strconv.Itoa(cache.Id))
}
} else {
toDelete = append(toDelete, opts.Identifier)
}
return deleteCaches(opts, client, repo, toDelete)
}
func deleteCaches(opts *DeleteOptions, client *api.Client, repo ghrepo.Interface, toDelete []string) error {
cs := opts.IO.ColorScheme()
repoName := ghrepo.FullName(repo)
opts.IO.StartProgressIndicator()
base := fmt.Sprintf("repos/%s/actions/caches", repoName)
for _, cache := range toDelete {
path := ""
if id, err := strconv.Atoi(cache); err == nil {
path = fmt.Sprintf("%s/%d", base, id)
} else {
path = fmt.Sprintf("%s?key=%s", base, cache)
}
err := client.REST(repo.RepoHost(), "DELETE", path, nil, nil)
if err != nil {
var httpErr api.HTTPError
if errors.As(err, &httpErr) {
if httpErr.StatusCode == http.StatusNotFound {
err = fmt.Errorf("%s Could not find a cache matching %s in %s", cs.FailureIcon(), cache, repoName)
} else {
err = fmt.Errorf("%s Failed to delete cache: %w", cs.FailureIcon(), err)
}
}
opts.IO.StopProgressIndicator()
return err
}
}
opts.IO.StopProgressIndicator()
if opts.IO.IsStdoutTTY() {
fmt.Fprintf(opts.IO.Out, "%s Deleted %s from %s\n", cs.SuccessIcon(), text.Pluralize(len(toDelete), "cache"), repoName)
}
return nil
}

209
pkg/cmd/cache/delete/delete_test.go vendored Normal file
View file

@ -0,0 +1,209 @@
package delete
import (
"bytes"
"net/http"
"testing"
"time"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmd/cache/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
)
func TestNewCmdDelete(t *testing.T) {
tests := []struct {
name string
cli string
wants DeleteOptions
wantsErr string
}{
{
name: "no arguments",
cli: "",
wantsErr: "must provide either cache id, cache key, or use --all",
},
{
name: "id argument",
cli: "123",
wants: DeleteOptions{Identifier: "123"},
},
{
name: "key argument",
cli: "A-Cache-Key",
wants: DeleteOptions{Identifier: "A-Cache-Key"},
},
{
name: "delete all flag",
cli: "--all",
wants: DeleteOptions{DeleteAll: true},
},
{
name: "id argument and delete all flag",
cli: "1 --all",
wantsErr: "specify only one of cache id, cache key, or --all",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := &cmdutil.Factory{}
argv, err := shlex.Split(tt.cli)
assert.NoError(t, err)
var gotOpts *DeleteOptions
cmd := NewCmdDelete(f, func(opts *DeleteOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
_, err = cmd.ExecuteC()
if tt.wantsErr != "" {
assert.EqualError(t, err, tt.wantsErr)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wants.DeleteAll, gotOpts.DeleteAll)
assert.Equal(t, tt.wants.Identifier, gotOpts.Identifier)
})
}
}
func TestDeleteRun(t *testing.T) {
tests := []struct {
name string
opts DeleteOptions
stubs func(*httpmock.Registry)
tty bool
wantErr bool
wantErrMsg string
wantStderr string
wantStdout string
}{
{
name: "deletes cache tty",
opts: DeleteOptions{Identifier: "123"},
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("DELETE", "repos/OWNER/REPO/actions/caches/123"),
httpmock.StatusStringResponse(204, ""),
)
},
tty: true,
wantStdout: "✓ Deleted 1 cache from OWNER/REPO\n",
},
{
name: "deletes cache notty",
opts: DeleteOptions{Identifier: "123"},
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("DELETE", "repos/OWNER/REPO/actions/caches/123"),
httpmock.StatusStringResponse(204, ""),
)
},
tty: false,
wantStdout: "",
},
{
name: "non-existent cache",
opts: DeleteOptions{Identifier: "123"},
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("DELETE", "repos/OWNER/REPO/actions/caches/123"),
httpmock.StatusStringResponse(404, ""),
)
},
wantErr: true,
wantErrMsg: "X Could not find a cache matching 123 in OWNER/REPO",
},
{
name: "deletes all caches",
opts: DeleteOptions{DeleteAll: true},
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"),
httpmock.JSONResponse(shared.CachePayload{
ActionsCaches: []shared.Cache{
{
Id: 123,
Key: "foo",
CreatedAt: time.Date(2021, 1, 1, 1, 1, 1, 1, time.UTC),
LastAccessedAt: time.Date(2022, 1, 1, 1, 1, 1, 1, time.UTC),
},
{
Id: 456,
Key: "bar",
CreatedAt: time.Date(2021, 1, 1, 1, 1, 1, 1, time.UTC),
LastAccessedAt: time.Date(2022, 1, 1, 1, 1, 1, 1, time.UTC),
},
},
TotalCount: 2,
}),
)
reg.Register(
httpmock.REST("DELETE", "repos/OWNER/REPO/actions/caches/123"),
httpmock.StatusStringResponse(204, ""),
)
reg.Register(
httpmock.REST("DELETE", "repos/OWNER/REPO/actions/caches/456"),
httpmock.StatusStringResponse(204, ""),
)
},
tty: true,
wantStdout: "✓ Deleted 2 caches from OWNER/REPO\n",
},
{
name: "displays delete error",
opts: DeleteOptions{Identifier: "123"},
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("DELETE", "repos/OWNER/REPO/actions/caches/123"),
httpmock.StatusStringResponse(500, ""),
)
},
wantErr: true,
wantErrMsg: "X Failed to delete cache: HTTP 500 (https://api.github.com/repos/OWNER/REPO/actions/caches/123)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
if tt.stubs != nil {
tt.stubs(reg)
}
tt.opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
ios, _, stdout, stderr := iostreams.Test()
ios.SetStdoutTTY(tt.tty)
ios.SetStdinTTY(tt.tty)
ios.SetStderrTTY(tt.tty)
tt.opts.IO = ios
tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
}
defer reg.Verify(t)
err := deleteRun(&tt.opts)
if tt.wantErr {
if tt.wantErrMsg != "" {
assert.EqualError(t, err, tt.wantErrMsg)
} else {
assert.Error(t, err)
}
} else {
assert.NoError(t, err)
}
assert.Equal(t, tt.wantStdout, stdout.String())
assert.Equal(t, tt.wantStderr, stderr.String())
})
}
}

149
pkg/cmd/cache/list/list.go vendored Normal file
View file

@ -0,0 +1,149 @@
package list
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/tableprinter"
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/pkg/cmd/cache/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/spf13/cobra"
)
type ListOptions struct {
BaseRepo func() (ghrepo.Interface, error)
HttpClient func() (*http.Client, error)
IO *iostreams.IOStreams
Now time.Time
Limit int
Order string
Sort string
}
func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
opts := ListOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
}
cmd := &cobra.Command{
Use: "list",
Short: "List Github Actions caches",
Example: heredoc.Doc(`
# List caches for current repository
$ gh cache list
# List caches for specific repository
$ gh cache list --repo cli/cli
# List caches sorted by least recently accessed
$ gh cache list --sort last_accessed_at --order asc
`),
Aliases: []string{"ls"},
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
if opts.Limit < 1 {
return cmdutil.FlagErrorf("invalid limit: %v", opts.Limit)
}
if runF != nil {
return runF(&opts)
}
return listRun(&opts)
},
}
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum number of caches to fetch")
cmdutil.StringEnumFlag(cmd, &opts.Order, "order", "O", "desc", []string{"asc", "desc"}, "Order of caches returned")
cmdutil.StringEnumFlag(cmd, &opts.Sort, "sort", "S", "last_accessed_at", []string{"created_at", "last_accessed_at", "size_in_bytes"}, "Sort fetched caches")
return cmd
}
func listRun(opts *ListOptions) error {
repo, err := opts.BaseRepo()
if err != nil {
return err
}
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
client := api.NewClientFromHTTP(httpClient)
opts.IO.StartProgressIndicator()
result, err := shared.GetCaches(client, repo, shared.GetCachesOptions{Limit: opts.Limit, Sort: opts.Sort, Order: opts.Order})
opts.IO.StopProgressIndicator()
if err != nil {
return fmt.Errorf("%s Failed to get caches: %w", opts.IO.ColorScheme().FailureIcon(), err)
}
if len(result.ActionsCaches) == 0 {
return cmdutil.NewNoResultsError(fmt.Sprintf("No caches found in %s", ghrepo.FullName(repo)))
}
if err := opts.IO.StartPager(); err == nil {
defer opts.IO.StopPager()
} else {
fmt.Fprintf(opts.IO.Out, "Failed to start pager: %v\n", err)
}
if opts.IO.IsStdoutTTY() {
fmt.Fprintf(opts.IO.Out, "\nShowing %d of %s in %s\n\n", len(result.ActionsCaches), text.Pluralize(result.TotalCount, "cache"), ghrepo.FullName(repo))
}
if opts.Now.IsZero() {
opts.Now = time.Now()
}
tp := tableprinter.New(opts.IO)
tp.HeaderRow("ID", "KEY", "SIZE", "CREATED", "ACCESSED")
for _, cache := range result.ActionsCaches {
tp.AddField(opts.IO.ColorScheme().Cyan(fmt.Sprintf("%d", cache.Id)))
tp.AddField(cache.Key)
tp.AddField(humanFileSize(cache.SizeInBytes))
tp.AddTimeField(time.Now(), cache.CreatedAt, opts.IO.ColorScheme().Gray)
tp.AddTimeField(time.Now(), cache.LastAccessedAt, opts.IO.ColorScheme().Gray)
tp.EndRow()
}
return tp.Render()
}
func humanFileSize(s int64) string {
if s < 1024 {
return fmt.Sprintf("%d B", s)
}
kb := float64(s) / 1024
if kb < 1024 {
return fmt.Sprintf("%s KiB", floatToString(kb, 2))
}
mb := kb / 1024
if mb < 1024 {
return fmt.Sprintf("%s MiB", floatToString(mb, 2))
}
gb := mb / 1024
return fmt.Sprintf("%s GiB", floatToString(gb, 2))
}
func floatToString(f float64, p uint8) string {
fs := fmt.Sprintf("%#f%0*s", f, p, "")
idx := strings.IndexRune(fs, '.')
return fs[:idx+int(p)+1]
}

296
pkg/cmd/cache/list/list_test.go vendored Normal file
View file

@ -0,0 +1,296 @@
package list
import (
"bytes"
"net/http"
"testing"
"time"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmd/cache/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
)
func TestNewCmdList(t *testing.T) {
tests := []struct {
name string
input string
wants ListOptions
wantsErr string
}{
{
name: "no arguments",
input: "",
wants: ListOptions{
Limit: 30,
Order: "desc",
Sort: "last_accessed_at",
},
},
{
name: "with limit",
input: "--limit 100",
wants: ListOptions{
Limit: 100,
Order: "desc",
Sort: "last_accessed_at",
},
},
{
name: "invalid limit",
input: "-L 0",
wantsErr: "invalid limit: 0",
},
{
name: "with sort",
input: "--sort created_at",
wants: ListOptions{
Limit: 30,
Order: "desc",
Sort: "created_at",
},
},
{
name: "with order",
input: "--order asc",
wants: ListOptions{
Limit: 30,
Order: "asc",
Sort: "last_accessed_at",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := &cmdutil.Factory{}
argv, err := shlex.Split(tt.input)
assert.NoError(t, err)
var gotOpts *ListOptions
cmd := NewCmdList(f, func(opts *ListOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
_, err = cmd.ExecuteC()
if tt.wantsErr != "" {
assert.EqualError(t, err, tt.wantsErr)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wants.Limit, gotOpts.Limit)
assert.Equal(t, tt.wants.Sort, gotOpts.Sort)
assert.Equal(t, tt.wants.Order, gotOpts.Order)
})
}
}
func TestListRun(t *testing.T) {
var now = time.Date(2023, 1, 1, 1, 1, 1, 1, time.UTC)
tests := []struct {
name string
opts ListOptions
stubs func(*httpmock.Registry)
tty bool
wantErr bool
wantErrMsg string
wantStderr string
wantStdout string
}{
{
name: "displays results tty",
tty: true,
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"),
httpmock.JSONResponse(shared.CachePayload{
ActionsCaches: []shared.Cache{
{
Id: 1,
Key: "foo",
CreatedAt: time.Date(2021, 1, 1, 1, 1, 1, 1, time.UTC),
LastAccessedAt: time.Date(2022, 1, 1, 1, 1, 1, 1, time.UTC),
SizeInBytes: 100,
},
{
Id: 2,
Key: "bar",
CreatedAt: time.Date(2021, 1, 1, 1, 1, 1, 1, time.UTC),
LastAccessedAt: time.Date(2022, 1, 1, 1, 1, 1, 1, time.UTC),
SizeInBytes: 1024,
},
},
TotalCount: 2,
}),
)
},
wantStdout: heredoc.Doc(`
Showing 2 of 2 caches in OWNER/REPO
ID KEY SIZE CREATED ACCESSED
1 foo 100 B about 2 years ago about 1 year ago
2 bar 1.00 KiB about 2 years ago about 1 year ago
`),
},
{
name: "displays results non-tty",
tty: false,
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"),
httpmock.JSONResponse(shared.CachePayload{
ActionsCaches: []shared.Cache{
{
Id: 1,
Key: "foo",
CreatedAt: time.Date(2021, 1, 1, 1, 1, 1, 1, time.UTC),
LastAccessedAt: time.Date(2022, 1, 1, 1, 1, 1, 1, time.UTC),
SizeInBytes: 100,
},
{
Id: 2,
Key: "bar",
CreatedAt: time.Date(2021, 1, 1, 1, 1, 1, 1, time.UTC),
LastAccessedAt: time.Date(2022, 1, 1, 1, 1, 1, 1, time.UTC),
SizeInBytes: 1024,
},
},
TotalCount: 2,
}),
)
},
wantStdout: "1\tfoo\t100 B\t2021-01-01T01:01:01Z\t2022-01-01T01:01:01Z\n2\tbar\t1.00 KiB\t2021-01-01T01:01:01Z\t2022-01-01T01:01:01Z\n",
},
{
name: "displays no results",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"),
httpmock.JSONResponse(shared.CachePayload{
ActionsCaches: []shared.Cache{},
TotalCount: 0,
}),
)
},
wantErr: true,
wantErrMsg: "No caches found in OWNER/REPO",
},
{
name: "displays list error",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"),
httpmock.StatusStringResponse(404, "Not Found"),
)
},
wantErr: true,
wantErrMsg: "X Failed to get caches: HTTP 404 (https://api.github.com/repos/OWNER/REPO/actions/caches?per_page=100)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
if tt.stubs != nil {
tt.stubs(reg)
}
tt.opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
ios, _, stdout, stderr := iostreams.Test()
ios.SetStdoutTTY(tt.tty)
ios.SetStdinTTY(tt.tty)
ios.SetStderrTTY(tt.tty)
tt.opts.IO = ios
tt.opts.Now = now
tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
}
defer reg.Verify(t)
err := listRun(&tt.opts)
if tt.wantErr {
if tt.wantErrMsg != "" {
assert.EqualError(t, err, tt.wantErrMsg)
} else {
assert.Error(t, err)
}
} else {
assert.NoError(t, err)
}
assert.Equal(t, tt.wantStdout, stdout.String())
assert.Equal(t, tt.wantStderr, stderr.String())
})
}
}
func Test_humanFileSize(t *testing.T) {
tests := []struct {
name string
size int64
want string
}{
{
name: "min bytes",
size: 1,
want: "1 B",
},
{
name: "max bytes",
size: 1023,
want: "1023 B",
},
{
name: "min kibibytes",
size: 1024,
want: "1.00 KiB",
},
{
name: "max kibibytes",
size: 1024*1024 - 1,
want: "1023.99 KiB",
},
{
name: "min mibibytes",
size: 1024 * 1024,
want: "1.00 MiB",
},
{
name: "fractional mibibytes",
size: 1024*1024*12 + 1024*350,
want: "12.34 MiB",
},
{
name: "max mibibytes",
size: 1024*1024*1024 - 1,
want: "1023.99 MiB",
},
{
name: "min gibibytes",
size: 1024 * 1024 * 1024,
want: "1.00 GiB",
},
{
name: "fractional gibibytes",
size: 1024 * 1024 * 1024 * 1.5,
want: "1.50 GiB",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := humanFileSize(tt.size); got != tt.want {
t.Errorf("humanFileSize() = %v, want %v", got, tt.want)
}
})
}
}

73
pkg/cmd/cache/shared/shared.go vendored Normal file
View file

@ -0,0 +1,73 @@
package shared
import (
"fmt"
"time"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghrepo"
)
type Cache struct {
CreatedAt time.Time `json:"created_at"`
Id int `json:"id"`
Key string `json:"key"`
LastAccessedAt time.Time `json:"last_accessed_at"`
Ref string `json:"ref"`
SizeInBytes int64 `json:"size_in_bytes"`
Version string `json:"version"`
}
type CachePayload struct {
ActionsCaches []Cache `json:"actions_caches"`
TotalCount int `json:"total_count"`
}
type GetCachesOptions struct {
Limit int
Order string
Sort string
}
// Return a list of caches for a repository. Pass a negative limit to request
// all pages from the API until all caches have been fetched.
func GetCaches(client *api.Client, repo ghrepo.Interface, opts GetCachesOptions) (*CachePayload, error) {
path := fmt.Sprintf("repos/%s/actions/caches", ghrepo.FullName(repo))
perPage := 100
if opts.Limit > 0 && opts.Limit < 100 {
perPage = opts.Limit
}
path += fmt.Sprintf("?per_page=%d", perPage)
if opts.Sort != "" {
path += fmt.Sprintf("&sort=%s", opts.Sort)
}
if opts.Order != "" {
path += fmt.Sprintf("&direction=%s", opts.Order)
}
var result *CachePayload
pagination:
for path != "" {
var response CachePayload
var err error
path, err = client.RESTWithNext(repo.RepoHost(), "GET", path, nil, &response)
if err != nil {
return nil, err
}
if result == nil {
result = &response
} else {
result.ActionsCaches = append(result.ActionsCaches, response.ActionsCaches...)
}
if opts.Limit > 0 && len(result.ActionsCaches) >= opts.Limit {
result.ActionsCaches = result.ActionsCaches[:opts.Limit]
break pagination
}
}
return result, nil
}

68
pkg/cmd/cache/shared/shared_test.go vendored Normal file
View file

@ -0,0 +1,68 @@
package shared
import (
"net/http"
"testing"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/stretchr/testify/assert"
)
func TestGetCaches(t *testing.T) {
tests := []struct {
name string
opts GetCachesOptions
stubs func(*httpmock.Registry)
wantsCount int
}{
{
name: "no caches",
opts: GetCachesOptions{},
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"),
httpmock.StringResponse(`{"actions_caches": [], "total_count": 0}`),
)
},
wantsCount: 0,
},
{
name: "limits cache count",
opts: GetCachesOptions{Limit: 1},
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"),
httpmock.StringResponse(`{"actions_caches": [{"id": 1}, {"id": 2}], "total_count": 2}`),
)
},
wantsCount: 1,
},
{
name: "negative limit returns all caches",
opts: GetCachesOptions{Limit: -1},
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"),
httpmock.StringResponse(`{"actions_caches": [{"id": 1}, {"id": 2}], "total_count": 2}`),
)
},
wantsCount: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
tt.stubs(reg)
httpClient := &http.Client{Transport: reg}
client := api.NewClientFromHTTP(httpClient)
repo, err := ghrepo.FromFullName("OWNER/REPO")
assert.NoError(t, err)
result, err := GetCaches(client, repo, tt.opts)
assert.NoError(t, err)
assert.Equal(t, tt.wantsCount, len(result.ActionsCaches))
})
}
}

View file

@ -88,6 +88,7 @@ func startLiveShareSession(ctx context.Context, codespace *api.Codespace, a *App
//go:generate moq -fmt goimports -rm -skip-ensure -out mock_api.go . apiClient
type apiClient interface {
ServerURL() string
GetUser(ctx context.Context) (*api.User, error)
GetCodespace(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error)
GetOrgMemberCodespace(ctx context.Context, orgName string, userName string, codespaceName string) (*api.Codespace, error)

View file

@ -129,7 +129,7 @@ func (a *App) Create(ctx context.Context, opts createOptions) error {
}
if opts.useWeb && userInputs.Repository == "" {
return a.browser.Browse("https://github.com/codespaces/new")
return a.browser.Browse(fmt.Sprintf("%s/codespaces/new", a.apiClient.ServerURL()))
}
promptForRepoAndBranch := userInputs.Repository == "" && !opts.useWeb
@ -293,7 +293,7 @@ func (a *App) Create(ctx context.Context, opts createOptions) error {
}
if opts.useWeb {
return a.browser.Browse(fmt.Sprintf("https://github.com/codespaces/new?repo=%d&ref=%s&machine=%s&location=%s", createParams.RepositoryID, createParams.Branch, createParams.Machine, createParams.Location))
return a.browser.Browse(fmt.Sprintf("%s/codespaces/new?repo=%d&ref=%s&machine=%s&location=%s", a.apiClient.ServerURL(), createParams.RepositoryID, createParams.Branch, createParams.Machine, createParams.Location))
}
var codespace *api.Codespace

View file

@ -453,11 +453,32 @@ Alternatively, you can run "create" with the "--default-permissions" option to c
},
{
name: "return default url when using web flag without other flags",
fields: fields{
apiClient: apiCreateDefaults(&apiClientMock{
ServerURLFunc: func() string {
return "https://github.com"
},
}),
},
opts: createOptions{
useWeb: true,
},
wantURL: "https://github.com/codespaces/new",
},
{
name: "return custom server url when using web flag",
fields: fields{
apiClient: apiCreateDefaults(&apiClientMock{
ServerURLFunc: func() string {
return "https://github.mycompany.com"
},
}),
},
opts: createOptions{
useWeb: true,
},
wantURL: "https://github.mycompany.com/codespaces/new",
},
{
name: "skip machine check when using web flag and no machine provided",
fields: fields{
@ -473,6 +494,9 @@ Alternatively, you can run "create" with the "--default-permissions" option to c
Name: "monalisa-dotfiles-abcd1234",
}, nil
},
ServerURLFunc: func() string {
return "https://github.com"
},
}),
},
opts: createOptions{
@ -499,6 +523,9 @@ Alternatively, you can run "create" with the "--default-permissions" option to c
Name: "monalisa-dotfiles-abcd1234",
}, nil
},
ServerURLFunc: func() string {
return "https://github.com"
},
}),
},
opts: createOptions{
@ -524,6 +551,9 @@ Alternatively, you can run "create" with the "--default-permissions" option to c
Machine: api.CodespaceMachine{Name: "GIGA"},
}, nil
},
ServerURLFunc: func() string {
return "https://github.com"
},
}),
},
opts: createOptions{

View file

@ -79,7 +79,7 @@ func newListCmd(app *App) *cobra.Command {
func (a *App) List(ctx context.Context, opts *listOptions, exporter cmdutil.Exporter) error {
if opts.useWeb && opts.repo == "" {
return a.browser.Browse("https://github.com/codespaces")
return a.browser.Browse(fmt.Sprintf("%s/codespaces", a.apiClient.ServerURL()))
}
var codespaces []*api.Codespace
@ -113,7 +113,7 @@ func (a *App) List(ctx context.Context, opts *listOptions, exporter cmdutil.Expo
}
if opts.useWeb && codespaces[0].Repository.ID > 0 {
return a.browser.Browse(fmt.Sprintf("https://github.com/codespaces?repository_id=%d", codespaces[0].Repository.ID))
return a.browser.Browse(fmt.Sprintf("%s/codespaces?repository_id=%d", a.apiClient.ServerURL(), codespaces[0].Repository.ID))
}
//nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter

View file

@ -170,11 +170,32 @@ func TestApp_List(t *testing.T) {
},
{
name: "list codespaces,--web",
fields: fields{
apiClient: &apiClientMock{
ServerURLFunc: func() string {
return "https://github.com"
},
},
},
opts: &listOptions{
useWeb: true,
},
wantURL: "https://github.com/codespaces",
},
{
name: "list codespaces,--web with custom server url",
fields: fields{
apiClient: &apiClientMock{
ServerURLFunc: func() string {
return "https://github.mycompany.com"
},
},
},
opts: &listOptions{
useWeb: true,
},
wantURL: "https://github.mycompany.com/codespaces",
},
{
name: "list codespaces,--web, --repo flag",
fields: fields{
@ -199,6 +220,9 @@ func TestApp_List(t *testing.T) {
},
}, nil
},
ServerURLFunc: func() string {
return "https://github.com"
},
},
},
opts: &listOptions{

View file

@ -7,7 +7,7 @@ import (
"context"
"sync"
"github.com/cli/cli/v2/internal/codespaces/api"
codespacesAPI "github.com/cli/cli/v2/internal/codespaces/api"
)
// apiClientMock is a mock implementation of apiClient.
@ -16,43 +16,46 @@ import (
//
// // make and configure a mocked apiClient
// mockedapiClient := &apiClientMock{
// CreateCodespaceFunc: func(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) {
// CreateCodespaceFunc: func(ctx context.Context, params *codespacesAPI.CreateCodespaceParams) (*codespacesAPI.Codespace, error) {
// panic("mock out the CreateCodespace method")
// },
// DeleteCodespaceFunc: func(ctx context.Context, name string, orgName string, userName string) error {
// panic("mock out the DeleteCodespace method")
// },
// EditCodespaceFunc: func(ctx context.Context, codespaceName string, params *api.EditCodespaceParams) (*api.Codespace, error) {
// EditCodespaceFunc: func(ctx context.Context, codespaceName string, params *codespacesAPI.EditCodespaceParams) (*codespacesAPI.Codespace, error) {
// panic("mock out the EditCodespace method")
// },
// GetCodespaceFunc: func(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error) {
// GetCodespaceFunc: func(ctx context.Context, name string, includeConnection bool) (*codespacesAPI.Codespace, error) {
// panic("mock out the GetCodespace method")
// },
// GetCodespaceBillableOwnerFunc: func(ctx context.Context, nwo string) (*api.User, error) {
// GetCodespaceBillableOwnerFunc: func(ctx context.Context, nwo string) (*codespacesAPI.User, error) {
// panic("mock out the GetCodespaceBillableOwner method")
// },
// GetCodespaceRepoSuggestionsFunc: func(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error) {
// GetCodespaceRepoSuggestionsFunc: func(ctx context.Context, partialSearch string, params codespacesAPI.RepoSearchParameters) ([]string, error) {
// panic("mock out the GetCodespaceRepoSuggestions method")
// },
// GetCodespaceRepositoryContentsFunc: func(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error) {
// GetCodespaceRepositoryContentsFunc: func(ctx context.Context, codespace *codespacesAPI.Codespace, path string) ([]byte, error) {
// panic("mock out the GetCodespaceRepositoryContents method")
// },
// GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch string, location string, devcontainerPath string) ([]*api.Machine, error) {
// GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch string, location string, devcontainerPath string) ([]*codespacesAPI.Machine, error) {
// panic("mock out the GetCodespacesMachines method")
// },
// GetOrgMemberCodespaceFunc: func(ctx context.Context, orgName string, userName string, codespaceName string) (*api.Codespace, error) {
// GetOrgMemberCodespaceFunc: func(ctx context.Context, orgName string, userName string, codespaceName string) (*codespacesAPI.Codespace, error) {
// panic("mock out the GetOrgMemberCodespace method")
// },
// GetRepositoryFunc: func(ctx context.Context, nwo string) (*api.Repository, error) {
// GetRepositoryFunc: func(ctx context.Context, nwo string) (*codespacesAPI.Repository, error) {
// panic("mock out the GetRepository method")
// },
// GetUserFunc: func(ctx context.Context) (*api.User, error) {
// ServerURLFunc: func() string {
// panic("mock out the ServerURL method")
// },
// GetUserFunc: func(ctx context.Context) (*codespacesAPI.User, error) {
// panic("mock out the GetUser method")
// },
// ListCodespacesFunc: func(ctx context.Context, opts api.ListCodespacesOptions) ([]*api.Codespace, error) {
// ListCodespacesFunc: func(ctx context.Context, opts codespacesAPI.ListCodespacesOptions) ([]*codespacesAPI.Codespace, error) {
// panic("mock out the ListCodespaces method")
// },
// ListDevContainersFunc: func(ctx context.Context, repoID int, branch string, limit int) ([]api.DevContainerEntry, error) {
// ListDevContainersFunc: func(ctx context.Context, repoID int, branch string, limit int) ([]codespacesAPI.DevContainerEntry, error) {
// panic("mock out the ListDevContainers method")
// },
// StartCodespaceFunc: func(ctx context.Context, name string) error {
@ -69,43 +72,46 @@ import (
// }
type apiClientMock struct {
// CreateCodespaceFunc mocks the CreateCodespace method.
CreateCodespaceFunc func(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error)
CreateCodespaceFunc func(ctx context.Context, params *codespacesAPI.CreateCodespaceParams) (*codespacesAPI.Codespace, error)
// DeleteCodespaceFunc mocks the DeleteCodespace method.
DeleteCodespaceFunc func(ctx context.Context, name string, orgName string, userName string) error
// EditCodespaceFunc mocks the EditCodespace method.
EditCodespaceFunc func(ctx context.Context, codespaceName string, params *api.EditCodespaceParams) (*api.Codespace, error)
EditCodespaceFunc func(ctx context.Context, codespaceName string, params *codespacesAPI.EditCodespaceParams) (*codespacesAPI.Codespace, error)
// GetCodespaceFunc mocks the GetCodespace method.
GetCodespaceFunc func(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error)
GetCodespaceFunc func(ctx context.Context, name string, includeConnection bool) (*codespacesAPI.Codespace, error)
// GetCodespaceBillableOwnerFunc mocks the GetCodespaceBillableOwner method.
GetCodespaceBillableOwnerFunc func(ctx context.Context, nwo string) (*api.User, error)
GetCodespaceBillableOwnerFunc func(ctx context.Context, nwo string) (*codespacesAPI.User, error)
// GetCodespaceRepoSuggestionsFunc mocks the GetCodespaceRepoSuggestions method.
GetCodespaceRepoSuggestionsFunc func(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error)
GetCodespaceRepoSuggestionsFunc func(ctx context.Context, partialSearch string, params codespacesAPI.RepoSearchParameters) ([]string, error)
// GetCodespaceRepositoryContentsFunc mocks the GetCodespaceRepositoryContents method.
GetCodespaceRepositoryContentsFunc func(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error)
GetCodespaceRepositoryContentsFunc func(ctx context.Context, codespace *codespacesAPI.Codespace, path string) ([]byte, error)
// GetCodespacesMachinesFunc mocks the GetCodespacesMachines method.
GetCodespacesMachinesFunc func(ctx context.Context, repoID int, branch string, location string, devcontainerPath string) ([]*api.Machine, error)
GetCodespacesMachinesFunc func(ctx context.Context, repoID int, branch string, location string, devcontainerPath string) ([]*codespacesAPI.Machine, error)
// GetOrgMemberCodespaceFunc mocks the GetOrgMemberCodespace method.
GetOrgMemberCodespaceFunc func(ctx context.Context, orgName string, userName string, codespaceName string) (*api.Codespace, error)
GetOrgMemberCodespaceFunc func(ctx context.Context, orgName string, userName string, codespaceName string) (*codespacesAPI.Codespace, error)
// GetRepositoryFunc mocks the GetRepository method.
GetRepositoryFunc func(ctx context.Context, nwo string) (*api.Repository, error)
GetRepositoryFunc func(ctx context.Context, nwo string) (*codespacesAPI.Repository, error)
// ServerURLFunc mocks the ServerURL method.
ServerURLFunc func() string
// GetUserFunc mocks the GetUser method.
GetUserFunc func(ctx context.Context) (*api.User, error)
GetUserFunc func(ctx context.Context) (*codespacesAPI.User, error)
// ListCodespacesFunc mocks the ListCodespaces method.
ListCodespacesFunc func(ctx context.Context, opts api.ListCodespacesOptions) ([]*api.Codespace, error)
ListCodespacesFunc func(ctx context.Context, opts codespacesAPI.ListCodespacesOptions) ([]*codespacesAPI.Codespace, error)
// ListDevContainersFunc mocks the ListDevContainers method.
ListDevContainersFunc func(ctx context.Context, repoID int, branch string, limit int) ([]api.DevContainerEntry, error)
ListDevContainersFunc func(ctx context.Context, repoID int, branch string, limit int) ([]codespacesAPI.DevContainerEntry, error)
// StartCodespaceFunc mocks the StartCodespace method.
StartCodespaceFunc func(ctx context.Context, name string) error
@ -120,7 +126,7 @@ type apiClientMock struct {
// Ctx is the ctx argument value.
Ctx context.Context
// Params is the params argument value.
Params *api.CreateCodespaceParams
Params *codespacesAPI.CreateCodespaceParams
}
// DeleteCodespace holds details about calls to the DeleteCodespace method.
DeleteCodespace []struct {
@ -140,7 +146,7 @@ type apiClientMock struct {
// CodespaceName is the codespaceName argument value.
CodespaceName string
// Params is the params argument value.
Params *api.EditCodespaceParams
Params *codespacesAPI.EditCodespaceParams
}
// GetCodespace holds details about calls to the GetCodespace method.
GetCodespace []struct {
@ -165,14 +171,14 @@ type apiClientMock struct {
// PartialSearch is the partialSearch argument value.
PartialSearch string
// Params is the params argument value.
Params api.RepoSearchParameters
Params codespacesAPI.RepoSearchParameters
}
// GetCodespaceRepositoryContents holds details about calls to the GetCodespaceRepositoryContents method.
GetCodespaceRepositoryContents []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// Codespace is the codespace argument value.
Codespace *api.Codespace
Codespace *codespacesAPI.Codespace
// Path is the path argument value.
Path string
}
@ -207,6 +213,9 @@ type apiClientMock struct {
// Nwo is the nwo argument value.
Nwo string
}
// ServerURL holds details about calls to the ServerURL method.
ServerURL []struct {
}
// GetUser holds details about calls to the GetUser method.
GetUser []struct {
// Ctx is the ctx argument value.
@ -217,7 +226,7 @@ type apiClientMock struct {
// Ctx is the ctx argument value.
Ctx context.Context
// Opts is the opts argument value.
Opts api.ListCodespacesOptions
Opts codespacesAPI.ListCodespacesOptions
}
// ListDevContainers holds details about calls to the ListDevContainers method.
ListDevContainers []struct {
@ -259,6 +268,7 @@ type apiClientMock struct {
lockGetCodespacesMachines sync.RWMutex
lockGetOrgMemberCodespace sync.RWMutex
lockGetRepository sync.RWMutex
lockServerURL sync.RWMutex
lockGetUser sync.RWMutex
lockListCodespaces sync.RWMutex
lockListDevContainers sync.RWMutex
@ -267,13 +277,13 @@ type apiClientMock struct {
}
// CreateCodespace calls CreateCodespaceFunc.
func (mock *apiClientMock) CreateCodespace(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) {
func (mock *apiClientMock) CreateCodespace(ctx context.Context, params *codespacesAPI.CreateCodespaceParams) (*codespacesAPI.Codespace, error) {
if mock.CreateCodespaceFunc == nil {
panic("apiClientMock.CreateCodespaceFunc: method is nil but apiClient.CreateCodespace was just called")
}
callInfo := struct {
Ctx context.Context
Params *api.CreateCodespaceParams
Params *codespacesAPI.CreateCodespaceParams
}{
Ctx: ctx,
Params: params,
@ -290,11 +300,11 @@ func (mock *apiClientMock) CreateCodespace(ctx context.Context, params *api.Crea
// len(mockedapiClient.CreateCodespaceCalls())
func (mock *apiClientMock) CreateCodespaceCalls() []struct {
Ctx context.Context
Params *api.CreateCodespaceParams
Params *codespacesAPI.CreateCodespaceParams
} {
var calls []struct {
Ctx context.Context
Params *api.CreateCodespaceParams
Params *codespacesAPI.CreateCodespaceParams
}
mock.lockCreateCodespace.RLock()
calls = mock.calls.CreateCodespace
@ -347,14 +357,14 @@ func (mock *apiClientMock) DeleteCodespaceCalls() []struct {
}
// EditCodespace calls EditCodespaceFunc.
func (mock *apiClientMock) EditCodespace(ctx context.Context, codespaceName string, params *api.EditCodespaceParams) (*api.Codespace, error) {
func (mock *apiClientMock) EditCodespace(ctx context.Context, codespaceName string, params *codespacesAPI.EditCodespaceParams) (*codespacesAPI.Codespace, error) {
if mock.EditCodespaceFunc == nil {
panic("apiClientMock.EditCodespaceFunc: method is nil but apiClient.EditCodespace was just called")
}
callInfo := struct {
Ctx context.Context
CodespaceName string
Params *api.EditCodespaceParams
Params *codespacesAPI.EditCodespaceParams
}{
Ctx: ctx,
CodespaceName: codespaceName,
@ -373,12 +383,12 @@ func (mock *apiClientMock) EditCodespace(ctx context.Context, codespaceName stri
func (mock *apiClientMock) EditCodespaceCalls() []struct {
Ctx context.Context
CodespaceName string
Params *api.EditCodespaceParams
Params *codespacesAPI.EditCodespaceParams
} {
var calls []struct {
Ctx context.Context
CodespaceName string
Params *api.EditCodespaceParams
Params *codespacesAPI.EditCodespaceParams
}
mock.lockEditCodespace.RLock()
calls = mock.calls.EditCodespace
@ -387,7 +397,7 @@ func (mock *apiClientMock) EditCodespaceCalls() []struct {
}
// GetCodespace calls GetCodespaceFunc.
func (mock *apiClientMock) GetCodespace(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error) {
func (mock *apiClientMock) GetCodespace(ctx context.Context, name string, includeConnection bool) (*codespacesAPI.Codespace, error) {
if mock.GetCodespaceFunc == nil {
panic("apiClientMock.GetCodespaceFunc: method is nil but apiClient.GetCodespace was just called")
}
@ -427,7 +437,7 @@ func (mock *apiClientMock) GetCodespaceCalls() []struct {
}
// GetCodespaceBillableOwner calls GetCodespaceBillableOwnerFunc.
func (mock *apiClientMock) GetCodespaceBillableOwner(ctx context.Context, nwo string) (*api.User, error) {
func (mock *apiClientMock) GetCodespaceBillableOwner(ctx context.Context, nwo string) (*codespacesAPI.User, error) {
if mock.GetCodespaceBillableOwnerFunc == nil {
panic("apiClientMock.GetCodespaceBillableOwnerFunc: method is nil but apiClient.GetCodespaceBillableOwner was just called")
}
@ -463,14 +473,14 @@ func (mock *apiClientMock) GetCodespaceBillableOwnerCalls() []struct {
}
// GetCodespaceRepoSuggestions calls GetCodespaceRepoSuggestionsFunc.
func (mock *apiClientMock) GetCodespaceRepoSuggestions(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error) {
func (mock *apiClientMock) GetCodespaceRepoSuggestions(ctx context.Context, partialSearch string, params codespacesAPI.RepoSearchParameters) ([]string, error) {
if mock.GetCodespaceRepoSuggestionsFunc == nil {
panic("apiClientMock.GetCodespaceRepoSuggestionsFunc: method is nil but apiClient.GetCodespaceRepoSuggestions was just called")
}
callInfo := struct {
Ctx context.Context
PartialSearch string
Params api.RepoSearchParameters
Params codespacesAPI.RepoSearchParameters
}{
Ctx: ctx,
PartialSearch: partialSearch,
@ -489,12 +499,12 @@ func (mock *apiClientMock) GetCodespaceRepoSuggestions(ctx context.Context, part
func (mock *apiClientMock) GetCodespaceRepoSuggestionsCalls() []struct {
Ctx context.Context
PartialSearch string
Params api.RepoSearchParameters
Params codespacesAPI.RepoSearchParameters
} {
var calls []struct {
Ctx context.Context
PartialSearch string
Params api.RepoSearchParameters
Params codespacesAPI.RepoSearchParameters
}
mock.lockGetCodespaceRepoSuggestions.RLock()
calls = mock.calls.GetCodespaceRepoSuggestions
@ -503,13 +513,13 @@ func (mock *apiClientMock) GetCodespaceRepoSuggestionsCalls() []struct {
}
// GetCodespaceRepositoryContents calls GetCodespaceRepositoryContentsFunc.
func (mock *apiClientMock) GetCodespaceRepositoryContents(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error) {
func (mock *apiClientMock) GetCodespaceRepositoryContents(ctx context.Context, codespace *codespacesAPI.Codespace, path string) ([]byte, error) {
if mock.GetCodespaceRepositoryContentsFunc == nil {
panic("apiClientMock.GetCodespaceRepositoryContentsFunc: method is nil but apiClient.GetCodespaceRepositoryContents was just called")
}
callInfo := struct {
Ctx context.Context
Codespace *api.Codespace
Codespace *codespacesAPI.Codespace
Path string
}{
Ctx: ctx,
@ -528,12 +538,12 @@ func (mock *apiClientMock) GetCodespaceRepositoryContents(ctx context.Context, c
// len(mockedapiClient.GetCodespaceRepositoryContentsCalls())
func (mock *apiClientMock) GetCodespaceRepositoryContentsCalls() []struct {
Ctx context.Context
Codespace *api.Codespace
Codespace *codespacesAPI.Codespace
Path string
} {
var calls []struct {
Ctx context.Context
Codespace *api.Codespace
Codespace *codespacesAPI.Codespace
Path string
}
mock.lockGetCodespaceRepositoryContents.RLock()
@ -543,7 +553,7 @@ func (mock *apiClientMock) GetCodespaceRepositoryContentsCalls() []struct {
}
// GetCodespacesMachines calls GetCodespacesMachinesFunc.
func (mock *apiClientMock) GetCodespacesMachines(ctx context.Context, repoID int, branch string, location string, devcontainerPath string) ([]*api.Machine, error) {
func (mock *apiClientMock) GetCodespacesMachines(ctx context.Context, repoID int, branch string, location string, devcontainerPath string) ([]*codespacesAPI.Machine, error) {
if mock.GetCodespacesMachinesFunc == nil {
panic("apiClientMock.GetCodespacesMachinesFunc: method is nil but apiClient.GetCodespacesMachines was just called")
}
@ -591,7 +601,7 @@ func (mock *apiClientMock) GetCodespacesMachinesCalls() []struct {
}
// GetOrgMemberCodespace calls GetOrgMemberCodespaceFunc.
func (mock *apiClientMock) GetOrgMemberCodespace(ctx context.Context, orgName string, userName string, codespaceName string) (*api.Codespace, error) {
func (mock *apiClientMock) GetOrgMemberCodespace(ctx context.Context, orgName string, userName string, codespaceName string) (*codespacesAPI.Codespace, error) {
if mock.GetOrgMemberCodespaceFunc == nil {
panic("apiClientMock.GetOrgMemberCodespaceFunc: method is nil but apiClient.GetOrgMemberCodespace was just called")
}
@ -635,7 +645,7 @@ func (mock *apiClientMock) GetOrgMemberCodespaceCalls() []struct {
}
// GetRepository calls GetRepositoryFunc.
func (mock *apiClientMock) GetRepository(ctx context.Context, nwo string) (*api.Repository, error) {
func (mock *apiClientMock) GetRepository(ctx context.Context, nwo string) (*codespacesAPI.Repository, error) {
if mock.GetRepositoryFunc == nil {
panic("apiClientMock.GetRepositoryFunc: method is nil but apiClient.GetRepository was just called")
}
@ -670,8 +680,35 @@ func (mock *apiClientMock) GetRepositoryCalls() []struct {
return calls
}
// ServerURL calls ServerURLFunc.
func (mock *apiClientMock) ServerURL() string {
if mock.ServerURLFunc == nil {
panic("apiClientMock.ServerURLFunc: method is nil but apiClient.ServerURL was just called")
}
callInfo := struct {
}{}
mock.lockServerURL.Lock()
mock.calls.ServerURL = append(mock.calls.ServerURL, callInfo)
mock.lockServerURL.Unlock()
return mock.ServerURLFunc()
}
// ServerURLCalls gets all the calls that were made to ServerURL.
// Check the length with:
//
// len(mockedapiClient.ServerURLCalls())
func (mock *apiClientMock) ServerURLCalls() []struct {
} {
var calls []struct {
}
mock.lockServerURL.RLock()
calls = mock.calls.ServerURL
mock.lockServerURL.RUnlock()
return calls
}
// GetUser calls GetUserFunc.
func (mock *apiClientMock) GetUser(ctx context.Context) (*api.User, error) {
func (mock *apiClientMock) GetUser(ctx context.Context) (*codespacesAPI.User, error) {
if mock.GetUserFunc == nil {
panic("apiClientMock.GetUserFunc: method is nil but apiClient.GetUser was just called")
}
@ -703,13 +740,13 @@ func (mock *apiClientMock) GetUserCalls() []struct {
}
// ListCodespaces calls ListCodespacesFunc.
func (mock *apiClientMock) ListCodespaces(ctx context.Context, opts api.ListCodespacesOptions) ([]*api.Codespace, error) {
func (mock *apiClientMock) ListCodespaces(ctx context.Context, opts codespacesAPI.ListCodespacesOptions) ([]*codespacesAPI.Codespace, error) {
if mock.ListCodespacesFunc == nil {
panic("apiClientMock.ListCodespacesFunc: method is nil but apiClient.ListCodespaces was just called")
}
callInfo := struct {
Ctx context.Context
Opts api.ListCodespacesOptions
Opts codespacesAPI.ListCodespacesOptions
}{
Ctx: ctx,
Opts: opts,
@ -726,11 +763,11 @@ func (mock *apiClientMock) ListCodespaces(ctx context.Context, opts api.ListCode
// len(mockedapiClient.ListCodespacesCalls())
func (mock *apiClientMock) ListCodespacesCalls() []struct {
Ctx context.Context
Opts api.ListCodespacesOptions
Opts codespacesAPI.ListCodespacesOptions
} {
var calls []struct {
Ctx context.Context
Opts api.ListCodespacesOptions
Opts codespacesAPI.ListCodespacesOptions
}
mock.lockListCodespaces.RLock()
calls = mock.calls.ListCodespaces
@ -739,7 +776,7 @@ func (mock *apiClientMock) ListCodespacesCalls() []struct {
}
// ListDevContainers calls ListDevContainersFunc.
func (mock *apiClientMock) ListDevContainers(ctx context.Context, repoID int, branch string, limit int) ([]api.DevContainerEntry, error) {
func (mock *apiClientMock) ListDevContainers(ctx context.Context, repoID int, branch string, limit int) ([]codespacesAPI.DevContainerEntry, error) {
if mock.ListDevContainersFunc == nil {
panic("apiClientMock.ListDevContainersFunc: method is nil but apiClient.ListDevContainers was just called")
}

View file

@ -1,15 +1,28 @@
package codespace
import (
codespacesAPI "github.com/cli/cli/v2/internal/codespaces/api"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/spf13/cobra"
)
func NewRootCmd(app *App) *cobra.Command {
func NewCmdCodespace(f *cmdutil.Factory) *cobra.Command {
root := &cobra.Command{
Use: "codespace",
Short: "Connect to and manage codespaces",
Use: "codespace",
Short: "Connect to and manage codespaces",
Aliases: []string{"cs"},
GroupID: "core",
}
app := NewApp(
f.IOStreams,
f,
codespacesAPI.New(f),
f.Browser,
f.Remotes,
)
root.AddCommand(newCodeCmd(app))
root.AddCommand(newCreateCmd(app))
root.AddCommand(newEditCmd(app))

View file

@ -0,0 +1,51 @@
package clearcache
import (
"fmt"
"os"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/go-gh/v2/pkg/config"
"github.com/spf13/cobra"
)
type ClearCacheOptions struct {
IO *iostreams.IOStreams
CacheDir string
}
func NewCmdConfigClearCache(f *cmdutil.Factory, runF func(*ClearCacheOptions) error) *cobra.Command {
opts := &ClearCacheOptions{
IO: f.IOStreams,
CacheDir: config.CacheDir(),
}
cmd := &cobra.Command{
Use: "clear-cache",
Short: "Clear the cli cache",
Example: heredoc.Doc(`
# Clear the cli cache
$ gh config clear-cache
`),
Args: cobra.ExactArgs(0),
RunE: func(_ *cobra.Command, _ []string) error {
if runF != nil {
return runF(opts)
}
return clearCacheRun(opts)
},
}
return cmd
}
func clearCacheRun(opts *ClearCacheOptions) error {
if err := os.RemoveAll(opts.CacheDir); err != nil {
return err
}
cs := opts.IO.ColorScheme()
fmt.Fprintf(opts.IO.Out, "%s Cleared the cache\n", cs.SuccessIcon())
return nil
}

View file

@ -0,0 +1,32 @@
package clearcache
import (
"fmt"
"os"
"path/filepath"
"testing"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/stretchr/testify/assert"
)
func TestClearCacheRun(t *testing.T) {
cacheDir := filepath.Join(t.TempDir(), "gh-cli-cache")
ios, _, stdout, stderr := iostreams.Test()
opts := &ClearCacheOptions{
IO: ios,
CacheDir: cacheDir,
}
if err := os.Mkdir(opts.CacheDir, 0600); err != nil {
assert.NoError(t, err)
}
if err := clearCacheRun(opts); err != nil {
assert.NoError(t, err)
}
assert.NoDirExistsf(t, opts.CacheDir, fmt.Sprintf("Cache dir: %s still exists", opts.CacheDir))
assert.Equal(t, "✓ Cleared the cache\n", stdout.String())
assert.Equal(t, "", stderr.String())
}

View file

@ -5,6 +5,7 @@ import (
"strings"
"github.com/cli/cli/v2/internal/config"
cmdClearCache "github.com/cli/cli/v2/pkg/cmd/config/clear-cache"
cmdGet "github.com/cli/cli/v2/pkg/cmd/config/get"
cmdList "github.com/cli/cli/v2/pkg/cmd/config/list"
cmdSet "github.com/cli/cli/v2/pkg/cmd/config/set"
@ -35,6 +36,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(cmdGet.NewCmdConfigGet(f, nil))
cmd.AddCommand(cmdSet.NewCmdConfigSet(f, nil))
cmd.AddCommand(cmdList.NewCmdConfigList(f, nil))
cmd.AddCommand(cmdClearCache.NewCmdConfigClearCache(f, nil))
return cmd
}

View file

@ -25,6 +25,7 @@ var ssoURLRE = regexp.MustCompile(`\burl=([^;]+)`)
func New(appVersion string) *cmdutil.Factory {
f := &cmdutil.Factory{
AppVersion: appVersion,
Config: configFunc(), // No factory dependencies
ExecutableName: "gh",
}

View file

@ -268,7 +268,7 @@ func createRun(opts *CreateOptions) (err error) {
Repo: baseRepo,
State: &tb,
}
err = prShared.MetadataSurvey(opts.IO, baseRepo, fetcher, &tb)
err = prShared.MetadataSurvey(opts.Prompter, opts.IO, baseRepo, fetcher, &tb)
if err != nil {
return
}

View file

@ -3,16 +3,13 @@ package delete
import (
"fmt"
"net/http"
"strconv"
"github.com/AlecAivazis/survey/v2"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmd/issue/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/pkg/prompt"
"github.com/shurcooL/githubv4"
"github.com/spf13/cobra"
)
@ -22,16 +19,22 @@ type DeleteOptions struct {
Config func() (config.Config, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
Prompter iprompter
SelectorArg string
Confirmed bool
}
type iprompter interface {
ConfirmDeletion(string) error
}
func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command {
opts := &DeleteOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
Config: f.Config,
Prompter: f.Prompter,
}
cmd := &cobra.Command{
@ -79,22 +82,12 @@ func deleteRun(opts *DeleteOptions) error {
// When executed in an interactive shell, require confirmation, unless
// already provided. Otherwise skip confirmation.
if opts.IO.CanPrompt() && !opts.Confirmed {
answer := ""
//nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter
err = prompt.SurveyAskOne(
&survey.Input{
Message: fmt.Sprintf("You're going to delete issue #%d. This action cannot be reversed. To confirm, type the issue number:", issue.Number),
},
&answer,
)
cs := opts.IO.ColorScheme()
fmt.Printf("%s Deleted issues cannot be recovered.\n", cs.WarningIcon())
err := opts.Prompter.ConfirmDeletion(fmt.Sprintf("%d", issue.Number))
if err != nil {
return err
}
answerInt, err := strconv.Atoi(answer)
if err != nil || answerInt != issue.Number {
fmt.Fprintf(opts.IO.Out, "Issue #%d was not deleted.\n", issue.Number)
return nil
}
}
if err := apiDelete(httpClient, baseRepo, issue.ID); err != nil {

View file

@ -2,6 +2,7 @@ package delete
import (
"bytes"
"errors"
"io"
"net/http"
"regexp"
@ -9,16 +10,16 @@ import (
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/prompter"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/pkg/prompt"
"github.com/cli/cli/v2/test"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
)
func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) {
func runCommand(rt http.RoundTripper, pm *prompter.MockPrompter, isTTY bool, cli string) (*test.CmdOut, error) {
ios, _, stdout, stderr := iostreams.Test()
ios.SetStdoutTTY(isTTY)
ios.SetStdinTTY(isTTY)
@ -26,6 +27,7 @@ func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, err
factory := &cmdutil.Factory{
IOStreams: ios,
Prompter: pm,
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: rt}, nil
},
@ -76,11 +78,10 @@ func TestIssueDelete(t *testing.T) {
}),
)
//nolint:staticcheck // SA1019: prompt.NewAskStubber is deprecated: use PrompterMock
as := prompt.NewAskStubber(t)
as.StubPrompt("You're going to delete issue #13. This action cannot be reversed. To confirm, type the issue number:").AnswerWith("13")
pm := prompter.NewMockPrompter(t)
pm.RegisterConfirmDeletion("13", func(_ string) error { return nil })
output, err := runCommand(httpRegistry, true, "13")
output, err := runCommand(httpRegistry, pm, true, "13")
if err != nil {
t.Fatalf("error running command `issue delete`: %v", err)
}
@ -112,7 +113,7 @@ func TestIssueDelete_confirm(t *testing.T) {
}),
)
output, err := runCommand(httpRegistry, true, "13 --confirm")
output, err := runCommand(httpRegistry, nil, true, "13 --confirm")
if err != nil {
t.Fatalf("error running command `issue delete`: %v", err)
}
@ -137,19 +138,17 @@ func TestIssueDelete_cancel(t *testing.T) {
} } }`),
)
//nolint:staticcheck // SA1019: prompt.NewAskStubber is deprecated: use PrompterMock
as := prompt.NewAskStubber(t)
as.StubPrompt("You're going to delete issue #13. This action cannot be reversed. To confirm, type the issue number:").AnswerWith("14")
pm := prompter.NewMockPrompter(t)
pm.RegisterConfirmDeletion("13", func(_ string) error {
return errors.New("You entered 14")
})
output, err := runCommand(httpRegistry, true, "13")
if err != nil {
t.Fatalf("error running command `issue delete`: %v", err)
_, err := runCommand(httpRegistry, pm, true, "13")
if err == nil {
t.Fatalf("expected error")
}
r := regexp.MustCompile(`Issue #13 was not deleted`)
if !r.MatchString(output.String()) {
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.String())
if err.Error() != "You entered 14" {
t.Fatalf("got unexpected error '%s'", err)
}
}
@ -166,7 +165,7 @@ func TestIssueDelete_doesNotExist(t *testing.T) {
`),
)
_, err := runCommand(httpRegistry, true, "13")
_, err := runCommand(httpRegistry, nil, true, "13")
if err == nil || err.Error() != "GraphQL: Could not resolve to an Issue with the number of 13." {
t.Errorf("error running command `issue delete`: %v", err)
}
@ -199,7 +198,7 @@ func TestIssueDelete_issuesDisabled(t *testing.T) {
}`),
)
_, err := runCommand(httpRegistry, true, "13")
_, err := runCommand(httpRegistry, nil, true, "13")
if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" {
t.Fatalf("got error: %v", err)
}

View file

@ -9,7 +9,6 @@ import (
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/context"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/tableprinter"
"github.com/cli/cli/v2/pkg/cmd/issue/shared"
@ -21,17 +20,16 @@ import (
type DevelopOptions struct {
HttpClient func() (*http.Client, error)
GitClient *git.Client
Config func() (config.Config, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
Remotes func() (context.Remotes, error)
IssueRepoSelector string
IssueSelector string
Name string
BaseBranch string
Checkout bool
List bool
IssueSelector string
Name string
BranchRepo string
BaseBranch string
Checkout bool
List bool
}
func NewCmdDevelop(f *cmdutil.Factory, runF func(*DevelopOptions) error) *cobra.Command {
@ -39,200 +37,159 @@ func NewCmdDevelop(f *cmdutil.Factory, runF func(*DevelopOptions) error) *cobra.
IO: f.IOStreams,
HttpClient: f.HttpClient,
GitClient: f.GitClient,
Config: f.Config,
BaseRepo: f.BaseRepo,
Remotes: f.Remotes,
}
cmd := &cobra.Command{
Use: "develop [flags] {<number> | <url>}",
Use: "develop {<number> | <url>}",
Short: "Manage linked branches for an issue",
Example: heredoc.Doc(`
$ gh issue develop --list 123 # list branches for issue 123
$ gh issue develop --list --issue-repo "github/cli" 123 # list branches for issue 123 in repo "github/cli"
$ gh issue develop --list https://github.com/github/cli/issues/123 # list branches for issue 123 in repo "github/cli"
$ gh issue develop 123 --name "my-branch" --base my-feature # create a branch for issue 123 based on the my-feature branch
$ gh issue develop 123 --checkout # fetch and checkout the branch for issue 123 after creating it
`),
# List branches for issue 123
$ gh issue develop --list 123
# List branches for issue 123 in repo cli/cli
$ gh issue develop --list --repo cli/cli 123
# Create a branch for issue 123 based on the my-feature branch
$ gh issue develop 123 --base my-feature
# Create a branch for issue 123 and checkout it out
$ gh issue develop 123 --checkout
# Create a branch in repo monalisa/cli for issue 123 in repo cli/cli
$ gh issue develop 123 --repo cli/cli --branch-repo monalisa/cli
`),
Args: cmdutil.ExactArgs(1, "issue number or url is required"),
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// This is all a hack to not break the deprecated issue-repo flag.
// It will be removed in the near future and this hack can be removed at the same time.
flags := cmd.Flags()
if flags.Changed("issue-repo") {
if flags.Changed("repo") {
if flags.Changed("branch-repo") {
return cmdutil.FlagErrorf("specify only `--repo` and `--branch-repo`")
}
branchRepo, _ := flags.GetString("repo")
_ = flags.Set("branch-repo", branchRepo)
}
repo, _ := flags.GetString("issue-repo")
_ = flags.Set("repo", repo)
}
if cmd.Parent() != nil {
return cmd.Parent().PersistentPreRunE(cmd, args)
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
opts.IssueSelector = args[0]
if err := cmdutil.MutuallyExclusive("specify only one of `--list` or `--branch-repo`", opts.List, opts.BranchRepo != ""); err != nil {
return err
}
if err := cmdutil.MutuallyExclusive("specify only one of `--list` or `--base`", opts.List, opts.BaseBranch != ""); err != nil {
return err
}
if err := cmdutil.MutuallyExclusive("specify only one of `--list` or `--checkout`", opts.List, opts.Checkout); err != nil {
return err
}
if err := cmdutil.MutuallyExclusive("specify only one of `--list` or `--name`", opts.List, opts.Name != ""); err != nil {
return err
}
if runF != nil {
return runF(opts)
}
opts.IssueSelector = args[0]
if opts.List {
return developRunList(opts)
}
return developRunCreate(opts)
return developRun(opts)
},
}
fl := cmd.Flags()
fl.StringVar(&opts.BranchRepo, "branch-repo", "", "Name or URL of the repository where you want to create your new branch")
fl.StringVarP(&opts.BaseBranch, "base", "b", "", "Name of the base branch you want to make your new branch from")
fl.BoolVarP(&opts.Checkout, "checkout", "c", false, "Checkout the branch after creating it")
fl.StringVarP(&opts.IssueRepoSelector, "issue-repo", "i", "", "Name or URL of the issue's repository")
fl.BoolVarP(&opts.List, "list", "l", false, "List linked branches for the issue")
fl.StringVarP(&opts.Name, "name", "n", "", "Name of the branch to create")
var issueRepoSelector string
fl.StringVarP(&issueRepoSelector, "issue-repo", "i", "", "Name or URL of the issue's repository")
_ = cmd.Flags().MarkDeprecated("issue-repo", "use `--repo` instead")
return cmd
}
func developRunCreate(opts *DevelopOptions) (err error) {
func developRun(opts *DevelopOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
baseRepo, err := opts.BaseRepo()
if err != nil {
return err
}
opts.IO.StartProgressIndicator()
err = api.CheckLinkedBranchFeature(apiClient, baseRepo.RepoHost())
if err != nil {
return err
}
repo, err := api.GitHubRepo(apiClient, baseRepo)
if err != nil {
return err
}
issueNumber, issueRepo, err := issueMetadata(opts.IssueSelector, opts.IssueRepoSelector, baseRepo)
if err != nil {
return err
}
// The mutation requires the issue id, not just its number
issue, _, err := shared.IssueFromArgWithFields(httpClient, func() (ghrepo.Interface, error) { return issueRepo, nil }, fmt.Sprint(issueNumber), []string{"id"})
if err != nil {
return err
}
// The mutation takes an oid instead of a branch name as it's a more stable reference
oid, default_branch_oid, err := api.FindBaseOid(apiClient, repo, opts.BaseBranch)
if err != nil {
return err
}
if oid == "" {
oid = default_branch_oid
}
// get the oid of the branch from the base repo
params := map[string]interface{}{
"issueId": issue.ID,
"name": opts.Name,
"oid": oid,
"repositoryId": repo.ID,
}
ref, err := api.CreateBranchIssueReference(apiClient, repo, params)
issue, issueRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.IssueSelector, []string{"id", "number"})
opts.IO.StopProgressIndicator()
if err != nil {
return nil
return err
}
baseRepo.RepoHost()
fmt.Fprintf(opts.IO.Out, "%s/%s/%s/tree/%s\n", baseRepo.RepoHost(), baseRepo.RepoOwner(), baseRepo.RepoName(), ref.BranchName)
return checkoutBranch(opts, baseRepo, ref.BranchName)
apiClient := api.NewClientFromHTTP(httpClient)
opts.IO.StartProgressIndicator()
err = api.CheckLinkedBranchFeature(apiClient, issueRepo.RepoHost())
opts.IO.StopProgressIndicator()
if err != nil {
return err
}
if opts.List {
return developRunList(opts, apiClient, issueRepo, issue)
}
return developRunCreate(opts, apiClient, issueRepo, issue)
}
// If the issue is in the base repo, we can use the issue number directly. Otherwise, we need to use the issue's url or the IssueRepoSelector argument.
// If the repo from the URL doesn't match the IssueRepoSelector argument, we error.
func issueMetadata(issueSelector string, issueRepoSelector string, baseRepo ghrepo.Interface) (issueNumber int, issueFlagRepo ghrepo.Interface, err error) {
var targetRepo ghrepo.Interface
if issueRepoSelector != "" {
issueFlagRepo, err = ghrepo.FromFullNameWithHost(issueRepoSelector, baseRepo.RepoHost())
func developRunCreate(opts *DevelopOptions, apiClient *api.Client, issueRepo ghrepo.Interface, issue *api.Issue) error {
branchRepo := issueRepo
var repoID string
if opts.BranchRepo != "" {
var err error
branchRepo, err = ghrepo.FromFullName(opts.BranchRepo)
if err != nil {
return 0, nil, err
return err
}
}
if issueFlagRepo != nil {
targetRepo = issueFlagRepo
}
issueNumber, issueArgRepo, err := shared.IssueNumberAndRepoFromArg(issueSelector)
if err != nil {
return 0, nil, err
}
if issueArgRepo != nil {
targetRepo = issueArgRepo
if issueFlagRepo != nil {
differentOwner := (issueFlagRepo.RepoOwner() != issueArgRepo.RepoOwner())
differentName := (issueFlagRepo.RepoName() != issueArgRepo.RepoName())
if differentOwner || differentName {
return 0, nil, fmt.Errorf("issue repo in url %s/%s does not match the repo from --issue-repo %s/%s", issueArgRepo.RepoOwner(), issueArgRepo.RepoName(), issueFlagRepo.RepoOwner(), issueFlagRepo.RepoName())
}
}
}
if issueFlagRepo == nil && issueArgRepo == nil {
targetRepo = baseRepo
}
if targetRepo == nil {
return 0, nil, fmt.Errorf("could not determine issue repo")
}
return issueNumber, targetRepo, nil
}
func printLinkedBranches(io *iostreams.IOStreams, branches []api.LinkedBranch) {
cs := io.ColorScheme()
table := tableprinter.New(io)
for _, branch := range branches {
table.AddField(branch.BranchName, tableprinter.WithColor(cs.ColorFromString("cyan")))
if io.CanPrompt() {
table.AddField(branch.Url())
}
table.EndRow()
}
_ = table.Render()
}
func developRunList(opts *DevelopOptions) (err error) {
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
baseRepo, err := opts.BaseRepo()
opts.IO.StartProgressIndicator()
repoID, branchID, err := api.FindRepoBranchID(apiClient, branchRepo, opts.BaseBranch)
opts.IO.StopProgressIndicator()
if err != nil {
return err
}
opts.IO.StartProgressIndicator()
err = api.CheckLinkedBranchFeature(apiClient, baseRepo.RepoHost())
if err != nil {
return err
}
issueNumber, issueRepo, err := issueMetadata(opts.IssueSelector, opts.IssueRepoSelector, baseRepo)
if err != nil {
return err
}
branches, err := api.ListLinkedBranches(apiClient, issueRepo, issueNumber)
if err != nil {
return err
}
branchName, err := api.CreateLinkedBranch(apiClient, branchRepo.RepoHost(), repoID, issue.ID, branchID, opts.Name)
opts.IO.StopProgressIndicator()
if err != nil {
return err
}
fmt.Fprintf(opts.IO.Out, "%s/%s/%s/tree/%s\n", branchRepo.RepoHost(), branchRepo.RepoOwner(), branchRepo.RepoName(), branchName)
return checkoutBranch(opts, branchRepo, branchName)
}
func developRunList(opts *DevelopOptions, apiClient *api.Client, issueRepo ghrepo.Interface, issue *api.Issue) error {
opts.IO.StartProgressIndicator()
branches, err := api.ListLinkedBranches(apiClient, issueRepo, issue.Number)
opts.IO.StopProgressIndicator()
if err != nil {
return err
}
if len(branches) == 0 {
return cmdutil.NewNoResultsError(fmt.Sprintf("no linked branches found for %s/%s#%d", issueRepo.RepoOwner(), issueRepo.RepoName(), issueNumber))
return cmdutil.NewNoResultsError(fmt.Sprintf("no linked branches found for %s/%s#%d", issueRepo.RepoOwner(), issueRepo.RepoName(), issue.Number))
}
if opts.IO.IsStdoutTTY() {
fmt.Fprintf(opts.IO.Out, "\nShowing linked branches for %s/%s#%d\n\n", issueRepo.RepoOwner(), issueRepo.RepoName(), issueNumber)
fmt.Fprintf(opts.IO.Out, "\nShowing linked branches for %s/%s#%d\n\n", issueRepo.RepoOwner(), issueRepo.RepoName(), issue.Number)
}
printLinkedBranches(opts.IO, branches)
@ -240,7 +197,18 @@ func developRunList(opts *DevelopOptions) (err error) {
return nil
}
func checkoutBranch(opts *DevelopOptions, baseRepo ghrepo.Interface, checkoutBranch string) (err error) {
func printLinkedBranches(io *iostreams.IOStreams, branches []api.LinkedBranch) {
cs := io.ColorScheme()
table := tableprinter.New(io)
for _, branch := range branches {
table.AddField(branch.BranchName, tableprinter.WithColor(cs.ColorFromString("cyan")))
table.AddField(branch.URL)
table.EndRow()
}
_ = table.Render()
}
func checkoutBranch(opts *DevelopOptions, branchRepo ghrepo.Interface, checkoutBranch string) (err error) {
remotes, err := opts.Remotes()
if err != nil {
// If the user specified the branch to be checked out and no remotes are found
@ -253,7 +221,7 @@ func checkoutBranch(opts *DevelopOptions, baseRepo ghrepo.Interface, checkoutBra
}
}
baseRemote, err := remotes.FindByRepo(baseRepo.RepoOwner(), baseRepo.RepoName())
baseRemote, err := remotes.FindByRepo(branchRepo.RepoOwner(), branchRepo.RepoName())
if err != nil {
// If the user specified the branch to be checked out and no remote matches the
// base repo, then display an error. Otherwise bail out silently.

View file

@ -1,418 +1,307 @@
package develop
import (
"bytes"
"errors"
"net/http"
"testing"
"github.com/cli/cli/v2/context"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/run"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/pkg/prompt"
"github.com/cli/cli/v2/test"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
)
func Test_developRun(t *testing.T) {
featureEnabledPayload := `{
"data": {
"LinkedBranch": {
"fields": [
{
"name": "id"
},
{
"name": "ref"
}
]
}
}
}`
func TestNewCmdDevelop(t *testing.T) {
tests := []struct {
name string
input string
output DevelopOptions
wantStdout string
wantStderr string
wantErr bool
errMsg string
}{
{
name: "no argument",
input: "",
output: DevelopOptions{},
wantErr: true,
errMsg: "issue number or url is required",
},
{
name: "issue number",
input: "1",
output: DevelopOptions{
IssueSelector: "1",
},
},
{
name: "issue url",
input: "https://github.com/cli/cli/issues/1",
output: DevelopOptions{
IssueSelector: "https://github.com/cli/cli/issues/1",
},
},
{
name: "branch-repo flag",
input: "1 --branch-repo owner/repo",
output: DevelopOptions{
IssueSelector: "1",
BranchRepo: "owner/repo",
},
},
{
name: "base flag",
input: "1 --base feature",
output: DevelopOptions{
IssueSelector: "1",
BaseBranch: "feature",
},
},
{
name: "checkout flag",
input: "1 --checkout",
output: DevelopOptions{
IssueSelector: "1",
Checkout: true,
},
},
{
name: "list flag",
input: "1 --list",
output: DevelopOptions{
IssueSelector: "1",
List: true,
},
},
{
name: "name flag",
input: "1 --name feature",
output: DevelopOptions{
IssueSelector: "1",
Name: "feature",
},
},
{
name: "issue-repo flag",
input: "1 --issue-repo cli/cli",
output: DevelopOptions{
IssueSelector: "1",
},
wantStdout: "Flag --issue-repo has been deprecated, use `--repo` instead\n",
},
{
name: "list and branch repo flags",
input: "1 --list --branch-repo owner/repo",
wantErr: true,
errMsg: "specify only one of `--list` or `--branch-repo`",
},
{
name: "list and base flags",
input: "1 --list --base feature",
wantErr: true,
errMsg: "specify only one of `--list` or `--base`",
},
{
name: "list and checkout flags",
input: "1 --list --checkout",
wantErr: true,
errMsg: "specify only one of `--list` or `--checkout`",
},
{
name: "list and name flags",
input: "1 --list --name my-branch",
wantErr: true,
errMsg: "specify only one of `--list` or `--name`",
},
}
featureDisabledPayload := `{ "data": { "LinkedBranch": null } }`
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, _, stdOut, stdErr := iostreams.Test()
f := &cmdutil.Factory{
IOStreams: ios,
}
argv, err := shlex.Split(tt.input)
assert.NoError(t, err)
var gotOpts *DevelopOptions
cmd := NewCmdDevelop(f, func(opts *DevelopOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(stdOut)
cmd.SetErr(stdErr)
_, err = cmd.ExecuteC()
if tt.wantErr {
assert.EqualError(t, err, tt.errMsg)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.output.IssueSelector, gotOpts.IssueSelector)
assert.Equal(t, tt.output.Name, gotOpts.Name)
assert.Equal(t, tt.output.BaseBranch, gotOpts.BaseBranch)
assert.Equal(t, tt.output.Checkout, gotOpts.Checkout)
assert.Equal(t, tt.output.List, gotOpts.List)
assert.Equal(t, tt.wantStdout, stdOut.String())
assert.Equal(t, tt.wantStderr, stdErr.String())
})
}
}
func TestDevelopRun(t *testing.T) {
featureEnabledPayload := `{"data":{"LinkedBranch":{"fields":[{"name":"id"},{"name":"ref"}]}}}`
featureDisabledPayload := `{"data":{"LinkedBranch":null}}`
tests := []struct {
name string
setup func(*DevelopOptions, *testing.T) func()
opts *DevelopOptions
cmdStubs func(*run.CommandStubber)
runStubs func(*run.CommandStubber)
remotes map[string]string
askStubs func(*prompt.AskStubber) // TODO eventually migrate to PrompterMock
httpStubs func(*httpmock.Registry, *testing.T)
expectedOut string
expectedErrOut string
expectedBrowse string
wantErr string
tty bool
}{
{
name: "list branches for an issue",
setup: func(opts *DevelopOptions, t *testing.T) func() {
opts.IssueSelector = "42"
opts.List = true
return func() {}
name: "returns an error when the feature is not supported by the API",
opts: &DevelopOptions{
IssueSelector: "42",
List: true,
},
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
reg.Register(
httpmock.GraphQL(`query LinkedBranch_fields\b`),
httpmock.StringResponse(featureEnabledPayload),
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(`{"data":{"repository":{"hasIssuesEnabled":true,"issue":{"id":"SOMEID","number":42}}}}`),
)
reg.Register(
httpmock.GraphQL(`query BranchIssueReferenceListLinkedBranches\b`),
httpmock.GraphQLQuery(`{
"data": {
"repository": {
"issue": {
"linkedBranches": {
"edges": [
{
"node": {
"ref": {
"name": "foo"
}
}
},
{
"node": {
"ref": {
"name": "bar"
}
}
}
]
}
}
}
}
}
`, func(query string, inputs map[string]interface{}) {
assert.Equal(t, float64(42), inputs["issueNumber"])
assert.Equal(t, "OWNER", inputs["repositoryOwner"])
assert.Equal(t, "REPO", inputs["repositoryName"])
}))
},
expectedOut: "foo\nbar\n",
},
{
name: "list branches for an issue in tty",
setup: func(opts *DevelopOptions, t *testing.T) func() {
opts.IssueSelector = "42"
opts.List = true
return func() {}
},
tty: true,
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
reg.Register(
httpmock.GraphQL(`query LinkedBranch_fields\b`),
httpmock.StringResponse(featureEnabledPayload),
)
reg.Register(
httpmock.GraphQL(`query BranchIssueReferenceListLinkedBranches\b`),
httpmock.GraphQLQuery(`{
"data": {
"repository": {
"issue": {
"linkedBranches": {
"edges": [
{
"node": {
"ref": {
"name": "foo",
"repository": {
"url": "http://github.localhost/OWNER/REPO"
}
}
}
},
{
"node": {
"ref": {
"name": "bar",
"repository": {
"url": "http://github.localhost/OWNER/OTHER-REPO"
}
}
}
}
]
}
}
}
}
}
`, func(query string, inputs map[string]interface{}) {
assert.Equal(t, float64(42), inputs["issueNumber"])
assert.Equal(t, "OWNER", inputs["repositoryOwner"])
assert.Equal(t, "REPO", inputs["repositoryName"])
}))
},
expectedOut: "\nShowing linked branches for OWNER/REPO#42\n\nfoo http://github.localhost/OWNER/REPO/tree/foo\nbar http://github.localhost/OWNER/OTHER-REPO/tree/bar\n",
},
{
name: "list branches for an issue providing an issue url",
setup: func(opts *DevelopOptions, t *testing.T) func() {
opts.IssueSelector = "https://github.com/cli/test-repo/issues/42"
opts.List = true
return func() {}
},
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
reg.Register(
httpmock.GraphQL(`query LinkedBranch_fields\b`),
httpmock.StringResponse(featureEnabledPayload),
)
reg.Register(
httpmock.GraphQL(`query BranchIssueReferenceListLinkedBranches\b`),
httpmock.GraphQLQuery(`{
"data": {
"repository": {
"issue": {
"linkedBranches": {
"edges": [
{
"node": {
"ref": {
"name": "foo"
}
}
},
{
"node": {
"ref": {
"name": "bar"
}
}
}
]
}
}
}
}
}
`, func(query string, inputs map[string]interface{}) {
assert.Equal(t, float64(42), inputs["issueNumber"])
assert.Equal(t, "cli", inputs["repositoryOwner"])
assert.Equal(t, "test-repo", inputs["repositoryName"])
}))
},
expectedOut: "foo\nbar\n",
},
{
name: "list branches for an issue providing an issue repo",
setup: func(opts *DevelopOptions, t *testing.T) func() {
opts.IssueSelector = "42"
opts.IssueRepoSelector = "cli/test-repo"
opts.List = true
return func() {}
},
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
reg.Register(
httpmock.GraphQL(`query LinkedBranch_fields\b`),
httpmock.StringResponse(featureEnabledPayload),
)
reg.Register(
httpmock.GraphQL(`query BranchIssueReferenceListLinkedBranches\b`),
httpmock.GraphQLQuery(`{
"data": {
"repository": {
"issue": {
"linkedBranches": {
"edges": [
{
"node": {
"ref": {
"name": "foo"
}
}
},
{
"node": {
"ref": {
"name": "bar"
}
}
}
]
}
}
}
}
}
`, func(query string, inputs map[string]interface{}) {
assert.Equal(t, float64(42), inputs["issueNumber"])
assert.Equal(t, "cli", inputs["repositoryOwner"])
assert.Equal(t, "test-repo", inputs["repositoryName"])
}))
},
expectedOut: "foo\nbar\n",
},
{
name: "list branches for an issue providing an issue url and specifying the same repo works",
setup: func(opts *DevelopOptions, t *testing.T) func() {
opts.IssueSelector = "https://github.com/cli/test-repo/issues/42"
opts.IssueRepoSelector = "cli/test-repo"
opts.List = true
return func() {}
},
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
reg.Register(
httpmock.GraphQL(`query LinkedBranch_fields\b`),
httpmock.StringResponse(featureEnabledPayload),
)
reg.Register(
httpmock.GraphQL(`query BranchIssueReferenceListLinkedBranches\b`),
httpmock.GraphQLQuery(`{
"data": {
"repository": {
"issue": {
"linkedBranches": {
"edges": [
{
"node": {
"ref": {
"name": "foo"
}
}
},
{
"node": {
"ref": {
"name": "bar"
}
}
}
]
}
}
}
}
}
`, func(query string, inputs map[string]interface{}) {
assert.Equal(t, float64(42), inputs["issueNumber"])
assert.Equal(t, "cli", inputs["repositoryOwner"])
assert.Equal(t, "test-repo", inputs["repositoryName"])
}))
},
expectedOut: "foo\nbar\n",
},
{
name: "list branches for an issue providing an issue url and specifying a different repo returns an error",
setup: func(opts *DevelopOptions, t *testing.T) func() {
opts.IssueSelector = "https://github.com/cli/test-repo/issues/42"
opts.IssueRepoSelector = "cli/other"
opts.List = true
return func() {}
},
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
reg.Register(
httpmock.GraphQL(`query LinkedBranch_fields\b`),
httpmock.StringResponse(featureEnabledPayload),
)
},
wantErr: "issue repo in url cli/test-repo does not match the repo from --issue-repo cli/other",
},
{
name: "returns an error when the feature isn't enabled in the GraphQL API",
setup: func(opts *DevelopOptions, t *testing.T) func() {
opts.IssueSelector = "https://github.com/cli/test-repo/issues/42"
opts.List = true
return func() {}
},
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
reg.Register(
httpmock.GraphQL(`query LinkedBranch_fields\b`),
httpmock.GraphQL(`query LinkedBranchFeature\b`),
httpmock.StringResponse(featureDisabledPayload),
)
},
wantErr: "the `gh issue develop` command is not currently available",
},
{
name: "develop new branch with name specified",
setup: func(opts *DevelopOptions, t *testing.T) func() {
opts.Name = "my-branch"
opts.BaseBranch = "main"
opts.IssueSelector = "123"
return func() {}
},
remotes: map[string]string{
"origin": "OWNER/REPO",
name: "list branches for an issue",
opts: &DevelopOptions{
IssueSelector: "42",
List: true,
},
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
reg.Register(
httpmock.GraphQL(`query LinkedBranch_fields\b`),
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(`{"data":{"repository":{"hasIssuesEnabled":true,"issue":{"id":"SOMEID","number":42}}}}`),
)
reg.Register(
httpmock.GraphQL(`query LinkedBranchFeature\b`),
httpmock.StringResponse(featureEnabledPayload),
)
reg.Register(
httpmock.GraphQL(`query RepositoryInfo\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"id": "REPOID",
"hasIssuesEnabled": true
} } }`),
)
reg.Register(
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled": true, "issue":{"id": "yar", "number":123, "title":"my issue"} }}}`))
reg.Register(
httpmock.GraphQL(`query BranchIssueReferenceFindBaseOid\b`),
httpmock.StringResponse(`{"data":{"repository":{"ref":{"target":{"oid":"123"}}}}}`))
reg.Register(
httpmock.GraphQL(`(?s)mutation CreateLinkedBranch\b.*issueId: \$issueId,\s+name: \$name,\s+oid: \$oid,`),
httpmock.GraphQLQuery(`{ "data": { "createLinkedBranch": { "linkedBranch": {"id": "2", "ref": {"name": "my-branch"} } } } }`,
func(query string, inputs map[string]interface{}) {
assert.Equal(t, "REPOID", inputs["repositoryId"])
assert.Equal(t, "my-branch", inputs["name"])
assert.Equal(t, "yar", inputs["issueId"])
}),
)
httpmock.GraphQL(`query ListLinkedBranches\b`),
httpmock.GraphQLQuery(`
{"data":{"repository":{"issue":{"linkedBranches":{"nodes":[{"ref":{"name":"foo","repository":{"url":"https://github.com/OWNER/REPO"}}},{"ref":{"name":"bar","repository":{"url":"https://github.com/OWNER/REPO"}}}]}}}}}
`, func(query string, inputs map[string]interface{}) {
assert.Equal(t, float64(42), inputs["number"])
assert.Equal(t, "OWNER", inputs["owner"])
assert.Equal(t, "REPO", inputs["name"])
}))
},
runStubs: func(cs *run.CommandStubber) {
cs.Register(`git fetch origin \+refs/heads/my-branch:refs/remotes/origin/my-branch`, 0, "")
},
expectedOut: "github.com/OWNER/REPO/tree/my-branch\n",
expectedOut: "foo\thttps://github.com/OWNER/REPO/tree/foo\nbar\thttps://github.com/OWNER/REPO/tree/bar\n",
},
{
name: "develop new branch without a name provided omits the param from the mutation",
setup: func(opts *DevelopOptions, t *testing.T) func() {
opts.Name = ""
opts.BaseBranch = "main"
opts.IssueSelector = "123"
return func() {}
name: "list branches for an issue in tty",
opts: &DevelopOptions{
IssueSelector: "42",
List: true,
},
tty: true,
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
reg.Register(
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(`{"data":{"repository":{"hasIssuesEnabled":true,"issue":{"id":"SOMEID","number":42}}}}`),
)
reg.Register(
httpmock.GraphQL(`query LinkedBranchFeature\b`),
httpmock.StringResponse(featureEnabledPayload),
)
reg.Register(
httpmock.GraphQL(`query ListLinkedBranches\b`),
httpmock.GraphQLQuery(`
{"data":{"repository":{"issue":{"linkedBranches":{"nodes":[{"ref":{"name":"foo","repository":{"url":"https://github.com/OWNER/REPO"}}},{"ref":{"name":"bar","repository":{"url":"https://github.com/OWNER/OTHER-REPO"}}}]}}}}}
`, func(query string, inputs map[string]interface{}) {
assert.Equal(t, float64(42), inputs["number"])
assert.Equal(t, "OWNER", inputs["owner"])
assert.Equal(t, "REPO", inputs["name"])
}))
},
expectedOut: "\nShowing linked branches for OWNER/REPO#42\n\nfoo https://github.com/OWNER/REPO/tree/foo\nbar https://github.com/OWNER/OTHER-REPO/tree/bar\n",
},
{
name: "list branches for an issue providing an issue url",
opts: &DevelopOptions{
IssueSelector: "https://github.com/cli/cli/issues/42",
List: true,
},
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
reg.Register(
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(`{"data":{"repository":{"hasIssuesEnabled":true,"issue":{"id":"SOMEID","number":42}}}}`),
)
reg.Register(
httpmock.GraphQL(`query LinkedBranchFeature\b`),
httpmock.StringResponse(featureEnabledPayload),
)
reg.Register(
httpmock.GraphQL(`query ListLinkedBranches\b`),
httpmock.GraphQLQuery(`
{"data":{"repository":{"issue":{"linkedBranches":{"nodes":[{"ref":{"name":"foo","repository":{"url":"https://github.com/OWNER/REPO"}}},{"ref":{"name":"bar","repository":{"url":"https://github.com/OWNER/OTHER-REPO"}}}]}}}}}
`, func(query string, inputs map[string]interface{}) {
assert.Equal(t, float64(42), inputs["number"])
assert.Equal(t, "cli", inputs["owner"])
assert.Equal(t, "cli", inputs["name"])
}))
},
expectedOut: "foo\thttps://github.com/OWNER/REPO/tree/foo\nbar\thttps://github.com/OWNER/OTHER-REPO/tree/bar\n",
},
{
name: "develop new branch",
opts: &DevelopOptions{
IssueSelector: "123",
},
remotes: map[string]string{
"origin": "OWNER/REPO",
},
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
reg.Register(
httpmock.GraphQL(`query LinkedBranch_fields\b`),
httpmock.GraphQL(`query LinkedBranchFeature\b`),
httpmock.StringResponse(featureEnabledPayload),
)
reg.Register(
httpmock.GraphQL(`query RepositoryInfo\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"id": "REPOID",
"hasIssuesEnabled": true
} } }`),
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(`{"data":{"repository":{"hasIssuesEnabled":true,"issue":{"id": "SOMEID","number":123,"title":"my issue"}}}}`),
)
reg.Register(
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled": true, "issue":{"id": "yar", "number":123, "title":"my issue"} }}}`))
httpmock.GraphQL(`query FindRepoBranchID\b`),
httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","defaultBranchRef":{"target":{"oid":"DEFAULTOID"}},"ref":{"target":{"oid":""}}}}}`),
)
reg.Register(
httpmock.GraphQL(`query BranchIssueReferenceFindBaseOid\b`),
httpmock.StringResponse(`{"data":{"repository":{"ref":{"target":{"oid":"123"}}}}}`))
reg.Register(
httpmock.GraphQL(`(?s)mutation CreateLinkedBranch\b.*\$oid: GitObjectID!, \$repositoryId:.*issueId: \$issueId,\s+oid: \$oid,`),
httpmock.GraphQLQuery(`{ "data": { "createLinkedBranch": { "linkedBranch": {"id": "2", "ref": {"name": "my-issue-1"} } } } }`,
func(query string, inputs map[string]interface{}) {
httpmock.GraphQL(`mutation CreateLinkedBranch\b`),
httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":"my-issue-1"}}}}}`,
func(inputs map[string]interface{}) {
assert.Equal(t, "REPOID", inputs["repositoryId"])
assert.Equal(t, "", inputs["name"])
assert.Equal(t, "yar", inputs["issueId"])
assert.Equal(t, "SOMEID", inputs["issueId"])
assert.Equal(t, "DEFAULTOID", inputs["oid"])
}),
)
},
@ -422,123 +311,198 @@ func Test_developRun(t *testing.T) {
expectedOut: "github.com/OWNER/REPO/tree/my-issue-1\n",
},
{
name: "develop providing an issue url and specifying a different repo returns an error",
setup: func(opts *DevelopOptions, t *testing.T) func() {
opts.IssueSelector = "https://github.com/cli/test-repo/issues/42"
opts.IssueRepoSelector = "cli/other"
return func() {}
name: "develop new branch in diffferent repo than issue",
opts: &DevelopOptions{
IssueSelector: "123",
BranchRepo: "OWNER2/REPO",
},
remotes: map[string]string{
"origin": "OWNER2/REPO",
},
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
reg.Register(
httpmock.GraphQL(`query LinkedBranch_fields\b`),
httpmock.GraphQL(`query LinkedBranchFeature\b`),
httpmock.StringResponse(featureEnabledPayload),
)
reg.Register(
httpmock.GraphQL(`query RepositoryInfo\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"id": "REPOID",
"hasIssuesEnabled": true
} } }`),
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.GraphQLQuery(`{"data":{"repository":{"hasIssuesEnabled":true,"issue":{"id": "SOMEID","number":123,"title":"my issue"}}}}`,
func(_ string, inputs map[string]interface{}) {
assert.Equal(t, "OWNER", inputs["owner"])
assert.Equal(t, "REPO", inputs["repo"])
assert.Equal(t, float64(123), inputs["number"])
}),
)
reg.Register(
httpmock.GraphQL(`query FindRepoBranchID\b`),
httpmock.GraphQLQuery(`{"data":{"repository":{"id":"REPOID","defaultBranchRef":{"target":{"oid":"DEFAULTOID"}},"ref":{"target":{"oid":""}}}}}`,
func(_ string, inputs map[string]interface{}) {
assert.Equal(t, "OWNER2", inputs["owner"])
assert.Equal(t, "REPO", inputs["name"])
}),
)
reg.Register(
httpmock.GraphQL(`mutation CreateLinkedBranch\b`),
httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":"my-issue-1"}}}}}`,
func(inputs map[string]interface{}) {
assert.Equal(t, "REPOID", inputs["repositoryId"])
assert.Equal(t, "SOMEID", inputs["issueId"])
assert.Equal(t, "DEFAULTOID", inputs["oid"])
}),
)
},
wantErr: "issue repo in url cli/test-repo does not match the repo from --issue-repo cli/other",
runStubs: func(cs *run.CommandStubber) {
cs.Register(`git fetch origin \+refs/heads/my-issue-1:refs/remotes/origin/my-issue-1`, 0, "")
},
expectedOut: "github.com/OWNER2/REPO/tree/my-issue-1\n",
},
{
name: "develop new branch with checkout when the branch exists locally",
setup: func(opts *DevelopOptions, t *testing.T) func() {
opts.Name = "my-branch"
opts.BaseBranch = "main"
opts.IssueSelector = "123"
opts.Checkout = true
return func() {}
name: "develop new branch with name and base specified",
opts: &DevelopOptions{
Name: "my-branch",
BaseBranch: "main",
IssueSelector: "123",
},
remotes: map[string]string{
"origin": "OWNER/REPO",
},
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
reg.Register(
httpmock.GraphQL(`query LinkedBranch_fields\b`),
httpmock.GraphQL(`query LinkedBranchFeature\b`),
httpmock.StringResponse(featureEnabledPayload),
)
reg.Register(
httpmock.GraphQL(`query RepositoryInfo\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"id": "REPOID",
"hasIssuesEnabled": true
} } }`),
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled":true,"issue":{"id":"SOMEID","number":123,"title":"my issue"}}}}`),
)
reg.Register(
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled": true, "issue":{"id": "yar", "number":123, "title":"my issue"} }}}`))
reg.Register(
httpmock.GraphQL(`query BranchIssueReferenceFindBaseOid\b`),
httpmock.StringResponse(`{"data":{"repository":{"ref":{"target":{"oid":"123"}}}}}`))
httpmock.GraphQL(`query FindRepoBranchID\b`),
httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`))
reg.Register(
httpmock.GraphQL(`mutation CreateLinkedBranch\b`),
httpmock.GraphQLQuery(`{ "data": { "createLinkedBranch": { "linkedBranch": {"id": "2", "ref": {"name": "my-branch"} } } } }`,
func(query string, inputs map[string]interface{}) {
httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":"my-branch"}}}}}`,
func(inputs map[string]interface{}) {
assert.Equal(t, "REPOID", inputs["repositoryId"])
assert.Equal(t, "SOMEID", inputs["issueId"])
assert.Equal(t, "OID", inputs["oid"])
assert.Equal(t, "my-branch", inputs["name"])
assert.Equal(t, "yar", inputs["issueId"])
}),
)
},
runStubs: func(cs *run.CommandStubber) {
cs.Register(`git rev-parse --verify refs/heads/my-branch`, 0, "")
cs.Register(`git fetch origin \+refs/heads/my-branch:refs/remotes/origin/my-branch`, 0, "")
},
expectedOut: "github.com/OWNER/REPO/tree/my-branch\n",
},
{
name: "develop new branch outside of local git repo",
opts: &DevelopOptions{
IssueSelector: "https://github.com/cli/cli/issues/123",
},
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
reg.Register(
httpmock.GraphQL(`query LinkedBranchFeature\b`),
httpmock.StringResponse(featureEnabledPayload),
)
reg.Register(
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(`{"data":{"repository":{"hasIssuesEnabled":true,"issue":{"id": "SOMEID","number":123,"title":"my issue"}}}}`),
)
reg.Register(
httpmock.GraphQL(`query FindRepoBranchID\b`),
httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","defaultBranchRef":{"target":{"oid":"DEFAULTOID"}},"ref":{"target":{"oid":""}}}}}`),
)
reg.Register(
httpmock.GraphQL(`mutation CreateLinkedBranch\b`),
httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":"my-issue-1"}}}}}`,
func(inputs map[string]interface{}) {
assert.Equal(t, "REPOID", inputs["repositoryId"])
assert.Equal(t, "SOMEID", inputs["issueId"])
assert.Equal(t, "DEFAULTOID", inputs["oid"])
}),
)
},
expectedOut: "github.com/cli/cli/tree/my-issue-1\n",
},
{
name: "develop new branch with checkout when local branch exists",
opts: &DevelopOptions{
Name: "my-branch",
IssueSelector: "123",
Checkout: true,
},
remotes: map[string]string{
"origin": "OWNER/REPO",
},
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
reg.Register(
httpmock.GraphQL(`query LinkedBranchFeature\b`),
httpmock.StringResponse(featureEnabledPayload),
)
reg.Register(
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(`{"data":{"repository":{"hasIssuesEnabled":true,"issue":{"id": "SOMEID","number":123,"title":"my issue"}}}}`),
)
reg.Register(
httpmock.GraphQL(`query FindRepoBranchID\b`),
httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`),
)
reg.Register(
httpmock.GraphQL(`mutation CreateLinkedBranch\b`),
httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":"my-branch"}}}}}`,
func(inputs map[string]interface{}) {
assert.Equal(t, "REPOID", inputs["repositoryId"])
assert.Equal(t, "SOMEID", inputs["issueId"])
assert.Equal(t, "OID", inputs["oid"])
assert.Equal(t, "my-branch", inputs["name"])
}),
)
},
runStubs: func(cs *run.CommandStubber) {
cs.Register(`git fetch origin \+refs/heads/my-branch:refs/remotes/origin/my-branch`, 0, "")
cs.Register(`git rev-parse --verify refs/heads/my-branch`, 0, "")
cs.Register(`git checkout my-branch`, 0, "")
cs.Register(`git pull --ff-only origin my-branch`, 0, "")
},
expectedOut: "github.com/OWNER/REPO/tree/my-branch\n",
},
{
name: "develop new branch with checkout when the branch does not exist locally",
setup: func(opts *DevelopOptions, t *testing.T) func() {
opts.Name = "my-branch"
opts.BaseBranch = "main"
opts.IssueSelector = "123"
opts.Checkout = true
return func() {}
name: "develop new branch with checkout when local branch does not exist",
opts: &DevelopOptions{
Name: "my-branch",
IssueSelector: "123",
Checkout: true,
},
remotes: map[string]string{
"origin": "OWNER/REPO",
},
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
reg.Register(
httpmock.GraphQL(`query LinkedBranch_fields\b`),
httpmock.GraphQL(`query LinkedBranchFeature\b`),
httpmock.StringResponse(featureEnabledPayload),
)
reg.Register(
httpmock.GraphQL(`query RepositoryInfo\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"id": "REPOID",
"hasIssuesEnabled": true
} } }`),
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(`{"data":{"repository":{"hasIssuesEnabled":true,"issue":{"id": "SOMEID","number":123,"title":"my issue"}}}}`),
)
reg.Register(
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled": true, "issue":{"id": "yar", "number":123, "title":"my issue"} }}}`))
reg.Register(
httpmock.GraphQL(`query BranchIssueReferenceFindBaseOid\b`),
httpmock.StringResponse(`{"data":{"repository":{"ref":{"target":{"oid":"123"}}}}}`))
httpmock.GraphQL(`query FindRepoBranchID\b`),
httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`),
)
reg.Register(
httpmock.GraphQL(`mutation CreateLinkedBranch\b`),
httpmock.GraphQLQuery(`{ "data": { "createLinkedBranch": { "linkedBranch": {"id": "2", "ref": {"name": "my-branch"} } } } }`,
func(query string, inputs map[string]interface{}) {
httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":"my-branch"}}}}}`,
func(inputs map[string]interface{}) {
assert.Equal(t, "REPOID", inputs["repositoryId"])
assert.Equal(t, "SOMEID", inputs["issueId"])
assert.Equal(t, "OID", inputs["oid"])
assert.Equal(t, "my-branch", inputs["name"])
assert.Equal(t, "yar", inputs["issueId"])
}),
)
},
runStubs: func(cs *run.CommandStubber) {
cs.Register(`git rev-parse --verify refs/heads/my-branch`, 1, "")
cs.Register(`git fetch origin \+refs/heads/my-branch:refs/remotes/origin/my-branch`, 0, "")
cs.Register(`git rev-parse --verify refs/heads/my-branch`, 1, "")
cs.Register(`git checkout -b my-branch --track origin/my-branch`, 0, "")
},
expectedOut: "github.com/OWNER/REPO/tree/my-branch\n",
@ -546,16 +510,18 @@ func Test_developRun(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
opts := tt.opts
reg := &httpmock.Registry{}
defer reg.Verify(t)
if tt.httpStubs != nil {
tt.httpStubs(reg, t)
}
opts := DevelopOptions{}
opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
ios, _, stdout, stderr := iostreams.Test()
ios.SetStdoutTTY(tt.tty)
ios.SetStdinTTY(tt.tty)
ios.SetStderrTTY(tt.tty)
@ -564,12 +530,6 @@ func Test_developRun(t *testing.T) {
opts.BaseRepo = func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
}
opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
opts.Config = func() (config.Config, error) {
return config.NewBlankConfig(), nil
}
opts.Remotes = func() (context.Remotes, error) {
if len(tt.remotes) == 0 {
@ -600,29 +560,14 @@ func Test_developRun(t *testing.T) {
tt.runStubs(cmdStubs)
}
cleanSetup := func() {}
if tt.setup != nil {
cleanSetup = tt.setup(&opts, t)
}
defer cleanSetup()
var err error
if opts.List {
err = developRunList(&opts)
} else {
err = developRunCreate(&opts)
}
output := &test.CmdOut{
OutBuf: stdout,
ErrBuf: stderr,
}
err := developRun(opts)
if tt.wantErr != "" {
assert.EqualError(t, err, tt.wantErr)
return
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expectedOut, output.String())
assert.Equal(t, tt.expectedErrOut, output.Stderr())
assert.Equal(t, tt.expectedOut, stdout.String())
assert.Equal(t, tt.expectedErrOut, stderr.String())
}
})
}

View file

@ -21,10 +21,11 @@ type EditOptions struct {
HttpClient func() (*http.Client, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
Prompter prShared.EditPrompter
DetermineEditor func() (string, error)
FieldsToEditSurvey func(*prShared.Editable) error
EditFieldsSurvey func(*prShared.Editable, string) error
FieldsToEditSurvey func(prShared.EditPrompter, *prShared.Editable) error
EditFieldsSurvey func(prShared.EditPrompter, *prShared.Editable, string) error
FetchOptions func(*api.Client, ghrepo.Interface, *prShared.Editable) error
SelectorArgs []string
@ -41,6 +42,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
FieldsToEditSurvey: prShared.FieldsToEditSurvey,
EditFieldsSurvey: prShared.EditFieldsSurvey,
FetchOptions: prShared.FetchOptions,
Prompter: f.Prompter,
}
var bodyFile string
@ -152,7 +154,7 @@ func editRun(opts *EditOptions) error {
// Prompt the user which fields they'd like to edit.
editable := opts.Editable
if opts.Interactive {
err = opts.FieldsToEditSurvey(&editable)
err = opts.FieldsToEditSurvey(opts.Prompter, &editable)
if err != nil {
return err
}
@ -222,7 +224,7 @@ func editRun(opts *EditOptions) error {
if err != nil {
return err
}
err = opts.EditFieldsSurvey(&editable, editorCommand)
err = opts.EditFieldsSurvey(opts.Prompter, &editable, editorCommand)
if err != nil {
return err
}

View file

@ -511,7 +511,7 @@ func Test_editRun(t *testing.T) {
input: &EditOptions{
SelectorArgs: []string{"123"},
Interactive: true,
FieldsToEditSurvey: func(eo *prShared.Editable) error {
FieldsToEditSurvey: func(p prShared.EditPrompter, eo *prShared.Editable) error {
eo.Title.Edited = true
eo.Body.Edited = true
eo.Assignees.Edited = true
@ -520,7 +520,7 @@ func Test_editRun(t *testing.T) {
eo.Milestone.Edited = true
return nil
},
EditFieldsSurvey: func(eo *prShared.Editable, _ string) error {
EditFieldsSurvey: func(p prShared.EditPrompter, eo *prShared.Editable, _ string) error {
eo.Title.Value = "new title"
eo.Body.Value = "new body"
eo.Assignees.Value = []string{"monalisa", "hubot"}

View file

@ -102,8 +102,8 @@ func createRun(opts *createOptions) error {
}
if opts.Color == "" {
rand.Seed(time.Now().UnixNano())
opts.Color = randomColors[rand.Intn(len(randomColors)-1)]
r := rand.New(rand.NewSource(time.Now().UnixNano()))
opts.Color = randomColors[r.Intn(len(randomColors)-1)]
}
opts.IO.StartProgressIndicator()

View file

@ -39,6 +39,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
# List more organizations
$ gh org list --limit 100
`),
Aliases: []string{"ls"},
RunE: func(cmd *cobra.Command, args []string) error {
if opts.Limit < 1 {
return cmdutil.FlagErrorf("invalid limit: %v", opts.Limit)

View file

@ -9,6 +9,7 @@ import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/browser"
fd "github.com/cli/cli/v2/internal/featuredetection"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/pkg/cmd/pr/shared"
@ -24,7 +25,8 @@ type ChecksOptions struct {
IO *iostreams.IOStreams
Browser browser.Browser
Finder shared.PRFinder
Finder shared.PRFinder
Detector fd.Detector
SelectorArg string
WebMode bool
@ -142,8 +144,19 @@ func checksRun(opts *ChecksOptions) error {
var checks []check
var counts checkCounts
var err error
var includeEvent bool
checks, counts, err = populateStatusChecks(client, repo, pr, opts.Required)
if opts.Detector == nil {
cachedClient := api.NewCachedHTTPClient(client, time.Hour*24)
opts.Detector = fd.NewDetector(cachedClient, repo.RepoHost())
}
if features, featuresErr := opts.Detector.PullRequestFeatures(); featuresErr != nil {
return featuresErr
} else {
includeEvent = features.CheckRunEvent
}
checks, counts, err = populateStatusChecks(client, repo, pr, opts.Required, includeEvent)
if err != nil {
return err
}
@ -183,7 +196,7 @@ func checksRun(opts *ChecksOptions) error {
time.Sleep(opts.Interval)
checks, counts, err = populateStatusChecks(client, repo, pr, opts.Required)
checks, counts, err = populateStatusChecks(client, repo, pr, opts.Required, includeEvent)
if err != nil {
break
}
@ -203,14 +216,16 @@ func checksRun(opts *ChecksOptions) error {
}
}
if counts.Failed+counts.Pending > 0 {
if counts.Failed > 0 {
return cmdutil.SilentError
} else if counts.Pending > 0 {
return cmdutil.PendingError
}
return nil
}
func populateStatusChecks(client *http.Client, repo ghrepo.Interface, pr *api.PullRequest, requiredChecks bool) ([]check, checkCounts, error) {
func populateStatusChecks(client *http.Client, repo ghrepo.Interface, pr *api.PullRequest, requiredChecks bool, includeEvent bool) ([]check, checkCounts, error) {
apiClient := api.NewClientFromHTTP(client)
type response struct {
@ -224,7 +239,7 @@ func populateStatusChecks(client *http.Client, repo ghrepo.Interface, pr *api.Pu
%s
}
}
}`, api.RequiredStatusCheckRollupGraphQL("$id", "$endCursor"))
}`, api.RequiredStatusCheckRollupGraphQL("$id", "$endCursor", includeEvent))
variables := map[string]interface{}{
"id": pr.ID,

View file

@ -9,6 +9,7 @@ import (
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/browser"
fd "github.com/cli/cli/v2/internal/featuredetection"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/run"
"github.com/cli/cli/v2/pkg/cmd/pr/shared"
@ -123,14 +124,15 @@ func TestNewCmdChecks(t *testing.T) {
func Test_checksRun(t *testing.T) {
tests := []struct {
name string
tty bool
watch bool
failFast bool
required bool
httpStubs func(*httpmock.Registry)
wantOut string
wantErr string
name string
tty bool
watch bool
failFast bool
required bool
disableDetector bool
httpStubs func(*httpmock.Registry)
wantOut string
wantErr string
}{
{
name: "no commits tty",
@ -178,7 +180,7 @@ func Test_checksRun(t *testing.T) {
)
},
wantOut: "Some checks are still pending\n0 failing, 2 successful, 0 skipped, and 1 pending checks\n\n✓ cool tests 1m26s sweet link\n✓ rad tests 1m26s sweet link\n* slow tests 1m26s sweet link\n",
wantErr: "SilentError",
wantErr: "PendingError",
},
{
name: "all passing tty",
@ -273,7 +275,7 @@ func Test_checksRun(t *testing.T) {
)
},
wantOut: "cool tests\tpass\t1m26s\tsweet link\t\nrad tests\tpass\t1m26s\tsweet link\t\nslow tests\tpending\t1m26s\tsweet link\t\n",
wantErr: "SilentError",
wantErr: "PendingError",
},
{
name: "all passing",
@ -393,6 +395,54 @@ func Test_checksRun(t *testing.T) {
wantOut: "awesome tests\tpass\t1m26s\tsweet link\tawesome description\ncool tests\tpass\t1m26s\tsweet link\tcool description\nrad tests\tpass\t1m26s\tsweet link\trad description\n",
wantErr: "",
},
{
name: "events tty",
tty: true,
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query PullRequestStatusChecks\b`),
httpmock.FileResponse("./fixtures/withEvents.json"),
)
},
wantOut: "All checks were successful\n0 failing, 2 successful, 0 skipped, and 0 pending checks\n\n✓ tests/cool tests (pull_request) cool description 1m26s sweet link\n✓ tests/cool tests (push) cool description 1m26s sweet link\n",
wantErr: "",
},
{
name: "events not supported tty",
tty: true,
disableDetector: true,
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query PullRequestStatusChecks\b`),
httpmock.FileResponse("./fixtures/withoutEvents.json"),
)
},
wantOut: "All checks were successful\n0 failing, 1 successful, 0 skipped, and 0 pending checks\n\n✓ tests/cool tests cool description 1m26s sweet link\n",
wantErr: "",
},
{
name: "events",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query PullRequestStatusChecks\b`),
httpmock.FileResponse("./fixtures/withEvents.json"),
)
},
wantOut: "cool tests\tpass\t1m26s\tsweet link\tcool description\ncool tests\tpass\t1m26s\tsweet link\tcool description\n",
wantErr: "",
},
{
name: "events not supported",
disableDetector: true,
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query PullRequestStatusChecks\b`),
httpmock.FileResponse("./fixtures/withoutEvents.json"),
)
},
wantOut: "cool tests\tpass\t1m26s\tsweet link\tcool description\n",
wantErr: "",
},
}
for _, tt := range tests {
@ -407,7 +457,14 @@ func Test_checksRun(t *testing.T) {
tt.httpStubs(reg)
}
var detector fd.Detector
detector = &fd.EnabledDetectorMock{}
if tt.disableDetector {
detector = &fd.DisabledDetectorMock{}
}
response := &api.PullRequest{Number: 123, HeadRefName: "trunk"}
opts := &ChecksOptions{
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
@ -415,6 +472,7 @@ func Test_checksRun(t *testing.T) {
IO: ios,
SelectorArg: "123",
Finder: shared.NewMockFinder("123", response, ghrepo.New("OWNER", "REPO")),
Detector: detector,
Watch: tt.watch,
FailFast: tt.failFast,
Required: tt.required,

View file

@ -0,0 +1,55 @@
{
"data": {
"node": {
"statusCheckRollup": {
"nodes": [
{
"commit": {
"oid": "abc",
"statusCheckRollup": {
"contexts": {
"nodes": [
{
"conclusion": "SUCCESS",
"status": "COMPLETED",
"name": "cool tests",
"description": "cool description",
"completedAt": "2020-08-27T19:00:12Z",
"startedAt": "2020-08-27T18:58:46Z",
"detailsUrl": "sweet link",
"checkSuite": {
"workflowRun": {
"event": "pull_request",
"workflow": {
"name": "tests"
}
}
}
},
{
"conclusion": "SUCCESS",
"status": "COMPLETED",
"name": "cool tests",
"description": "cool description",
"completedAt": "2020-08-27T19:00:12Z",
"startedAt": "2020-08-27T18:58:46Z",
"detailsUrl": "sweet link",
"checkSuite": {
"workflowRun": {
"event": "push",
"workflow": {
"name": "tests"
}
}
}
}
]
}
}
}
}
]
}
}
}
}

View file

@ -0,0 +1,53 @@
{
"data": {
"node": {
"statusCheckRollup": {
"nodes": [
{
"commit": {
"oid": "abc",
"statusCheckRollup": {
"contexts": {
"nodes": [
{
"conclusion": "SUCCESS",
"status": "COMPLETED",
"name": "cool tests",
"description": "cool description",
"completedAt": "2020-08-27T19:00:12Z",
"startedAt": "2020-08-27T18:58:46Z",
"detailsUrl": "sweet link",
"checkSuite": {
"workflowRun": {
"workflow": {
"name": "tests"
}
}
}
},
{
"conclusion": "SUCCESS",
"status": "COMPLETED",
"name": "cool tests",
"description": "cool description",
"completedAt": "2020-08-27T19:00:12Z",
"startedAt": "2020-08-27T18:58:46Z",
"detailsUrl": "sweet link",
"checkSuite": {
"workflowRun": {
"workflow": {
"name": "tests"
}
}
}
}
]
}
}
}
}
]
}
}
}
}

View file

@ -45,6 +45,7 @@ type CreateOptions struct {
RepoOverride string
Autofill bool
FillFirst bool
WebMode bool
RecoverFile string
@ -144,13 +145,19 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
}
if opts.IsDraft && opts.WebMode {
return errors.New("the `--draft` flag is not supported with `--web`")
return cmdutil.FlagErrorf("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 cmdutil.FlagErrorf("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 cmdutil.FlagErrorf("the `--no-maintainer-edit` flag is not supported with `--web`")
}
if opts.Autofill && opts.FillFirst {
return cmdutil.FlagErrorf("`--fill` is not supported with `--fill-first`")
}
opts.BodyProvided = cmd.Flags().Changed("body")
@ -164,11 +171,11 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
}
if opts.Template != "" && opts.BodyProvided {
return errors.New("`--template` is not supported when using `--body` or `--body-file`")
return cmdutil.FlagErrorf("`--template` is not supported when using `--body` or `--body-file`")
}
if !opts.IO.CanPrompt() && !opts.WebMode && !opts.Autofill && (!opts.TitleProvided || !opts.BodyProvided) {
return cmdutil.FlagErrorf("must provide `--title` and `--body` (or `--fill`) when not running interactively")
if !opts.IO.CanPrompt() && !opts.WebMode && !(opts.Autofill || opts.FillFirst) && (!opts.TitleProvided || !opts.BodyProvided) {
return cmdutil.FlagErrorf("must provide `--title` and `--body` (or `--fill` or `fill-first`) when not running interactively")
}
if runF != nil {
@ -187,6 +194,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
fl.StringVarP(&opts.HeadBranch, "head", "H", "", "The `branch` that contains commits for your pull request (default: current branch)")
fl.BoolVarP(&opts.WebMode, "web", "w", false, "Open the web browser to create a pull request")
fl.BoolVarP(&opts.Autofill, "fill", "f", false, "Do not prompt for title/body and just use commit info")
fl.BoolVar(&opts.FillFirst, "fill-first", false, "Do not prompt for title/body and just use first commit info")
fl.StringSliceVarP(&opts.Reviewers, "reviewer", "r", nil, "Request reviews from people or teams by their `handle`")
fl.StringSliceVarP(&opts.Assignees, "assignee", "a", nil, "Assign people by their `login`. Use \"@me\" to self-assign.")
fl.StringSliceVarP(&opts.Labels, "label", "l", nil, "Add labels by `name`")
@ -225,7 +233,7 @@ func createRun(opts *CreateOptions) (err error) {
var openURL string
if opts.WebMode {
if !opts.Autofill {
if !(opts.Autofill || opts.FillFirst) {
state.Title = opts.Title
state.Body = opts.Body
}
@ -281,7 +289,7 @@ func createRun(opts *CreateOptions) (err error) {
ghrepo.FullName(ctx.BaseRepo))
}
if opts.Autofill || (opts.TitleProvided && opts.BodyProvided) {
if opts.Autofill || opts.FillFirst || (opts.TitleProvided && opts.BodyProvided) {
err = handlePush(*opts, *ctx)
if err != nil {
return
@ -353,7 +361,7 @@ func createRun(opts *CreateOptions) (err error) {
Repo: ctx.BaseRepo,
State: state,
}
err = shared.MetadataSurvey(opts.IO, ctx.BaseRepo, fetcher, state)
err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.BaseRepo, fetcher, state)
if err != nil {
return
}
@ -392,7 +400,7 @@ func createRun(opts *CreateOptions) (err error) {
return
}
func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState) error {
func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState, useFirstCommit bool) error {
baseRef := ctx.BaseTrackingBranch
headRef := ctx.HeadBranch
gitClient := ctx.GitClient
@ -401,17 +409,16 @@ func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState) e
if err != nil {
return err
}
if len(commits) == 1 {
state.Title = commits[0].Title
body, err := gitClient.CommitBody(context.Background(), commits[0].Sha)
if len(commits) == 1 || useFirstCommit {
commitIndex := len(commits) - 1
state.Title = commits[commitIndex].Title
body, err := gitClient.CommitBody(context.Background(), commits[commitIndex].Sha)
if err != nil {
return err
}
state.Body = body
} else {
state.Title = humanize(headRef)
var body strings.Builder
for i := len(commits) - 1; i >= 0; i-- {
fmt.Fprintf(&body, "- %s\n", commits[i].Title)
@ -485,9 +492,9 @@ func NewIssueState(ctx CreateContext, opts CreateOptions) (*shared.IssueMetadata
Draft: opts.IsDraft,
}
if opts.Autofill || !opts.TitleProvided || !opts.BodyProvided {
err := initDefaultTitleBody(ctx, state)
if err != nil && opts.Autofill {
if opts.Autofill || opts.FillFirst || !opts.TitleProvided || !opts.BodyProvided {
err := initDefaultTitleBody(ctx, state, opts.FillFirst)
if err != nil && (opts.Autofill || opts.FillFirst) {
return nil, fmt.Errorf("could not compute title or body defaults: %w", err)
}
}

View file

@ -168,6 +168,32 @@ func TestNewCmdCreate(t *testing.T) {
cli: "-t mytitle --template bug_fix.md --body-file body_file.md",
wantsErr: true,
},
{
name: "fill-first",
tty: false,
cli: "--fill-first",
wantsErr: false,
wantsOpts: CreateOptions{
Title: "",
TitleProvided: false,
Body: "",
BodyProvided: false,
Autofill: false,
FillFirst: true,
RecoverFile: "",
WebMode: false,
IsDraft: false,
BaseBranch: "",
HeadBranch: "",
MaintainerCanModify: true,
},
},
{
name: "fill and fill-first",
tty: false,
cli: "--fill --fill-first",
wantsErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -210,6 +236,7 @@ func TestNewCmdCreate(t *testing.T) {
assert.Equal(t, tt.wantsOpts.Title, opts.Title)
assert.Equal(t, tt.wantsOpts.TitleProvided, opts.TitleProvided)
assert.Equal(t, tt.wantsOpts.Autofill, opts.Autofill)
assert.Equal(t, tt.wantsOpts.FillFirst, opts.FillFirst)
assert.Equal(t, tt.wantsOpts.WebMode, opts.WebMode)
assert.Equal(t, tt.wantsOpts.RecoverFile, opts.RecoverFile)
assert.Equal(t, tt.wantsOpts.IsDraft, opts.IsDraft)
@ -969,6 +996,47 @@ func Test_createRun(t *testing.T) {
},
expectedOut: "https://github.com/OWNER/REPO/pull/12\n",
},
{
name: "fill-first flag provided",
tty: true,
setup: func(opts *CreateOptions, t *testing.T) func() {
opts.FillFirst = true
opts.HeadBranch = "feature"
return func() {}
},
cmdStubs: func(cs *run.CommandStubber) {
cs.Register(
"git -c log.ShowSignature=false log --pretty=format:%H,%s --cherry origin/master...feature",
0,
"56b6f8bb7c9e3a30093cd17e48934ce354148e80,second commit of pr\n"+
"343jdfe47c9e3a30093cd17e48934ce354148e80,first commit of pr",
)
cs.Register(
"git -c log.ShowSignature=false show -s --pretty=format:%b 343jdfe47c9e3a30093cd17e48934ce354148e80",
0,
"first commit description",
)
},
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
reg.Register(
httpmock.GraphQL(`mutation PullRequestCreate\b`),
httpmock.GraphQLMutation(`
{
"data": { "createPullRequest": { "pullRequest": {
"URL": "https://github.com/OWNER/REPO/pull/12"
} } }
}
`,
func(input map[string]interface{}) {
assert.Equal(t, "first commit of pr", input["title"], "pr title should be first commit message")
assert.Equal(t, "first commit description", input["body"], "pr body should be first commit description")
},
),
)
},
expectedOut: "https://github.com/OWNER/REPO/pull/12\n",
expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -24,6 +24,7 @@ type EditOptions struct {
Surveyor Surveyor
Fetcher EditableOptionsFetcher
EditorRetriever EditorRetriever
Prompter shared.EditPrompter
SelectorArg string
Interactive bool
@ -35,9 +36,10 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
opts := &EditOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
Surveyor: surveyor{},
Surveyor: surveyor{P: f.Prompter},
Fetcher: fetcher{},
EditorRetriever: editorRetriever{config: f.Config},
Prompter: f.Prompter,
}
var bodyFile string
@ -260,6 +262,10 @@ func updatePullRequestReviews(httpClient *http.Client, repo ghrepo.Interface, id
if err != nil {
return err
}
if (userIds == nil || len(*userIds) == 0) &&
(teamIds == nil || len(*teamIds) == 0) {
return nil
}
union := githubv4.Boolean(false)
reviewsRequestParams := githubv4.RequestReviewsInput{
PullRequestID: id,
@ -276,14 +282,16 @@ type Surveyor interface {
EditFields(*shared.Editable, string) error
}
type surveyor struct{}
type surveyor struct {
P shared.EditPrompter
}
func (s surveyor) FieldsToEdit(editable *shared.Editable) error {
return shared.FieldsToEditSurvey(editable)
return shared.FieldsToEditSurvey(s.P, editable)
}
func (s surveyor) EditFields(editable *shared.Editable, editorCmd string) error {
return shared.EditFieldsSurvey(editable, editorCmd)
return shared.EditFieldsSurvey(s.P, editable, editorCmd)
}
type EditableOptionsFetcher interface {

View file

@ -199,7 +199,6 @@ type mergeContext struct {
autoMerge bool
crossRepoPR bool
deleteBranch bool
switchedToBranch string
mergeQueueRequired bool
}
@ -370,7 +369,7 @@ func (m *mergeContext) merge() error {
// Delete local branch if requested and if allowed.
func (m *mergeContext) deleteLocalBranch() error {
if m.crossRepoPR || m.autoMerge {
if m.autoMerge {
return nil
}
@ -395,6 +394,8 @@ func (m *mergeContext) deleteLocalBranch() error {
return err
}
switchedToBranch := ""
ctx := context.Background()
// branch the command was run on is the same as the pull request branch
@ -424,14 +425,19 @@ func (m *mergeContext) deleteLocalBranch() error {
_ = m.warnf(fmt.Sprintf("%s warning: not possible to fast-forward to: %q\n", m.cs.WarningIcon(), targetBranch))
}
m.switchedToBranch = targetBranch
switchedToBranch = targetBranch
}
if err := m.opts.GitClient.DeleteLocalBranch(ctx, m.pr.HeadRefName); err != nil {
return fmt.Errorf("failed to delete local branch %s: %w", m.cs.Cyan(m.pr.HeadRefName), err)
}
return nil
switchedStatement := ""
if switchedToBranch != "" {
switchedStatement = fmt.Sprintf(" and switched to branch %s", m.cs.Cyan(switchedToBranch))
}
return m.infof("%s Deleted local branch %s%s\n", m.cs.SuccessIconWithColor(m.cs.Red), m.cs.Cyan(m.pr.HeadRefName), switchedStatement)
}
// Delete the remote branch if requested and if allowed.
@ -451,11 +457,7 @@ func (m *mergeContext) deleteRemoteBranch() error {
}
}
branch := ""
if m.switchedToBranch != "" {
branch = fmt.Sprintf(" and switched to branch %s", m.cs.Cyan(m.switchedToBranch))
}
return m.infof("%s Deleted branch %s%s\n", m.cs.SuccessIconWithColor(m.cs.Red), m.cs.Cyan(m.pr.HeadRefName), branch)
return m.infof("%s Deleted remote branch %s\n", m.cs.SuccessIconWithColor(m.cs.Red), m.cs.Cyan(m.pr.HeadRefName))
}
// Add the Pull Request to a merge queue
@ -577,11 +579,16 @@ func mergeMethodSurvey(p shared.Prompt, baseRepo *api.Repository) (PullRequestMe
}
func deleteBranchSurvey(opts *MergeOptions, crossRepoPR, localBranchExists bool) (bool, error) {
if !crossRepoPR && !opts.IsDeleteBranchIndicated {
if !opts.IsDeleteBranchIndicated {
var message string
if opts.CanDeleteLocalBranch && localBranchExists {
message = "Delete the branch locally and on GitHub?"
} else {
if crossRepoPR {
message = "Delete the branch locally?"
} else {
message = "Delete the branch locally and on GitHub?"
}
} else if !crossRepoPR {
message = "Delete the branch on GitHub?"
}

View file

@ -654,7 +654,8 @@ func TestPrMerge_deleteBranch(t *testing.T) {
assert.Equal(t, "", output.String())
assert.Equal(t, heredoc.Doc(`
Merged pull request #10 (Blueberries are a good fruit)
Deleted branch blueberries and switched to branch main
Deleted local branch blueberries and switched to branch main
Deleted remote branch blueberries
`), output.Stderr())
}
@ -704,7 +705,56 @@ func TestPrMerge_deleteBranch_nonDefault(t *testing.T) {
assert.Equal(t, "", output.String())
assert.Equal(t, heredoc.Doc(`
Merged pull request #10 (Blueberries are a good fruit)
Deleted branch blueberries and switched to branch fruit
Deleted local branch blueberries and switched to branch fruit
Deleted remote branch blueberries
`), output.Stderr())
}
func TestPrMerge_deleteBranch_onlyLocally(t *testing.T) {
http := initFakeHTTP()
defer http.Verify(t)
shared.RunCommandFinder(
"",
&api.PullRequest{
ID: "PR_10",
Number: 10,
State: "OPEN",
Title: "Blueberries are a good fruit",
HeadRefName: "blueberries",
BaseRefName: "main",
MergeStateStatus: "CLEAN",
HeadRepositoryOwner: api.Owner{Login: "HEAD"}, // Not the same owner as the base repo
},
baseRepo("OWNER", "REPO", "main"),
)
http.Register(
httpmock.GraphQL(`mutation PullRequestMerge\b`),
httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) {
assert.Equal(t, "PR_10", input["pullRequestId"].(string))
assert.Equal(t, "MERGE", input["mergeMethod"].(string))
assert.NotContains(t, input, "commitHeadline")
}))
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git rev-parse --verify refs/heads/main`, 0, "")
cs.Register(`git checkout main`, 0, "")
cs.Register(`git rev-parse --verify refs/heads/blueberries`, 0, "")
cs.Register(`git branch -D blueberries`, 0, "")
cs.Register(`git pull --ff-only`, 0, "")
output, err := runCommand(http, nil, "blueberries", true, `pr merge --merge --delete-branch`)
if err != nil {
t.Fatalf("Got unexpected error running `pr merge` %s", err)
}
assert.Equal(t, "", output.String())
assert.Equal(t, heredoc.Doc(`
Merged pull request #10 (Blueberries are a good fruit)
Deleted local branch blueberries and switched to branch main
`), output.Stderr())
}
@ -754,7 +804,8 @@ func TestPrMerge_deleteBranch_checkoutNewBranch(t *testing.T) {
assert.Equal(t, "", output.String())
assert.Equal(t, heredoc.Doc(`
Merged pull request #10 (Blueberries are a good fruit)
Deleted branch blueberries and switched to branch fruit
Deleted local branch blueberries and switched to branch fruit
Deleted remote branch blueberries
`), output.Stderr())
}
@ -800,7 +851,8 @@ func TestPrMerge_deleteNonCurrentBranch(t *testing.T) {
assert.Equal(t, "", output.String())
assert.Equal(t, heredoc.Doc(`
Merged pull request #10 (Blueberries are a good fruit)
Deleted branch blueberries
Deleted local branch blueberries
Deleted remote branch blueberries
`), output.Stderr())
}
@ -1044,7 +1096,10 @@ func TestPrMerge_alreadyMerged(t *testing.T) {
output, err := runCommand(http, pm, "blueberries", true, "pr merge 4")
assert.NoError(t, err)
assert.Equal(t, "", output.String())
assert.Equal(t, "✓ Deleted branch blueberries and switched to branch main\n", output.Stderr())
assert.Equal(t, heredoc.Doc(`
Deleted local branch blueberries and switched to branch main
Deleted remote branch blueberries
`), output.Stderr())
}
func TestPrMerge_alreadyMerged_withMergeStrategy(t *testing.T) {
@ -1115,7 +1170,7 @@ func TestPrMerge_alreadyMerged_withMergeStrategy_TTY(t *testing.T) {
}
assert.Equal(t, "", output.String())
assert.Equal(t, "✓ Deleted branch \n", output.Stderr())
assert.Equal(t, "✓ Deleted local branch \n✓ Deleted remote branch \n", output.Stderr())
}
func TestPrMerge_alreadyMerged_withMergeStrategy_crossRepo(t *testing.T) {
@ -1139,7 +1194,17 @@ func TestPrMerge_alreadyMerged_withMergeStrategy_crossRepo(t *testing.T) {
cs.Register(`git rev-parse --verify refs/heads/`, 0, "")
output, err := runCommand(http, nil, "blueberries", true, "pr merge 4 --merge")
pm := &prompter.PrompterMock{
ConfirmFunc: func(p string, d bool) (bool, error) {
if p == "Pull request #4 was already merged. Delete the branch locally?" {
return d, nil
} else {
return false, prompter.NoSuchPromptErr(p)
}
},
}
output, err := runCommand(http, pm, "blueberries", true, "pr merge 4 --merge")
if err != nil {
t.Fatalf("Got unexpected error running `pr merge` %s", err)
}
@ -1282,7 +1347,8 @@ func TestPRMergeTTY_withDeleteBranch(t *testing.T) {
assert.Equal(t, "", output.String())
assert.Equal(t, heredoc.Doc(`
Merged pull request #3 (It was the best of times)
Deleted branch blueberries and switched to branch main
Deleted local branch blueberries and switched to branch main
Deleted remote branch blueberries
`), output.Stderr())
}

View file

@ -4,12 +4,9 @@ import (
"fmt"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/prompter"
"github.com/cli/cli/v2/pkg/set"
"github.com/cli/cli/v2/pkg/surveyext"
)
type Editable struct {
@ -255,34 +252,45 @@ func (ep *EditableProjects) clone() EditableProjects {
}
}
func EditFieldsSurvey(editable *Editable, editorCommand string) error {
type EditPrompter interface {
Select(string, string, []string) (int, error)
Input(string, string) (string, error)
MarkdownEditor(string, string, bool) (string, error)
MultiSelect(string, []string, []string) ([]int, error)
Confirm(string, bool) (bool, error)
}
func EditFieldsSurvey(p EditPrompter, editable *Editable, editorCommand string) error {
var err error
if editable.Title.Edited {
editable.Title.Value, err = titleSurvey(editable.Title.Default)
editable.Title.Value, err = p.Input("Title", editable.Title.Default)
if err != nil {
return err
}
}
if editable.Body.Edited {
editable.Body.Value, err = bodySurvey(editable.Body.Default, editorCommand)
editable.Body.Value, err = p.MarkdownEditor("Body", editable.Body.Default, false)
if err != nil {
return err
}
}
if editable.Reviewers.Edited {
editable.Reviewers.Value, err = multiSelectSurvey("Reviewers", editable.Reviewers.Default, editable.Reviewers.Options)
editable.Reviewers.Value, err = multiSelectSurvey(
p, "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)
editable.Assignees.Value, err = multiSelectSurvey(
p, "Assignees", editable.Assignees.Default, editable.Assignees.Options)
if err != nil {
return err
}
}
if editable.Labels.Edited {
editable.Labels.Add, err = multiSelectSurvey("Labels", editable.Labels.Default, editable.Labels.Options)
editable.Labels.Add, err = multiSelectSurvey(
p, "Labels", editable.Labels.Default, editable.Labels.Options)
if err != nil {
return err
}
@ -300,18 +308,19 @@ func EditFieldsSurvey(editable *Editable, editorCommand string) error {
}
}
if editable.Projects.Edited {
editable.Projects.Value, err = multiSelectSurvey("Projects", editable.Projects.Default, editable.Projects.Options)
editable.Projects.Value, err = multiSelectSurvey(
p, "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)
editable.Milestone.Value, err = milestoneSurvey(p, editable.Milestone.Default, editable.Milestone.Options)
if err != nil {
return err
}
}
confirm, err := confirmSurvey()
confirm, err := p.Confirm("Submit?", true)
if err != nil {
return err
}
@ -322,7 +331,7 @@ func EditFieldsSurvey(editable *Editable, editorCommand string) error {
return nil
}
func FieldsToEditSurvey(editable *Editable) error {
func FieldsToEditSurvey(p EditPrompter, editable *Editable) error {
contains := func(s []string, str string) bool {
for _, v := range s {
if v == str {
@ -337,7 +346,7 @@ func FieldsToEditSurvey(editable *Editable) error {
opts = append(opts, "Reviewers")
}
opts = append(opts, "Assignees", "Labels", "Projects", "Milestone")
results, err := multiSelectSurvey("What would you like to edit?", []string{}, opts)
results, err := multiSelectSurvey(p, "What would you like to edit?", []string{}, opts)
if err != nil {
return err
}
@ -414,67 +423,34 @@ func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable)
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) {
func multiSelectSurvey(p EditPrompter, message string, defaults, options []string) (results []string, err error) {
if len(options) == 0 {
return nil, nil
}
var results []string
q := &survey.MultiSelect{
Message: message,
Options: options,
Default: defaults,
Filter: prompter.LatinMatchingFilter,
var selected []int
selected, err = p.MultiSelect(message, defaults, options)
if err != nil {
return
}
err := survey.AskOne(q, &results)
for _, i := range selected {
results = append(results, options[i])
}
return results, err
}
func milestoneSurvey(title string, opts []string) (string, error) {
func milestoneSurvey(p EditPrompter, title string, opts []string) (result string, err error) {
if len(opts) == 0 {
return "", nil
}
var result string
q := &survey.Select{
Message: "Milestone",
Options: opts,
Default: title,
var selected int
selected, err = p.Select("Milestone", title, opts)
if err != nil {
return
}
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
result = opts[selected]
return
}

View file

@ -4,12 +4,9 @@ import (
"fmt"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/prompter"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/pkg/prompt"
)
type Action int
@ -37,6 +34,7 @@ type Prompt interface {
Select(string, string, []string) (int, error)
MarkdownEditor(string, string, bool) (string, error)
Confirm(string, bool) (bool, error)
MultiSelect(string, []string, []string) ([]int, error)
}
func ConfirmIssueSubmission(p Prompt, allowPreview bool, allowMetadata bool) (Action, error) {
@ -142,7 +140,7 @@ type RepoMetadataFetcher interface {
RepoMetadataFetch(api.RepoMetadataInput) (*api.RepoMetadataResult, error)
}
func MetadataSurvey(io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher RepoMetadataFetcher, state *IssueMetadataState) error {
func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher RepoMetadataFetcher, state *IssueMetadataState) error {
isChosen := func(m string) bool {
for _, c := range state.Metadata {
if m == c {
@ -160,18 +158,12 @@ func MetadataSurvey(io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher
}
extraFieldsOptions = append(extraFieldsOptions, "Assignees", "Labels", "Projects", "Milestone")
//nolint:staticcheck // SA1019: prompt.SurveyAsk is deprecated: use Prompter
err := prompt.SurveyAsk([]*survey.Question{
{
Name: "metadata",
Prompt: &survey.MultiSelect{
Message: "What would you like to add?",
Options: extraFieldsOptions,
},
},
}, state)
selected, err := p.MultiSelect("What would you like to add?", nil, extraFieldsOptions)
if err != nil {
return fmt.Errorf("could not prompt: %w", err)
return err
}
for _, i := range selected {
state.Metadata = append(state.Metadata, extraFieldsOptions[i])
}
metadataInput := api.RepoMetadataInput{
@ -215,63 +207,62 @@ func MetadataSurvey(io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher
milestones = append(milestones, m.Title)
}
var mqs []*survey.Question
values := struct {
Reviewers []string
Assignees []string
Labels []string
Projects []string
Milestone string
}{}
if isChosen("Reviewers") {
if len(reviewers) > 0 {
mqs = append(mqs, &survey.Question{
Name: "reviewers",
Prompt: &survey.MultiSelect{
Message: "Reviewers",
Options: reviewers,
Default: state.Reviewers,
Filter: prompter.LatinMatchingFilter,
},
})
selected, err := p.MultiSelect("Reviewers", state.Reviewers, reviewers)
if err != nil {
return err
}
for _, i := range selected {
values.Reviewers = append(values.Reviewers, reviewers[i])
}
} else {
fmt.Fprintln(io.ErrOut, "warning: no available reviewers")
}
}
if isChosen("Assignees") {
if len(assignees) > 0 {
mqs = append(mqs, &survey.Question{
Name: "assignees",
Prompt: &survey.MultiSelect{
Message: "Assignees",
Options: assignees,
Default: state.Assignees,
Filter: prompter.LatinMatchingFilter,
},
})
selected, err := p.MultiSelect("Assignees", state.Assignees, assignees)
if err != nil {
return err
}
for _, i := range selected {
values.Assignees = append(values.Assignees, assignees[i])
}
} else {
fmt.Fprintln(io.ErrOut, "warning: no assignable users")
}
}
if isChosen("Labels") {
if len(labels) > 0 {
mqs = append(mqs, &survey.Question{
Name: "labels",
Prompt: &survey.MultiSelect{
Message: "Labels",
Options: labels,
Default: state.Labels,
Filter: prompter.LatinMatchingFilter,
},
})
selected, err := p.MultiSelect("Labels", state.Labels, labels)
if err != nil {
return err
}
for _, i := range selected {
values.Labels = append(values.Labels, labels[i])
}
} else {
fmt.Fprintln(io.ErrOut, "warning: no labels in the repository")
}
}
if isChosen("Projects") {
if len(projects) > 0 {
mqs = append(mqs, &survey.Question{
Name: "projects",
Prompt: &survey.MultiSelect{
Message: "Projects",
Options: projects,
Default: state.Projects,
Filter: prompter.LatinMatchingFilter,
},
})
selected, err := p.MultiSelect("Projects", state.Projects, projects)
if err != nil {
return err
}
for _, i := range selected {
values.Projects = append(values.Projects, projects[i])
}
} else {
fmt.Fprintln(io.ErrOut, "warning: no projects to choose from")
}
@ -284,33 +275,16 @@ func MetadataSurvey(io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher
} else {
milestoneDefault = milestones[1]
}
mqs = append(mqs, &survey.Question{
Name: "milestone",
Prompt: &survey.Select{
Message: "Milestone",
Options: milestones,
Default: milestoneDefault,
},
})
selected, err := p.Select("Milestone", milestoneDefault, milestones)
if err != nil {
return err
}
values.Milestone = milestones[selected]
} else {
fmt.Fprintln(io.ErrOut, "warning: no milestones in the repository")
}
}
values := struct {
Reviewers []string
Assignees []string
Labels []string
Projects []string
Milestone string
}{}
//nolint:staticcheck // SA1019: prompt.SurveyAsk is deprecated: use Prompter
err = prompt.SurveyAsk(mqs, &values)
if err != nil {
return fmt.Errorf("could not prompt: %w", err)
}
if isChosen("Reviewers") {
var logins []string
for _, r := range values.Reviewers {

View file

@ -5,8 +5,8 @@ import (
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/prompter"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/pkg/prompt"
"github.com/stretchr/testify/assert"
)
@ -43,45 +43,32 @@ func TestMetadataSurvey_selectAll(t *testing.T) {
},
}
//nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber
as, restoreAsk := prompt.InitAskStubber()
defer restoreAsk()
//nolint:staticcheck // SA1019: as.Stub is deprecated: use StubPrompt
as.Stub([]*prompt.QuestionStub{
{
Name: "metadata",
Value: []string{"Labels", "Projects", "Assignees", "Reviewers", "Milestone"},
},
pm := prompter.NewMockPrompter(t)
pm.RegisterMultiSelect("What would you like to add?",
[]string{}, []string{"Reviewers", "Assignees", "Labels", "Projects", "Milestone"}, func(_ string, _, _ []string) ([]int, error) {
return []int{0, 1, 2, 3, 4}, nil
})
pm.RegisterMultiSelect("Reviewers", []string{}, []string{"hubot", "monalisa"}, func(_ string, _, _ []string) ([]int, error) {
return []int{1}, nil
})
//nolint:staticcheck // SA1019: as.Stub is deprecated: use StubPrompt
as.Stub([]*prompt.QuestionStub{
{
Name: "reviewers",
Value: []string{"monalisa"},
},
{
Name: "assignees",
Value: []string{"hubot"},
},
{
Name: "labels",
Value: []string{"good first issue"},
},
{
Name: "projects",
Value: []string{"The road to 1.0"},
},
{
Name: "milestone",
Value: "(none)",
},
pm.RegisterMultiSelect("Assignees", []string{}, []string{"hubot", "monalisa"}, func(_ string, _, _ []string) ([]int, error) {
return []int{0}, nil
})
pm.RegisterMultiSelect("Labels", []string{}, []string{"help wanted", "good first issue"}, func(_ string, _, _ []string) ([]int, error) {
return []int{1}, nil
})
pm.RegisterMultiSelect("Projects", []string{}, []string{"Huge Refactoring", "The road to 1.0"}, func(_ string, _, _ []string) ([]int, error) {
return []int{1}, nil
})
pm.RegisterSelect("Milestone", []string{"(none)", "1.2 patch release"}, func(_, _ string, _ []string) (int, error) {
return 0, nil
})
state := &IssueMetadataState{
Assignees: []string{"hubot"},
Type: PRMetadata,
}
err := MetadataSurvey(ios, repo, fetcher, state)
err := MetadataSurvey(pm, ios, repo, fetcher, state)
assert.NoError(t, err)
assert.Equal(t, "", stdout.String())
@ -112,33 +99,21 @@ func TestMetadataSurvey_keepExisting(t *testing.T) {
},
}
//nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber
as, restoreAsk := prompt.InitAskStubber()
defer restoreAsk()
//nolint:staticcheck // SA1019: as.Stub is deprecated: use StubPrompt
as.Stub([]*prompt.QuestionStub{
{
Name: "metadata",
Value: []string{"Labels", "Projects"},
},
pm := prompter.NewMockPrompter(t)
pm.RegisterMultiSelect("What would you like to add?", []string{}, []string{"Assignees", "Labels", "Projects", "Milestone"}, func(_ string, _, _ []string) ([]int, error) {
return []int{1, 2}, nil
})
//nolint:staticcheck // SA1019: as.Stub is deprecated: use StubPrompt
as.Stub([]*prompt.QuestionStub{
{
Name: "labels",
Value: []string{"good first issue"},
},
{
Name: "projects",
Value: []string{"The road to 1.0"},
},
pm.RegisterMultiSelect("Labels", []string{}, []string{"help wanted", "good first issue"}, func(_ string, _, _ []string) ([]int, error) {
return []int{1}, nil
})
pm.RegisterMultiSelect("Projects", []string{}, []string{"Huge Refactoring", "The road to 1.0"}, func(_ string, _, _ []string) ([]int, error) {
return []int{1}, nil
})
state := &IssueMetadataState{
Assignees: []string{"hubot"},
}
err := MetadataSurvey(ios, repo, fetcher, state)
err := MetadataSurvey(pm, ios, repo, fetcher, state)
assert.NoError(t, err)
assert.Equal(t, "", stdout.String())

View file

@ -1,7 +1,6 @@
package close
import (
"os"
"testing"
"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
@ -56,8 +55,7 @@ func TestNewCmdClose(t *testing.T) {
},
}
os.Setenv("GH_TOKEN", "auth-token")
defer os.Unsetenv("GH_TOKEN")
t.Setenv("GH_TOKEN", "auth-token")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -1,7 +1,6 @@
package copy
import (
"os"
"testing"
"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
@ -75,8 +74,7 @@ func TestNewCmdCopy(t *testing.T) {
},
}
os.Setenv("GH_TOKEN", "auth-token")
defer os.Unsetenv("GH_TOKEN")
t.Setenv("GH_TOKEN", "auth-token")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -1,7 +1,6 @@
package create
import (
"os"
"testing"
"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
@ -45,8 +44,7 @@ func TestNewCmdCreate(t *testing.T) {
},
}
os.Setenv("GH_TOKEN", "auth-token")
defer os.Unsetenv("GH_TOKEN")
t.Setenv("GH_TOKEN", "auth-token")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -1,7 +1,6 @@
package delete
import (
"os"
"testing"
"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
@ -49,8 +48,7 @@ func TestNewCmdDelete(t *testing.T) {
},
}
os.Setenv("GH_TOKEN", "auth-token")
defer os.Unsetenv("GH_TOKEN")
t.Setenv("GH_TOKEN", "auth-token")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -1,7 +1,6 @@
package edit
import (
"os"
"testing"
"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
@ -92,8 +91,7 @@ func TestNewCmdEdit(t *testing.T) {
},
}
os.Setenv("GH_TOKEN", "auth-token")
defer os.Unsetenv("GH_TOKEN")
t.Setenv("GH_TOKEN", "auth-token")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -1,7 +1,6 @@
package fieldcreate
import (
"os"
"testing"
"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
@ -79,8 +78,7 @@ func TestNewCmdCreateField(t *testing.T) {
},
}
os.Setenv("GH_TOKEN", "auth-token")
defer os.Unsetenv("GH_TOKEN")
t.Setenv("GH_TOKEN", "auth-token")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -1,7 +1,6 @@
package fielddelete
import (
"os"
"testing"
"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
@ -43,8 +42,7 @@ func TestNewCmdDeleteField(t *testing.T) {
},
}
os.Setenv("GH_TOKEN", "auth-token")
defer os.Unsetenv("GH_TOKEN")
t.Setenv("GH_TOKEN", "auth-token")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -1,7 +1,6 @@
package fieldlist
import (
"os"
"testing"
"github.com/cli/cli/v2/internal/tableprinter"
@ -53,8 +52,7 @@ func TestNewCmdList(t *testing.T) {
},
}
os.Setenv("GH_TOKEN", "auth-token")
defer os.Unsetenv("GH_TOKEN")
t.Setenv("GH_TOKEN", "auth-token")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -1,7 +1,6 @@
package itemadd
import (
"os"
"testing"
"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
@ -65,8 +64,7 @@ func TestNewCmdaddItem(t *testing.T) {
},
}
os.Setenv("GH_TOKEN", "auth-token")
defer os.Unsetenv("GH_TOKEN")
t.Setenv("GH_TOKEN", "auth-token")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -1,7 +1,6 @@
package itemarchive
import (
"os"
"testing"
"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
@ -73,8 +72,7 @@ func TestNewCmdarchiveItem(t *testing.T) {
},
}
os.Setenv("GH_TOKEN", "auth-token")
defer os.Unsetenv("GH_TOKEN")
t.Setenv("GH_TOKEN", "auth-token")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -1,7 +1,6 @@
package itemcreate
import (
"os"
"testing"
"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
@ -73,8 +72,7 @@ func TestNewCmdCreateItem(t *testing.T) {
},
}
os.Setenv("GH_TOKEN", "auth-token")
defer os.Unsetenv("GH_TOKEN")
t.Setenv("GH_TOKEN", "auth-token")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -1,7 +1,6 @@
package itemdelete
import (
"os"
"testing"
"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
@ -65,8 +64,7 @@ func TestNewCmdDeleteItem(t *testing.T) {
},
}
os.Setenv("GH_TOKEN", "auth-token")
defer os.Unsetenv("GH_TOKEN")
t.Setenv("GH_TOKEN", "auth-token")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -1,7 +1,6 @@
package itemedit
import (
"os"
"testing"
"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
@ -105,8 +104,7 @@ func TestNewCmdeditItem(t *testing.T) {
},
}
os.Setenv("GH_TOKEN", "auth-token")
defer os.Unsetenv("GH_TOKEN")
t.Setenv("GH_TOKEN", "auth-token")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -1,7 +1,6 @@
package itemlist
import (
"os"
"testing"
"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
@ -54,8 +53,7 @@ func TestNewCmdList(t *testing.T) {
},
}
os.Setenv("GH_TOKEN", "auth-token")
defer os.Unsetenv("GH_TOKEN")
t.Setenv("GH_TOKEN", "auth-token")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -33,8 +33,8 @@ type listConfig struct {
func NewCmdList(f *cmdutil.Factory, runF func(config listConfig) error) *cobra.Command {
opts := listOpts{}
listCmd := &cobra.Command{
Short: "List the projects for an owner",
Use: "list",
Short: "List the projects for an owner",
Example: heredoc.Doc(`
# list the current user's projects
gh project list
@ -42,6 +42,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(config listConfig) error) *cobra.C
# list the projects for org github including closed projects
gh project list --owner github --closed
`),
Aliases: []string{"ls"},
RunE: func(cmd *cobra.Command, args []string) error {
client, err := client.New(f)
if err != nil {

View file

@ -2,7 +2,6 @@ package list
import (
"bytes"
"os"
"testing"
"github.com/cli/cli/v2/internal/tableprinter"
@ -56,8 +55,7 @@ func TestNewCmdlist(t *testing.T) {
},
}
os.Setenv("GH_TOKEN", "auth-token")
defer os.Unsetenv("GH_TOKEN")
t.Setenv("GH_TOKEN", "auth-token")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -17,6 +17,7 @@ import (
cmdItemEdit "github.com/cli/cli/v2/pkg/cmd/project/item-edit"
cmdItemList "github.com/cli/cli/v2/pkg/cmd/project/item-list"
cmdList "github.com/cli/cli/v2/pkg/cmd/project/list"
cmdTemplate "github.com/cli/cli/v2/pkg/cmd/project/template"
cmdView "github.com/cli/cli/v2/pkg/cmd/project/view"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/spf13/cobra"
@ -24,7 +25,7 @@ import (
func NewCmdProject(f *cmdutil.Factory) *cobra.Command {
var cmd = &cobra.Command{
Use: "project <command> [flags]",
Use: "project <command>",
Short: "Work with GitHub Projects.",
Long: "Work with GitHub Projects. Note that the token you are using must have 'project' scope, which is not set by default. You can verify your token scope by running 'gh auth status' and add the project scope by running 'gh auth refresh -s project'.",
Example: heredoc.Doc(`
@ -43,6 +44,7 @@ func NewCmdProject(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(cmdDelete.NewCmdDelete(f, nil))
cmd.AddCommand(cmdEdit.NewCmdEdit(f, nil))
cmd.AddCommand(cmdView.NewCmdView(f, nil))
cmd.AddCommand(cmdTemplate.NewCmdTemplate(f, nil))
// items
cmd.AddCommand(cmdItemList.NewCmdList(f, nil))

View file

@ -15,6 +15,7 @@ func JSONProject(project queries.Project) ([]byte, error) {
ShortDescription: project.ShortDescription,
Public: project.Public,
Closed: project.Closed,
Template: project.Template,
Title: project.Title,
ID: project.ID,
Readme: project.Readme,
@ -49,6 +50,7 @@ func JSONProjects(projects []queries.Project, totalCount int) ([]byte, error) {
ShortDescription: p.ShortDescription,
Public: p.Public,
Closed: p.Closed,
Template: p.Template,
Title: p.Title,
ID: p.ID,
Readme: p.Readme,
@ -87,6 +89,7 @@ type projectJSON struct {
ShortDescription string `json:"shortDescription"`
Public bool `json:"public"`
Closed bool `json:"closed"`
Template bool `json:"template"`
Title string `json:"title"`
ID string `json:"id"`
Readme string `json:"readme"`

View file

@ -25,7 +25,7 @@ func TestJSONProject_User(t *testing.T) {
b, err := JSONProject(project)
assert.NoError(t, err)
assert.Equal(t, `{"number":2,"url":"a url","shortDescription":"short description","public":true,"closed":false,"title":"","id":"123","readme":"readme","items":{"totalCount":1},"fields":{"totalCount":2},"owner":{"type":"User","login":"monalisa"}}`, string(b))
assert.Equal(t, `{"number":2,"url":"a url","shortDescription":"short description","public":true,"closed":false,"template":false,"title":"","id":"123","readme":"readme","items":{"totalCount":1},"fields":{"totalCount":2},"owner":{"type":"User","login":"monalisa"}}`, string(b))
}
func TestJSONProject_Org(t *testing.T) {
@ -36,6 +36,7 @@ func TestJSONProject_Org(t *testing.T) {
ShortDescription: "short description",
Public: true,
Readme: "readme",
Template: true,
}
project.Items.TotalCount = 1
@ -45,7 +46,7 @@ func TestJSONProject_Org(t *testing.T) {
b, err := JSONProject(project)
assert.NoError(t, err)
assert.Equal(t, `{"number":2,"url":"a url","shortDescription":"short description","public":true,"closed":false,"title":"","id":"123","readme":"readme","items":{"totalCount":1},"fields":{"totalCount":2},"owner":{"type":"Organization","login":"github"}}`, string(b))
assert.Equal(t, `{"number":2,"url":"a url","shortDescription":"short description","public":true,"closed":false,"template":true,"title":"","id":"123","readme":"readme","items":{"totalCount":1},"fields":{"totalCount":2},"owner":{"type":"Organization","login":"github"}}`, string(b))
}
func TestJSONProjects(t *testing.T) {
@ -70,6 +71,7 @@ func TestJSONProjects(t *testing.T) {
ShortDescription: "short description",
Public: true,
Readme: "readme",
Template: true,
}
orgProject.Items.TotalCount = 1
@ -81,7 +83,7 @@ func TestJSONProjects(t *testing.T) {
assert.Equal(
t,
`{"projects":[{"number":2,"url":"a url","shortDescription":"short description","public":true,"closed":false,"title":"","id":"123","readme":"readme","items":{"totalCount":1},"fields":{"totalCount":2},"owner":{"type":"User","login":"monalisa"}},{"number":2,"url":"a url","shortDescription":"short description","public":true,"closed":false,"title":"","id":"123","readme":"readme","items":{"totalCount":1},"fields":{"totalCount":2},"owner":{"type":"Organization","login":"github"}}],"totalCount":2}`,
`{"projects":[{"number":2,"url":"a url","shortDescription":"short description","public":true,"closed":false,"template":false,"title":"","id":"123","readme":"readme","items":{"totalCount":1},"fields":{"totalCount":2},"owner":{"type":"User","login":"monalisa"}},{"number":2,"url":"a url","shortDescription":"short description","public":true,"closed":false,"template":true,"title":"","id":"123","readme":"readme","items":{"totalCount":1},"fields":{"totalCount":2},"owner":{"type":"Organization","login":"github"}}],"totalCount":2}`,
string(b))
}

View file

@ -109,6 +109,7 @@ type Project struct {
ShortDescription string
Public bool
Closed bool
Template bool
Title string
ID string
Readme string

View file

@ -0,0 +1,176 @@
package template
import (
"fmt"
"strconv"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/pkg/cmd/project/shared/client"
"github.com/cli/cli/v2/pkg/cmd/project/shared/format"
"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/shurcooL/githubv4"
"github.com/spf13/cobra"
)
type templateOpts struct {
owner string
undo bool
number int32
projectID string
format string
}
type templateConfig struct {
client *queries.Client
opts templateOpts
io *iostreams.IOStreams
}
type markProjectTemplateMutation struct {
TemplateProject struct {
Project queries.Project `graphql:"projectV2"`
} `graphql:"markProjectV2AsTemplate(input:$input)"`
}
type unmarkProjectTemplateMutation struct {
TemplateProject struct {
Project queries.Project `graphql:"projectV2"`
} `graphql:"unmarkProjectV2AsTemplate(input:$input)"`
}
func NewCmdTemplate(f *cmdutil.Factory, runF func(config templateConfig) error) *cobra.Command {
opts := templateOpts{}
templateCmd := &cobra.Command{
Short: "Mark a project as a template",
Use: "template [<number>]",
Example: heredoc.Doc(`
# mark the github org's project "1" as a template
gh project template 1 --owner "github"
# unmark the github org's project "1" as a template
gh project template 1 --owner "github" --undo
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := client.New(f)
if err != nil {
return err
}
if len(args) == 1 {
num, err := strconv.ParseInt(args[0], 10, 32)
if err != nil {
return cmdutil.FlagErrorf("invalid number: %v", args[0])
}
opts.number = int32(num)
}
config := templateConfig{
client: client,
opts: opts,
io: f.IOStreams,
}
// allow testing of the command without actually running it
if runF != nil {
return runF(config)
}
return runTemplate(config)
},
}
templateCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the org owner.")
templateCmd.Flags().BoolVar(&opts.undo, "undo", false, "Unmark the project as a template.")
cmdutil.StringEnumFlag(templateCmd, &opts.format, "format", "", "", []string{"json"}, "Output format")
return templateCmd
}
func runTemplate(config templateConfig) error {
canPrompt := config.io.CanPrompt()
owner, err := config.client.NewOwner(canPrompt, config.opts.owner)
if err != nil {
return err
}
project, err := config.client.NewProject(canPrompt, owner, config.opts.number, false)
if err != nil {
return err
}
config.opts.projectID = project.ID
if config.opts.undo {
query, variables := unmarkTemplateArgs(config)
err = config.client.Mutate("UnmarkProjectTemplate", query, variables)
if err != nil {
return err
}
if config.opts.format == "json" {
return printJSON(config, *project)
}
return printResults(config, query.TemplateProject.Project)
}
query, variables := markTemplateArgs(config)
err = config.client.Mutate("MarkProjectTemplate", query, variables)
if err != nil {
return err
}
if config.opts.format == "json" {
return printJSON(config, *project)
}
return printResults(config, query.TemplateProject.Project)
}
func markTemplateArgs(config templateConfig) (*markProjectTemplateMutation, map[string]interface{}) {
return &markProjectTemplateMutation{}, map[string]interface{}{
"input": githubv4.MarkProjectV2AsTemplateInput{
ProjectID: githubv4.ID(config.opts.projectID),
},
"firstItems": githubv4.Int(0),
"afterItems": (*githubv4.String)(nil),
"firstFields": githubv4.Int(0),
"afterFields": (*githubv4.String)(nil),
}
}
func unmarkTemplateArgs(config templateConfig) (*unmarkProjectTemplateMutation, map[string]interface{}) {
return &unmarkProjectTemplateMutation{}, map[string]interface{}{
"input": githubv4.UnmarkProjectV2AsTemplateInput{
ProjectID: githubv4.ID(config.opts.projectID),
},
"firstItems": githubv4.Int(0),
"afterItems": (*githubv4.String)(nil),
"firstFields": githubv4.Int(0),
"afterFields": (*githubv4.String)(nil),
}
}
func printResults(config templateConfig, project queries.Project) error {
if !config.io.IsStdoutTTY() {
return nil
}
if config.opts.undo {
_, err := fmt.Fprintf(config.io.Out, "Unmarked project %d as a template.\n", project.Number)
return err
}
_, err := fmt.Fprintf(config.io.Out, "Marked project %d as a template.\n", project.Number)
return err
}
func printJSON(config templateConfig, project queries.Project) error {
b, err := format.JSONProject(project)
if err != nil {
return err
}
_, err = config.io.Out.Write(b)
return err
}

View file

@ -0,0 +1,275 @@
package template
import (
"testing"
"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
"gopkg.in/h2non/gock.v1"
)
func TestNewCmdTemplate(t *testing.T) {
tests := []struct {
name string
cli string
wants templateOpts
wantsErr bool
wantsErrMsg string
}{
{
name: "not-a-number",
cli: "x",
wantsErr: true,
wantsErrMsg: "invalid number: x",
},
{
name: "number",
cli: "123",
wants: templateOpts{
number: 123,
},
},
{
name: "owner",
cli: "--owner monalisa",
wants: templateOpts{
owner: "monalisa",
},
},
{
name: "undo",
cli: "--undo",
wants: templateOpts{
undo: true,
},
},
{
name: "json",
cli: "--format json",
wants: templateOpts{
format: "json",
},
},
}
t.Setenv("GH_TOKEN", "auth-token")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, _, _, _ := iostreams.Test()
f := &cmdutil.Factory{
IOStreams: ios,
}
argv, err := shlex.Split(tt.cli)
assert.NoError(t, err)
var gotOpts templateOpts
cmd := NewCmdTemplate(f, func(config templateConfig) error {
gotOpts = config.opts
return nil
})
cmd.SetArgs(argv)
_, err = cmd.ExecuteC()
if tt.wantsErr {
assert.Error(t, err)
assert.Equal(t, tt.wantsErrMsg, err.Error())
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wants.number, gotOpts.number)
assert.Equal(t, tt.wants.owner, gotOpts.owner)
assert.Equal(t, tt.wants.format, gotOpts.format)
})
}
}
func TestRunMarkTemplate_Org(t *testing.T) {
defer gock.Off()
gock.Observe(gock.DumpRequest)
// get org ID
gock.New("https://api.github.com").
Post("/graphql").
MatchType("json").
JSON(map[string]interface{}{
"query": "query UserOrgOwner.*",
"variables": map[string]interface{}{
"login": "github",
},
}).
Reply(200).
JSON(map[string]interface{}{
"data": map[string]interface{}{
"organization": map[string]interface{}{
"id": "an ID",
},
},
"errors": []interface{}{
map[string]interface{}{
"type": "NOT_FOUND",
"path": []string{"user"},
},
},
})
// get project ID
gock.New("https://api.github.com").
Post("/graphql").
MatchType("json").
JSON(map[string]interface{}{
"query": "query OrgProject.*",
"variables": map[string]interface{}{
"login": "github",
"number": 1,
"firstItems": 0,
"afterItems": nil,
"firstFields": 0,
"afterFields": nil,
},
}).
Reply(200).
JSON(map[string]interface{}{
"data": map[string]interface{}{
"organization": map[string]interface{}{
"projectV2": map[string]interface{}{
"id": "an ID",
},
},
},
})
// template project
gock.New("https://api.github.com").
Post("/graphql").
BodyString(`{"query":"mutation MarkProjectTemplate.*","variables":{"afterFields":null,"afterItems":null,"firstFields":0,"firstItems":0,"input":{"projectId":"an ID"}}}`).
Reply(200).
JSON(map[string]interface{}{
"data": map[string]interface{}{
"markProjectV2AsTemplate": map[string]interface{}{
"projectV2": map[string]interface{}{
"id": "project ID",
"number": 1,
},
},
},
})
client := queries.NewTestClient()
ios, _, stdout, _ := iostreams.Test()
ios.SetStdoutTTY(true)
config := templateConfig{
opts: templateOpts{
owner: "github",
number: 1,
},
client: client,
io: ios,
}
err := runTemplate(config)
assert.NoError(t, err)
assert.Equal(
t,
"Marked project 1 as a template.\n",
stdout.String())
}
func TestRunUnmarkTemplate_Org(t *testing.T) {
defer gock.Off()
gock.Observe(gock.DumpRequest)
// get org ID
gock.New("https://api.github.com").
Post("/graphql").
MatchType("json").
JSON(map[string]interface{}{
"query": "query UserOrgOwner.*",
"variables": map[string]interface{}{
"login": "github",
},
}).
Reply(200).
JSON(map[string]interface{}{
"data": map[string]interface{}{
"organization": map[string]interface{}{
"id": "an ID",
},
},
"errors": []interface{}{
map[string]interface{}{
"type": "NOT_FOUND",
"path": []string{"user"},
},
},
})
// get project ID
gock.New("https://api.github.com").
Post("/graphql").
MatchType("json").
JSON(map[string]interface{}{
"query": "query OrgProject.*",
"variables": map[string]interface{}{
"login": "github",
"number": 1,
"firstItems": 0,
"afterItems": nil,
"firstFields": 0,
"afterFields": nil,
},
}).
Reply(200).
JSON(map[string]interface{}{
"data": map[string]interface{}{
"organization": map[string]interface{}{
"projectV2": map[string]interface{}{
"id": "an ID",
},
},
},
})
// template project
gock.New("https://api.github.com").
Post("/graphql").
BodyString(`{"query":"mutation UnmarkProjectTemplate.*","variables":{"afterFields":null,"afterItems":null,"firstFields":0,"firstItems":0,"input":{"projectId":"an ID"}}}`).
Reply(200).
JSON(map[string]interface{}{
"data": map[string]interface{}{
"unmarkProjectV2AsTemplate": map[string]interface{}{
"projectV2": map[string]interface{}{
"id": "project ID",
"number": 1,
},
},
},
})
client := queries.NewTestClient()
ios, _, stdout, _ := iostreams.Test()
ios.SetStdoutTTY(true)
config := templateConfig{
opts: templateOpts{
owner: "github",
number: 1,
undo: true,
},
client: client,
io: ios,
}
err := runTemplate(config)
assert.NoError(t, err)
assert.Equal(
t,
"Unmarked project 1 as a template.\n",
stdout.String())
}

View file

@ -2,7 +2,6 @@ package view
import (
"bytes"
"os"
"testing"
"github.com/cli/cli/v2/pkg/cmd/project/shared/queries"
@ -57,8 +56,7 @@ func TestNewCmdview(t *testing.T) {
},
}
os.Setenv("GH_TOKEN", "auth-token")
defer os.Unsetenv("GH_TOKEN")
t.Setenv("GH_TOKEN", "auth-token")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

Some files were not shown because too many files have changed in this diff Show more