diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index 226050ea9..f12d8f0d0 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -17,6 +17,15 @@ jobs:
- name: Check out code
uses: actions/checkout@v2
+ - name: Cache Go modules
+ uses: actions/cache@v2
+ with:
+ path: ~/go
+ key: ${{ runner.os }}-build-${{ hashFiles('go.mod') }}
+ restore-keys: |
+ ${{ runner.os }}-build-
+ ${{ runner.os }}-
+
- name: Download dependencies
run: go mod download
diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml
index f44689804..6511fc71c 100644
--- a/.github/workflows/releases.yml
+++ b/.github/workflows/releases.yml
@@ -25,6 +25,15 @@ jobs:
-q .body > CHANGELOG.md
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
+ - name: Install osslsigncode
+ run: sudo apt-get install -y osslsigncode
+ - name: Obtain signing cert
+ run: |
+ cert="$(mktemp -t cert.XXX)"
+ base64 -d <<<"$CERT_CONTENTS" > "$cert"
+ echo "CERT_FILE=$cert" >> $GITHUB_ENV
+ env:
+ CERT_CONTENTS: ${{ secrets.WINDOWS_CERT_PFX }}
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v2
with:
@@ -33,6 +42,7 @@ jobs:
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
GORELEASER_CURRENT_TAG: ${{steps.changelog.outputs.tag-name}}
+ CERT_PASSWORD: ${{secrets.WINDOWS_CERT_PASSWORD}}
- name: Checkout documentation site
uses: actions/checkout@v2
with:
@@ -61,7 +71,6 @@ jobs:
api-write --silent projects/columns/cards/$card/moves -f position=top -F column_id=$DONE_COLUMN
done
echo "moved ${#cards[@]} cards to the Done column"
-
- name: Install packaging dependencies
run: sudo apt-get install -y rpm reprepro
- name: Set up GPG
@@ -129,34 +138,33 @@ jobs:
unzip -o *.zip && rm -v *.zip
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
- - name: Install go-msi
- run: choco install -y "go-msi"
- name: Prepare PATH
- shell: bash
- run: |
- echo "$WIX\\bin" >> $GITHUB_PATH
- echo "C:\\Program Files\\go-msi" >> $GITHUB_PATH
+ id: setupmsbuild
+ uses: microsoft/setup-msbuild@v1.0.3
- name: Build MSI
id: buildmsi
shell: bash
env:
ZIP_FILE: ${{ steps.download_exe.outputs.zip }}
+ MSBUILD_PATH: ${{ steps.setupmsbuild.outputs.msbuildPath }}
run: |
- mkdir -p build
- msi="$(basename "$ZIP_FILE" ".zip").msi"
- printf "::set-output name=msi::%s\n" "$msi"
- go-msi make --msi "$PWD/$msi" --out "$PWD/build" --version "${GITHUB_REF#refs/tags/}"
+ name="$(basename "$ZIP_FILE" ".zip")"
+ version="$(echo -e ${GITHUB_REF#refs/tags/v} | sed s/-.*$//)"
+ "${MSBUILD_PATH}\MSBuild.exe" ./build/windows/gh.wixproj -p:SourceDir="$PWD" -p:OutputPath="$PWD" -p:OutputName="$name" -p:ProductVersion="$version"
- name: Obtain signing cert
id: obtain_cert
+ shell: bash
+ run: |
+ base64 -d <<<"$CERT_CONTENTS" > ./cert.pfx
+ printf "::set-output name=cert-file::%s\n" ".\\cert.pfx"
env:
- DESKTOP_CERT_TOKEN: ${{ secrets.DESKTOP_CERT_TOKEN }}
- run: .\script\setup-windows-certificate.ps1
+ CERT_CONTENTS: ${{ secrets.WINDOWS_CERT_PFX }}
- name: Sign MSI
env:
CERT_FILE: ${{ steps.obtain_cert.outputs.cert-file }}
EXE_FILE: ${{ steps.buildmsi.outputs.msi }}
- GITHUB_CERT_PASSWORD: ${{ secrets.GITHUB_CERT_PASSWORD }}
- run: .\script\sign.ps1 -Certificate $env:CERT_FILE -Executable $env:EXE_FILE
+ CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }}
+ run: .\script\signtool sign /d "GitHub CLI" /f $env:CERT_FILE /p $env:CERT_PASSWORD /fd sha256 /tr http://timestamp.digicert.com /v $env:EXE_FILE
- name: Upload MSI
shell: bash
run: |
diff --git a/.goreleaser.yml b/.goreleaser.yml
index 95d43500d..68d3dd9c9 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -32,6 +32,9 @@ builds:
id: windows
goos: [windows]
goarch: [386, amd64]
+ hooks:
+ post:
+ - ./script/sign-windows-executable.sh '{{ .Path }}'
archives:
- id: nix
diff --git a/api/client.go b/api/client.go
index 3fcf0d930..bfd526fbc 100644
--- a/api/client.go
+++ b/api/client.go
@@ -98,6 +98,22 @@ func ReplaceTripper(tr http.RoundTripper) ClientOption {
}
}
+// ExtractHeader extracts a named header from any response received by this client and, if non-blank, saves
+// it to dest.
+func ExtractHeader(name string, dest *string) ClientOption {
+ return func(tr http.RoundTripper) http.RoundTripper {
+ return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
+ res, err := tr.RoundTrip(req)
+ if err == nil {
+ if value := res.Header.Get(name); value != "" {
+ *dest = value
+ }
+ }
+ return res, err
+ }}
+ }
+}
+
type funcTripper struct {
roundTrip func(*http.Request) (*http.Response, error)
}
@@ -220,7 +236,23 @@ func ScopesSuggestion(resp *http.Response) string {
for _, s := range strings.Split(tokenHasScopes, ",") {
s = strings.TrimSpace(s)
gotScopes[s] = struct{}{}
- if strings.HasPrefix(s, "admin:") {
+
+ // Certain scopes may be grouped under a single "top-level" scope. The following branch
+ // statements include these grouped/implied scopes when the top-level scope is encountered.
+ // See https://docs.github.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps.
+ if s == "repo" {
+ gotScopes["repo:status"] = struct{}{}
+ gotScopes["repo_deployment"] = struct{}{}
+ gotScopes["public_repo"] = struct{}{}
+ gotScopes["repo:invite"] = struct{}{}
+ gotScopes["security_events"] = struct{}{}
+ } else if s == "user" {
+ gotScopes["read:user"] = struct{}{}
+ gotScopes["user:email"] = struct{}{}
+ gotScopes["user:follow"] = struct{}{}
+ } else if s == "codespace" {
+ gotScopes["codespace:secrets"] = struct{}{}
+ } else if strings.HasPrefix(s, "admin:") {
gotScopes["read:"+strings.TrimPrefix(s, "admin:")] = struct{}{}
gotScopes["write:"+strings.TrimPrefix(s, "admin:")] = struct{}{}
} else if strings.HasPrefix(s, "write:") {
@@ -310,6 +342,7 @@ func (c Client) REST(hostname string, method string, p string, body io.Reader, d
if err != nil {
return err
}
+
err = json.Unmarshal(b, &data)
if err != nil {
return err
diff --git a/api/export_pr.go b/api/export_pr.go
index 18bce025b..3c4d14078 100644
--- a/api/export_pr.go
+++ b/api/export_pr.go
@@ -75,6 +75,8 @@ func (pr *PullRequest) ExportData(fields []string) map[string]interface{} {
data[f] = pr.ProjectCards.Nodes
case "reviews":
data[f] = pr.Reviews.Nodes
+ case "latestReviews":
+ data[f] = pr.LatestReviews.Nodes
case "files":
data[f] = pr.Files.Nodes
case "reviewRequests":
diff --git a/api/queries_pr.go b/api/queries_pr.go
index 300846c23..b89e20609 100644
--- a/api/queries_pr.go
+++ b/api/queries_pr.go
@@ -64,7 +64,8 @@ type PullRequest struct {
BaseRef struct {
BranchProtectionRule struct {
- RequiresStrictStatusChecks bool
+ RequiresStrictStatusChecks bool
+ RequiredApprovingReviewCount int
}
}
@@ -79,18 +80,7 @@ type PullRequest struct {
Commit struct {
StatusCheckRollup struct {
Contexts struct {
- Nodes []struct {
- TypeName string `json:"__typename"`
- Name string `json:"name"`
- Context string `json:"context,omitempty"`
- State string `json:"state,omitempty"`
- Status string `json:"status"`
- Conclusion string `json:"conclusion"`
- StartedAt time.Time `json:"startedAt"`
- CompletedAt time.Time `json:"completedAt"`
- DetailsURL string `json:"detailsUrl"`
- TargetURL string `json:"targetUrl,omitempty"`
- }
+ Nodes []CheckContext
PageInfo struct {
HasNextPage bool
EndCursor string
@@ -108,9 +98,23 @@ type PullRequest struct {
Comments Comments
ReactionGroups ReactionGroups
Reviews PullRequestReviews
+ LatestReviews PullRequestReviews
ReviewRequests ReviewRequests
}
+type CheckContext struct {
+ TypeName string `json:"__typename"`
+ Name string `json:"name"`
+ Context string `json:"context,omitempty"`
+ State string `json:"state,omitempty"`
+ Status string `json:"status"`
+ Conclusion string `json:"conclusion"`
+ StartedAt time.Time `json:"startedAt"`
+ CompletedAt time.Time `json:"completedAt"`
+ DetailsURL string `json:"detailsUrl"`
+ TargetURL string `json:"targetUrl,omitempty"`
+}
+
type PRRepository struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -405,6 +409,11 @@ func PullRequestStatus(client *Client, repo ghrepo.Interface, options StatusOpti
}
pullRequest(number: $number) {
...prWithReviews
+ baseRef {
+ branchProtectionRule {
+ requiredApprovingReviewCount
+ }
+ }
}
}
`
@@ -519,7 +528,7 @@ func pullRequestFragment(httpClient *http.Client, hostname string) (string, erro
var reviewFields []string
if prFeatures.HasReviewDecision {
- reviewFields = append(reviewFields, "reviewDecision")
+ reviewFields = append(reviewFields, "reviewDecision", "latestReviews")
}
fragments := fmt.Sprintf(`
diff --git a/api/queries_repo.go b/api/queries_repo.go
index 9fbc26e31..b7ea14915 100644
--- a/api/queries_repo.go
+++ b/api/queries_repo.go
@@ -11,6 +11,8 @@ import (
"strings"
"time"
+ "github.com/cli/cli/v2/internal/ghinstance"
+
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/shurcooL/githubv4"
)
@@ -524,6 +526,37 @@ func ForkRepo(client *Client, repo ghrepo.Interface, org string) (*Repository, e
}, nil
}
+// RenameRepo renames the repository on GitHub and returns the renamed repository
+func RenameRepo(client *Client, repo ghrepo.Interface, newRepoName string) (*Repository, error) {
+ input := map[string]string{"name": newRepoName}
+ body := &bytes.Buffer{}
+ enc := json.NewEncoder(body)
+ if err := enc.Encode(input); err != nil {
+ return nil, err
+ }
+
+ path := fmt.Sprintf("%srepos/%s",
+ ghinstance.RESTPrefix(repo.RepoHost()),
+ ghrepo.FullName(repo))
+
+ result := repositoryV3{}
+ err := client.REST(repo.RepoHost(), "PATCH", path, body, &result)
+ if err != nil {
+ return nil, err
+ }
+
+ return &Repository{
+ ID: result.NodeID,
+ Name: result.Name,
+ CreatedAt: result.CreatedAt,
+ Owner: RepositoryOwner{
+ Login: result.Owner.Login,
+ },
+ ViewerPermission: "WRITE",
+ hostname: repo.RepoHost(),
+ }, nil
+}
+
func LastCommit(client *Client, repo ghrepo.Interface) (*Commit, error) {
var responseData struct {
Repository struct {
@@ -983,6 +1016,15 @@ func RepoAndOrgProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, e
type RepoAssignee struct {
ID string
Login string
+ Name string
+}
+
+// DisplayName returns a formatted string that uses Login and Name to be displayed e.g. 'Login (Name)' or 'Login'
+func (ra RepoAssignee) DisplayName() string {
+ if ra.Name != "" {
+ return fmt.Sprintf("%s (%s)", ra.Login, ra.Name)
+ }
+ return ra.Login
}
// RepoAssignableUsers fetches all the assignable users for a repository
@@ -1128,46 +1170,6 @@ func RepoMilestones(client *Client, repo ghrepo.Interface, state string) ([]Repo
return milestones, nil
}
-func MilestoneByTitle(client *Client, repo ghrepo.Interface, state, title string) (*RepoMilestone, error) {
- milestones, err := RepoMilestones(client, repo, state)
- if err != nil {
- return nil, err
- }
-
- for i := range milestones {
- if strings.EqualFold(milestones[i].Title, title) {
- return &milestones[i], nil
- }
- }
- return nil, fmt.Errorf("no milestone found with title %q", title)
-}
-
-func MilestoneByNumber(client *Client, repo ghrepo.Interface, number int32) (*RepoMilestone, error) {
- var query struct {
- Repository struct {
- Milestone *RepoMilestone `graphql:"milestone(number: $number)"`
- } `graphql:"repository(owner: $owner, name: $name)"`
- }
-
- variables := map[string]interface{}{
- "owner": githubv4.String(repo.RepoOwner()),
- "name": githubv4.String(repo.RepoName()),
- "number": githubv4.Int(number),
- }
-
- gql := graphQLClient(client.http, repo.RepoHost())
-
- err := gql.QueryNamed(context.Background(), "RepositoryMilestoneByNumber", &query, variables)
- if err != nil {
- return nil, err
- }
- if query.Repository.Milestone == nil {
- return nil, fmt.Errorf("no milestone found with number '%d'", number)
- }
-
- return query.Repository.Milestone, nil
-}
-
func ProjectNamesToPaths(client *Client, repo ghrepo.Interface, projectNames []string) ([]string, error) {
var paths []string
projects, err := RepoAndOrgProjects(client, repo)
diff --git a/api/queries_repo_test.go b/api/queries_repo_test.go
index 4b6e2837d..5091e656f 100644
--- a/api/queries_repo_test.go
+++ b/api/queries_repo_test.go
@@ -362,3 +362,28 @@ func Test_RepoMilestones(t *testing.T) {
}
}
}
+
+func TestDisplayName(t *testing.T) {
+ tests := []struct {
+ name string
+ assignee RepoAssignee
+ want string
+ }{
+ {
+ name: "assignee with name",
+ assignee: RepoAssignee{"123", "octocat123", "Octavious Cath"},
+ want: "octocat123 (Octavious Cath)",
+ },
+ {
+ name: "assignee without name",
+ assignee: RepoAssignee{"123", "octocat123", ""},
+ want: "octocat123",
+ },
+ }
+ for _, tt := range tests {
+ actual := tt.assignee.DisplayName()
+ if actual != tt.want {
+ t.Errorf("display name was %s wanted %s", actual, tt.want)
+ }
+ }
+}
diff --git a/api/query_builder.go b/api/query_builder.go
index 88f8bfa4b..977089df6 100644
--- a/api/query_builder.go
+++ b/api/query_builder.go
@@ -82,6 +82,18 @@ var prReviews = shortenQuery(`
}
`)
+var prLatestReviews = shortenQuery(`
+ latestReviews(first: 100) {
+ nodes {
+ author{login},
+ authorAssociation,
+ submittedAt,
+ body,
+ state
+ }
+ }
+`)
+
var prFiles = shortenQuery(`
files(first: 100) {
nodes {
@@ -180,6 +192,7 @@ var PullRequestFields = append(IssueFields,
"headRepositoryOwner",
"isCrossRepository",
"isDraft",
+ "latestReviews",
"maintainerCanModify",
"mergeable",
"mergeCommit",
@@ -229,6 +242,8 @@ func PullRequestGraphQL(fields []string) string {
q = append(q, prReviewRequests)
case "reviews":
q = append(q, prReviews)
+ case "latestReviews":
+ q = append(q, prLatestReviews)
case "files":
q = append(q, prFiles)
case "commits":
diff --git a/build/windows/gh.wixproj b/build/windows/gh.wixproj
new file mode 100644
index 000000000..aa72d4da3
--- /dev/null
+++ b/build/windows/gh.wixproj
@@ -0,0 +1,38 @@
+
+
+
+ Release
+ x64
+ 0.1.0
+ $(MSBuildProjectName)
+ package
+ $([MSBuild]::NormalizeDirectory($(MSBuildProjectDirectory)\..\..))
+ $(RepoPath)bin\$(Platform)\
+ $(RepoPath)bin\obj\$(Platform)\
+
+ $(DefineConstants);
+ ProductVersion=$(ProductVersion);
+
+ ICE39
+ false
+ $(MSBuildExtensionsPath)\Microsoft\WiX\v3.x\Wix.targets
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/build/windows/gh.wxs b/build/windows/gh.wxs
new file mode 100644
index 000000000..1e91734f1
--- /dev/null
+++ b/build/windows/gh.wxs
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/build/windows/ui.wxs b/build/windows/ui.wxs
new file mode 100644
index 000000000..8c534adc8
--- /dev/null
+++ b/build/windows/ui.wxs
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1
+ "1"]]>
+
+ 1
+
+ NOT Installed
+ Installed AND PATCH
+
+ 1
+ 1
+ NOT WIXUI_DONTVALIDATEPATH
+ "1"]]>
+ WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID="1"
+ 1
+ 1
+
+ NOT Installed
+ Installed AND NOT PATCH
+ Installed AND PATCH
+
+ 1
+
+ 1
+ 1
+ 1
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/cmd/gh/main.go b/cmd/gh/main.go
index 50f8335a3..a8f1ed141 100644
--- a/cmd/gh/main.go
+++ b/cmd/gh/main.go
@@ -24,6 +24,7 @@ import (
"github.com/cli/cli/v2/pkg/cmd/factory"
"github.com/cli/cli/v2/pkg/cmd/root"
"github.com/cli/cli/v2/pkg/cmdutil"
+ "github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/utils"
"github.com/cli/safeexec"
"github.com/mattn/go-colorable"
@@ -145,7 +146,7 @@ func mainRun() exitCode {
if errors.As(err, &execError) {
return exitCode(execError.ExitCode())
}
- fmt.Fprintf(stderr, "failed to run external command: %s", err)
+ fmt.Fprintf(stderr, "failed to run external command: %s\n", err)
return exitError
}
@@ -157,7 +158,7 @@ func mainRun() exitCode {
if errors.As(err, &execError) {
return exitCode(execError.ExitCode())
}
- fmt.Fprintf(stderr, "failed to run extension: %s", err)
+ fmt.Fprintf(stderr, "failed to run extension: %s\n", err)
return exitError
} else if found {
return exitOK
@@ -201,6 +202,7 @@ func mainRun() exitCode {
rootCmd.SetArgs(expandedArgs)
if cmd, err := rootCmd.ExecuteC(); err != nil {
+ var pagerPipeError *iostreams.ErrClosedPagerPipe
if err == cmdutil.SilentError {
return exitError
} else if cmdutil.IsUserCancellation(err) {
@@ -211,6 +213,9 @@ func mainRun() exitCode {
return exitCancel
} else if errors.Is(err, authError) {
return exitAuth
+ } else if errors.As(err, &pagerPipeError) {
+ // ignore the error raised when piping to a closed pager
+ return exitOK
}
printError(stderr, err, cmd, hasDebug)
@@ -224,8 +229,9 @@ func mainRun() exitCode {
var httpErr api.HTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode == 401 {
fmt.Fprintln(stderr, "Try authenticating with: gh auth login")
- } else if strings.Contains(err.Error(), "Resource protected by organization SAML enforcement") {
- fmt.Fprintln(stderr, "Try re-authenticating with: gh auth refresh")
+ } else if u := factory.SSOURL(); u != "" {
+ // handles organization SAML enforcement error
+ fmt.Fprintf(stderr, "Authorize in your web browser: %s\n", u)
} else if msg := httpErr.ScopesSuggestion(); msg != "" {
fmt.Fprintln(stderr, msg)
}
diff --git a/docs/install_linux.md b/docs/install_linux.md
index b11c2faa9..2c3579d65 100644
--- a/docs/install_linux.md
+++ b/docs/install_linux.md
@@ -29,13 +29,19 @@ sudo apt install gh
### Fedora, CentOS, Red Hat Enterprise Linux (dnf)
-Install:
+Install from our package repository for immediate access to latest releases:
```bash
sudo dnf config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo
sudo dnf install gh
```
+Alternatively, install from the [community repository](https://packages.fedoraproject.org/pkgs/gh/gh/):
+
+```bash
+sudo dnf install gh
+```
+
Upgrade:
```bash
@@ -179,6 +185,30 @@ openSUSE Tumbleweed users can install from the [official distribution repo](http
sudo zypper in gh
```
+### Alpine Linux
+
+Alpine Linux users can install from the [stable releases' community packaage repository](https://pkgs.alpinelinux.org/packages?name=github-cli&branch=v3.15).
+
+```bash
+apk add github-cli
+```
+
+Users wanting the latest version of the CLI without waiting to be backported into the stable release they're using should use the edge release's
+community repo through this method below, without mixing packages from stable and unstable repos.[^1]
+
+```bash
+echo "@community http://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories
+apk add github-cli@community
+```
+
+### Void Linux
+Void Linux users can install from the [official distribution repo](https://voidlinux.org/packages/?arch=x86_64&q=github-cli):
+
+```bash
+sudo xbps-install github-cli
+```
+
[releases page]: https://github.com/cli/cli/releases/latest
[arch linux repo]: https://www.archlinux.org/packages/community/x86_64/github-cli
[arch linux aur]: https://aur.archlinux.org/packages/github-cli-git
+[^1]: https://wiki.alpinelinux.org/wiki/Package_management#Repository_pinning
diff --git a/docs/triage.md b/docs/triage.md
index caad1e52c..8a329de7c 100644
--- a/docs/triage.md
+++ b/docs/triage.md
@@ -6,31 +6,23 @@ triage role. The initial expectation is that the person in the role for the week
## Expectations for incoming issues
-All incoming issues need either an **enhancement**, **bug**, or **docs** label.
+All incoming issues need either an `enhancement`, `bug`, or `docs` label.
-To be considered triaged, **enhancement** issues require at least one of the following additional labels:
+To be considered triaged, `enhancement` issues require at least one of the following additional labels:
-- **core**: work reserved for the core CLI team
-- **help wanted**: work that we would accept contributions for
-- **needs-design**: work that requires input from a UX designer before it can move forward
-- **needs-investigation**: work that requires a mystery be solved by the core team before it can move forward
-- **needs-user-input**: work that requires more information from the reporter before it can move forward
+- `core`: reserved for the core CLI team
+- `help wanted`: signal that we are accepting contributions for this
+- `discuss`: add to our team's queue to discuss during a sync
+- `needs-investigation`: work that requires a mystery be solved by the core team before it can move forward
+- `needs-user-input`: we need more information from our users before the task can move forward
-To be considered triaged, **bug** issues require a severity label: one of **p1**, **p2**, or **p3**
-
-For a more detailed breakdown of **how** to triage an issue, see the _Issue triage flowchart_ below.
+To be considered triaged, `bug` issues require a severity label: one of `p1`, `p2`, or `p3`
## Expectations for community pull requests
-To be considered triaged, incoming pull requests should:
-
-- be checked for a corresponding **help wanted** issue
-- be checked for basic quality: are the builds passing? have tests been added?
-- be checked for redundancy: is there already a PR dealing with this?
-
-Once a pull request has been triaged, it should be moved to the **Needs Review** column of the project board.
-
-For a more detailed breakdown of **how** to triage an issue, see the _PR triage flowchart_ below.
+All incoming pull requests are assigned to one of the engineers for review on a round-robin basis.
+The person in a triage role for a week could take a glance at these pull requests, mostly to see whether
+the changeset is feasible and to allow the associated CI run for new contributors.
## Issue triage flowchart
@@ -46,30 +38,17 @@ For a more detailed breakdown of **how** to triage an issue, see the _PR triage
- add `help wanted` label
- consider adding `good first issue` label
- do we want to do it?
- - comment acknowledging it
+ - comment acknowledging that
- add `core` label
- - add to project TODO column if this is something that should ship soon
+ - add to the project “TODO” column if this is something that should ship soon
- is it intriguing, but requires discussion?
- - label `needs-design` if design input is needed, ping
+ - label `discuss`
- label `needs-investigation` if engineering research is required before action can be taken
- does it need more info from the issue author?
- ask the user for details
- add `needs-user-input` label
- is it a usage/support question?
- - offer some instructions/workaround and close
-
-## Pull request triage flowchart
-
-- can it be closed outright?
- - e.g. spam/junk
- - close
-- do we not want to do it?
- - comment and close
-- is it intriguing, but requires discussion and there is no referenced issue?
- - request an issue
- - close
-- is it something we want to include?
- - add to `needs review` column
+ - consider converting the Issue to a Discussion
## Weekly PR audit
diff --git a/go.mod b/go.mod
index ca455277d..59cd5006d 100644
--- a/go.mod
+++ b/go.mod
@@ -5,8 +5,8 @@ go 1.16
require (
github.com/AlecAivazis/survey/v2 v2.3.2
github.com/MakeNowJust/heredoc v1.0.0
- github.com/briandowns/spinner v1.18.0
- github.com/charmbracelet/glamour v0.3.0
+ github.com/briandowns/spinner v1.18.1
+ github.com/charmbracelet/glamour v0.4.0
github.com/cli/browser v1.1.0
github.com/cli/oauth v0.9.0
github.com/cli/safeexec v1.0.0
@@ -14,20 +14,19 @@ require (
github.com/cpuguy83/go-md2man/v2 v2.0.1
github.com/creack/pty v1.1.17
github.com/gabriel-vasile/mimetype v1.4.0
- github.com/google/go-cmp v0.5.6
+ github.com/google/go-cmp v0.5.7
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/gorilla/websocket v1.4.2
github.com/hashicorp/go-multierror v1.1.1
github.com/hashicorp/go-version v1.3.0
github.com/henvic/httpretty v0.0.6
- github.com/itchyny/gojq v0.12.6
+ github.com/itchyny/gojq v0.12.7
github.com/joho/godotenv v1.4.0
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/mattn/go-colorable v0.1.12
github.com/mattn/go-isatty v0.0.14
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d
- github.com/microcosm-cc/bluemonday v1.0.16 // indirect
- github.com/muesli/reflow v0.2.1-0.20210502190812-c80126ec2ad5
+ github.com/muesli/reflow v0.3.0
github.com/muesli/termenv v0.9.0
github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38
github.com/opentracing/opentracing-go v1.1.0
@@ -39,7 +38,7 @@ require (
github.com/stretchr/testify v1.7.0
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
- golang.org/x/sys v0.0.0-20211205182925-97ca703d548d
+ golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9
golang.org/x/term v0.0.0-20210503060354-a79de5458b56
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
)
diff --git a/go.sum b/go.sum
index f2c325538..b91f9192a 100644
--- a/go.sum
+++ b/go.sum
@@ -56,15 +56,8 @@ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6
github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8 h1:xzYJEypr/85nBpB11F9br+3HUrpgb+fcm5iADzXXYEw=
github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
-github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U=
-github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI=
-github.com/alecthomas/chroma v0.8.2 h1:x3zkuE2lUk/RIekyAJ3XRqSCP4zwWDfcw/YJCuCAACg=
-github.com/alecthomas/chroma v0.8.2/go.mod h1:sko8vR34/90zvl5QdcUdvzL3J8NKjAUx9va9jPuFNoM=
-github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo=
-github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0=
-github.com/alecthomas/kong v0.2.4/go.mod h1:kQOmtJgV+Lb4aj+I2LEn40cbtawdWJ9Y8QLq+lElKxE=
-github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897 h1:p9Sln00KOTlrYkxI1zYWl1QLnEqAqEARBEYa8FQnQcY=
-github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
+github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
+github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
@@ -81,15 +74,15 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
-github.com/briandowns/spinner v1.18.0 h1:SJs0maNOs4FqhBwiJ3Gr7Z1D39/rukIVGQvpNZVHVcM=
-github.com/briandowns/spinner v1.18.0/go.mod h1:QOuQk7x+EaDASo80FEXwlwiA+j/PPIcX3FScO+3/ZPQ=
+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/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/charmbracelet/glamour v0.3.0 h1:3H+ZrKlSg8s+WU6V7eF2eRVYt8lCueffbi7r2+ffGkc=
-github.com/charmbracelet/glamour v0.3.0/go.mod h1:TzF0koPZhqq0YVBNL100cPHznAAjVj7fksX2RInwjGw=
+github.com/charmbracelet/glamour v0.4.0 h1:scR+smyB7WdmrlIaff6IVlm48P48JaNM7JypM/VGl4k=
+github.com/charmbracelet/glamour v0.4.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5UyZBbQsMQN0XIqc=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@@ -123,13 +116,11 @@ github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKY
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
-github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
-github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk=
-github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
+github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
+github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -206,8 +197,9 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
+github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
@@ -285,8 +277,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
-github.com/itchyny/gojq v0.12.6 h1:VjaFn59Em2wTxDNGcrRkDK9ZHMNa8IksOgL13sLL4d0=
-github.com/itchyny/gojq v0.12.6/go.mod h1:ZHrkfu7A+RbZLy5J1/JKpS4poEqrzItSTGDItqsfP0A=
+github.com/itchyny/gojq v0.12.7 h1:hYPTpeWfrJ1OT+2j6cvBScbhl0TkdwGM4bc66onUSOQ=
+github.com/itchyny/gojq v0.12.7/go.mod h1:ZdvNHVlzPgUf8pgjnuDTmGfHA/21KoutQUJ3An/xNuw=
github.com/itchyny/timefmt-go v0.1.3 h1:7M3LGVDsqcd0VZH2U+x393obrzZisp7C0uEe921iRkU=
github.com/itchyny/timefmt-go v0.1.3/go.mod h1:0osSSCQSASBJMsIZnhAaF1C2fCBTJZXrnj37mG8/c+A=
github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
@@ -333,16 +325,15 @@ github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
-github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
+github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
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.6/go.mod h1:HOT/6NaBlR0f9XlxD3zolN6Z3N8Lp4pvhp+jLS5ihnI=
-github.com/microcosm-cc/bluemonday v1.0.16 h1:kHmAq2t7WPWLjiGvzKa5o3HzSfahUKiOq7fAPUiMNIc=
-github.com/microcosm-cc/bluemonday v1.0.16/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM=
+github.com/microcosm-cc/bluemonday v1.0.17 h1:Z1a//hgsQ4yjC+8zEkV8IWySkXnsxmdSY642CTFQb5Y=
+github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
@@ -357,10 +348,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
-github.com/muesli/reflow v0.2.0/go.mod h1:qT22vjVmM9MIUeLgsVYe/Ye7eZlbv9dZjL3dVhUqLX8=
-github.com/muesli/reflow v0.2.1-0.20210502190812-c80126ec2ad5 h1:T+Fc6qGlSfM+z0JPlp+n5rijvlg6C6JYFSNaqnCifDU=
-github.com/muesli/reflow v0.2.1-0.20210502190812-c80126ec2ad5/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
-github.com/muesli/termenv v0.8.1/go.mod h1:kzt/D/4a88RoheZmwfqorY3A+tnsSMA9HJC/fQSFKo0=
+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.9.0 h1:wnbOaGz+LUR3jNT0zOzinPnyDaCZUQRZj9GxK8eRVl8=
github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw=
github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38 h1:0FrBxrkJ0hVembTb/e4EU5Ml6vLcOusAqymmYISg5Uo=
@@ -375,7 +364,6 @@ github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144T
github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -403,8 +391,6 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
-github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
-github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/shurcooL/githubv4 v0.0.0-20200928013246-d292edc3691b h1:0/ecDXh/HTHRtSDSFnD2/Ta1yQ5J76ZspVY4u0/jGFk=
github.com/shurcooL/githubv4 v0.0.0-20200928013246-d292edc3691b/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo=
github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a h1:KikTa6HtAK8cS1qjvUvvq4QO21QnwC+EfvB+OAuZ/ZU=
@@ -440,9 +426,9 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.3.3/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
-github.com/yuin/goldmark v1.3.5 h1:dPmz1Snjq0kmkz159iL7S6WzdahUTHnHB5M56WFVifs=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+github.com/yuin/goldmark v1.4.4 h1:zNWRjYUW32G9KirMXYHQHVNFkXvMI7LpgNW2AgYAoIs=
+github.com/yuin/goldmark v1.4.4/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg=
github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os=
github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ=
go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
@@ -531,7 +517,6 @@ golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
-golang.org/x/net v0.0.0-20210331212208-0fccb6fa2b5c/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
@@ -601,7 +586,6 @@ golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -635,8 +619,9 @@ golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211205182925-97ca703d548d h1:FjkYO/PPp4Wi0EAUOVLxePm7qVW4r4ctbWpURyuOD0E=
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9 h1:nhht2DYV/Sn3qOayu8lM+cU1ii9sTLUeBQwQQfUHtrs=
+golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/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-20210503060354-a79de5458b56 h1:b8jxX3zqjpqb2LklXPzKSGJhzyxCOZSz8ncv8Nv+y7w=
golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY=
diff --git a/internal/authflow/flow.go b/internal/authflow/flow.go
index bfe9d1299..fbf0a9e34 100644
--- a/internal/authflow/flow.go
+++ b/internal/authflow/flow.go
@@ -27,13 +27,11 @@ type iconfig interface {
Write() error
}
-func AuthFlowWithConfig(cfg iconfig, IO *iostreams.IOStreams, hostname, notice string, additionalScopes []string) (string, error) {
+func AuthFlowWithConfig(cfg iconfig, IO *iostreams.IOStreams, hostname, notice string, additionalScopes []string, isInteractive bool) (string, error) {
// TODO this probably shouldn't live in this package. It should probably be in a new package that
// depends on both iostreams and config.
- stderr := IO.ErrOut
- cs := IO.ColorScheme()
- token, userLogin, err := authFlow(hostname, IO, notice, additionalScopes)
+ token, userLogin, err := authFlow(hostname, IO, notice, additionalScopes, isInteractive)
if err != nil {
return "", err
}
@@ -47,19 +45,10 @@ func AuthFlowWithConfig(cfg iconfig, IO *iostreams.IOStreams, hostname, notice s
return "", err
}
- err = cfg.Write()
- if err != nil {
- return "", err
- }
-
- fmt.Fprintf(stderr, "%s Authentication complete. %s to continue...\n",
- cs.SuccessIcon(), cs.Bold("Press Enter"))
- _ = waitForEnter(IO.In)
-
- return token, nil
+ return token, cfg.Write()
}
-func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, additionalScopes []string) (string, string, error) {
+func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, additionalScopes []string, isInteractive bool) (string, string, error) {
w := IO.ErrOut
cs := IO.ColorScheme()
@@ -90,7 +79,12 @@ func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition
return nil
},
BrowseURL: func(url string) error {
- fmt.Fprintf(w, "- %s to open %s in your browser... ", cs.Bold("Press Enter"), oauthHost)
+ if !isInteractive {
+ fmt.Fprintf(w, "%s to continue in your web browser: %s\n", cs.Bold("Open this URL"), url)
+ return nil
+ }
+
+ fmt.Fprintf(w, "%s to open %s in your browser... ", cs.Bold("Press Enter"), oauthHost)
_ = waitForEnter(IO.In)
// FIXME: read the browser from cmd Factory rather than recreating it
@@ -103,7 +97,7 @@ func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition
return nil
},
WriteSuccessHTML: func(w io.Writer) {
- fmt.Fprintln(w, oauthSuccessPage)
+ fmt.Fprint(w, oauthSuccessPage)
},
HTTPClient: httpClient,
Stdin: IO.In,
diff --git a/internal/codespaces/api/api.go b/internal/codespaces/api/api.go
index 8de16bbb8..14da851ae 100644
--- a/internal/codespaces/api/api.go
+++ b/internal/codespaces/api/api.go
@@ -32,6 +32,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "io"
"io/ioutil"
"net/http"
"net/url"
@@ -57,6 +58,7 @@ type API struct {
vscsAPI string
githubAPI string
githubServer string
+ retryBackoff time.Duration
}
type httpClient interface {
@@ -79,6 +81,7 @@ func New(serverURL, apiURL, vscsURL string, httpClient httpClient) *API {
vscsAPI: strings.TrimSuffix(vscsURL, "/"),
githubAPI: strings.TrimSuffix(apiURL, "/"),
githubServer: strings.TrimSuffix(serverURL, "/"),
+ retryBackoff: 100 * time.Millisecond,
}
}
@@ -158,14 +161,15 @@ func (a *API) GetRepository(ctx context.Context, nwo string) (*Repository, error
// Codespace represents a codespace.
type Codespace struct {
- Name string `json:"name"`
- CreatedAt string `json:"created_at"`
- LastUsedAt string `json:"last_used_at"`
- Owner User `json:"owner"`
- Repository Repository `json:"repository"`
- State string `json:"state"`
- GitStatus CodespaceGitStatus `json:"git_status"`
- Connection CodespaceConnection `json:"connection"`
+ Name string `json:"name"`
+ CreatedAt string `json:"created_at"`
+ DisplayName string `json:"display_name"`
+ LastUsedAt string `json:"last_used_at"`
+ Owner User `json:"owner"`
+ Repository Repository `json:"repository"`
+ State string `json:"state"`
+ GitStatus CodespaceGitStatus `json:"git_status"`
+ Connection CodespaceConnection `json:"connection"`
}
type CodespaceGitStatus struct {
@@ -195,6 +199,7 @@ type CodespaceConnection struct {
// CodespaceFields is the list of exportable fields for a codespace.
var CodespaceFields = []string{
+ "displayName",
"name",
"owner",
"repository",
@@ -301,24 +306,24 @@ func findNextPage(linkValue string) string {
// If the codespace is not found, an error is returned.
// If includeConnection is true, it will return the connection information for the codespace.
func (a *API) GetCodespace(ctx context.Context, codespaceName string, includeConnection bool) (*Codespace, error) {
- req, err := http.NewRequest(
- http.MethodGet,
- a.githubAPI+"/user/codespaces/"+codespaceName,
- nil,
- )
- if err != nil {
- return nil, fmt.Errorf("error creating request: %w", err)
- }
-
- if includeConnection {
- q := req.URL.Query()
- q.Add("internal", "true")
- q.Add("refresh", "true")
- req.URL.RawQuery = q.Encode()
- }
-
- a.setHeaders(req)
- resp, err := a.do(ctx, req, "/user/codespaces/*")
+ resp, err := a.withRetry(func() (*http.Response, error) {
+ req, err := http.NewRequest(
+ http.MethodGet,
+ a.githubAPI+"/user/codespaces/"+codespaceName,
+ nil,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("error creating request: %w", err)
+ }
+ if includeConnection {
+ q := req.URL.Query()
+ q.Add("internal", "true")
+ q.Add("refresh", "true")
+ req.URL.RawQuery = q.Encode()
+ }
+ a.setHeaders(req)
+ return a.do(ctx, req, "/user/codespaces/*")
+ })
if err != nil {
return nil, fmt.Errorf("error making request: %w", err)
}
@@ -344,17 +349,18 @@ func (a *API) GetCodespace(ctx context.Context, codespaceName string, includeCon
// StartCodespace starts a codespace for the user.
// If the codespace is already running, the returned error from the API is ignored.
func (a *API) StartCodespace(ctx context.Context, codespaceName string) error {
- req, err := http.NewRequest(
- http.MethodPost,
- a.githubAPI+"/user/codespaces/"+codespaceName+"/start",
- nil,
- )
- if err != nil {
- return fmt.Errorf("error creating request: %w", err)
- }
-
- a.setHeaders(req)
- resp, err := a.do(ctx, req, "/user/codespaces/*/start")
+ resp, err := a.withRetry(func() (*http.Response, error) {
+ req, err := http.NewRequest(
+ http.MethodPost,
+ a.githubAPI+"/user/codespaces/"+codespaceName+"/start",
+ nil,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("error creating request: %w", err)
+ }
+ a.setHeaders(req)
+ return a.do(ctx, req, "/user/codespaces/*/start")
+ })
if err != nil {
return fmt.Errorf("error making request: %w", err)
}
@@ -474,6 +480,84 @@ func (a *API) GetCodespacesMachines(ctx context.Context, repoID int, branch, loc
return response.Machines, nil
}
+// RepoSearchParameters are the optional parameters for searching for repositories.
+type RepoSearchParameters struct {
+ // The maximum number of repos to return. At most 100 repos are returned even if this value is greater than 100.
+ MaxRepos int
+ // The sort order for returned repos. Possible values are 'stars', 'forks', 'help-wanted-issues', or 'updated'. If empty the API's default ordering is used.
+ Sort string
+}
+
+// GetCodespaceRepoSuggestions searches for and returns repo names based on the provided search text.
+func (a *API) GetCodespaceRepoSuggestions(ctx context.Context, partialSearch string, parameters RepoSearchParameters) ([]string, error) {
+ reqURL := fmt.Sprintf("%s/search/repositories", a.githubAPI)
+ req, err := http.NewRequest(http.MethodGet, reqURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("error creating request: %w", err)
+ }
+
+ parts := strings.SplitN(partialSearch, "/", 2)
+
+ var nameSearch string
+ if len(parts) == 2 {
+ user := parts[0]
+ repo := parts[1]
+ nameSearch = fmt.Sprintf("%s user:%s", repo, user)
+ } else {
+ /*
+ * This results in searching for the text within the owner or the name. It's possible to
+ * do an owner search and then look up some repos for those owners, but that adds a
+ * good amount of latency to the fetch which slows down showing the suggestions.
+ */
+ nameSearch = partialSearch
+ }
+
+ queryStr := fmt.Sprintf("%s in:name", nameSearch)
+
+ q := req.URL.Query()
+ q.Add("q", queryStr)
+
+ if len(parameters.Sort) > 0 {
+ q.Add("sort", parameters.Sort)
+ }
+
+ if parameters.MaxRepos > 0 {
+ q.Add("per_page", strconv.Itoa(parameters.MaxRepos))
+ }
+
+ req.URL.RawQuery = q.Encode()
+
+ a.setHeaders(req)
+ resp, err := a.do(ctx, req, "/search/repositories/*")
+ if err != nil {
+ return nil, fmt.Errorf("error searching repositories: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, api.HandleHTTPError(resp)
+ }
+
+ b, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("error reading response body: %w", err)
+ }
+
+ var response struct {
+ Items []*Repository `json:"items"`
+ }
+ if err := json.Unmarshal(b, &response); err != nil {
+ return nil, fmt.Errorf("error unmarshaling response: %w", err)
+ }
+
+ repoNames := make([]string, len(response.Items))
+ for i, repo := range response.Items {
+ repoNames[i] = repo.FullName
+ }
+
+ return repoNames, nil
+}
+
// CreateCodespaceParams are the required parameters for provisioning a Codespace.
type CreateCodespaceParams struct {
RepositoryID int
@@ -481,6 +565,7 @@ type CreateCodespaceParams struct {
Branch string
Machine string
Location string
+ PermissionsOptOut bool
}
// CreateCodespace creates a codespace with the given parameters and returns a non-nil error if it
@@ -526,10 +611,20 @@ type startCreateRequest struct {
Ref string `json:"ref"`
Location string `json:"location"`
Machine string `json:"machine"`
+ PermissionsOptOut bool `json:"devcontainer_permissions_opt_out"`
}
var errProvisioningInProgress = errors.New("provisioning in progress")
+type AcceptPermissionsRequiredError struct {
+ Message string `json:"message"`
+ AllowPermissionsURL string `json:"allow_permissions_url"`
+}
+
+func (e AcceptPermissionsRequiredError) Error() string {
+ return e.Message
+}
+
// startCreate starts the creation of a codespace.
// It may return success or an error, or errProvisioningInProgress indicating that the operation
// did not complete before the GitHub API's time limit for RPCs (10s), in which case the caller
@@ -545,6 +640,7 @@ func (a *API) startCreate(ctx context.Context, params *CreateCodespaceParams) (*
Ref: params.Branch,
Location: params.Location,
Machine: params.Machine,
+ PermissionsOptOut: params.PermissionsOptOut,
})
if err != nil {
return nil, fmt.Errorf("error marshaling request: %w", err)
@@ -564,6 +660,29 @@ func (a *API) startCreate(ctx context.Context, params *CreateCodespaceParams) (*
if resp.StatusCode == http.StatusAccepted {
return nil, errProvisioningInProgress // RPC finished before result of creation known
+ } else if resp.StatusCode == http.StatusUnauthorized {
+ var (
+ ue AcceptPermissionsRequiredError
+ bodyCopy = &bytes.Buffer{}
+ r = io.TeeReader(resp.Body, bodyCopy)
+ )
+
+ b, err := ioutil.ReadAll(r)
+ if err != nil {
+ return nil, fmt.Errorf("error reading response body: %w", err)
+ }
+ if err := json.Unmarshal(b, &ue); err != nil {
+ return nil, fmt.Errorf("error unmarshaling response: %w", err)
+ }
+
+ if ue.AllowPermissionsURL != "" {
+ return nil, ue
+ }
+
+ resp.Body = ioutil.NopCloser(bodyCopy)
+
+ return nil, api.HandleHTTPError(resp)
+
} else if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, api.HandleHTTPError(resp)
}
@@ -686,3 +805,19 @@ func (a *API) do(ctx context.Context, req *http.Request, spanName string) (*http
func (a *API) setHeaders(req *http.Request) {
req.Header.Set("Accept", "application/vnd.github.v3+json")
}
+
+// withRetry takes a generic function that sends an http request and retries
+// only when the returned response has a >=500 status code.
+func (a *API) withRetry(f func() (*http.Response, error)) (resp *http.Response, err error) {
+ for i := 0; i < 5; i++ {
+ resp, err = f()
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode < 500 {
+ break
+ }
+ time.Sleep(a.retryBackoff * (time.Duration(i) + 1))
+ }
+ return resp, err
+}
diff --git a/internal/codespaces/api/api_test.go b/internal/codespaces/api/api_test.go
index 6dcd06a04..af394cf7e 100644
--- a/internal/codespaces/api/api_test.go
+++ b/internal/codespaces/api/api_test.go
@@ -114,3 +114,154 @@ func TestListCodespaces_unlimited(t *testing.T) {
t.Fatalf("expected codespace-249, got %s", codespaces[0].Name)
}
}
+
+func TestGetRepoSuggestions(t *testing.T) {
+ tests := []struct {
+ searchText string // The input search string
+ queryText string // The wanted query string (based off searchText)
+ sort string // (Optional) The RepoSearchParameters.Sort param
+ maxRepos string // (Optional) The RepoSearchParameters.MaxRepos param
+ }{
+ {
+ searchText: "test",
+ queryText: "test",
+ },
+ {
+ searchText: "org/repo",
+ queryText: "repo user:org",
+ },
+ {
+ searchText: "org/repo/extra",
+ queryText: "repo/extra user:org",
+ },
+ {
+ searchText: "test",
+ queryText: "test",
+ sort: "stars",
+ maxRepos: "1000",
+ },
+ }
+
+ for _, tt := range tests {
+ runRepoSearchTest(t, tt.searchText, tt.queryText, tt.sort, tt.maxRepos)
+ }
+}
+
+func createFakeSearchReposServer(t *testing.T, wantSearchText string, wantSort string, wantPerPage string, responseRepos []*Repository) *httptest.Server {
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/search/repositories" {
+ t.Error("Incorrect path")
+ return
+ }
+
+ query := r.URL.Query()
+ got := fmt.Sprintf("q=%q sort=%s per_page=%s", query.Get("q"), query.Get("sort"), query.Get("per_page"))
+ want := fmt.Sprintf("q=%q sort=%s per_page=%s", wantSearchText+" in:name", wantSort, wantPerPage)
+ if got != want {
+ t.Errorf("for query, got %s, want %s", got, want)
+ return
+ }
+
+ response := struct {
+ Items []*Repository `json:"items"`
+ }{
+ responseRepos,
+ }
+
+ if err := json.NewEncoder(w).Encode(response); err != nil {
+ t.Error(err)
+ }
+ }))
+}
+
+func runRepoSearchTest(t *testing.T, searchText, wantQueryText, wantSort, wantMaxRepos string) {
+ wantRepoNames := []string{"repo1", "repo2"}
+
+ apiResponseRepositories := make([]*Repository, 0)
+ for _, name := range wantRepoNames {
+ apiResponseRepositories = append(apiResponseRepositories, &Repository{FullName: name})
+ }
+
+ svr := createFakeSearchReposServer(t, wantQueryText, wantSort, wantMaxRepos, apiResponseRepositories)
+ defer svr.Close()
+
+ api := API{
+ githubAPI: svr.URL,
+ client: &http.Client{},
+ }
+
+ ctx := context.Background()
+
+ searchParameters := RepoSearchParameters{}
+ if len(wantSort) > 0 {
+ searchParameters.Sort = wantSort
+ }
+ if len(wantMaxRepos) > 0 {
+ searchParameters.MaxRepos, _ = strconv.Atoi(wantMaxRepos)
+ }
+
+ gotRepoNames, err := api.GetCodespaceRepoSuggestions(ctx, searchText, searchParameters)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ gotNamesStr := fmt.Sprintf("%v", gotRepoNames)
+ wantNamesStr := fmt.Sprintf("%v", wantRepoNames)
+ if gotNamesStr != wantNamesStr {
+ t.Fatalf("got repo names %s, want %s", gotNamesStr, wantNamesStr)
+ }
+}
+
+func TestRetries(t *testing.T) {
+ var callCount int
+ csName := "test_codespace"
+ handler := func(w http.ResponseWriter, r *http.Request) {
+ if callCount == 3 {
+ err := json.NewEncoder(w).Encode(Codespace{
+ Name: csName,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return
+ }
+ callCount++
+ w.WriteHeader(502)
+ }
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler(w, r) }))
+ t.Cleanup(srv.Close)
+ a := &API{
+ githubAPI: srv.URL,
+ client: &http.Client{},
+ }
+ cs, err := a.GetCodespace(context.Background(), "test", false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if callCount != 3 {
+ t.Fatalf("expected at least 2 retries but got %d", callCount)
+ }
+ if cs.Name != csName {
+ t.Fatalf("expected codespace name to be %q but got %q", csName, cs.Name)
+ }
+ callCount = 0
+ handler = func(w http.ResponseWriter, r *http.Request) {
+ callCount++
+ err := json.NewEncoder(w).Encode(Codespace{
+ Name: csName,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ cs, err = a.GetCodespace(context.Background(), "test", false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if callCount != 1 {
+ t.Fatalf("expected no retries but got %d calls", callCount)
+ }
+ if cs.Name != csName {
+ t.Fatalf("expected codespace name to be %q but got %q", csName, cs.Name)
+ }
+}
diff --git a/internal/config/config_file_test.go b/internal/config/config_file_test.go
index 4c35f24f9..100c065f3 100644
--- a/internal/config/config_file_test.go
+++ b/internal/config/config_file_test.go
@@ -80,13 +80,13 @@ example.com:
`)()
config, err := parseConfig("config.yml")
assert.NoError(t, err)
- val, err := config.Get("example.com", "git_protocol")
+ val, err := config.GetOrDefault("example.com", "git_protocol")
assert.NoError(t, err)
assert.Equal(t, "https", val)
- val, err = config.Get("github.com", "git_protocol")
+ val, err = config.GetOrDefault("github.com", "git_protocol")
assert.NoError(t, err)
assert.Equal(t, "ssh", val)
- val, err = config.Get("nonexistent.io", "git_protocol")
+ val, err = config.GetOrDefault("nonexistent.io", "git_protocol")
assert.NoError(t, err)
assert.Equal(t, "ssh", val)
}
diff --git a/internal/config/config_type.go b/internal/config/config_type.go
index 92792e93f..68da2af29 100644
--- a/internal/config/config_type.go
+++ b/internal/config/config_type.go
@@ -9,7 +9,10 @@ import (
// This interface describes interacting with some persistent configuration for gh.
type Config interface {
Get(string, string) (string, error)
+ GetOrDefault(string, string) (string, error)
GetWithSource(string, string) (string, string, error)
+ GetOrDefaultWithSource(string, string) (string, string, error)
+ Default(string) string
Set(string, string, string) error
UnsetHost(string)
Hosts() ([]string, error)
@@ -52,7 +55,7 @@ var configOptions = []ConfigOption{
},
{
Key: "http_unix_socket",
- Description: "the path to a unix socket through which to make HTTP connection",
+ Description: "the path to a Unix socket through which to make an HTTP connection",
DefaultValue: "",
},
{
diff --git a/internal/config/config_type_test.go b/internal/config/config_type_test.go
index bf53aabe4..c16455bcc 100644
--- a/internal/config/config_type_test.go
+++ b/internal/config/config_type_test.go
@@ -58,7 +58,7 @@ func Test_defaultConfig(t *testing.T) {
assert.Equal(t, expected, mainBuf.String())
assert.Equal(t, "", hostsBuf.String())
- proto, err := cfg.Get("", "git_protocol")
+ proto, err := cfg.GetOrDefault("", "git_protocol")
assert.NoError(t, err)
assert.Equal(t, "https", proto)
diff --git a/internal/config/from_env.go b/internal/config/from_env.go
index ad31537f4..3cc19879d 100644
--- a/internal/config/from_env.go
+++ b/internal/config/from_env.go
@@ -3,9 +3,11 @@ package config
import (
"fmt"
"os"
+ "sort"
"strconv"
"github.com/cli/cli/v2/internal/ghinstance"
+ "github.com/cli/cli/v2/pkg/set"
)
const (
@@ -34,19 +36,29 @@ type envConfig struct {
}
func (c *envConfig) Hosts() ([]string, error) {
- hasDefault := false
hosts, err := c.Config.Hosts()
- for _, h := range hosts {
- if h == ghinstance.Default() {
- hasDefault = true
- }
+ if err != nil {
+ return nil, err
}
- token, _ := AuthTokenFromEnv(ghinstance.Default())
- if (err != nil || !hasDefault) && token != "" {
- hosts = append([]string{ghinstance.Default()}, hosts...)
- return hosts, nil
+
+ hostSet := set.NewStringSet()
+ hostSet.AddValues(hosts)
+
+ // If GH_HOST is set then add it to list.
+ if host := os.Getenv(GH_HOST); host != "" {
+ hostSet.Add(host)
}
- return hosts, err
+
+ // If there is a valid environment variable token for the
+ // default host then add default host to list.
+ if token, _ := AuthTokenFromEnv(ghinstance.Default()); token != "" {
+ hostSet.Add(ghinstance.Default())
+ }
+
+ s := hostSet.ToSlice()
+ // If default host is in list then move it to the front.
+ sort.SliceStable(s, func(i, j int) bool { return s[i] == ghinstance.Default() })
+ return s, nil
}
func (c *envConfig) DefaultHost() (string, error) {
@@ -76,6 +88,24 @@ func (c *envConfig) GetWithSource(hostname, key string) (string, string, error)
return c.Config.GetWithSource(hostname, key)
}
+func (c *envConfig) GetOrDefault(hostname, key string) (val string, err error) {
+ val, _, err = c.GetOrDefaultWithSource(hostname, key)
+ return
+}
+
+func (c *envConfig) GetOrDefaultWithSource(hostname, key string) (val string, src string, err error) {
+ val, src, err = c.GetWithSource(hostname, key)
+ if err == nil && val == "" {
+ val = c.Default(key)
+ }
+
+ return
+}
+
+func (c *envConfig) Default(key string) string {
+ return c.Config.Default(key)
+}
+
func (c *envConfig) CheckWriteable(hostname, key string) error {
if hostname != "" && key == "oauth_token" {
if token, env := AuthTokenFromEnv(hostname); token != "" {
diff --git a/internal/config/from_env_test.go b/internal/config/from_env_test.go
index bf81c7976..4bce09e85 100644
--- a/internal/config/from_env_test.go
+++ b/internal/config/from_env_test.go
@@ -44,6 +44,7 @@ func TestInheritEnv(t *testing.T) {
tests := []struct {
name string
baseConfig string
+ GH_HOST string
GITHUB_TOKEN string
GITHUB_ENTERPRISE_TOKEN string
GH_TOKEN string
@@ -285,9 +286,23 @@ func TestInheritEnv(t *testing.T) {
writeable: false,
},
},
+ {
+ name: "GH_HOST adds host entry when paired with environment token",
+ baseConfig: ``,
+ GH_HOST: "example.org",
+ GH_ENTERPRISE_TOKEN: "GH_ENTERPRISE_TOKEN",
+ hostname: "example.org",
+ wants: wants{
+ hosts: []string{"example.org"},
+ token: "GH_ENTERPRISE_TOKEN",
+ source: "GH_ENTERPRISE_TOKEN",
+ writeable: false,
+ },
+ },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+ setenv(t, "GH_HOST", tt.GH_HOST)
setenv(t, "GITHUB_TOKEN", tt.GITHUB_TOKEN)
setenv(t, "GITHUB_ENTERPRISE_TOKEN", tt.GITHUB_ENTERPRISE_TOKEN)
setenv(t, "GH_TOKEN", tt.GH_TOKEN)
diff --git a/internal/config/from_file.go b/internal/config/from_file.go
index 080143df4..3c1cfd65b 100644
--- a/internal/config/from_file.go
+++ b/internal/config/from_file.go
@@ -65,13 +65,26 @@ func (c *fileConfig) GetWithSource(hostname, key string) (string, string, error)
return "", defaultSource, err
}
- if value == "" {
- return defaultFor(key), defaultSource, nil
- }
-
return value, defaultSource, nil
}
+func (c *fileConfig) GetOrDefault(hostname, key string) (val string, err error) {
+ val, _, err = c.GetOrDefaultWithSource(hostname, key)
+ return
+}
+
+func (c *fileConfig) GetOrDefaultWithSource(hostname, key string) (val string, src string, err error) {
+ val, src, err = c.GetWithSource(hostname, key)
+ if err != nil && val == "" {
+ val = c.Default(key)
+ }
+ return
+}
+
+func (c *fileConfig) Default(key string) string {
+ return defaultFor(key)
+}
+
func (c *fileConfig) Set(hostname, key, value string) error {
if hostname == "" {
return c.SetStringValue(key, value)
diff --git a/internal/config/stub.go b/internal/config/stub.go
index e68183d32..aeb2e5526 100644
--- a/internal/config/stub.go
+++ b/internal/config/stub.go
@@ -25,6 +25,23 @@ func (c ConfigStub) GetWithSource(host, key string) (string, string, error) {
return "", "", errors.New("not found")
}
+func (c ConfigStub) GetOrDefault(hostname, key string) (val string, err error) {
+ val, _, err = c.GetOrDefaultWithSource(hostname, key)
+ return
+}
+
+func (c ConfigStub) GetOrDefaultWithSource(hostname, key string) (val string, src string, err error) {
+ val, src, err = c.GetWithSource(hostname, key)
+ if err == nil && val == "" {
+ val = c.Default(key)
+ }
+ return
+}
+
+func (c ConfigStub) Default(key string) string {
+ return defaultFor(key)
+}
+
func (c ConfigStub) Set(host, key, value string) error {
c[genKey(host, key)] = value
return nil
diff --git a/internal/ghinstance/host.go b/internal/ghinstance/host.go
index 146a94e77..b96852a4d 100644
--- a/internal/ghinstance/host.go
+++ b/internal/ghinstance/host.go
@@ -72,13 +72,23 @@ func RESTPrefix(hostname string) string {
}
func GistPrefix(hostname string) string {
+ prefix := "https://"
+
+ if strings.EqualFold(hostname, localhost) {
+ prefix = "http://"
+ }
+
+ return prefix + GistHost(hostname)
+}
+
+func GistHost(hostname string) string {
if IsEnterprise(hostname) {
- return fmt.Sprintf("https://%s/gist/", hostname)
+ return fmt.Sprintf("%s/gist/", hostname)
}
if strings.EqualFold(hostname, localhost) {
- return fmt.Sprintf("http://%s/gist/", hostname)
+ return fmt.Sprintf("%s/gist/", hostname)
}
- return fmt.Sprintf("https://gist.%s/", hostname)
+ return fmt.Sprintf("gist.%s/", hostname)
}
func HostPrefix(hostname string) string {
diff --git a/pkg/cmd/actions/actions.go b/pkg/cmd/actions/actions.go
index e5620c42e..d24df6631 100644
--- a/pkg/cmd/actions/actions.go
+++ b/pkg/cmd/actions/actions.go
@@ -11,12 +11,10 @@ func NewCmdActions(f *cmdutil.Factory) *cobra.Command {
cs := f.IOStreams.ColorScheme()
cmd := &cobra.Command{
- Use: "actions",
- Short: "Learn about working with GitHub Actions",
- Long: actionsExplainer(cs),
- Annotations: map[string]string{
- "IsActions": "true",
- },
+ Use: "actions",
+ Short: "Learn about working with GitHub Actions",
+ Long: actionsExplainer(cs),
+ Hidden: true,
}
cmdutil.DisableAuthCheck(cmd)
diff --git a/pkg/cmd/alias/list/list.go b/pkg/cmd/alias/list/list.go
index 85609e7a4..973a4ebee 100644
--- a/pkg/cmd/alias/list/list.go
+++ b/pkg/cmd/alias/list/list.go
@@ -24,8 +24,9 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
}
cmd := &cobra.Command{
- Use: "list",
- Short: "List your aliases",
+ Use: "list",
+ Short: "List your aliases",
+ Aliases: []string{"ls"},
Long: heredoc.Doc(`
This command prints out all of the aliases gh is configured to use.
`),
diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go
index 473139faf..36f8eeb23 100644
--- a/pkg/cmd/api/api.go
+++ b/pkg/cmd/api/api.go
@@ -3,7 +3,6 @@ package api
import (
"bytes"
"encoding/json"
- "errors"
"fmt"
"io"
"io/ioutil"
@@ -13,7 +12,6 @@ import (
"sort"
"strconv"
"strings"
- "syscall"
"time"
"github.com/MakeNowJust/heredoc"
@@ -21,6 +19,7 @@ import (
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/internal/ghrepo"
+ "github.com/cli/cli/v2/pkg/cmd/factory"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/export"
"github.com/cli/cli/v2/pkg/iostreams"
@@ -71,10 +70,12 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
The endpoint argument should either be a path of a GitHub API v3 endpoint, or
"graphql" to access the GitHub API v4.
- Placeholder values "{owner}", "{repo}", and "{branch}" in the endpoint argument will
- get replaced with values from the repository of the current directory. Note that in
- some shells, for example PowerShell, you may need to enclose any value that contains
- "{...}" in quotes to prevent the shell from applying special meaning to curly braces.
+ Placeholder values "{owner}", "{repo}", and "{branch}" in the endpoint
+ argument will get replaced with values from the repository of the current
+ directory or the repository specified in the GH_REPO environment variable.
+ Note that in some shells, for example PowerShell, you may need to enclose
+ any value that contains "{...}" in quotes to prevent the shell from
+ applying special meaning to curly braces.
The default HTTP request method is "GET" normally and "POST" if any parameters
were added. Override the method with %[1]s--method%[1]s.
@@ -167,6 +168,9 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
`),
},
Args: cobra.ExactArgs(1),
+ PreRun: func(c *cobra.Command, args []string) {
+ opts.BaseRepo = cmdutil.OverrideBaseRepoFunc(f, "")
+ },
RunE: func(c *cobra.Command, args []string) error {
opts.RequestPath = args[0]
opts.RequestMethodPassed = c.Flags().Changed("method")
@@ -210,7 +214,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
cmd.Flags().StringArrayVarP(&opts.MagicFields, "field", "F", nil, "Add a typed parameter in `key=value` format")
cmd.Flags().StringArrayVarP(&opts.RawFields, "raw-field", "f", nil, "Add a string parameter in `key=value` format")
cmd.Flags().StringArrayVarP(&opts.RequestHeaders, "header", "H", nil, "Add a HTTP request header in `key:value` format")
- cmd.Flags().StringSliceVarP(&opts.Previews, "preview", "p", nil, "Opt into GitHub API previews")
+ cmd.Flags().StringSliceVarP(&opts.Previews, "preview", "p", nil, "GitHub API preview `names` to request (without the \"-preview\" suffix)")
cmd.Flags().BoolVarP(&opts.ShowResponseHeaders, "include", "i", false, "Include HTTP response headers in the output")
cmd.Flags().BoolVar(&opts.Paginate, "paginate", false, "Make additional HTTP requests to fetch all pages of results")
cmd.Flags().StringVar(&opts.RequestInputFile, "input", "", "The `file` to use as body for the HTTP request (use \"-\" to read from standard input)")
@@ -273,11 +277,11 @@ func apiRun(opts *ApiOptions) error {
if opts.Silent {
opts.IO.Out = ioutil.Discard
} else {
- err := opts.IO.StartPager()
- if err != nil {
- return err
+ if err := opts.IO.StartPager(); err == nil {
+ defer opts.IO.StopPager()
+ } else {
+ fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err)
}
- defer opts.IO.StopPager()
}
cfg, err := opts.Config()
@@ -360,13 +364,13 @@ func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream
responseBody = io.TeeReader(responseBody, bodyCopy)
}
- if opts.FilterOutput != "" {
+ if opts.FilterOutput != "" && serverError == "" {
// TODO: reuse parsed query across pagination invocations
err = export.FilterJSON(opts.IO.Out, responseBody, opts.FilterOutput)
if err != nil {
return
}
- } else if opts.Template != "" {
+ } else if opts.Template != "" && serverError == "" {
// TODO: reuse parsed template across pagination invocations
err = template.Execute(responseBody)
if err != nil {
@@ -378,11 +382,7 @@ func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream
_, err = io.Copy(opts.IO.Out, responseBody)
}
if err != nil {
- if errors.Is(err, syscall.EPIPE) {
- err = nil
- } else {
- return
- }
+ return
}
if serverError == "" && resp.StatusCode > 299 {
@@ -393,6 +393,9 @@ func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream
if msg := api.ScopesSuggestion(resp); msg != "" {
fmt.Fprintf(opts.IO.ErrOut, "gh: %s\n", msg)
}
+ if u := factory.SSOURL(); u != "" {
+ fmt.Fprintf(opts.IO.ErrOut, "Authorize in your web browser: %s\n", u)
+ }
err = cmdutil.SilentError
return
}
diff --git a/pkg/cmd/api/api_test.go b/pkg/cmd/api/api_test.go
index 04da8cc29..25190ea5f 100644
--- a/pkg/cmd/api/api_test.go
+++ b/pkg/cmd/api/api_test.go
@@ -490,6 +490,20 @@ func Test_apiRun(t *testing.T) {
stdout: "not a cat",
stderr: ``,
},
+ {
+ name: "output template when REST error",
+ options: ApiOptions{
+ Template: `{{.status}}`,
+ },
+ httpResponse: &http.Response{
+ StatusCode: 400,
+ Body: ioutil.NopCloser(bytes.NewBufferString(`{"message": "THIS IS FINE"}`)),
+ Header: http.Header{"Content-Type": []string{"application/json; charset=utf-8"}},
+ },
+ err: cmdutil.SilentError,
+ stdout: `{"message": "THIS IS FINE"}`,
+ stderr: "gh: THIS IS FINE (HTTP 400)\n",
+ },
{
name: "jq filter",
options: ApiOptions{
@@ -504,6 +518,20 @@ func Test_apiRun(t *testing.T) {
stdout: "Mona\nHubot\n",
stderr: ``,
},
+ {
+ name: "jq filter when REST error",
+ options: ApiOptions{
+ FilterOutput: `.[].name`,
+ },
+ httpResponse: &http.Response{
+ StatusCode: 400,
+ Body: ioutil.NopCloser(bytes.NewBufferString(`{"message": "THIS IS FINE"}`)),
+ Header: http.Header{"Content-Type": []string{"application/json; charset=utf-8"}},
+ },
+ err: cmdutil.SilentError,
+ stdout: `{"message": "THIS IS FINE"}`,
+ stderr: "gh: THIS IS FINE (HTTP 400)\n",
+ },
}
for _, tt := range tests {
diff --git a/pkg/cmd/auth/auth.go b/pkg/cmd/auth/auth.go
index e5ae67fa2..c3df8486d 100644
--- a/pkg/cmd/auth/auth.go
+++ b/pkg/cmd/auth/auth.go
@@ -14,8 +14,10 @@ import (
func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "auth ",
- Short: "Login, logout, and refresh your authentication",
- Long: `Manage gh's authentication state.`,
+ Short: "Authenticate gh and git with GitHub",
+ Annotations: map[string]string{
+ "IsCore": "true",
+ },
}
cmdutil.DisableAuthCheck(cmd)
diff --git a/pkg/cmd/auth/gitcredential/helper.go b/pkg/cmd/auth/gitcredential/helper.go
index 8d1ab7ff3..bda962e50 100644
--- a/pkg/cmd/auth/gitcredential/helper.go
+++ b/pkg/cmd/auth/gitcredential/helper.go
@@ -100,12 +100,21 @@ func helperRun(opts *CredentialOptions) error {
return err
}
+ lookupHost := wants["host"]
var gotUser string
- gotToken, source, _ := cfg.GetWithSource(wants["host"], "oauth_token")
+ gotToken, source, _ := cfg.GetWithSource(lookupHost, "oauth_token")
+ if gotToken == "" && strings.HasPrefix(lookupHost, "gist.") {
+ lookupHost = strings.TrimPrefix(lookupHost, "gist.")
+ gotToken, source, _ = cfg.GetWithSource(lookupHost, "oauth_token")
+ }
+
if strings.HasSuffix(source, "_TOKEN") {
gotUser = tokenUser
} else {
- gotUser, _, _ = cfg.GetWithSource(wants["host"], "user")
+ gotUser, _, _ = cfg.GetWithSource(lookupHost, "user")
+ if gotUser == "" {
+ gotUser = tokenUser
+ }
}
if gotUser == "" || gotToken == "" {
diff --git a/pkg/cmd/auth/gitcredential/helper_test.go b/pkg/cmd/auth/gitcredential/helper_test.go
index 7e30ec495..45488682d 100644
--- a/pkg/cmd/auth/gitcredential/helper_test.go
+++ b/pkg/cmd/auth/gitcredential/helper_test.go
@@ -8,6 +8,7 @@ import (
"github.com/cli/cli/v2/pkg/iostreams"
)
+// why not just use the config stub argh
type tinyConfig map[string]string
func (c tinyConfig) GetWithSource(host, key string) (string, string, error) {
@@ -74,6 +75,32 @@ func Test_helperRun(t *testing.T) {
`),
wantStderr: "",
},
+ {
+ name: "gist host",
+ opts: CredentialOptions{
+ Operation: "get",
+ Config: func() (config, error) {
+ return tinyConfig{
+ "_source": "/Users/monalisa/.config/gh/hosts.yml",
+ "github.com:user": "monalisa",
+ "github.com:oauth_token": "OTOKEN",
+ }, nil
+ },
+ },
+ input: heredoc.Doc(`
+ protocol=https
+ host=gist.github.com
+ username=monalisa
+ `),
+ wantErr: false,
+ wantStdout: heredoc.Doc(`
+ protocol=https
+ host=gist.github.com
+ username=monalisa
+ password=OTOKEN
+ `),
+ wantStderr: "",
+ },
{
name: "url input",
opts: CredentialOptions{
@@ -138,6 +165,30 @@ func Test_helperRun(t *testing.T) {
wantStdout: "",
wantStderr: "",
},
+ {
+ name: "no username configured",
+ opts: CredentialOptions{
+ Operation: "get",
+ Config: func() (config, error) {
+ return tinyConfig{
+ "_source": "/Users/monalisa/.config/gh/hosts.yml",
+ "example.com:oauth_token": "OTOKEN",
+ }, nil
+ },
+ },
+ input: heredoc.Doc(`
+ protocol=https
+ host=example.com
+ `),
+ wantErr: false,
+ wantStdout: heredoc.Doc(`
+ protocol=https
+ host=example.com
+ username=x-access-token
+ password=OTOKEN
+ `),
+ wantStderr: "",
+ },
{
name: "token from env",
opts: CredentialOptions{
diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go
index f591fcbc6..e8353fd4a 100644
--- a/pkg/cmd/auth/login/login.go
+++ b/pkg/cmd/auth/login/login.go
@@ -27,10 +27,11 @@ type LoginOptions struct {
Interactive bool
- Hostname string
- Scopes []string
- Token string
- Web bool
+ Hostname string
+ Scopes []string
+ Token string
+ Web bool
+ GitProtocol string
}
func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command {
@@ -49,13 +50,17 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm
Long: heredoc.Docf(`
Authenticate with a GitHub host.
- The default authentication mode is a web-based browser flow.
+ The default authentication mode is a web-based browser flow. After completion, an
+ authentication token will be stored internally.
- Alternatively, pass in a token on standard input by using %[1]s--with-token%[1]s.
+ Alternatively, use %[1]s--with-token%[1]s to pass in a token on standard input.
The minimum required scopes for the token are: "repo", "read:org".
- The --scopes flag accepts a comma separated list of scopes you want your gh credentials to have. If
- absent, this command ensures that gh has access to a minimum set of scopes.
+ Alternatively, gh will use the authentication token found in environment variables.
+ This method is most suitable for "headless" use of gh such as in automation. See
+ %[1]sgh help environment%[1]s for more info.
+
+ To use gh in GitHub Actions, add %[1]sGH_TOKEN: ${{secrets.GITHUB_TOKEN}}%[1]s to "env".
`, "`"),
Example: heredoc.Doc(`
# start interactive setup
@@ -64,41 +69,38 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm
# authenticate against github.com by reading the token from a file
$ gh auth login --with-token < mytoken.txt
- # authenticate with a specific GitHub Enterprise Server instance
+ # authenticate with a specific GitHub instance
$ gh auth login --hostname enterprise.internal
`),
RunE: func(cmd *cobra.Command, args []string) error {
- if !opts.IO.CanPrompt() && !(tokenStdin || opts.Web) {
- return cmdutil.FlagErrorf("--web or --with-token required when not running interactively")
- }
-
if tokenStdin && opts.Web {
- return cmdutil.FlagErrorf("specify only one of --web or --with-token")
+ return cmdutil.FlagErrorf("specify only one of `--web` or `--with-token`")
+ }
+ if tokenStdin && len(opts.Scopes) > 0 {
+ return cmdutil.FlagErrorf("specify only one of `--scopes` or `--with-token`")
}
if tokenStdin {
defer opts.IO.In.Close()
token, err := ioutil.ReadAll(opts.IO.In)
if err != nil {
- return fmt.Errorf("failed to read token from STDIN: %w", err)
+ return fmt.Errorf("failed to read token from standard input: %w", err)
}
opts.Token = strings.TrimSpace(string(token))
}
- if opts.IO.CanPrompt() && opts.Token == "" && !opts.Web {
+ if opts.IO.CanPrompt() && opts.Token == "" {
opts.Interactive = true
}
if cmd.Flags().Changed("hostname") {
if err := ghinstance.HostnameValidator(opts.Hostname); err != nil {
- return cmdutil.FlagErrorf("error parsing --hostname: %w", err)
+ return cmdutil.FlagErrorf("error parsing hostname: %w", err)
}
}
- if !opts.Interactive {
- if opts.Hostname == "" {
- opts.Hostname = ghinstance.Default()
- }
+ if opts.Hostname == "" && (!opts.Interactive || opts.Web) {
+ opts.Hostname = ghinstance.Default()
}
opts.MainExecutable = f.Executable()
@@ -111,9 +113,10 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm
}
cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The hostname of the GitHub instance to authenticate with")
- cmd.Flags().StringSliceVarP(&opts.Scopes, "scopes", "s", nil, "Additional authentication scopes for gh to have")
+ cmd.Flags().StringSliceVarP(&opts.Scopes, "scopes", "s", nil, "Additional authentication scopes to request")
cmd.Flags().BoolVar(&tokenStdin, "with-token", false, "Read token from standard input")
cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open a browser to authenticate")
+ cmdutil.StringEnumFlag(cmd, &opts.GitProtocol, "git-protocol", "p", "", []string{"ssh", "https"}, "The protocol to use for git operations")
return cmd
}
@@ -125,15 +128,11 @@ func loginRun(opts *LoginOptions) error {
}
hostname := opts.Hostname
- if hostname == "" {
- if opts.Interactive {
- var err error
- hostname, err = promptForHostname()
- if err != nil {
- return err
- }
- } else {
- return errors.New("must specify --hostname")
+ if opts.Interactive && hostname == "" {
+ var err error
+ hostname, err = promptForHostname()
+ if err != nil {
+ return err
}
}
@@ -193,6 +192,7 @@ func loginRun(opts *LoginOptions) error {
Web: opts.Web,
Scopes: opts.Scopes,
Executable: opts.MainExecutable,
+ GitProtocol: opts.GitProtocol,
})
}
diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go
index f6fbcabd5..b7c8438cb 100644
--- a/pkg/cmd/auth/login/login_test.go
+++ b/pkg/cmd/auth/login/login_test.go
@@ -5,6 +5,7 @@ import (
"net/http"
"os"
"regexp"
+ "runtime"
"testing"
"github.com/MakeNowJust/heredoc"
@@ -18,6 +19,21 @@ import (
"github.com/stretchr/testify/assert"
)
+func stubHomeDir(t *testing.T, dir string) {
+ homeEnv := "HOME"
+ switch runtime.GOOS {
+ case "windows":
+ homeEnv = "USERPROFILE"
+ case "plan9":
+ homeEnv = "home"
+ }
+ oldHomeDir := os.Getenv(homeEnv)
+ os.Setenv(homeEnv, dir)
+ t.Cleanup(func() {
+ os.Setenv(homeEnv, oldHomeDir)
+ })
+}
+
func Test_NewCmdLogin(t *testing.T) {
tests := []struct {
name string
@@ -50,13 +66,19 @@ func Test_NewCmdLogin(t *testing.T) {
name: "nontty, hostname",
stdinTTY: false,
cli: "--hostname claire.redfield",
- wantsErr: true,
+ wants: LoginOptions{
+ Hostname: "claire.redfield",
+ Token: "",
+ },
},
{
name: "nontty",
stdinTTY: false,
cli: "",
- wantsErr: true,
+ wants: LoginOptions{
+ Hostname: "github.com",
+ Token: "",
+ },
},
{
name: "nontty, with-token, hostname",
@@ -102,8 +124,9 @@ func Test_NewCmdLogin(t *testing.T) {
stdinTTY: true,
cli: "--web",
wants: LoginOptions{
- Hostname: "github.com",
- Web: true,
+ Hostname: "github.com",
+ Web: true,
+ Interactive: true,
},
},
{
@@ -147,8 +170,7 @@ func Test_NewCmdLogin(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
io, stdin, _, _ := iostreams.Test()
f := &cmdutil.Factory{
- IOStreams: io,
- Executable: func() string { return "/path/to/gh" },
+ IOStreams: io,
}
io.SetStdoutTTY(true)
@@ -346,6 +368,8 @@ func Test_loginRun_nontty(t *testing.T) {
}
func Test_loginRun_Survey(t *testing.T) {
+ stubHomeDir(t, t.TempDir())
+
tests := []struct {
name string
opts *LoginOptions
@@ -371,8 +395,8 @@ func Test_loginRun_Survey(t *testing.T) {
// httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`))
},
askStubs: func(as *prompt.AskStubber) {
- as.StubOne(0) // host type github.com
- as.StubOne(false) // do not continue
+ as.StubPrompt("What account do you want to log into?").AnswerWith("GitHub.com")
+ as.StubPrompt("You're already logged into github.com. Do you want to re-authenticate?").AnswerWith(false)
},
wantHosts: "", // nothing should have been written to hosts
wantErrOut: nil,
@@ -390,10 +414,10 @@ func Test_loginRun_Survey(t *testing.T) {
git_protocol: https
`),
askStubs: func(as *prompt.AskStubber) {
- as.StubOne("HTTPS") // git_protocol
- as.StubOne(false) // cache credentials
- as.StubOne(1) // auth mode: token
- as.StubOne("def456") // auth token
+ as.StubPrompt("What is your preferred protocol for Git operations?").AnswerWith("HTTPS")
+ as.StubPrompt("Authenticate Git with your GitHub credentials?").AnswerWith(false)
+ as.StubPrompt("How would you like to authenticate GitHub CLI?").AnswerWith("Paste an authentication token")
+ as.StubPrompt("Paste your authentication token:").AnswerWith("def456")
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git config credential\.https:/`, 1, "")
@@ -419,12 +443,12 @@ func Test_loginRun_Survey(t *testing.T) {
Interactive: true,
},
askStubs: func(as *prompt.AskStubber) {
- as.StubOne(1) // host type enterprise
- as.StubOne("brad.vickers") // hostname
- as.StubOne("HTTPS") // git_protocol
- as.StubOne(false) // cache credentials
- as.StubOne(1) // auth mode: token
- as.StubOne("def456") // auth token
+ as.StubPrompt("What account do you want to log into?").AnswerWith("GitHub Enterprise Server")
+ as.StubPrompt("GHE hostname:").AnswerWith("brad.vickers")
+ as.StubPrompt("What is your preferred protocol for Git operations?").AnswerWith("HTTPS")
+ as.StubPrompt("Authenticate Git with your GitHub credentials?").AnswerWith(false)
+ as.StubPrompt("How would you like to authenticate GitHub CLI?").AnswerWith("Paste an authentication token")
+ as.StubPrompt("Paste your authentication token:").AnswerWith("def456")
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git config credential\.https:/`, 1, "")
@@ -450,11 +474,11 @@ func Test_loginRun_Survey(t *testing.T) {
Interactive: true,
},
askStubs: func(as *prompt.AskStubber) {
- as.StubOne(0) // host type github.com
- as.StubOne("HTTPS") // git_protocol
- as.StubOne(false) // cache credentials
- as.StubOne(1) // auth mode: token
- as.StubOne("def456") // auth token
+ as.StubPrompt("What account do you want to log into?").AnswerWith("GitHub.com")
+ as.StubPrompt("What is your preferred protocol for Git operations?").AnswerWith("HTTPS")
+ as.StubPrompt("Authenticate Git with your GitHub credentials?").AnswerWith(false)
+ as.StubPrompt("How would you like to authenticate GitHub CLI?").AnswerWith("Paste an authentication token")
+ as.StubPrompt("Paste your authentication token:").AnswerWith("def456")
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git config credential\.https:/`, 1, "")
@@ -474,11 +498,11 @@ func Test_loginRun_Survey(t *testing.T) {
Interactive: true,
},
askStubs: func(as *prompt.AskStubber) {
- as.StubOne(0) // host type github.com
- as.StubOne("SSH") // git_protocol
- as.StubOne(10) // TODO: SSH key selection
- as.StubOne(1) // auth mode: token
- as.StubOne("def456") // auth token
+ as.StubPrompt("What account do you want to log into?").AnswerWith("GitHub.com")
+ as.StubPrompt("What is your preferred protocol for Git operations?").AnswerWith("SSH")
+ as.StubPrompt("Generate a new SSH key to add to your GitHub account?").AnswerWith(false)
+ as.StubPrompt("How would you like to authenticate GitHub CLI?").AnswerWith("Paste an authentication token")
+ as.StubPrompt("Paste your authentication token:").AnswerWith("def456")
},
wantErrOut: regexp.MustCompile("Tip: you can generate a Personal Access Token here https://github.com/settings/tokens"),
},
@@ -524,8 +548,7 @@ func Test_loginRun_Survey(t *testing.T) {
hostsBuf := bytes.Buffer{}
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
- as, teardown := prompt.InitAskStubber()
- defer teardown()
+ as := prompt.NewAskStubber(t)
if tt.askStubs != nil {
tt.askStubs(as)
}
diff --git a/pkg/cmd/auth/logout/logout_test.go b/pkg/cmd/auth/logout/logout_test.go
index a5d46ef09..4d74caaf5 100644
--- a/pkg/cmd/auth/logout/logout_test.go
+++ b/pkg/cmd/auth/logout/logout_test.go
@@ -106,8 +106,8 @@ func Test_logoutRun_tty(t *testing.T) {
cfgHosts: []string{"cheryl.mason", "github.com"},
wantHosts: "cheryl.mason:\n oauth_token: abc123\n",
askStubs: func(as *prompt.AskStubber) {
- as.StubOne("github.com")
- as.StubOne(true)
+ as.StubPrompt("What account do you want to log out of?").AnswerWith("github.com")
+ as.StubPrompt("Are you sure you want to log out of github.com account 'cybilb'?").AnswerWith(true)
},
wantErrOut: regexp.MustCompile(`Logged out of github.com account 'cybilb'`),
},
@@ -116,7 +116,7 @@ func Test_logoutRun_tty(t *testing.T) {
opts: &LogoutOptions{},
cfgHosts: []string{"github.com"},
askStubs: func(as *prompt.AskStubber) {
- as.StubOne(true)
+ as.StubPrompt("Are you sure you want to log out of github.com account 'cybilb'?").AnswerWith(true)
},
wantErrOut: regexp.MustCompile(`Logged out of github.com account 'cybilb'`),
},
@@ -133,7 +133,7 @@ func Test_logoutRun_tty(t *testing.T) {
cfgHosts: []string{"cheryl.mason", "github.com"},
wantHosts: "github.com:\n oauth_token: abc123\n",
askStubs: func(as *prompt.AskStubber) {
- as.StubOne(true)
+ as.StubPrompt("Are you sure you want to log out of cheryl.mason account 'cybilb'?").AnswerWith(true)
},
wantErrOut: regexp.MustCompile(`Logged out of cheryl.mason account 'cybilb'`),
},
@@ -169,8 +169,7 @@ func Test_logoutRun_tty(t *testing.T) {
hostsBuf := bytes.Buffer{}
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
- as, teardown := prompt.InitAskStubber()
- defer teardown()
+ as := prompt.NewAskStubber(t)
if tt.askStubs != nil {
tt.askStubs(as)
}
diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go
index f2b9cbbb4..1b7336f0b 100644
--- a/pkg/cmd/auth/refresh/refresh.go
+++ b/pkg/cmd/auth/refresh/refresh.go
@@ -26,7 +26,7 @@ type RefreshOptions struct {
Hostname string
Scopes []string
- AuthFlow func(config.Config, *iostreams.IOStreams, string, []string) error
+ AuthFlow func(config.Config, *iostreams.IOStreams, string, []string, bool) error
Interactive bool
}
@@ -35,8 +35,8 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.
opts := &RefreshOptions{
IO: f.IOStreams,
Config: f.Config,
- AuthFlow: func(cfg config.Config, io *iostreams.IOStreams, hostname string, scopes []string) error {
- _, err := authflow.AuthFlowWithConfig(cfg, io, hostname, "", scopes)
+ AuthFlow: func(cfg config.Config, io *iostreams.IOStreams, hostname string, scopes []string, interactive bool) error {
+ _, err := authflow.AuthFlowWithConfig(cfg, io, hostname, "", scopes, interactive)
return err
},
httpClient: http.DefaultClient,
@@ -46,7 +46,7 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.
Use: "refresh",
Args: cobra.ExactArgs(0),
Short: "Refresh stored authentication credentials",
- Long: heredoc.Doc(`Expand or fix the permission scopes for stored credentials
+ Long: heredoc.Doc(`Expand or fix the permission scopes for stored credentials.
The --scopes flag accepts a comma separated list of scopes you want your gh credentials to have. If
absent, this command ensures that gh has access to a minimum set of scopes.
@@ -146,7 +146,7 @@ func refreshRun(opts *RefreshOptions) error {
credentialFlow := &shared.GitCredentialFlow{
Executable: opts.MainExecutable,
}
- gitProtocol, _ := cfg.Get(hostname, "git_protocol")
+ gitProtocol, _ := cfg.GetOrDefault(hostname, "git_protocol")
if opts.Interactive && gitProtocol == "https" {
if err := credentialFlow.Prompt(hostname); err != nil {
return err
@@ -154,10 +154,13 @@ func refreshRun(opts *RefreshOptions) error {
additionalScopes = append(additionalScopes, credentialFlow.Scopes()...)
}
- if err := opts.AuthFlow(cfg, opts.IO, hostname, append(opts.Scopes, additionalScopes...)); err != nil {
+ if err := opts.AuthFlow(cfg, opts.IO, hostname, append(opts.Scopes, additionalScopes...), opts.Interactive); err != nil {
return err
}
+ cs := opts.IO.ColorScheme()
+ fmt.Fprintf(opts.IO.ErrOut, "%s Authentication complete.\n", cs.SuccessIcon())
+
if credentialFlow.ShouldSetup() {
username, _ := cfg.Get(hostname, "user")
password, _ := cfg.Get(hostname, "oauth_token")
diff --git a/pkg/cmd/auth/refresh/refresh_test.go b/pkg/cmd/auth/refresh/refresh_test.go
index dbdae26af..1bee8435d 100644
--- a/pkg/cmd/auth/refresh/refresh_test.go
+++ b/pkg/cmd/auth/refresh/refresh_test.go
@@ -91,8 +91,7 @@ func Test_NewCmdRefresh(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
io, _, _, _ := iostreams.Test()
f := &cmdutil.Factory{
- IOStreams: io,
- Executable: func() string { return "/path/to/gh" },
+ IOStreams: io,
}
io.SetStdinTTY(tt.tty)
io.SetStdoutTTY(tt.tty)
@@ -195,7 +194,7 @@ func Test_refreshRun(t *testing.T) {
Hostname: "",
},
askStubs: func(as *prompt.AskStubber) {
- as.StubOne("github.com")
+ as.StubPrompt("What account do you want to refresh auth for?").AnswerWith("github.com")
},
wantAuthArgs: authArgs{
hostname: "github.com",
@@ -233,7 +232,7 @@ func Test_refreshRun(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
aa := authArgs{}
- tt.opts.AuthFlow = func(_ config.Config, _ *iostreams.IOStreams, hostname string, scopes []string) error {
+ tt.opts.AuthFlow = func(_ config.Config, _ *iostreams.IOStreams, hostname string, scopes []string, interactive bool) error {
aa.hostname = hostname
aa.scopes = scopes
return nil
@@ -277,8 +276,7 @@ func Test_refreshRun(t *testing.T) {
hostsBuf := bytes.Buffer{}
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
- as, teardown := prompt.InitAskStubber()
- defer teardown()
+ as := prompt.NewAskStubber(t)
if tt.askStubs != nil {
tt.askStubs(as)
}
diff --git a/pkg/cmd/auth/shared/git_credential.go b/pkg/cmd/auth/shared/git_credential.go
index 9e47bcc7e..fb8ba31c2 100644
--- a/pkg/cmd/auth/shared/git_credential.go
+++ b/pkg/cmd/auth/shared/git_credential.go
@@ -63,25 +63,46 @@ func (flow *GitCredentialFlow) Setup(hostname, username, authToken string) error
func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password string) error {
if flow.helper == "" {
- // first use a blank value to indicate to git we want to sever the chain of credential helpers
- preConfigureCmd, err := git.GitCommand("config", "--global", "--replace-all", gitCredentialHelperKey(hostname), "")
- if err != nil {
- return err
- }
- if err = run.PrepareCmd(preConfigureCmd).Run(); err != nil {
- return err
+ credHelperKeys := []string{
+ gitCredentialHelperKey(hostname),
}
- // use GitHub CLI as a credential helper (for this host only)
- configureCmd, err := git.GitCommand(
- "config", "--global", "--add",
- gitCredentialHelperKey(hostname),
- fmt.Sprintf("!%s auth git-credential", shellQuote(flow.Executable)),
- )
- if err != nil {
- return err
+ gistHost := strings.TrimSuffix(ghinstance.GistHost(hostname), "/")
+ if strings.HasPrefix(gistHost, "gist.") {
+ credHelperKeys = append(credHelperKeys, gitCredentialHelperKey(gistHost))
}
- return run.PrepareCmd(configureCmd).Run()
+
+ var configErr error
+
+ for _, credHelperKey := range credHelperKeys {
+ if configErr != nil {
+ break
+ }
+ // first use a blank value to indicate to git we want to sever the chain of credential helpers
+ preConfigureCmd, err := git.GitCommand("config", "--global", "--replace-all", credHelperKey, "")
+ if err != nil {
+ configErr = err
+ break
+ }
+ if err = run.PrepareCmd(preConfigureCmd).Run(); err != nil {
+ configErr = err
+ break
+ }
+
+ // second configure the actual helper for this host
+ configureCmd, err := git.GitCommand(
+ "config", "--global", "--add",
+ credHelperKey,
+ fmt.Sprintf("!%s auth git-credential", shellQuote(flow.Executable)),
+ )
+ if err != nil {
+ configErr = err
+ } else {
+ configErr = run.PrepareCmd(configureCmd).Run()
+ }
+ }
+
+ return configErr
}
// clear previous cached credentials
@@ -121,7 +142,8 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s
}
func gitCredentialHelperKey(hostname string) string {
- return fmt.Sprintf("credential.%s.helper", strings.TrimSuffix(ghinstance.HostPrefix(hostname), "/"))
+ host := strings.TrimSuffix(ghinstance.HostPrefix(hostname), "/")
+ return fmt.Sprintf("credential.%s.helper", host)
}
func gitCredentialHelper(hostname string) (helper string, err error) {
diff --git a/pkg/cmd/auth/shared/git_credential_test.go b/pkg/cmd/auth/shared/git_credential_test.go
index dfdb72db9..fe674e1d7 100644
--- a/pkg/cmd/auth/shared/git_credential_test.go
+++ b/pkg/cmd/auth/shared/git_credential_test.go
@@ -22,7 +22,54 @@ func TestGitCredentialSetup_configureExisting(t *testing.T) {
}
}
-func TestGitCredentialSetup_setOurs(t *testing.T) {
+func TestGitCredentialsSetup_setOurs_GH(t *testing.T) {
+ cs, restoreRun := run.Stub()
+ defer restoreRun(t)
+ cs.Register(`git config --global --replace-all credential\.`, 0, "", func(args []string) {
+ if key := args[len(args)-2]; key != "credential.https://github.com.helper" {
+ t.Errorf("git config key was %q", key)
+ }
+ if val := args[len(args)-1]; val != "" {
+ t.Errorf("global credential helper configured to %q", val)
+ }
+ })
+ cs.Register(`git config --global --add credential\.`, 0, "", func(args []string) {
+ if key := args[len(args)-2]; key != "credential.https://github.com.helper" {
+ t.Errorf("git config key was %q", key)
+ }
+ if val := args[len(args)-1]; val != "!/path/to/gh auth git-credential" {
+ t.Errorf("global credential helper configured to %q", val)
+ }
+ })
+ cs.Register(`git config --global --replace-all credential\.`, 0, "", func(args []string) {
+ if key := args[len(args)-2]; key != "credential.https://gist.github.com.helper" {
+ t.Errorf("git config key was %q", key)
+ }
+ if val := args[len(args)-1]; val != "" {
+ t.Errorf("global credential helper configured to %q", val)
+ }
+ })
+ cs.Register(`git config --global --add credential\.`, 0, "", func(args []string) {
+ if key := args[len(args)-2]; key != "credential.https://gist.github.com.helper" {
+ t.Errorf("git config key was %q", key)
+ }
+ if val := args[len(args)-1]; val != "!/path/to/gh auth git-credential" {
+ t.Errorf("global credential helper configured to %q", val)
+ }
+ })
+
+ f := GitCredentialFlow{
+ Executable: "/path/to/gh",
+ helper: "",
+ }
+
+ if err := f.gitCredentialSetup("github.com", "monalisa", "PASSWD"); err != nil {
+ t.Errorf("GitCredentialSetup() error = %v", err)
+ }
+
+}
+
+func TestGitCredentialSetup_setOurs_nonGH(t *testing.T) {
cs, restoreRun := run.Stub()
defer restoreRun(t)
cs.Register(`git config --global --replace-all credential\.`, 0, "", func(args []string) {
diff --git a/pkg/cmd/auth/shared/login_flow.go b/pkg/cmd/auth/shared/login_flow.go
index 0bac49b35..c33051e44 100644
--- a/pkg/cmd/auth/shared/login_flow.go
+++ b/pkg/cmd/auth/shared/login_flow.go
@@ -29,6 +29,7 @@ type LoginOptions struct {
Web bool
Scopes []string
Executable string
+ GitProtocol string
sshContext sshContext
}
@@ -39,8 +40,8 @@ func Login(opts *LoginOptions) error {
httpClient := opts.HTTPClient
cs := opts.IO.ColorScheme()
- var gitProtocol string
- if opts.Interactive {
+ gitProtocol := strings.ToLower(opts.GitProtocol)
+ if opts.Interactive && gitProtocol == "" {
var proto string
err := prompt.SurveyAskOne(&survey.Select{
Message: "What is your preferred protocol for Git operations?",
@@ -99,7 +100,7 @@ func Login(opts *LoginOptions) error {
var authMode int
if opts.Web {
authMode = 0
- } else {
+ } else if opts.Interactive {
err := prompt.SurveyAskOne(&survey.Select{
Message: "How would you like to authenticate GitHub CLI?",
Options: []string{
@@ -117,10 +118,11 @@ func Login(opts *LoginOptions) error {
if authMode == 0 {
var err error
- authToken, err = authflow.AuthFlowWithConfig(cfg, opts.IO, hostname, "", append(opts.Scopes, additionalScopes...))
+ authToken, err = authflow.AuthFlowWithConfig(cfg, opts.IO, hostname, "", append(opts.Scopes, additionalScopes...), opts.Interactive)
if err != nil {
return fmt.Errorf("failed to authenticate via web browser: %w", err)
}
+ fmt.Fprintf(opts.IO.ErrOut, "%s Authentication complete.\n", cs.SuccessIcon())
userValidated = true
} else {
minimumScopes := append([]string{"repo", "read:org"}, additionalScopes...)
diff --git a/pkg/cmd/auth/shared/login_flow_test.go b/pkg/cmd/auth/shared/login_flow_test.go
index 530e34045..6f1b35ebe 100644
--- a/pkg/cmd/auth/shared/login_flow_test.go
+++ b/pkg/cmd/auth/shared/login_flow_test.go
@@ -47,14 +47,13 @@ func TestLogin_ssh(t *testing.T) {
httpmock.REST("POST", "api/v3/user/keys"),
httpmock.StringResponse(`{}`))
- ask, askRestore := prompt.InitAskStubber()
- defer askRestore()
+ ask := prompt.NewAskStubber(t)
- ask.StubOne("SSH") // preferred protocol
- ask.StubOne(true) // generate a new key
- ask.StubOne("monkey") // enter a passphrase
- ask.StubOne(1) // paste a token
- ask.StubOne("ATOKEN") // token
+ ask.StubPrompt("What is your preferred protocol for Git operations?").AnswerWith("SSH")
+ ask.StubPrompt("Generate a new SSH key to add to your GitHub account?").AnswerWith(true)
+ ask.StubPrompt("Enter a passphrase for your new SSH key (Optional)").AnswerWith("monkey")
+ ask.StubPrompt("How would you like to authenticate GitHub CLI?").AnswerWith("Paste an authentication token")
+ ask.StubPrompt("Paste your authentication token:").AnswerWith("ATOKEN")
rs, runRestore := run.Stub()
defer runRestore(t)
diff --git a/pkg/cmd/auth/status/status.go b/pkg/cmd/auth/status/status.go
index f84004c87..e09273e99 100644
--- a/pkg/cmd/auth/status/status.go
+++ b/pkg/cmd/auth/status/status.go
@@ -35,7 +35,7 @@ func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Co
Args: cobra.ExactArgs(0),
Short: "View authentication status",
Long: heredoc.Doc(`Verifies and displays information about your authentication state.
-
+
This command will test your authentication state for each GitHub host that gh knows about and
report on any issues.
`),
@@ -127,7 +127,7 @@ func statusRun(opts *StatusOptions) error {
addMsg("%s %s: api call failed: %s", cs.Red("X"), hostname, err)
}
addMsg("%s Logged in to %s as %s (%s)", cs.SuccessIcon(), hostname, cs.Bold(username), tokenSource)
- proto, _ := cfg.Get(hostname, "git_protocol")
+ proto, _ := cfg.GetOrDefault(hostname, "git_protocol")
if proto != "" {
addMsg("%s Git operations for %s configured to use %s protocol.",
cs.SuccessIcon(), hostname, cs.Bold(proto))
diff --git a/pkg/cmd/codespace/code.go b/pkg/cmd/codespace/code.go
index 2c66b4655..f80e32527 100644
--- a/pkg/cmd/codespace/code.go
+++ b/pkg/cmd/codespace/code.go
@@ -3,17 +3,11 @@ package codespace
import (
"context"
"fmt"
- "io/ioutil"
"net/url"
- "github.com/cli/cli/v2/pkg/cmdutil"
"github.com/spf13/cobra"
)
-type browser interface {
- Browse(string) error
-}
-
func newCodeCmd(app *App) *cobra.Command {
var (
codespace string
@@ -25,8 +19,7 @@ func newCodeCmd(app *App) *cobra.Command {
Short: "Open a codespace in Visual Studio Code",
Args: noArgsConstraint,
RunE: func(cmd *cobra.Command, args []string) error {
- b := cmdutil.NewBrowser("", ioutil.Discard, app.io.ErrOut)
- return app.VSCode(cmd.Context(), b, codespace, useInsiders)
+ return app.VSCode(cmd.Context(), codespace, useInsiders)
},
}
@@ -37,7 +30,7 @@ func newCodeCmd(app *App) *cobra.Command {
}
// VSCode opens a codespace in the local VS VSCode application.
-func (a *App) VSCode(ctx context.Context, browser browser, codespaceName string, useInsiders bool) error {
+func (a *App) VSCode(ctx context.Context, codespaceName string, useInsiders bool) error {
if codespaceName == "" {
codespace, err := chooseCodespace(ctx, a.apiClient)
if err != nil {
@@ -50,7 +43,7 @@ func (a *App) VSCode(ctx context.Context, browser browser, codespaceName string,
}
url := vscodeProtocolURL(codespaceName, useInsiders)
- if err := browser.Browse(url); err != nil {
+ if err := a.browser.Browse(url); err != nil {
return fmt.Errorf("error opening Visual Studio Code: %w", err)
}
diff --git a/pkg/cmd/codespace/code_test.go b/pkg/cmd/codespace/code_test.go
index 956219bb2..cbc59864a 100644
--- a/pkg/cmd/codespace/code_test.go
+++ b/pkg/cmd/codespace/code_test.go
@@ -40,8 +40,10 @@ func TestApp_VSCode(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b := &cmdutil.TestBrowser{}
- a := &App{}
- if err := a.VSCode(context.Background(), b, tt.args.codespaceName, tt.args.useInsiders); (err != nil) != tt.wantErr {
+ a := &App{
+ browser: b,
+ }
+ if err := a.VSCode(context.Background(), tt.args.codespaceName, tt.args.useInsiders); (err != nil) != tt.wantErr {
t.Errorf("App.VSCode() error = %v, wantErr %v", err, tt.wantErr)
}
b.Verify(t, tt.wantURL)
diff --git a/pkg/cmd/codespace/common.go b/pkg/cmd/codespace/common.go
index 0f6f9cf4e..d21f9ff6c 100644
--- a/pkg/cmd/codespace/common.go
+++ b/pkg/cmd/codespace/common.go
@@ -21,19 +21,31 @@ import (
"golang.org/x/term"
)
-type App struct {
- io *iostreams.IOStreams
- apiClient apiClient
- errLogger *log.Logger
+type browser interface {
+ Browse(string) error
}
-func NewApp(io *iostreams.IOStreams, apiClient apiClient) *App {
+type executable interface {
+ Executable() string
+}
+
+type App struct {
+ io *iostreams.IOStreams
+ apiClient apiClient
+ errLogger *log.Logger
+ executable executable
+ browser browser
+}
+
+func NewApp(io *iostreams.IOStreams, exe executable, apiClient apiClient, browser browser) *App {
errLogger := log.New(io.ErrOut, "", 0)
return &App{
- io: io,
- apiClient: apiClient,
- errLogger: errLogger,
+ io: io,
+ apiClient: apiClient,
+ errLogger: errLogger,
+ executable: exe,
+ browser: browser,
}
}
@@ -61,6 +73,7 @@ type apiClient interface {
GetCodespaceRegionLocation(ctx context.Context) (string, error)
GetCodespacesMachines(ctx context.Context, repoID int, branch, location string) ([]*api.Machine, error)
GetCodespaceRepositoryContents(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error)
+ GetCodespaceRepoSuggestions(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error)
}
var errNoCodespaces = errors.New("you have no codespaces")
@@ -209,8 +222,13 @@ func ask(qs []*survey.Question, response interface{}) error {
// checkAuthorizedKeys reports an error if the user has not registered any SSH keys;
// see https://github.com/cli/cli/v2/issues/166#issuecomment-921769703.
// The check is not required for security but it improves the error message.
-func checkAuthorizedKeys(ctx context.Context, client apiClient, user string) error {
- keys, err := client.AuthorizedKeys(ctx, user)
+func checkAuthorizedKeys(ctx context.Context, client apiClient) error {
+ user, err := client.GetUser(ctx)
+ if err != nil {
+ return fmt.Errorf("error getting user: %w", err)
+ }
+
+ keys, err := client.AuthorizedKeys(ctx, user.Login)
if err != nil {
return fmt.Errorf("failed to read GitHub-authorized SSH keys for %s: %w", user, err)
}
@@ -238,7 +256,7 @@ type codespace struct {
}
// displayName returns the repository nwo and branch.
-// If includeName is true, the name of the codespace is included.
+// If includeName is true, the name of the codespace (including displayName) is included.
// If includeGitStatus is true, the branch will include a star if
// the codespace has unsaved changes.
func (c codespace) displayName(includeName, includeGitStatus bool) string {
@@ -248,11 +266,18 @@ func (c codespace) displayName(includeName, includeGitStatus bool) string {
}
if includeName {
+ var displayName = c.Name
+ if c.DisplayName != "" {
+ displayName = c.DisplayName
+ }
return fmt.Sprintf(
- "%s: %s [%s]", c.Repository.FullName, branch, c.Name,
+ "%s: %s (%s)", c.Repository.FullName, displayName, branch,
)
}
- return c.Repository.FullName + ": " + branch
+ return fmt.Sprintf(
+ "%s: %s", c.Repository.FullName, branch,
+ )
+
}
// gitStatusDirty represents an unsaved changes status.
diff --git a/pkg/cmd/codespace/common_test.go b/pkg/cmd/codespace/common_test.go
new file mode 100644
index 000000000..c5fc01590
--- /dev/null
+++ b/pkg/cmd/codespace/common_test.go
@@ -0,0 +1,132 @@
+package codespace
+
+import (
+ "testing"
+
+ "github.com/cli/cli/v2/internal/codespaces/api"
+)
+
+func Test_codespace_displayName(t *testing.T) {
+ type fields struct {
+ Codespace *api.Codespace
+ }
+ type args struct {
+ includeName bool
+ includeGitStatus bool
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ want string
+ }{
+ {
+ name: "No included name or gitstatus",
+ fields: fields{
+ Codespace: &api.Codespace{
+ GitStatus: api.CodespaceGitStatus{
+ Ref: "trunk",
+ },
+ Repository: api.Repository{
+ FullName: "cli/cli",
+ },
+ DisplayName: "scuba steve",
+ },
+ },
+ args: args{
+ includeName: false,
+ includeGitStatus: false,
+ },
+ want: "cli/cli: trunk",
+ },
+ {
+ name: "No included name - included gitstatus - no unsaved changes",
+ fields: fields{
+ Codespace: &api.Codespace{
+ GitStatus: api.CodespaceGitStatus{
+ Ref: "trunk",
+ },
+ Repository: api.Repository{
+ FullName: "cli/cli",
+ },
+ DisplayName: "scuba steve",
+ },
+ },
+ args: args{
+ includeName: false,
+ includeGitStatus: true,
+ },
+ want: "cli/cli: trunk",
+ },
+ {
+ name: "No included name - included gitstatus - unsaved changes",
+ fields: fields{
+ Codespace: &api.Codespace{
+ GitStatus: api.CodespaceGitStatus{
+ Ref: "trunk",
+ HasUncommitedChanges: true,
+ },
+ Repository: api.Repository{
+ FullName: "cli/cli",
+ },
+ DisplayName: "scuba steve",
+ },
+ },
+ args: args{
+ includeName: false,
+ includeGitStatus: true,
+ },
+ want: "cli/cli: trunk*",
+ },
+ {
+ name: "Included name - included gitstatus - unsaved changes",
+ fields: fields{
+ Codespace: &api.Codespace{
+ GitStatus: api.CodespaceGitStatus{
+ Ref: "trunk",
+ HasUncommitedChanges: true,
+ },
+ Repository: api.Repository{
+ FullName: "cli/cli",
+ },
+ DisplayName: "scuba steve",
+ },
+ },
+ args: args{
+ includeName: true,
+ includeGitStatus: true,
+ },
+ want: "cli/cli: scuba steve (trunk*)",
+ },
+ {
+ name: "Included name - included gitstatus - no unsaved changes",
+ fields: fields{
+ Codespace: &api.Codespace{
+ GitStatus: api.CodespaceGitStatus{
+ Ref: "trunk",
+ HasUncommitedChanges: false,
+ },
+ Repository: api.Repository{
+ FullName: "cli/cli",
+ },
+ DisplayName: "scuba steve",
+ },
+ },
+ args: args{
+ includeName: true,
+ includeGitStatus: true,
+ },
+ want: "cli/cli: scuba steve (trunk)",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := codespace{
+ Codespace: tt.fields.Codespace,
+ }
+ if got := c.displayName(tt.args.includeName, tt.args.includeGitStatus); got != tt.want {
+ t.Errorf("codespace.displayName() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/pkg/cmd/codespace/create.go b/pkg/cmd/codespace/create.go
index ee9d81fe7..b145a6a7d 100644
--- a/pkg/cmd/codespace/create.go
+++ b/pkg/cmd/codespace/create.go
@@ -9,15 +9,18 @@ import (
"github.com/AlecAivazis/survey/v2"
"github.com/cli/cli/v2/internal/codespaces"
"github.com/cli/cli/v2/internal/codespaces/api"
+ "github.com/cli/cli/v2/pkg/cmdutil"
+ "github.com/cli/cli/v2/utils"
"github.com/spf13/cobra"
)
type createOptions struct {
- repo string
- branch string
- machine string
- showStatus bool
- idleTimeout time.Duration
+ repo string
+ branch string
+ machine string
+ showStatus bool
+ permissionsOptOut bool
+ idleTimeout time.Duration
}
func newCreateCmd(app *App) *cobra.Command {
@@ -35,6 +38,7 @@ func newCreateCmd(app *App) *cobra.Command {
createCmd.Flags().StringVarP(&opts.repo, "repo", "r", "", "repository name with owner: user/repo")
createCmd.Flags().StringVarP(&opts.branch, "branch", "b", "", "repository branch")
createCmd.Flags().StringVarP(&opts.machine, "machine", "m", "", "hardware specifications for the VM")
+ createCmd.Flags().BoolVarP(&opts.permissionsOptOut, "default-permissions", "", false, "do not prompt to accept additional permissions requested by the codespace")
createCmd.Flags().BoolVarP(&opts.showStatus, "status", "s", false, "show status of post-create command and dotfiles")
createCmd.Flags().DurationVar(&opts.idleTimeout, "idle-timeout", 0, "allowed inactivity before codespace is stopped, e.g. \"10m\", \"1h\"")
@@ -60,8 +64,14 @@ func (a *App) Create(ctx context.Context, opts createOptions) error {
}
questions := []*survey.Question{
{
- Name: "repository",
- Prompt: &survey.Input{Message: "Repository:"},
+ Name: "repository",
+ Prompt: &survey.Input{
+ Message: "Repository:",
+ Help: "Search for repos by name. To search within an org or user, or to see private repos, enter at least ':user/'.",
+ Suggest: func(toComplete string) []string {
+ return getRepoSuggestions(ctx, a.apiClient, toComplete)
+ },
+ },
Validate: survey.Required,
},
{
@@ -102,17 +112,30 @@ func (a *App) Create(ctx context.Context, opts createOptions) error {
return errors.New("there are no available machine types for this repository")
}
- a.StartProgressIndicatorWithLabel("Creating codespace")
- codespace, err := a.apiClient.CreateCodespace(ctx, &api.CreateCodespaceParams{
+ createParams := &api.CreateCodespaceParams{
RepositoryID: repository.ID,
Branch: branch,
Machine: machine,
Location: locationResult.Location,
IdleTimeoutMinutes: int(opts.idleTimeout.Minutes()),
- })
+ PermissionsOptOut: opts.permissionsOptOut,
+ }
+
+ a.StartProgressIndicatorWithLabel("Creating codespace")
+ codespace, err := a.apiClient.CreateCodespace(ctx, createParams)
a.StopProgressIndicator()
+
if err != nil {
- return fmt.Errorf("error creating codespace: %w", err)
+ var aerr api.AcceptPermissionsRequiredError
+ if !errors.As(err, &aerr) || aerr.AllowPermissionsURL == "" {
+ return fmt.Errorf("error creating codespace: %w", err)
+ }
+
+ codespace, err = a.handleAdditionalPermissions(ctx, createParams, aerr.AllowPermissionsURL)
+ if err != nil {
+ // this error could be a cmdutil.SilentError (in the case that the user opened the browser) so we don't want to wrap it
+ return err
+ }
}
if opts.showStatus {
@@ -125,6 +148,71 @@ func (a *App) Create(ctx context.Context, opts createOptions) error {
return nil
}
+func (a *App) handleAdditionalPermissions(ctx context.Context, createParams *api.CreateCodespaceParams, allowPermissionsURL string) (*api.Codespace, error) {
+ var (
+ isInteractive = a.io.CanPrompt()
+ cs = a.io.ColorScheme()
+ displayURL = utils.DisplayURL(allowPermissionsURL)
+ )
+
+ fmt.Fprintf(a.io.ErrOut, "You must authorize or deny additional permissions requested by this codespace before continuing.\n")
+
+ if !isInteractive {
+ fmt.Fprintf(a.io.ErrOut, "%s in your browser to review and authorize additional permissions: %s\n", cs.Bold("Open this URL"), displayURL)
+ fmt.Fprintf(a.io.ErrOut, "Alternatively, you can run %q with the %q option to continue without authorizing additional permissions.\n", a.io.ColorScheme().Bold("create"), cs.Bold("--default-permissions"))
+ return nil, cmdutil.SilentError
+ }
+
+ choices := []string{
+ "Continue in browser to review and authorize additional permissions",
+ "Continue without authorizing additional permissions",
+ }
+
+ permsSurvey := []*survey.Question{
+ {
+ Name: "accept",
+ Prompt: &survey.Select{
+ Message: "What would you like to do?",
+ Options: choices,
+ Default: choices[0],
+ },
+ Validate: survey.Required,
+ },
+ }
+
+ var answers struct {
+ Accept string
+ }
+
+ if err := ask(permsSurvey, &answers); err != nil {
+ return nil, fmt.Errorf("error getting answers: %w", err)
+ }
+
+ // if the user chose to continue in the browser, open the URL
+ if answers.Accept == choices[0] {
+ if err := a.browser.Browse(allowPermissionsURL); err != nil {
+ return nil, fmt.Errorf("error opening browser: %w", err)
+ }
+ // browser opened successfully but we do not know if they accepted the permissions
+ // so we must exit and wait for the user to attempt the create again
+ return nil, cmdutil.SilentError
+ }
+
+ // if the user chose to create the codespace without the permissions,
+ // we can continue with the create opting out of the additional permissions
+ createParams.PermissionsOptOut = true
+
+ a.StartProgressIndicatorWithLabel("Creating codespace")
+ codespace, err := a.apiClient.CreateCodespace(ctx, createParams)
+ a.StopProgressIndicator()
+
+ if err != nil {
+ return nil, fmt.Errorf("error creating codespace: %w", err)
+ }
+
+ return codespace, nil
+}
+
// showStatus polls the codespace for a list of post create states and their status. It will keep polling
// until all states have finished. Once all states have finished, we poll once more to check if any new
// states have been introduced and stop polling otherwise.
@@ -265,6 +353,21 @@ func getMachineName(ctx context.Context, apiClient apiClient, repoID int, machin
return selectedMachine.Name, nil
}
+func getRepoSuggestions(ctx context.Context, apiClient apiClient, partialSearch string) []string {
+ searchParams := api.RepoSearchParameters{
+ // The prompt shows 7 items so 7 effectively turns off scrolling which is similar behavior to other clients
+ MaxRepos: 7,
+ Sort: "repo",
+ }
+
+ repos, err := apiClient.GetCodespaceRepoSuggestions(ctx, partialSearch, searchParams)
+ if err != nil {
+ return nil
+ }
+
+ return repos
+}
+
// buildDisplayName returns display name to be used in the machine survey prompt.
func buildDisplayName(displayName string, prebuildAvailability string) string {
prebuildText := ""
diff --git a/pkg/cmd/codespace/create_test.go b/pkg/cmd/codespace/create_test.go
index 4b266ffaa..2d417620d 100644
--- a/pkg/cmd/codespace/create_test.go
+++ b/pkg/cmd/codespace/create_test.go
@@ -7,6 +7,7 @@ import (
"time"
"github.com/cli/cli/v2/internal/codespaces/api"
+ "github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
)
@@ -18,7 +19,7 @@ func TestApp_Create(t *testing.T) {
name string
fields fields
opts createOptions
- wantErr bool
+ wantErr error
wantStdout string
wantStderr string
}{
@@ -55,6 +56,9 @@ func TestApp_Create(t *testing.T) {
Name: "monalisa-dotfiles-abcd1234",
}, nil
},
+ GetCodespaceRepoSuggestionsFunc: func(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error) {
+ return nil, nil // We can't ask for suggestions without a terminal.
+ },
},
},
opts: createOptions{
@@ -66,6 +70,57 @@ func TestApp_Create(t *testing.T) {
},
wantStdout: "monalisa-dotfiles-abcd1234\n",
},
+ {
+ name: "create codespace that requires accepting additional permissions",
+ fields: fields{
+ apiClient: &apiClientMock{
+ GetCodespaceRegionLocationFunc: func(ctx context.Context) (string, error) {
+ return "EUROPE", nil
+ },
+ GetRepositoryFunc: func(ctx context.Context, nwo string) (*api.Repository, error) {
+ return &api.Repository{
+ ID: 1234,
+ FullName: nwo,
+ DefaultBranch: "main",
+ }, nil
+ },
+ GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string) ([]*api.Machine, error) {
+ return []*api.Machine{
+ {
+ Name: "GIGA",
+ DisplayName: "Gigabits of a machine",
+ },
+ }, nil
+ },
+ CreateCodespaceFunc: func(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) {
+ if params.Branch != "main" {
+ return nil, fmt.Errorf("got branch %q, want %q", params.Branch, "main")
+ }
+ if params.IdleTimeoutMinutes != 30 {
+ return nil, fmt.Errorf("idle timeout minutes was %v", params.IdleTimeoutMinutes)
+ }
+ return &api.Codespace{}, api.AcceptPermissionsRequiredError{
+ AllowPermissionsURL: "https://example.com/permissions",
+ }
+ },
+ GetCodespaceRepoSuggestionsFunc: func(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error) {
+ return nil, nil // We can't ask for suggestions without a terminal.
+ },
+ },
+ },
+ opts: createOptions{
+ repo: "monalisa/dotfiles",
+ branch: "",
+ machine: "GIGA",
+ showStatus: false,
+ idleTimeout: 30 * time.Minute,
+ },
+ wantErr: cmdutil.SilentError,
+ wantStderr: `You must authorize or deny additional permissions requested by this codespace before continuing.
+Open this URL in your browser to review and authorize additional permissions: example.com/permissions
+Alternatively, you can run "create" with the "--default-permissions" option to continue without authorizing additional permissions.
+`,
+ },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -74,7 +129,7 @@ func TestApp_Create(t *testing.T) {
io: io,
apiClient: tt.fields.apiClient,
}
- if err := a.Create(context.Background(), tt.opts); (err != nil) != tt.wantErr {
+ if err := a.Create(context.Background(), tt.opts); err != tt.wantErr {
t.Errorf("App.Create() error = %v, wantErr %v", err, tt.wantErr)
}
if got := stdout.String(); got != tt.wantStdout {
diff --git a/pkg/cmd/codespace/delete_test.go b/pkg/cmd/codespace/delete_test.go
index 58090c809..638641d64 100644
--- a/pkg/cmd/codespace/delete_test.go
+++ b/pkg/cmd/codespace/delete_test.go
@@ -190,7 +190,7 @@ func TestDelete(t *testing.T) {
io, _, stdout, stderr := iostreams.Test()
io.SetStdinTTY(true)
io.SetStdoutTTY(true)
- app := NewApp(io, apiMock)
+ app := NewApp(io, nil, apiMock, nil)
err := app.Delete(context.Background(), opts)
if (err != nil) != tt.wantErr {
t.Errorf("delete() error = %v, wantErr %v", err, tt.wantErr)
diff --git a/pkg/cmd/codespace/list.go b/pkg/cmd/codespace/list.go
index 7fe71d6fa..661d1da1c 100644
--- a/pkg/cmd/codespace/list.go
+++ b/pkg/cmd/codespace/list.go
@@ -16,9 +16,10 @@ func newListCmd(app *App) *cobra.Command {
var exporter cmdutil.Exporter
listCmd := &cobra.Command{
- Use: "list",
- Short: "List your codespaces",
- Args: noArgsConstraint,
+ Use: "list",
+ Short: "List your codespaces",
+ Aliases: []string{"ls"},
+ Args: noArgsConstraint,
RunE: func(cmd *cobra.Command, args []string) error {
if limit < 1 {
return cmdutil.FlagErrorf("invalid limit: %v", limit)
diff --git a/pkg/cmd/codespace/logs.go b/pkg/cmd/codespace/logs.go
index 9fc6010b4..d0a0c233b 100644
--- a/pkg/cmd/codespace/logs.go
+++ b/pkg/cmd/codespace/logs.go
@@ -41,14 +41,9 @@ func (a *App) Logs(ctx context.Context, codespaceName string, follow bool) (err
return fmt.Errorf("get or choose codespace: %w", err)
}
- user, err := a.apiClient.GetUser(ctx)
- if err != nil {
- return fmt.Errorf("getting user: %w", err)
- }
-
authkeys := make(chan error, 1)
go func() {
- authkeys <- checkAuthorizedKeys(ctx, a.apiClient, user.Login)
+ authkeys <- checkAuthorizedKeys(ctx, a.apiClient)
}()
session, err := codespaces.ConnectToLiveshare(ctx, a, noopLogger(), a.apiClient, codespace)
diff --git a/pkg/cmd/codespace/mock_api.go b/pkg/cmd/codespace/mock_api.go
index 8d40934da..fd275e802 100644
--- a/pkg/cmd/codespace/mock_api.go
+++ b/pkg/cmd/codespace/mock_api.go
@@ -31,6 +31,9 @@ import (
// GetCodespaceRegionLocationFunc: func(ctx context.Context) (string, error) {
// panic("mock out the GetCodespaceRegionLocation method")
// },
+// GetCodespaceRepoSuggestionsFunc: func(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error) {
+// panic("mock out the GetCodespaceRepoSuggestions method")
+// },
// GetCodespaceRepositoryContentsFunc: func(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error) {
// panic("mock out the GetCodespaceRepositoryContents method")
// },
@@ -74,6 +77,9 @@ type apiClientMock struct {
// GetCodespaceRegionLocationFunc mocks the GetCodespaceRegionLocation method.
GetCodespaceRegionLocationFunc func(ctx context.Context) (string, error)
+ // GetCodespaceRepoSuggestionsFunc mocks the GetCodespaceRepoSuggestions method.
+ GetCodespaceRepoSuggestionsFunc func(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error)
+
// GetCodespaceRepositoryContentsFunc mocks the GetCodespaceRepositoryContents method.
GetCodespaceRepositoryContentsFunc func(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error)
@@ -132,6 +138,15 @@ type apiClientMock struct {
// Ctx is the ctx argument value.
Ctx context.Context
}
+ // GetCodespaceRepoSuggestions holds details about calls to the GetCodespaceRepoSuggestions method.
+ GetCodespaceRepoSuggestions []struct {
+ // Ctx is the ctx argument value.
+ Ctx context.Context
+ // PartialSearch is the partialSearch argument value.
+ PartialSearch string
+ // Params is the params argument value.
+ Params api.RepoSearchParameters
+ }
// GetCodespaceRepositoryContents holds details about calls to the GetCodespaceRepositoryContents method.
GetCodespaceRepositoryContents []struct {
// Ctx is the ctx argument value.
@@ -191,6 +206,7 @@ type apiClientMock struct {
lockDeleteCodespace sync.RWMutex
lockGetCodespace sync.RWMutex
lockGetCodespaceRegionLocation sync.RWMutex
+ lockGetCodespaceRepoSuggestions sync.RWMutex
lockGetCodespaceRepositoryContents sync.RWMutex
lockGetCodespacesMachines sync.RWMutex
lockGetRepository sync.RWMutex
@@ -375,6 +391,45 @@ func (mock *apiClientMock) GetCodespaceRegionLocationCalls() []struct {
return calls
}
+// GetCodespaceRepoSuggestions calls GetCodespaceRepoSuggestionsFunc.
+func (mock *apiClientMock) GetCodespaceRepoSuggestions(ctx context.Context, partialSearch string, params api.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
+ }{
+ Ctx: ctx,
+ PartialSearch: partialSearch,
+ Params: params,
+ }
+ mock.lockGetCodespaceRepoSuggestions.Lock()
+ mock.calls.GetCodespaceRepoSuggestions = append(mock.calls.GetCodespaceRepoSuggestions, callInfo)
+ mock.lockGetCodespaceRepoSuggestions.Unlock()
+ return mock.GetCodespaceRepoSuggestionsFunc(ctx, partialSearch, params)
+}
+
+// GetCodespaceRepoSuggestionsCalls gets all the calls that were made to GetCodespaceRepoSuggestions.
+// Check the length with:
+// len(mockedapiClient.GetCodespaceRepoSuggestionsCalls())
+func (mock *apiClientMock) GetCodespaceRepoSuggestionsCalls() []struct {
+ Ctx context.Context
+ PartialSearch string
+ Params api.RepoSearchParameters
+} {
+ var calls []struct {
+ Ctx context.Context
+ PartialSearch string
+ Params api.RepoSearchParameters
+ }
+ mock.lockGetCodespaceRepoSuggestions.RLock()
+ calls = mock.calls.GetCodespaceRepoSuggestions
+ mock.lockGetCodespaceRepoSuggestions.RUnlock()
+ return calls
+}
+
// GetCodespaceRepositoryContents calls GetCodespaceRepositoryContentsFunc.
func (mock *apiClientMock) GetCodespaceRepositoryContents(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error) {
if mock.GetCodespaceRepositoryContentsFunc == nil {
diff --git a/pkg/cmd/codespace/ssh.go b/pkg/cmd/codespace/ssh.go
index 840386d5c..726f2152f 100644
--- a/pkg/cmd/codespace/ssh.go
+++ b/pkg/cmd/codespace/ssh.go
@@ -4,16 +4,21 @@ package codespace
import (
"context"
+ "errors"
"fmt"
+ "io"
"io/ioutil"
"log"
"net"
"os"
"path/filepath"
"strings"
+ "sync"
+ "text/template"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/codespaces"
+ "github.com/cli/cli/v2/internal/codespaces/api"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/liveshare"
"github.com/spf13/cobra"
@@ -25,6 +30,8 @@ type sshOptions struct {
serverPort int
debug bool
debugFile string
+ stdio bool
+ config bool
scpArgs []string // scp arguments, for 'cs cp' (nil for 'cs ssh')
}
@@ -34,8 +41,57 @@ func newSSHCmd(app *App) *cobra.Command {
sshCmd := &cobra.Command{
Use: "ssh [...] [-- ...] []",
Short: "SSH into a codespace",
+ Long: heredoc.Doc(`
+ The 'ssh' command is used to SSH into a codespace. In its simplest form, you can
+ run 'gh cs ssh', select a codespace interactively, and connect.
+
+ The 'ssh' command also supports deeper integration with OpenSSH using a
+ '--config' option that generates per-codespace ssh configuration in OpenSSH
+ format. Including this configuration in your ~/.ssh/config improves the user
+ experience of tools that integrate with OpenSSH, such as bash/zsh completion of
+ ssh hostnames, remote path completion for scp/rsync/sshfs, git ssh remotes, and
+ so on.
+
+ Once that is set up (see the second example below), you can ssh to codespaces as
+ if they were ordinary remote hosts (using 'ssh', not 'gh cs ssh').
+ `),
+ Example: heredoc.Doc(`
+ $ gh codespace ssh
+
+ $ gh codespace ssh --config > ~/.ssh/codespaces
+ $ echo 'include ~/.ssh/codespaces' >> ~/.ssh/config'
+ `),
+ PreRunE: func(c *cobra.Command, args []string) error {
+ if opts.stdio {
+ if opts.codespace == "" {
+ return errors.New("`--stdio` requires explicit `--codespace`")
+ }
+ if opts.config {
+ return errors.New("cannot use `--stdio` with `--config`")
+ }
+ if opts.serverPort != 0 {
+ return errors.New("cannot use `--stdio` with `--server-port`")
+ }
+ if opts.profile != "" {
+ return errors.New("cannot use `--stdio` with `--profile`")
+ }
+ }
+ if opts.config {
+ if opts.profile != "" {
+ return errors.New("cannot use `--config` with `--profile`")
+ }
+ if opts.serverPort != 0 {
+ return errors.New("cannot use `--config` with `--server-port`")
+ }
+ }
+ return nil
+ },
RunE: func(cmd *cobra.Command, args []string) error {
- return app.SSH(cmd.Context(), args, opts)
+ if opts.config {
+ return app.printOpenSSHConfig(cmd.Context(), opts)
+ } else {
+ return app.SSH(cmd.Context(), args, opts)
+ }
},
DisableFlagsInUseLine: true,
}
@@ -45,6 +101,11 @@ func newSSHCmd(app *App) *cobra.Command {
sshCmd.Flags().StringVarP(&opts.codespace, "codespace", "c", "", "Name of the codespace")
sshCmd.Flags().BoolVarP(&opts.debug, "debug", "d", false, "Log debug data to a file")
sshCmd.Flags().StringVarP(&opts.debugFile, "debug-file", "", "", "Path of the file log to")
+ sshCmd.Flags().BoolVarP(&opts.config, "config", "", false, "Write OpenSSH configuration to stdout")
+ sshCmd.Flags().BoolVar(&opts.stdio, "stdio", false, "Proxy sshd connection to stdio")
+ if err := sshCmd.Flags().MarkHidden("stdio"); err != nil {
+ fmt.Fprintf(os.Stderr, "%v\n", err)
+ }
return sshCmd
}
@@ -55,23 +116,18 @@ func (a *App) SSH(ctx context.Context, sshArgs []string, opts sshOptions) (err e
ctx, cancel := context.WithCancel(ctx)
defer cancel()
+ // While connecting, ensure in the background that the user has keys installed.
+ // That lets us report a more useful error message if they don't.
+ authkeys := make(chan error, 1)
+ go func() {
+ authkeys <- checkAuthorizedKeys(ctx, a.apiClient)
+ }()
+
codespace, err := getOrChooseCodespace(ctx, a.apiClient, opts.codespace)
if err != nil {
return fmt.Errorf("get or choose codespace: %w", err)
}
- // TODO(josebalius): We can fetch the user in parallel to everything else
- // we should convert this call and others to happen async
- user, err := a.apiClient.GetUser(ctx)
- if err != nil {
- return fmt.Errorf("error getting user: %w", err)
- }
-
- authkeys := make(chan error, 1)
- go func() {
- authkeys <- checkAuthorizedKeys(ctx, a.apiClient, user.Login)
- }()
-
liveshareLogger := noopLogger()
if opts.debug {
debugLogger, err := newFileLogger(opts.debugFile)
@@ -86,14 +142,13 @@ func (a *App) SSH(ctx context.Context, sshArgs []string, opts sshOptions) (err e
session, err := codespaces.ConnectToLiveshare(ctx, a, liveshareLogger, a.apiClient, codespace)
if err != nil {
+ if authErr := <-authkeys; authErr != nil {
+ return authErr
+ }
return fmt.Errorf("error connecting to codespace: %w", err)
}
defer safeClose(session, &err)
- if err := <-authkeys; err != nil {
- return err
- }
-
a.StartProgressIndicatorWithLabel("Fetching SSH Details")
remoteSSHServerPort, sshUser, err := session.StartSSHServer(ctx)
a.StopProgressIndicator()
@@ -101,6 +156,13 @@ func (a *App) SSH(ctx context.Context, sshArgs []string, opts sshOptions) (err e
return fmt.Errorf("error getting ssh server details: %w", err)
}
+ if opts.stdio {
+ fwd := liveshare.NewPortForwarder(session, "sshd", remoteSSHServerPort, true)
+ stdio := newReadWriteCloser(os.Stdin, os.Stdout)
+ err := fwd.Forward(ctx, stdio) // always non-nil
+ return fmt.Errorf("tunnel closed: %w", err)
+ }
+
localSSHServerPort := opts.serverPort
usingCustomPort := localSSHServerPort != 0 // suppress log of command line in Shell
@@ -147,6 +209,130 @@ func (a *App) SSH(ctx context.Context, sshArgs []string, opts sshOptions) (err e
}
}
+func (a *App) printOpenSSHConfig(ctx context.Context, opts sshOptions) error {
+ ctx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ var err error
+ var csList []*api.Codespace
+ if opts.codespace == "" {
+ a.StartProgressIndicatorWithLabel("Fetching codespaces")
+ csList, err = a.apiClient.ListCodespaces(ctx, -1)
+ a.StopProgressIndicator()
+ } else {
+ var codespace *api.Codespace
+ codespace, err = getOrChooseCodespace(ctx, a.apiClient, opts.codespace)
+ csList = []*api.Codespace{codespace}
+ }
+ if err != nil {
+ return fmt.Errorf("error getting codespace info: %w", err)
+ }
+
+ type sshResult struct {
+ codespace *api.Codespace
+ user string // on success, the remote ssh username; else nil
+ err error
+ }
+
+ sshUsers := make(chan sshResult, len(csList))
+ var wg sync.WaitGroup
+ var status error
+ for _, cs := range csList {
+ if cs.State != "Available" && opts.codespace == "" {
+ fmt.Fprintf(os.Stderr, "skipping unavailable codespace %s: %s\n", cs.Name, cs.State)
+ status = cmdutil.SilentError
+ continue
+ }
+
+ cs := cs
+ wg.Add(1)
+ go func() {
+ result := sshResult{}
+ defer wg.Done()
+
+ session, err := codespaces.ConnectToLiveshare(ctx, a, noopLogger(), a.apiClient, cs)
+ if err != nil {
+ result.err = fmt.Errorf("error connecting to codespace: %w", err)
+ } else {
+ defer session.Close()
+
+ _, result.user, err = session.StartSSHServer(ctx)
+ if err != nil {
+ result.err = fmt.Errorf("error getting ssh server details: %w", err)
+ } else {
+ result.codespace = cs
+ }
+ }
+
+ sshUsers <- result
+ }()
+ }
+
+ go func() {
+ wg.Wait()
+ close(sshUsers)
+ }()
+
+ // While the above fetches are running, ensure that the user has keys installed.
+ // That lets us report a more useful error message if they don't.
+ if err = checkAuthorizedKeys(ctx, a.apiClient); err != nil {
+ return err
+ }
+
+ t, err := template.New("ssh_config").Parse(heredoc.Doc(`
+ Host cs.{{.Name}}.{{.EscapedRef}}
+ User {{.SSHUser}}
+ ProxyCommand {{.GHExec}} cs ssh -c {{.Name}} --stdio
+ UserKnownHostsFile=/dev/null
+ StrictHostKeyChecking no
+ LogLevel quiet
+ ControlMaster auto
+
+ `))
+ if err != nil {
+ return fmt.Errorf("error formatting template: %w", err)
+ }
+
+ ghExec := a.executable.Executable()
+ for result := range sshUsers {
+ if result.err != nil {
+ fmt.Fprintf(os.Stderr, "%v\n", result.err)
+ status = cmdutil.SilentError
+ continue
+ }
+
+ // codespaceSSHConfig contains values needed to write an OpenSSH host
+ // configuration for a single codespace. For example:
+ //
+ // Host {{Name}}.{{EscapedRef}
+ // User {{SSHUser}
+ // ProxyCommand {{GHExec}} cs ssh -c {{Name}} --stdio
+ //
+ // EscapedRef is included in the name to help distinguish between codespaces
+ // when tab-completing ssh hostnames. '/' characters in EscapedRef are
+ // flattened to '-' to prevent problems with tab completion or when the
+ // hostname appears in ControlMaster socket paths.
+ type codespaceSSHConfig struct {
+ Name string // the codespace name, passed to `ssh -c`
+ EscapedRef string // the currently checked-out branch
+ SSHUser string // the remote ssh username
+ GHExec string // path used for invoking the current `gh` binary
+ }
+
+ conf := codespaceSSHConfig{
+ Name: result.codespace.Name,
+ EscapedRef: strings.ReplaceAll(result.codespace.GitStatus.Ref, "/", "-"),
+ SSHUser: result.user,
+ GHExec: ghExec,
+ }
+ if err := t.Execute(a.io.Out, conf); err != nil {
+ return err
+ }
+ }
+
+ return status
+}
+
type cpOptions struct {
sshOptions
recursive bool // -r
@@ -277,3 +463,21 @@ func (fl *fileLogger) Name() string {
func (fl *fileLogger) Close() error {
return fl.f.Close()
}
+
+type combinedReadWriteCloser struct {
+ io.ReadCloser
+ io.WriteCloser
+}
+
+func newReadWriteCloser(reader io.ReadCloser, writer io.WriteCloser) io.ReadWriteCloser {
+ return &combinedReadWriteCloser{reader, writer}
+}
+
+func (crwc *combinedReadWriteCloser) Close() error {
+ werr := crwc.WriteCloser.Close()
+ rerr := crwc.ReadCloser.Close()
+ if werr != nil {
+ return werr
+ }
+ return rerr
+}
diff --git a/pkg/cmd/completion/completion.go b/pkg/cmd/completion/completion.go
index 496f06784..d83dd31ef 100644
--- a/pkg/cmd/completion/completion.go
+++ b/pkg/cmd/completion/completion.go
@@ -92,8 +92,7 @@ func NewCmdCompletion(io *iostreams.IOStreams) *cobra.Command {
}
cmdutil.DisableAuthCheck(cmd)
-
- cmd.Flags().StringVarP(&shellType, "shell", "s", "", "Shell type: {bash|zsh|fish|powershell}")
+ cmdutil.StringEnumFlag(cmd, &shellType, "shell", "s", "", []string{"bash", "zsh", "fish", "powershell"}, "Shell type")
return cmd
}
diff --git a/pkg/cmd/completion/completion_test.go b/pkg/cmd/completion/completion_test.go
index 06b3d2f27..a5068f747 100644
--- a/pkg/cmd/completion/completion_test.go
+++ b/pkg/cmd/completion/completion_test.go
@@ -39,7 +39,7 @@ func TestNewCmdCompletion(t *testing.T) {
{
name: "unsupported shell",
args: "completion -s csh",
- wantErr: "unsupported shell type \"csh\"",
+ wantErr: "invalid argument \"csh\" for \"-s, --shell\" flag: valid values are {bash|zsh|fish|powershell}",
},
}
for _, tt := range tests {
diff --git a/pkg/cmd/config/get/get.go b/pkg/cmd/config/get/get.go
index 3a5634458..94694adb2 100644
--- a/pkg/cmd/config/get/get.go
+++ b/pkg/cmd/config/get/get.go
@@ -53,7 +53,7 @@ func NewCmdConfigGet(f *cmdutil.Factory, runF func(*GetOptions) error) *cobra.Co
}
func getRun(opts *GetOptions) error {
- val, err := opts.Config.Get(opts.Hostname, opts.Key)
+ val, err := opts.Config.GetOrDefault(opts.Hostname, opts.Key)
if err != nil {
return err
}
diff --git a/pkg/cmd/config/get/get_test.go b/pkg/cmd/config/get/get_test.go
index 7c5efa9be..46f187394 100644
--- a/pkg/cmd/config/get/get_test.go
+++ b/pkg/cmd/config/get/get_test.go
@@ -115,6 +115,8 @@ func Test_getRun(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, tt.stdout, stdout.String())
assert.Equal(t, tt.stderr, stderr.String())
+ _, err = tt.input.Config.GetOrDefault("", "_written")
+ assert.Error(t, err)
_, err = tt.input.Config.Get("", "_written")
assert.Error(t, err)
})
diff --git a/pkg/cmd/config/list/list.go b/pkg/cmd/config/list/list.go
index 8b1157b1f..cf1422f0f 100644
--- a/pkg/cmd/config/list/list.go
+++ b/pkg/cmd/config/list/list.go
@@ -23,9 +23,10 @@ func NewCmdConfigList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.
}
cmd := &cobra.Command{
- Use: "list",
- Short: "Print a list of configuration keys and values",
- Args: cobra.ExactArgs(0),
+ Use: "list",
+ Short: "Print a list of configuration keys and values",
+ Aliases: []string{"ls"},
+ Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
@@ -59,7 +60,7 @@ func listRun(opts *ListOptions) error {
configOptions := config.ConfigOptions()
for _, key := range configOptions {
- val, err := cfg.Get(host, key.Key)
+ val, err := cfg.GetOrDefault(host, key.Key)
if err != nil {
return err
}
diff --git a/pkg/cmd/config/set/set_test.go b/pkg/cmd/config/set/set_test.go
index cdd2e7c94..2beb20edc 100644
--- a/pkg/cmd/config/set/set_test.go
+++ b/pkg/cmd/config/set/set_test.go
@@ -145,11 +145,11 @@ func Test_setRun(t *testing.T) {
assert.Equal(t, tt.stdout, stdout.String())
assert.Equal(t, tt.stderr, stderr.String())
- val, err := tt.input.Config.Get(tt.input.Hostname, tt.input.Key)
+ val, err := tt.input.Config.GetOrDefault(tt.input.Hostname, tt.input.Key)
assert.NoError(t, err)
assert.Equal(t, tt.expectedValue, val)
- val, err = tt.input.Config.Get("", "_written")
+ val, err = tt.input.Config.GetOrDefault("", "_written")
assert.NoError(t, err)
assert.Equal(t, "true", val)
})
diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go
index c4a08152b..916940749 100644
--- a/pkg/cmd/extension/command.go
+++ b/pkg/cmd/extension/command.go
@@ -33,16 +33,17 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
An extension cannot override any of the core gh commands.
- See the list of available extensions at
+ See the list of available extensions at .
`, "`"),
Aliases: []string{"extensions"},
}
extCmd.AddCommand(
&cobra.Command{
- Use: "list",
- Short: "List installed extension commands",
- Args: cobra.NoArgs,
+ Use: "list",
+ Short: "List installed extension commands",
+ Aliases: []string{"ls"},
+ Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cmds := m.List(true)
if len(cmds) == 0 {
@@ -84,7 +85,7 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
To install an extension in development from the current directory, use "." as the
value of the repository argument.
- See the list of available extensions at
+ See the list of available extensions at .
`),
Example: heredoc.Doc(`
$ gh extension install owner/gh-extension
diff --git a/pkg/cmd/extension/command_test.go b/pkg/cmd/extension/command_test.go
index 5a46592f9..8f896eab0 100644
--- a/pkg/cmd/extension/command_test.go
+++ b/pkg/cmd/extension/command_test.go
@@ -316,8 +316,10 @@ func TestNewCmdExtension(t *testing.T) {
},
isTTY: true,
askStubs: func(as *prompt.AskStubber) {
- as.StubOne("test")
- as.StubOne(0)
+ as.StubPrompt("Extension name:").AnswerWith("test")
+ as.StubPrompt("What kind of extension?").
+ AssertOptions([]string{"Script (Bash, Ruby, Python, etc)", "Go", "Other Precompiled (C++, Rust, etc)"}).
+ AnswerDefault()
},
wantStdout: heredoc.Doc(`
✓ Created directory gh-test
@@ -456,8 +458,7 @@ func TestNewCmdExtension(t *testing.T) {
assertFunc = tt.managerStubs(em)
}
- as, teardown := prompt.InitAskStubber()
- defer teardown()
+ as := prompt.NewAskStubber(t)
if tt.askStubs != nil {
tt.askStubs(as)
}
diff --git a/pkg/cmd/extension/manager.go b/pkg/cmd/extension/manager.go
index 6ff5f461a..41dab528d 100644
--- a/pkg/cmd/extension/manager.go
+++ b/pkg/cmd/extension/manager.go
@@ -334,10 +334,10 @@ func (m *Manager) Install(repo ghrepo.Interface, targetCommitish string) error {
return err
}
if !hs {
- return errors.New("extension is uninstallable: missing executable")
+ return errors.New("extension is not installable: missing executable")
}
- protocol, _ := m.config.Get(repo.RepoHost(), "git_protocol")
+ protocol, _ := m.config.GetOrDefault(repo.RepoHost(), "git_protocol")
return m.installGit(ghrepo.FormatRemoteURL(repo, protocol), m.io.Out, m.io.ErrOut)
}
diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go
index 08d93c2be..b89d28e21 100644
--- a/pkg/cmd/factory/default.go
+++ b/pkg/cmd/factory/default.go
@@ -5,7 +5,6 @@ import (
"fmt"
"net/http"
"os"
- "path/filepath"
"time"
"github.com/cli/cli/v2/api"
@@ -19,17 +18,10 @@ import (
)
func New(appVersion string) *cmdutil.Factory {
- var exe string
f := &cmdutil.Factory{
- Config: configFunc(), // No factory dependencies
- Branch: branchFunc(), // No factory dependencies
- Executable: func() string {
- if exe != "" {
- return exe
- }
- exe = executable("gh")
- return exe
- },
+ Config: configFunc(), // No factory dependencies
+ Branch: branchFunc(), // No factory dependencies
+ ExecutableName: "gh",
}
f.IOStreams = ioStreams(f) // Depends on Config
@@ -121,52 +113,6 @@ func browserLauncher(f *cmdutil.Factory) string {
return os.Getenv("BROWSER")
}
-// Finds the location of the executable for the current process as it's found in PATH, respecting symlinks.
-// If the process couldn't determine its location, return fallbackName. If the executable wasn't found in
-// PATH, return the absolute location to the program.
-//
-// The idea is that the result of this function is callable in the future and refers to the same
-// installation of gh, even across upgrades. This is needed primarily for Homebrew, which installs software
-// under a location such as `/usr/local/Cellar/gh/1.13.1/bin/gh` and symlinks it from `/usr/local/bin/gh`.
-// When the version is upgraded, Homebrew will often delete older versions, but keep the symlink. Because of
-// this, we want to refer to the `gh` binary as `/usr/local/bin/gh` and not as its internal Homebrew
-// location.
-//
-// None of this would be needed if we could just refer to GitHub CLI as `gh`, i.e. without using an absolute
-// path. However, for some reason Homebrew does not include `/usr/local/bin` in PATH when it invokes git
-// commands to update its taps. If `gh` (no path) is being used as git credential helper, as set up by `gh
-// auth login`, running `brew update` will print out authentication errors as git is unable to locate
-// Homebrew-installed `gh`.
-func executable(fallbackName string) string {
- exe, err := os.Executable()
- if err != nil {
- return fallbackName
- }
-
- base := filepath.Base(exe)
- path := os.Getenv("PATH")
- for _, dir := range filepath.SplitList(path) {
- p, err := filepath.Abs(filepath.Join(dir, base))
- if err != nil {
- continue
- }
- f, err := os.Stat(p)
- if err != nil {
- continue
- }
-
- if p == exe {
- return p
- } else if f.Mode()&os.ModeSymlink != 0 {
- if t, err := os.Readlink(p); err == nil && t == exe {
- return p
- }
- }
- }
-
- return exe
-}
-
func configFunc() func() (config.Config, error) {
var cachedConfig config.Config
var configError error
@@ -220,7 +166,7 @@ func ioStreams(f *cmdutil.Factory) *iostreams.IOStreams {
return io
}
- if prompt, _ := cfg.Get("", "prompt"); prompt == "disabled" {
+ if prompt, _ := cfg.GetOrDefault("", "prompt"); prompt == "disabled" {
io.SetNeverPrompt(true)
}
diff --git a/pkg/cmd/factory/http.go b/pkg/cmd/factory/http.go
index fd61f2dc7..7037b1558 100644
--- a/pkg/cmd/factory/http.go
+++ b/pkg/cmd/factory/http.go
@@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"os"
+ "regexp"
"strings"
"time"
@@ -107,6 +108,7 @@ func NewHTTPClient(io *iostreams.IOStreams, cfg configGetter, appVersion string,
}
return "", nil
}),
+ api.ExtractHeader("X-GitHub-SSO", &ssoHeader),
)
if setAccept {
@@ -126,6 +128,22 @@ func NewHTTPClient(io *iostreams.IOStreams, cfg configGetter, appVersion string,
return api.NewHTTPClient(opts...), nil
}
+var ssoHeader string
+var ssoURLRE = regexp.MustCompile(`\burl=([^;]+)`)
+
+// SSOURL returns the URL of a SAML SSO challenge received by the server for clients that use ExtractHeader
+// to extract the value of the "X-GitHub-SSO" response header.
+func SSOURL() string {
+ if ssoHeader == "" {
+ return ""
+ }
+ m := ssoURLRE.FindStringSubmatch(ssoHeader)
+ if m == nil {
+ return ""
+ }
+ return m[1]
+}
+
func getHost(r *http.Request) string {
if r.Host != "" {
return r.Host
diff --git a/pkg/cmd/factory/http_test.go b/pkg/cmd/factory/http_test.go
index 1505d1b65..0cb5ac15c 100644
--- a/pkg/cmd/factory/http_test.go
+++ b/pkg/cmd/factory/http_test.go
@@ -25,8 +25,10 @@ func TestNewHTTPClient(t *testing.T) {
args args
envDebug string
host string
+ sso string
wantHeader map[string]string
wantStderr string
+ wantSSO string
}{
{
name: "github.com with Accept header",
@@ -95,10 +97,10 @@ func TestNewHTTPClient(t *testing.T) {
> Accept: application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview
> Authorization: token ████████████████████
> User-Agent: GitHub CLI v1.2.3
-
+
< HTTP/1.1 204 No Content
< Date: