Merge branch 'trunk' into phillmv/fail-verification-if-no-attestations

This commit is contained in:
Phill MV 2024-10-21 12:49:41 -04:00 committed by GitHub
commit a8b3f050ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
97 changed files with 4703 additions and 638 deletions

View file

@ -6,7 +6,7 @@ We accept pull requests for bug fixes and features where we've discussed the app
Please do:
* Check existing issues to verify that an existing [bug][bug issues] or [feature request][feature request issues] issue does not already exist for the same problem or feature.
* Check issues to verify that a [bug][bug issues] or [feature request][feature request issues] issue does not already exist for the same problem or feature.
* Open an issue if things aren't working as expected.
* Open an issue to propose a significant change.
* Open an issue to propose a design for an issue labelled [`needs-design` and `help wanted`][needs design and help wanted], following the [proposing a design guidelines](#proposing-a-design) instructions below.

View file

@ -38,11 +38,16 @@ completions: bin/gh$(EXE)
bin/gh$(EXE) completion -s fish > ./share/fish/vendor_completions.d/gh.fish
bin/gh$(EXE) completion -s zsh > ./share/zsh/site-functions/_gh
# just a convenience task around `go test`
# just convenience tasks around `go test`
.PHONY: test
test:
go test ./...
# For more information, see https://github.com/cli/cli/blob/trunk/acceptance/README.md
.PHONY: acceptance
acceptance:
go test -tags acceptance ./acceptance
## Site-related tasks are exclusively intended for use by the GitHub CLI team and for our release automation.
site:

151
acceptance/README.md Normal file
View file

@ -0,0 +1,151 @@
## Acceptance Tests
The acceptance tests are blackbox* tests that are expected to interact with resources on a real GitHub instance. They are built on top of the [`go-internal/testscript`](https://pkg.go.dev/github.com/rogpeppe/go-internal/testscript) package, which provides a framework for building tests for command line tools.
*Note: they aren't strictly blackbox because `exec gh` commands delegate to a binary set up by `testscript` that calls into `ghcmd.Main`. However, since our real `func main` is an extremely thin adapter over `ghcmd.Main`, this is reasonable. This tradeoff avoids us building the binary ourselves for the tests, and allows us to get code coverage metrics.
### Running the Acceptance Tests
The acceptance tests have a build constraint of `//go:build acceptance`, this means that `go test ./...` will continue to work without any modifications. The `acceptance` tag must therefore be provided when running `go test`.
The following environment variables are required:
#### `GH_ACCEPTANCE_HOST`
The GitHub host to target e.g. `github.com`
#### `GH_ACCEPTANCE_ORG`
The organization in which the acceptance tests can manage resources in. Consider using `gh-acceptance-testing` on `github.com`.
#### `GH_ACCEPTANCE_TOKEN`
The token to use for authenticatin with the `GH_ACCEPTANCE_HOST`. This must already have the necessary scopes for each test, and must have permissions to act in the `GH_ACCEPTANCE_ORG`. See [Effective Test Authoring](#effective-test-authoring) for how tests must handle tokens without sufficient scopes.
It's recommended to create and use a Legacy PAT for this; Fine-Grained PATs do not offer all the necessary privileges required. You can use an OAuth token provided via `gh auth login --web` and can provide it to the acceptance tests via `GH_ACCEPTANCE_TOKEN=$(gh auth token --hostname <host>)` but this can be a bit confusing and annoying if you `gh auth login` again without `-s` and lose the required scopes.
---
A full example invocation can be found below:
```
GH_ACCEPTANCE_HOST=<host> GH_ACCEPTANCE_ORG=<org> GH_ACCEPTANCE_TOKEN=<token> go test -tags=acceptance ./acceptance
```
While writing a new test, it can be useful to target that specific script by providing the `GH_ACCEPTANCE_SCRIPT` env var in combination with the `-run` flag, for example:
```
GH_ACCEPTANCE_SCRIPT=pr-view.txtar GH_ACCEPTANCE_HOST=<host> GH_ACCEPTANCE_ORG=<org> GH_ACCEPTANCE_TOKEN=<token> go test -tags=acceptance -run ^TestPullRequests$ ./acceptance
```
#### Code Coverage
To get code coverage, `go test` can be invoked with `coverpkg` and `coverprofile` like so:
```
GH_ACCEPTANCE_HOST=<host> GH_ACCEPTANCE_ORG=<org> GH_ACCEPTANCE_TOKEN=<token> go test -tags=acceptance -coverprofile=coverage.out -coverpkg=./... ./acceptance
```
### Writing Tests
This section is to be expanded over time as we write more tests and learn more.
#### Environment Variables
The following custom environment variables are made available to the scripts:
* `GH_HOST`: Set to value of the `GH_ACCEPTANCE_ORG` env var provided to `go test`
* `ORG`: Set to the value of the `GH_ACCEPTANCE_ORG` env var provided to `go test`
* `GH_TOKEN`: Set to the value of the `GH_ACCEPTANCE_TOKEN` env var provided to `go test`
* `RANDOM_STRING`: Set to a length 10 random string of letters to help isolate globally visible resources
* `SCRIPT_NAME`: Set to the name of the `testscript` currently running, without extension e.g. `pr-view`
* `HOME`: Set to the initial working directory. Required for `git` operations
* `GH_CONFIG_DIR`: Set to the initial working directory. Required for `gh` operations
### Acceptance Test VS Code Support
Due to the `//go:build acceptance` build constraint, some functionality is limited because `gopls` isn't being informed about the tag. To resolve this, set the following in your `settings.json`:
```json
"gopls": {
"buildFlags": [
"-tags=acceptance"
]
},
```
You can install the [`txtar`](https://marketplace.visualstudio.com/items?itemName=brody715.txtar) or [`vscode-testscript`](https://marketplace.visualstudio.com/items?itemName=twpayne.vscode-testscript) extensions to get syntax highlighting.
### Debugging Tests
When tests fail they fail like this:
```
➜ go test -tags=acceptance ./acceptance
--- FAIL: TestPullRequests (0.00s)
--- FAIL: TestPullRequests/pr-merge (11.07s)
testscript.go:584: WORK=/private/var/folders/45/sdnm1hp10nj1s9q57dp3bc5h0000gn/T/go-test-script2778137936/script-pr-merge
# Use gh as a credential helper (0.693s)
# Create a repository with a file so it has a default branch (1.155s)
# Defer repo cleanup (0.000s)
# Clone the repo (1.551s)
# Prepare a branch to PR with a single file (1.168s)
# Create the PR (1.903s)
# Check that the file doesn't exist on the main branch (0.059s)
# Merge the PR (2.426s)
# Check that the state of the PR is now merged (0.571s)
# Pull and check the file exists on the main branch (1.074s)
# And check we had a merge commit (0.462s)
> exec git show HEAD
[stdout]
commit 85d32c1a83ace270f6754c61f3f7e14956be0a47
Author: William Martin <williammartin@william-github-laptop.kpn>
Date: Fri Oct 11 15:23:56 2024 +0200
Add file.txt
diff --git a/file.txt b/file.txt
new file mode 100644
index 0000000..7449899
--- /dev/null
+++ b/file.txt
@@ -0,0 +1 @@
+Unimportant contents
> stdout 'Merge pull request #1'
FAIL: testdata/pr/pr-merge.txtar:42: no match for `Merge pull request #1` found in stdout
```
This is generally enough information to understand why a test has failed. However, we can get more information by providing the `-v` flag to `go test`, which turns on verbose mode and shows each command and any associated `stdio`.
> [!WARNING]
> Verbose mode dumps the `testscript` environment variables, including the `GH_TOKEN`, so be careful.
By default `testscript` removes the directory in which it was running the script, and if you've been a conscientious engineer, you should be cleaning up resources using the `defer` statement. However, this can be an impediment to debugging. As such you can set `GH_ACCEPTANCE_PRESERVE_WORK_DIR=true` and `GH_ACCEPTANCE_SKIP_DEFER=true` to skip these cleanup steps.
### Effective Test Authoring
This section is to be expanded over time as we write more tests and learn more.
#### Test Isolation
The `testscript` library creates a somewhat isolated environment for each script. Each script gets a directory with limited environment variables by default. As far as reasonable, we should look to write scripts that depend on nothing more than themselves, the GitHub resources they manage, and limited additional environmental injection from our own `testscript` setup.
Here are some guidelines around test isolation:
* Favour duplication in test setup over abstracting a new `testscript` command
* Favour a `testscript` owning an entire resource lifecycle over shared resource until we see a performance or rate limiting issue
* Use the `RANDOM_STRING` env var for globally visible resources to avoid conflicts
### Debris
Since these scripts are creating resources on a GitHub instance, we should try our best to cleanup after them. Use the `defer` keyword to ensure a command runs at the end of a test even in the case of failure.
#### Scope Validation
TODO: I believe tests should early exit if the correct scopes aren't in place to execute the entire lifecycle. It's extremely annoying if a `defer` fails to clean up resources because there's no `delete_repo` scope for example. However, I'm not sure yet whether this scope checking should be in the Go tests or in the scripts themselves. It seems very cool to understand required scopes for a script just by looking at the script itself.
### Further Reading
https://bitfieldconsulting.com/posts/test-scripts
https://atlasgo.io/blog/2024/09/09/how-go-tests-go-test
https://encore.dev/blog/testscript-hidden-testing-gem

View file

@ -0,0 +1,273 @@
//go:build acceptance
package acceptance_test
import (
"fmt"
"os"
"path"
"strconv"
"strings"
"testing"
"time"
"math/rand"
"github.com/cli/cli/v2/internal/ghcmd"
"github.com/rogpeppe/go-internal/testscript"
)
func ghMain() int {
return int(ghcmd.Main())
}
func TestMain(m *testing.M) {
os.Exit(testscript.RunMain(m, map[string]func() int{
"gh": ghMain,
}))
}
func TestPullRequests(t *testing.T) {
var tsEnv testScriptEnv
if err := tsEnv.fromEnv(); err != nil {
t.Fatal(err)
}
testscript.Run(t, testScriptParamsFor(tsEnv, "pr"))
}
func TestIssues(t *testing.T) {
var tsEnv testScriptEnv
if err := tsEnv.fromEnv(); err != nil {
t.Fatal(err)
}
testscript.Run(t, testScriptParamsFor(tsEnv, "pr"))
}
func TestWorkflows(t *testing.T) {
var tsEnv testScriptEnv
if err := tsEnv.fromEnv(); err != nil {
t.Fatal(err)
}
testscript.Run(t, testScriptParamsFor(tsEnv, "workflow"))
}
func TestAPI(t *testing.T) {
var tsEnv testScriptEnv
if err := tsEnv.fromEnv(); err != nil {
t.Fatal(err)
}
testscript.Run(t, testScriptParamsFor(tsEnv, "api"))
}
func TestReleases(t *testing.T) {
var tsEnv testScriptEnv
if err := tsEnv.fromEnv(); err != nil {
t.Fatal(err)
}
testscript.Run(t, testScriptParamsFor(tsEnv, "release"))
}
func TestSearches(t *testing.T) {
var tsEnv testScriptEnv
if err := tsEnv.fromEnv(); err != nil {
t.Fatal(err)
}
testscript.Run(t, testScriptParamsFor(tsEnv, "search"))
}
func TestRepo(t *testing.T) {
var tsEnv testScriptEnv
if err := tsEnv.fromEnv(); err != nil {
t.Fatal(err)
}
testscript.Run(t, testScriptParamsFor(tsEnv, "repo"))
}
func testScriptParamsFor(tsEnv testScriptEnv, command string) testscript.Params {
var files []string
if tsEnv.script != "" {
files = []string{path.Join("testdata", command, tsEnv.script)}
}
var dir string
if len(files) == 0 {
dir = path.Join("testdata", command)
}
return testscript.Params{
Dir: dir,
Files: files,
Setup: sharedSetup(tsEnv),
Cmds: sharedCmds(tsEnv),
RequireExplicitExec: true,
RequireUniqueNames: true,
TestWork: tsEnv.preserveWorkDir,
}
}
var keyT struct{}
func sharedSetup(tsEnv testScriptEnv) func(ts *testscript.Env) error {
return func(ts *testscript.Env) error {
scriptName, ok := extractScriptName(ts.Vars)
if !ok {
ts.T().Fatal("script name not found")
}
ts.Setenv("SCRIPT_NAME", scriptName)
ts.Setenv("HOME", ts.Cd)
ts.Setenv("GH_CONFIG_DIR", ts.Cd)
ts.Setenv("GH_HOST", tsEnv.host)
ts.Setenv("ORG", tsEnv.org)
ts.Setenv("GH_TOKEN", tsEnv.token)
ts.Setenv("RANDOM_STRING", randomString(10))
ts.Values[keyT] = ts.T()
return nil
}
}
// sharedCmds defines a collection of custom testscript commands for our use.
func sharedCmds(tsEnv testScriptEnv) map[string]func(ts *testscript.TestScript, neg bool, args []string) {
return map[string]func(ts *testscript.TestScript, neg bool, args []string){
"defer": func(ts *testscript.TestScript, neg bool, args []string) {
if neg {
ts.Fatalf("unsupported: ! defer")
}
if tsEnv.skipDefer {
return
}
tt, ok := ts.Value(keyT).(testscript.T)
if !ok {
ts.Fatalf("%v is not a testscript.T", ts.Value(keyT))
}
ts.Defer(func() {
// If you're wondering why we're not using ts.Check here, it's because it raises a panic, and testscript
// only catches the panics directly from commands, not from the deferred functions. So what we do
// instead is grab the `t` in the setup function and store it as a value. It's important that we use
// `t` from the setup function because it represents the subtest created for each individual script,
// rather than each top-level test.
// See: https://github.com/rogpeppe/go-internal/issues/276
if err := ts.Exec(args[0], args[1:]...); err != nil {
tt.FailNow()
}
})
},
"stdout2env": func(ts *testscript.TestScript, neg bool, args []string) {
if neg {
ts.Fatalf("unsupported: ! stdout2env")
}
if len(args) != 1 {
ts.Fatalf("usage: stdout2env name")
}
ts.Setenv(args[0], strings.TrimRight(ts.ReadFile("stdout"), "\n"))
},
"sleep": func(ts *testscript.TestScript, neg bool, args []string) {
if neg {
ts.Fatalf("unsupported: ! sleep")
}
if len(args) != 1 {
ts.Fatalf("usage: sleep seconds")
}
// sleep for the given number of seconds
seconds, err := strconv.Atoi(args[0])
if err != nil {
ts.Fatalf("invalid number of seconds: %v", err)
}
d := time.Duration(seconds) * time.Second
time.Sleep(d)
},
}
}
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
func randomString(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}
func extractScriptName(vars []string) (string, bool) {
for _, kv := range vars {
if strings.HasPrefix(kv, "WORK=") {
v := strings.Split(kv, "=")[1]
return strings.CutPrefix(path.Base(v), "script-")
}
}
return "", false
}
type missingEnvError struct {
missingEnvs []string
}
func (e missingEnvError) Error() string {
return fmt.Sprintf("environment variable(s) %s must be set and non-empty", strings.Join(e.missingEnvs, ", "))
}
type testScriptEnv struct {
host string
org string
token string
script string
skipDefer bool
preserveWorkDir bool
}
func (e *testScriptEnv) fromEnv() error {
envMap := map[string]string{}
requiredEnvVars := []string{
"GH_ACCEPTANCE_HOST",
"GH_ACCEPTANCE_ORG",
"GH_ACCEPTANCE_TOKEN",
}
var missingEnvs []string
for _, key := range requiredEnvVars {
val, ok := os.LookupEnv(key)
if val == "" || !ok {
missingEnvs = append(missingEnvs, key)
continue
}
envMap[key] = val
}
if len(missingEnvs) > 0 {
return missingEnvError{missingEnvs: missingEnvs}
}
if envMap["GH_ACCEPTANCE_ORG"] == "github" || envMap["GH_ACCEPTANCE_ORG"] == "cli" {
return fmt.Errorf("GH_ACCEPTANCE_ORG cannot be 'github' or 'cli'")
}
e.host = envMap["GH_ACCEPTANCE_HOST"]
e.org = envMap["GH_ACCEPTANCE_ORG"]
e.token = envMap["GH_ACCEPTANCE_TOKEN"]
e.script = os.Getenv("GH_ACCEPTANCE_SCRIPT")
e.preserveWorkDir = os.Getenv("GH_ACCEPTANCE_PRESERVE_WORK_DIR") == "true"
e.skipDefer = os.Getenv("GH_ACCEPTANCE_SKIP_DEFER") == "true"
return nil
}

View file

@ -0,0 +1,3 @@
# Basic graphql request
exec gh api graphql -f query='query { viewer { login } }'
stdout '"login":'

View file

@ -0,0 +1,3 @@
# Basic REST request
exec gh api /user
stdout '"login":'

View file

@ -0,0 +1,20 @@
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Create an issue in the repo
cd $SCRIPT_NAME-$RANDOM_STRING
exec gh issue create --title 'Feature Request' --body 'Feature Body'
stdout2env ISSUE_URL
# Comment on the issue
exec gh issue comment $ISSUE_URL --body 'Looks like a great feature!'
# View the issue
exec gh issue view $ISSUE_URL --comments
stdout 'Looks like a great feature!'

View file

@ -0,0 +1,17 @@
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Create an issue in the repo
cd $SCRIPT_NAME-$RANDOM_STRING
exec gh issue create --title 'Feature Request' --body 'Feature Body'
stdout2env ISSUE_URL
# Check the issue was created
exec gh issue view $ISSUE_URL
stdout 'title:\tFeature Request$'

View file

@ -0,0 +1,19 @@
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Create an issue in the repo
cd $SCRIPT_NAME-$RANDOM_STRING
exec gh issue create --title 'Feature Request' --body 'Feature Body' --assignee '@me' --label 'bug'
stdout2env ISSUE_URL
# Check the issue was create
exec gh issue view $ISSUE_URL
stdout 'title:\tFeature Request$'
stdout 'assignees:\t.+$'
stdout 'labels:\tbug$'

View file

@ -0,0 +1,16 @@
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Create an issue in the repo
cd $SCRIPT_NAME-$RANDOM_STRING
exec gh issue create --title 'Feature Request' --body 'Feature Body'
# Check the issue is included in the list output
exec gh issue list
stdout 'OPEN\tFeature Request'

View file

@ -0,0 +1,17 @@
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Create an issue in the repo
cd $SCRIPT_NAME-$RANDOM_STRING
exec gh issue create --title 'Feature Request' --body 'Feature Body'
stdout2env ISSUE_URL
# Check the issue was created
exec gh issue view $ISSUE_URL
stdout 'title:\tFeature Request$'

View file

@ -0,0 +1,30 @@
# Use gh as a credential helper
exec gh auth setup-git
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Prepare a branch to PR
cd $SCRIPT_NAME-$RANDOM_STRING
exec git checkout -b feature-branch
exec git commit --allow-empty -m 'Empty Commit'
exec git push -u origin feature-branch
# Create the PR
exec gh pr create --title 'Feature Title' --body 'Feature Body'
stdout2env PR_URL
# Remove the local branch
exec git checkout main
exec git branch -D feature-branch
stdout 'Deleted branch feature-branch'
# Checkout the PR
exec gh pr checkout $PR_URL
stderr 'Switched to a new branch ''feature-branch'''

28
acceptance/testdata/pr/pr-comment.txtar vendored Normal file
View file

@ -0,0 +1,28 @@
# Use gh as a credential helper
exec gh auth setup-git
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Prepare a branch to PR
cd $SCRIPT_NAME-$RANDOM_STRING
exec git checkout -b feature-branch
exec git commit --allow-empty -m 'Empty Commit'
exec git push -u origin feature-branch
# Create the PR
exec gh pr create --title 'Feature Title' --body 'Feature Body'
stdout2env PR_URL
# Comment on the PR
exec gh pr comment $PR_URL --body 'Looks like a great feature!'
# View the PR
exec gh pr view $PR_URL --comments
stdout 'Looks like a great feature!'

View file

@ -0,0 +1,24 @@
# Use gh as a credential helper
exec gh auth setup-git
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Prepare a branch to PR
cd $SCRIPT_NAME-$RANDOM_STRING
exec git checkout -b feature-branch
exec git commit --allow-empty -m 'Empty Commit'
exec git push -u origin feature-branch
# Create the PR
exec gh pr create --title 'Feature Title' --body 'Feature Body'
# Check the PR is indeed created
exec gh pr view
stdout 'Feature Title'

View file

@ -0,0 +1,26 @@
# Use gh as a credential helper
exec gh auth setup-git
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Prepare a branch to PR
cd $SCRIPT_NAME-$RANDOM_STRING
exec git checkout -b feature-branch
exec git commit --allow-empty -m 'Empty Commit'
exec git push -u origin feature-branch
# Create the PR
exec gh pr create --title 'Feature Title' --body 'Feature Body' --assignee '@me' --label 'bug'
stdout2env PR_URL
# Check the PR is indeed created
exec gh pr view $PR_URL
stdout 'assignees:\t.+$'
stdout 'labels:\tbug$'

24
acceptance/testdata/pr/pr-list.txtar vendored Normal file
View file

@ -0,0 +1,24 @@
# Use gh as a credential helper
exec gh auth setup-git
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Prepare a branch to PR
cd $SCRIPT_NAME-$RANDOM_STRING
exec git checkout -b feature-branch
exec git commit --allow-empty -m 'Empty Commit'
exec git push -u origin feature-branch
# Create the PR
exec gh pr create --title 'Feature Title' --body 'Feature Body'
# List PRs and see the new PR is in the list
exec gh pr list
stdout 'Feature Title\tfeature-branch\tOPEN'

View file

@ -0,0 +1,45 @@
# Use gh as a credential helper
exec gh auth setup-git
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Prepare a branch to PR with a single file
cd $SCRIPT_NAME-$RANDOM_STRING
exec git checkout -b feature-branch
mv ../file.txt file.txt
exec git add .
exec git commit -m 'Add file.txt'
exec git push -u origin feature-branch
# Create the PR
exec gh pr create --title 'Feature Title' --body 'Feature Body'
stdout2env PR_URL
# Check that the file doesn't exist on the main branch
exec git checkout main
! exists file.txt
# Merge the PR
exec gh pr merge $PR_URL --merge
# Check that the state of the PR is now merged
exec gh pr view $PR_URL
stdout 'state:\tMERGED$'
# Pull and check the file exists on the main branch
exec git pull -r
exists file.txt
# And check we had a merge commit
exec git show HEAD
stdout 'Merge pull request #1'
-- file.txt --
Unimportant contents

View file

@ -0,0 +1,45 @@
# Use gh as a credential helper
exec gh auth setup-git
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Prepare a branch to PR with a single file
cd $SCRIPT_NAME-$RANDOM_STRING
exec git checkout -b feature-branch
mv ../file.txt file.txt
exec git add .
exec git commit -m 'Add file.txt'
exec git push -u origin feature-branch
# Create the PR
exec gh pr create --title 'Feature Title' --body 'Feature Body'
stdout2env PR_URL
# Check that the file doesn't exist on the main branch
exec git checkout main
! exists file.txt
# Merge the PR
exec gh pr merge $PR_URL --rebase
# Check that the state of the PR is now merged
exec gh pr view $PR_URL
stdout 'state:\tMERGED$'
# Pull and check the file exists on the main branch
exec git pull -r
exists file.txt
# And check our commit was rebased
exec git show HEAD
stdout 'Add file.txt'
-- file.txt --
Unimportant contents

25
acceptance/testdata/pr/pr-view.txtar vendored Normal file
View file

@ -0,0 +1,25 @@
# Use gh as a credential helper
exec gh auth setup-git
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Prepare a branch to PR
cd $SCRIPT_NAME-$RANDOM_STRING
exec git checkout -b feature-branch
exec git commit --allow-empty -m 'Empty Commit'
exec git push -u origin feature-branch
# Create the PR
exec gh pr create --title 'Feature Title' --body 'Feature Body'
stdout2env PR_URL
# View the PR
exec gh pr view $PR_URL
stdout 'Feature Title'

View file

@ -0,0 +1,12 @@
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Create a release in the repo
cd $SCRIPT_NAME-$RANDOM_STRING
exec gh release create v1.2.3 --notes 'awesome release' --latest

View file

@ -0,0 +1,16 @@
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Create a release in the repo
cd $SCRIPT_NAME-$RANDOM_STRING
exec gh release create v1.2.3 --notes 'awesome release' --latest
# List the releases
exec gh release list
stdout 'v1.2.3'

View file

@ -0,0 +1,26 @@
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Create a release in the repo
cd $SCRIPT_NAME-$RANDOM_STRING
exec gh release create v1.2.3 --notes 'awesome release' --latest
# Upload an asset to the release
exec gh release upload v1.2.3 ../asset.txt
# Download the asset from the release
exec gh release download v1.2.3
exists asset.txt
# Download the asset in archive form
exec gh release download v1.2.3 --archive=zip
exists $SCRIPT_NAME-$RANDOM_STRING-1.2.3.zip
-- asset.txt --
Hello, world!

View file

@ -0,0 +1,16 @@
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Create a release in the repo
cd $SCRIPT_NAME-$RANDOM_STRING
exec gh release create v1.2.3 --notes 'awesome release' --latest
# View the release
exec gh release view v1.2.3
stdout 'v1.2.3'

View file

@ -0,0 +1,23 @@
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Check that the repo exists and isn't archived
exec gh repo view $ORG/$SCRIPT_NAME-$RANDOM_STRING --json=isArchived --jq='.isArchived'
stdout 'false'
# Archive the repo
exec gh repo archive $ORG/$SCRIPT_NAME-$RANDOM_STRING --yes
# Check that the repo is archived
exec gh repo view $ORG/$SCRIPT_NAME-$RANDOM_STRING --json=isArchived --jq='.isArchived'
stdout 'true'
# Unarchive the repo
exec gh repo unarchive $ORG/$SCRIPT_NAME-$RANDOM_STRING --yes
# Check that the repo is unarchived
exec gh repo view $ORG/$SCRIPT_NAME-$RANDOM_STRING --json=isArchived --jq='.isArchived'
stdout 'false'

View file

@ -0,0 +1,11 @@
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Ensure the repo was cloned
exists $SCRIPT_NAME-$RANDOM_STRING/README.md

View file

@ -0,0 +1,9 @@
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Check that the repo exists
exec gh repo view $ORG/$SCRIPT_NAME-$RANDOM_STRING --json=name --jq='.name'
stdout $SCRIPT_NAME-$RANDOM_STRING

View file

@ -0,0 +1,13 @@
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Check that the repo exists
exec gh repo view $ORG/$SCRIPT_NAME-$RANDOM_STRING --json name --jq '.name'
stdout $SCRIPT_NAME-$RANDOM_STRING
# Delete the repo
exec gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Ensure that the repo was deleted
! exec gh repo view $ORG/$SCRIPT_NAME-$RANDOM_STRING
stderr 'Could not resolve to a Repository with the name'

View file

@ -0,0 +1,31 @@
# Create and clone a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private --clone
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# cd to the repo and list the deploy keys. There should be no keys
cd $SCRIPT_NAME-$RANDOM_STRING
exec gh repo deploy-key list --json=title
! stdout title
# Add a deploy key
exec gh repo deploy-key add ../deployKey.pub
# Ensure the deploy key was added
exec gh repo deploy-key list --json=title --jq='.[].title'
stdout myTitle
# Get the deploy key id
exec gh repo deploy-key list --json=title,id --jq='.[].title="myTitle" | .[].id'
stdout2env DEPLOY_KEY_ID
# Delete the deploy key
exec gh repo deploy-key delete $DEPLOY_KEY_ID
# Ensure the deploy key was deleted
exec gh repo deploy-key list --json=id --jq='.[].id'
! stdout $DEPLOY_KEY_ID
-- deployKey.pub --
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAZmdeRNskfpvYL5YHB/YJaW8hTEXpnvPMkx5Ri+YwUr myTitle

View file

@ -0,0 +1,16 @@
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Check that the repo description is empty
exec gh repo view $ORG/$SCRIPT_NAME-$RANDOM_STRING --json description --jq '.description'
! stdout '.'
# Edit the repo description
exec gh repo edit $ORG/$SCRIPT_NAME-$RANDOM_STRING --description 'newDescription'
# Check that the repo description is updated
exec gh repo view $ORG/$SCRIPT_NAME-$RANDOM_STRING --json description --jq '.description'
stdout 'newDescription'

View file

@ -0,0 +1,42 @@
# Use gh as a credential helper
exec gh auth setup-git
# Create and clone a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private --clone
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Fork and clone the repo
exec gh repo fork $ORG/$SCRIPT_NAME-$RANDOM_STRING --org $ORG --fork-name $SCRIPT_NAME-$RANDOM_STRING-fork --clone
# Defer fork cleanup
defer gh repo delete $ORG/$SCRIPT_NAME-$RANDOM_STRING-fork --yes
# Sleep so that the BE has time to sync
sleep 5
# Check that the repo was forked
exec gh repo view $ORG/$SCRIPT_NAME-$RANDOM_STRING-fork --json='isFork' --jq='.isFork'
stdout 'true'
# Modify original repo
cd $SCRIPT_NAME-$RANDOM_STRING
mv ../asset.txt asset.txt
exec git add .
exec git commit -m 'Add asset.txt'
exec git push
# Checkout the forked repo and ensure asset.txt is not present
cd ../$SCRIPT_NAME-$RANDOM_STRING-fork
exec git checkout main
! exists asset.txt
# Sync the forked repo with the original repo
exec gh repo sync
# Check that asset.txt now exists in the fork
exists asset.txt
-- asset.txt --
Hello, world!

View file

@ -0,0 +1,16 @@
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# List the repos and check for the new repo
exec gh repo list $ORG --json=name --jq='.[].name'
stdout $SCRIPT_NAME-$RANDOM_STRING
# Rename the repo
exec gh repo rename $SCRIPT_NAME-$RANDOM_STRING-renamed --repo=$ORG/$SCRIPT_NAME-$RANDOM_STRING --yes
# Defer repo deletion
defer gh repo delete $ORG/$SCRIPT_NAME-$RANDOM_STRING-renamed --yes
# List the repos and check for the renamed repo
exec gh repo list $ORG --json=name --jq='.[].name'
stdout $SCRIPT_NAME-$RANDOM_STRING-renamed

View file

@ -0,0 +1,17 @@
# Create and clone a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private --clone
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Ensure that no default is set
cd $SCRIPT_NAME-$RANDOM_STRING
exec gh repo set-default --view
stderr 'no default repository has been set; use `gh repo set-default` to select one'
# Set the default
exec gh repo set-default $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Check that the default is set
exec gh repo set-default --view
stdout $ORG/$SCRIPT_NAME-$RANDOM_STRING

View file

@ -0,0 +1,20 @@
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Create an issue in the repo
cd $SCRIPT_NAME-$RANDOM_STRING
exec gh issue create --title 'Feature Request' --body $RANDOM_STRING
# It takes some time for the issue to be created and indexed
sleep 5
# Search for the issue
exec gh search issues $RANDOM_STRING -R $ORG/$SCRIPT_NAME-$RANDOM_STRING
stdout $RANDOM_STRING

View file

@ -0,0 +1,69 @@
# Use gh as a credential helper
exec gh auth setup-git
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
# commit the workflow file
cd $SCRIPT_NAME-$RANDOM_STRING
mkdir .github/workflows
mv ../workflow.yml .github/workflows/workflow.yml
exec git add .github/workflows/workflow.yml
exec git commit -m 'Create workflow file'
exec git push -u origin main
# Sleep because it takes a second for the workflow to register
sleep 1
# Check the workflow is indeed created
exec gh workflow list
stdout 'Test Workflow Name'
# Run the workflow
exec gh workflow run 'Test Workflow Name'
# It takes some time for a workflow run to register
sleep 10
# Get the run ID we want to watch
exec gh run list --json databaseId --jq '.[0].databaseId'
stdout2env RUN_ID
# Wait for workflow to complete
exec gh run watch $RUN_ID --exit-status
# List the cache
exec gh cache list
stdout 'Linux-values'
# Delete the cache
exec gh cache delete 'Linux-values'
-- workflow.yml --
name: Test Workflow Name
on: workflow_dispatch
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Cache values
id: cache-values
uses: actions/cache@v4
with:
path: values.txt
key: ${{ runner.os }}-values
- name: Generate values file
if: steps.cache-values.outputs.cache-hit != 'true'
run: echo "values" > values.txt

View file

@ -0,0 +1,73 @@
# Use gh as a credential helper
exec gh auth setup-git
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
# commit the workflow file
cd $SCRIPT_NAME-$RANDOM_STRING
mkdir .github/workflows
mv ../workflow.yml .github/workflows/workflow.yml
exec git add .github/workflows/workflow.yml
exec git commit -m 'Create workflow file'
exec git push -u origin main
# Sleep because it takes a second for the workflow to register
sleep 1
# Check the workflow is indeed created
exec gh workflow list
stdout 'Test Workflow Name'
# Run the workflow
exec gh workflow run 'Test Workflow Name'
# It takes some time for a workflow run to register
sleep 10
# Get the run ID we want to cancel
exec gh run list --json databaseId --jq '.[0].databaseId'
stdout2env RUN_ID
# cancel the workflow run
exec gh run cancel $RUN_ID
stdout '✓ Request to cancel workflow [0-9]+ submitted.'
# Wait for workflow to complete
exec gh run watch $RUN_ID
# Check the workflow run is cancelled
exec gh run list --json conclusion --jq '.[0].conclusion'
stdout 'cancelled'
-- workflow.yml --
# This is a basic workflow to help you get started with Actions
name: Test Workflow Name
# Controls when the workflow will run
on:
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "build"
build:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v4
# Runs a single command using the runners shell
- name: Run a one-line script
run: sleep 30

View file

@ -0,0 +1,74 @@
# Use gh as a credential helper
exec gh auth setup-git
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
# commit the workflow file
cd $SCRIPT_NAME-$RANDOM_STRING
mkdir .github/workflows
mv ../workflow.yml .github/workflows/workflow.yml
exec git add .github/workflows/workflow.yml
exec git commit -m 'Create workflow file'
exec git push -u origin main
# Sleep because it takes a second for the workflow to register
sleep 1
# Check the workflow is indeed created
exec gh workflow list
stdout 'Test Workflow Name'
# Run the workflow
exec gh workflow run 'Test Workflow Name'
# It takes some time for a workflow run to register
sleep 10
# Get the run ID we want to watch & delete
exec gh run list --json databaseId --jq '.[0].databaseId'
stdout2env RUN_ID
# Wait for workflow to complete
exec gh run watch $RUN_ID --exit-status
# Delete the workflow run
exec gh run delete $RUN_ID
stdout '✓ Request to delete workflow submitted.'
# It takes some time for a workflow run to be deleted
sleep 5
# Check the workflow run is cancelled, which is implied by an empty list
exec gh run list
stdout ''
-- workflow.yml --
# This is a basic workflow to help you get started with Actions
name: Test Workflow Name
# Controls when the workflow will run
on:
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "build"
build:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Runs a single command using the runners shell
- name: Run a one-line script
run: echo Hello, world!

View file

@ -0,0 +1,70 @@
# Use gh as a credential helper
exec gh auth setup-git
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
# commit the workflow file
cd $SCRIPT_NAME-$RANDOM_STRING
mkdir .github/workflows
mv ../workflow.yml .github/workflows/workflow.yml
exec git add .github/workflows/workflow.yml
exec git commit -m 'Create workflow file'
exec git push -u origin main
# Sleep because it takes a second for the workflow to register
sleep 1
# Check the workflow is indeed created
exec gh workflow list
stdout 'Test Workflow Name'
# Run the workflow
exec gh workflow run 'Test Workflow Name'
# It takes some time for a workflow run to register
sleep 10
# Get the run ID we want to watch
exec gh run list --json databaseId --jq '.[0].databaseId'
stdout2env RUN_ID
# Wait for workflow to complete
exec gh run watch $RUN_ID --exit-status
# Download the artifact
exec gh run download $RUN_ID
# Check if we downloaded the artifact
exists ./my-artifact/world.txt
-- workflow.yml --
# This is a basic workflow to help you get started with Actions
name: Test Workflow Name
# Controls when the workflow will run
on:
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "build"
build:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
- run: echo hello > world.txt
- uses: actions/upload-artifact@v4
with:
name: my-artifact
path: world.txt

View file

@ -0,0 +1,72 @@
# Use gh as a credential helper
exec gh auth setup-git
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
# commit the workflow file
cd $SCRIPT_NAME-$RANDOM_STRING
mkdir .github/workflows
mv ../workflow.yml .github/workflows/workflow.yml
exec git add .github/workflows/workflow.yml
exec git commit -m 'Create workflow file'
exec git push -u origin main
# Sleep because it takes a second for the workflow to register
sleep 1
# Check the workflow is indeed created
exec gh workflow list
stdout 'Test Workflow Name'
# Run the workflow
exec gh workflow run 'Test Workflow Name'
# It takes some time for a workflow run to register
sleep 10
# Get the run ID we want to rerun
exec gh run list --json databaseId --jq '.[0].databaseId'
stdout2env RUN_ID
# Wait for workflow to complete
exec gh run watch $RUN_ID --exit-status
# Rerun the workflow run
exec gh run rerun $RUN_ID
# It takes some time for a workflow run to register
sleep 10
# Wait for workflow to complete
exec gh run watch $RUN_ID --exit-status
-- workflow.yml --
# This is a basic workflow to help you get started with Actions
name: Test Workflow Name
# Controls when the workflow will run
on:
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "build"
build:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Runs a single command using the runners shell
- name: Run a one-line script
run: echo Hello, world!

View file

@ -0,0 +1,66 @@
# Use gh as a credential helper
exec gh auth setup-git
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
# commit the workflow file
cd $SCRIPT_NAME-$RANDOM_STRING
mkdir .github/workflows
mv ../workflow.yml .github/workflows/workflow.yml
exec git add .github/workflows/workflow.yml
exec git commit -m 'Create workflow file'
exec git push -u origin main
# Sleep because it takes a second for the workflow to register
sleep 1
# Check the workflow is indeed created
exec gh workflow list
stdout 'Test Workflow Name'
# Run the workflow
exec gh workflow run 'Test Workflow Name'
# It takes some time for a workflow run to register
sleep 10
# Get the run ID we want to view
exec gh run list --json databaseId --jq '.[0].databaseId'
stdout2env RUN_ID
# Wait for workflow to complete
exec gh run watch $RUN_ID --exit-status
# View the workflow run
exec gh run view $RUN_ID
-- workflow.yml --
# This is a basic workflow to help you get started with Actions
name: Test Workflow Name
# Controls when the workflow will run
on:
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "build"
build:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Runs a single command using the runners shell
- name: Run a one-line script
run: echo Hello, world!

View file

@ -0,0 +1,66 @@
# Use gh as a credential helper
exec gh auth setup-git
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
# commit the workflow file
cd $SCRIPT_NAME-$RANDOM_STRING
mkdir .github/workflows
mv ../workflow.yml .github/workflows/workflow.yml
exec git add .github/workflows/workflow.yml
exec git commit -m 'Create workflow file'
exec git push -u origin main
# Sleep because it takes a second for the workflow to register
sleep 1
# Check the workflow is indeed created
exec gh workflow list
stdout 'Test Workflow Name'
# disable the workflow
exec gh workflow disable 'Test Workflow Name'
# Check that the listing shows it is disabled
exec gh workflow list --all
stdout 'Test\s+Workflow\s+Name\s+disabled_manually\s+\d+'
# enable the workflow
exec gh workflow enable 'Test Workflow Name'
# Check the workflow is indeed enabled
exec gh workflow list
stdout 'Test\s+Workflow\s+Name\s+active\s+\d+'
-- workflow.yml --
# This is a basic workflow to help you get started with Actions
name: Test Workflow Name
# Controls when the workflow will run
on:
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "build"
build:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v4
# Runs a single command using the runners shell
- name: Run a one-line script
run: echo Hello, world!

View file

@ -0,0 +1,52 @@
# Use gh as a credential helper
exec gh auth setup-git
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
# commit the workflow file
cd $SCRIPT_NAME-$RANDOM_STRING
mkdir .github/workflows
mv ../workflow.yml .github/workflows/workflow.yml
exec git add .github/workflows/workflow.yml
exec git commit -m 'Create workflow file'
exec git push -u origin main
# Sleep because it takes a second for the workflow to register
sleep 1
# Check the workflow is indeed created
exec gh workflow list
stdout 'Test Workflow Name'
-- workflow.yml --
# This is a basic workflow to help you get started with Actions
name: Test Workflow Name
# Controls when the workflow will run
on:
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "build"
build:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v4
# Runs a single command using the runners shell
- name: Run a one-line script
run: echo Hello, world!

View file

@ -0,0 +1,62 @@
# Use gh as a credential helper
exec gh auth setup-git
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
# commit the workflow file
cd $SCRIPT_NAME-$RANDOM_STRING
mkdir .github/workflows
mv ../workflow.yml .github/workflows/workflow.yml
exec git add .github/workflows/workflow.yml
exec git commit -m 'Create workflow file'
exec git push -u origin main
# Sleep because it takes a second for the workflow to register
sleep 1
# Check the workflow is indeed created
exec gh workflow list
stdout 'Test Workflow Name'
# Run the workflow
exec gh workflow run 'Test Workflow Name'
# It takes some time for a workflow run to register
sleep 10
# Check the workflow run exists
exec gh run list
stdout 'Test Workflow Name'
-- workflow.yml --
# This is a basic workflow to help you get started with Actions
name: Test Workflow Name
# Controls when the workflow will run
on:
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "build"
build:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v4
# Runs a single command using the runners shell
- name: Run a one-line script
run: echo Hello, world!

View file

@ -0,0 +1,52 @@
# Use gh as a credential helper
exec gh auth setup-git
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
# commit the workflow file
cd $SCRIPT_NAME-$RANDOM_STRING
mkdir .github/workflows
mv ../workflow.yml .github/workflows/workflow.yml
exec git add .github/workflows/workflow.yml
exec git commit -m 'Create workflow file'
exec git push -u origin main
# Sleep because it takes a second for the workflow to register
sleep 1
# Check the workflow is indeed created
exec gh workflow view 'Test Workflow Name'
stdout 'Test Workflow Name - workflow.yml'
-- workflow.yml --
# This is a basic workflow to help you get started with Actions
name: Test Workflow Name
# Controls when the workflow will run
on:
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "build"
build:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v4
# Runs a single command using the runners shell
- name: Run a one-line script
run: echo Hello, world!

View file

@ -10,8 +10,8 @@ import (
"regexp"
"strings"
"github.com/cli/cli/v2/internal/ghinstance"
ghAPI "github.com/cli/go-gh/v2/pkg/api"
ghauth "github.com/cli/go-gh/v2/pkg/auth"
)
const (
@ -249,7 +249,7 @@ func generateScopesSuggestion(statusCode int, endpointNeedsScopes, tokenHasScope
return fmt.Sprintf(
"This API operation needs the %[1]q scope. To request it, run: gh auth refresh -h %[2]s -s %[1]s",
s,
ghinstance.NormalizeHostname(hostname),
ghauth.NormalizeHostname(hostname),
)
}

View file

@ -7,9 +7,9 @@ import (
"strings"
"time"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/utils"
ghAPI "github.com/cli/go-gh/v2/pkg/api"
ghauth "github.com/cli/go-gh/v2/pkg/auth"
)
type tokenGetter interface {
@ -98,7 +98,7 @@ func AddAuthTokenHeader(rt http.RoundTripper, cfg tokenGetter) http.RoundTripper
// Only set header if an initial request or redirect request to the same host as the initial request.
// If the host has changed during a redirect do not add the authentication token header.
if !redirectHostnameChange {
hostname := ghinstance.NormalizeHostname(getHost(req))
hostname := ghauth.NormalizeHostname(getHost(req))
if token, _ := cfg.ActiveToken(hostname); token != "" {
req.Header.Set(authorization, fmt.Sprintf("token %s", token))
}

View file

@ -207,8 +207,24 @@ type IssueLabel struct {
}
type License struct {
Key string `json:"key"`
Name string `json:"name"`
Key string `json:"key"`
Name string `json:"name"`
SPDXID string `json:"spdx_id"`
URL string `json:"url"`
NodeID string `json:"node_id"`
HTMLURL string `json:"html_url"`
Description string `json:"description"`
Implementation string `json:"implementation"`
Permissions []string `json:"permissions"`
Conditions []string `json:"conditions"`
Limitations []string `json:"limitations"`
Body string `json:"body"`
Featured bool `json:"featured"`
}
type GitIgnore struct {
Name string `json:"name"`
Source string `json:"source"`
}
// RepoOwner is the login name of the owner
@ -1391,3 +1407,53 @@ func RepoExists(client *Client, repo ghrepo.Interface) (bool, error) {
return false, ghAPI.HandleHTTPError(resp)
}
}
// RepoLicenses fetches available repository licenses.
// It uses API v3 because licenses are not supported by GraphQL.
func RepoLicenses(httpClient *http.Client, hostname string) ([]License, error) {
var licenses []License
client := NewClientFromHTTP(httpClient)
err := client.REST(hostname, "GET", "licenses", nil, &licenses)
if err != nil {
return nil, err
}
return licenses, nil
}
// RepoLicense fetches an available repository license.
// It uses API v3 because licenses are not supported by GraphQL.
func RepoLicense(httpClient *http.Client, hostname string, licenseName string) (*License, error) {
var license License
client := NewClientFromHTTP(httpClient)
path := fmt.Sprintf("licenses/%s", licenseName)
err := client.REST(hostname, "GET", path, nil, &license)
if err != nil {
return nil, err
}
return &license, nil
}
// RepoGitIgnoreTemplates fetches available repository gitignore templates.
// It uses API v3 here because gitignore template isn't supported by GraphQL.
func RepoGitIgnoreTemplates(httpClient *http.Client, hostname string) ([]string, error) {
var gitIgnoreTemplates []string
client := NewClientFromHTTP(httpClient)
err := client.REST(hostname, "GET", "gitignore/templates", nil, &gitIgnoreTemplates)
if err != nil {
return nil, err
}
return gitIgnoreTemplates, nil
}
// RepoGitIgnoreTemplate fetches an available repository gitignore template.
// It uses API v3 here because gitignore template isn't supported by GraphQL.
func RepoGitIgnoreTemplate(httpClient *http.Client, hostname string, gitIgnoreTemplateName string) (*GitIgnore, error) {
var gitIgnoreTemplate GitIgnore
client := NewClientFromHTTP(httpClient)
path := fmt.Sprintf("gitignore/templates/%s", gitIgnoreTemplateName)
err := client.REST(hostname, "GET", path, nil, &gitIgnoreTemplate)
if err != nil {
return nil, err
}
return &gitIgnoreTemplate, nil
}

View file

@ -7,6 +7,7 @@ import (
"strings"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/stretchr/testify/assert"
@ -566,3 +567,416 @@ func TestForkRepoReturnsErrorWhenForkIsNotPossible(t *testing.T) {
// Then it provides a useful error message
require.Equal(t, fmt.Errorf("%s/%s cannot be forked. A single user account cannot own both a parent and fork.", ownerLogin, repoName), err)
}
func TestListLicenseTemplatesReturnsLicenses(t *testing.T) {
hostname := "api.github.com"
httpStubs := func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "licenses"),
httpmock.StringResponse(`[
{
"key": "mit",
"name": "MIT License",
"spdx_id": "MIT",
"url": "https://api.github.com/licenses/mit",
"node_id": "MDc6TGljZW5zZW1pdA=="
},
{
"key": "lgpl-3.0",
"name": "GNU Lesser General Public License v3.0",
"spdx_id": "LGPL-3.0",
"url": "https://api.github.com/licenses/lgpl-3.0",
"node_id": "MDc6TGljZW5zZW1pdA=="
},
{
"key": "mpl-2.0",
"name": "Mozilla Public License 2.0",
"spdx_id": "MPL-2.0",
"url": "https://api.github.com/licenses/mpl-2.0",
"node_id": "MDc6TGljZW5zZW1pdA=="
},
{
"key": "agpl-3.0",
"name": "GNU Affero General Public License v3.0",
"spdx_id": "AGPL-3.0",
"url": "https://api.github.com/licenses/agpl-3.0",
"node_id": "MDc6TGljZW5zZW1pdA=="
},
{
"key": "unlicense",
"name": "The Unlicense",
"spdx_id": "Unlicense",
"url": "https://api.github.com/licenses/unlicense",
"node_id": "MDc6TGljZW5zZW1pdA=="
},
{
"key": "apache-2.0",
"name": "Apache License 2.0",
"spdx_id": "Apache-2.0",
"url": "https://api.github.com/licenses/apache-2.0",
"node_id": "MDc6TGljZW5zZW1pdA=="
},
{
"key": "gpl-3.0",
"name": "GNU General Public License v3.0",
"spdx_id": "GPL-3.0",
"url": "https://api.github.com/licenses/gpl-3.0",
"node_id": "MDc6TGljZW5zZW1pdA=="
}
]`,
))
}
wantLicenses := []License{
{
Key: "mit",
Name: "MIT License",
SPDXID: "MIT",
URL: "https://api.github.com/licenses/mit",
NodeID: "MDc6TGljZW5zZW1pdA==",
HTMLURL: "",
Description: "",
Implementation: "",
Permissions: nil,
Conditions: nil,
Limitations: nil,
Body: "",
},
{
Key: "lgpl-3.0",
Name: "GNU Lesser General Public License v3.0",
SPDXID: "LGPL-3.0",
URL: "https://api.github.com/licenses/lgpl-3.0",
NodeID: "MDc6TGljZW5zZW1pdA==",
HTMLURL: "",
Description: "",
Implementation: "",
Permissions: nil,
Conditions: nil,
Limitations: nil,
Body: "",
},
{
Key: "mpl-2.0",
Name: "Mozilla Public License 2.0",
SPDXID: "MPL-2.0",
URL: "https://api.github.com/licenses/mpl-2.0",
NodeID: "MDc6TGljZW5zZW1pdA==",
HTMLURL: "",
Description: "",
Implementation: "",
Permissions: nil,
Conditions: nil,
Limitations: nil,
Body: "",
},
{
Key: "agpl-3.0",
Name: "GNU Affero General Public License v3.0",
SPDXID: "AGPL-3.0",
URL: "https://api.github.com/licenses/agpl-3.0",
NodeID: "MDc6TGljZW5zZW1pdA==",
HTMLURL: "",
Description: "",
Implementation: "",
Permissions: nil,
Conditions: nil,
Limitations: nil,
Body: "",
},
{
Key: "unlicense",
Name: "The Unlicense",
SPDXID: "Unlicense",
URL: "https://api.github.com/licenses/unlicense",
NodeID: "MDc6TGljZW5zZW1pdA==",
HTMLURL: "",
Description: "",
Implementation: "",
Permissions: nil,
Conditions: nil,
Limitations: nil,
Body: "",
},
{
Key: "apache-2.0",
Name: "Apache License 2.0",
SPDXID: "Apache-2.0",
URL: "https://api.github.com/licenses/apache-2.0",
NodeID: "MDc6TGljZW5zZW1pdA==",
HTMLURL: "",
Description: "",
Implementation: "",
Permissions: nil,
Conditions: nil,
Limitations: nil,
Body: "",
},
{
Key: "gpl-3.0",
Name: "GNU General Public License v3.0",
SPDXID: "GPL-3.0",
URL: "https://api.github.com/licenses/gpl-3.0",
NodeID: "MDc6TGljZW5zZW1pdA==",
HTMLURL: "",
Description: "",
Implementation: "",
Permissions: nil,
Conditions: nil,
Limitations: nil,
Body: "",
},
}
reg := &httpmock.Registry{}
httpStubs(reg)
httpClient := func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
client, _ := httpClient()
defer reg.Verify(t)
gotLicenses, err := RepoLicenses(client, hostname)
assert.NoError(t, err, "Expected no error while fetching /licenses")
assert.Equal(t, wantLicenses, gotLicenses, "Licenses fetched is not as expected")
}
func TestLicenseTemplateReturnsLicense(t *testing.T) {
licenseTemplateName := "mit"
hostname := "api.github.com"
httpStubs := func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", fmt.Sprintf("licenses/%v", licenseTemplateName)),
httpmock.StringResponse(`{
"key": "mit",
"name": "MIT License",
"spdx_id": "MIT",
"url": "https://api.github.com/licenses/mit",
"node_id": "MDc6TGljZW5zZTEz",
"html_url": "http://choosealicense.com/licenses/mit/",
"description": "A short and simple permissive license with conditions only requiring preservation of copyright and license notices. Licensed works, modifications, and larger works may be distributed under different terms and without source code.",
"implementation": "Create a text file (typically named LICENSE or LICENSE.txt) in the root of your source code and copy the text of the license into the file. Replace [year] with the current year and [fullname] with the name (or names) of the copyright holders.",
"permissions": [
"commercial-use",
"modifications",
"distribution",
"private-use"
],
"conditions": [
"include-copyright"
],
"limitations": [
"liability",
"warranty"
],
"body": "MIT License\n\nCopyright (c) [year] [fullname]\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n",
"featured": true
}`,
))
}
wantLicense := &License{
Key: "mit",
Name: "MIT License",
SPDXID: "MIT",
URL: "https://api.github.com/licenses/mit",
NodeID: "MDc6TGljZW5zZTEz",
HTMLURL: "http://choosealicense.com/licenses/mit/",
Description: "A short and simple permissive license with conditions only requiring preservation of copyright and license notices. Licensed works, modifications, and larger works may be distributed under different terms and without source code.",
Implementation: "Create a text file (typically named LICENSE or LICENSE.txt) in the root of your source code and copy the text of the license into the file. Replace [year] with the current year and [fullname] with the name (or names) of the copyright holders.",
Permissions: []string{
"commercial-use",
"modifications",
"distribution",
"private-use",
},
Conditions: []string{
"include-copyright",
},
Limitations: []string{
"liability",
"warranty",
},
Body: "MIT License\n\nCopyright (c) [year] [fullname]\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n",
Featured: true,
}
reg := &httpmock.Registry{}
httpStubs(reg)
httpClient := func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
client, _ := httpClient()
defer reg.Verify(t)
gotLicenseTemplate, err := RepoLicense(client, hostname, licenseTemplateName)
assert.NoError(t, err, fmt.Sprintf("Expected no error while fetching /licenses/%v", licenseTemplateName))
assert.Equal(t, wantLicense, gotLicenseTemplate, fmt.Sprintf("License \"%v\" fetched is not as expected", licenseTemplateName))
}
func TestLicenseTemplateReturnsErrorWhenLicenseTemplateNotFound(t *testing.T) {
licenseTemplateName := "invalid-license"
hostname := "api.github.com"
httpStubs := func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", fmt.Sprintf("licenses/%v", licenseTemplateName)),
httpmock.StatusStringResponse(404, heredoc.Doc(`
{
"message": "Not Found",
"documentation_url": "https://docs.github.com/rest/licenses/licenses#get-a-license",
"status": "404"
}`)),
)
}
reg := &httpmock.Registry{}
httpStubs(reg)
httpClient := func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
client, _ := httpClient()
defer reg.Verify(t)
_, err := RepoLicense(client, hostname, licenseTemplateName)
assert.Error(t, err, fmt.Sprintf("Expected error while fetching /licenses/%v", licenseTemplateName))
}
func TestListGitIgnoreTemplatesReturnsGitIgnoreTemplates(t *testing.T) {
hostname := "api.github.com"
httpStubs := func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "gitignore/templates"),
httpmock.StringResponse(`[
"AL",
"Actionscript",
"Ada",
"Agda",
"Android",
"AppEngine",
"AppceleratorTitanium",
"ArchLinuxPackages",
"Autotools",
"Ballerina",
"C",
"C++",
"CFWheels",
"CMake",
"CUDA",
"CakePHP",
"ChefCookbook",
"Clojure",
"CodeIgniter",
"CommonLisp",
"Composer",
"Concrete5",
"Coq",
"CraftCMS",
"D"
]`,
))
}
wantGitIgnoreTemplates := []string{
"AL",
"Actionscript",
"Ada",
"Agda",
"Android",
"AppEngine",
"AppceleratorTitanium",
"ArchLinuxPackages",
"Autotools",
"Ballerina",
"C",
"C++",
"CFWheels",
"CMake",
"CUDA",
"CakePHP",
"ChefCookbook",
"Clojure",
"CodeIgniter",
"CommonLisp",
"Composer",
"Concrete5",
"Coq",
"CraftCMS",
"D",
}
reg := &httpmock.Registry{}
httpStubs(reg)
httpClient := func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
client, _ := httpClient()
defer reg.Verify(t)
gotGitIgnoreTemplates, err := RepoGitIgnoreTemplates(client, hostname)
assert.NoError(t, err, "Expected no error while fetching /gitignore/templates")
assert.Equal(t, wantGitIgnoreTemplates, gotGitIgnoreTemplates, "GitIgnore templates fetched is not as expected")
}
func TestGitIgnoreTemplateReturnsGitIgnoreTemplate(t *testing.T) {
gitIgnoreTemplateName := "Go"
httpStubs := func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", fmt.Sprintf("gitignore/templates/%v", gitIgnoreTemplateName)),
httpmock.StringResponse(`{
"name": "Go",
"source": "# If you prefer the allow list template instead of the deny list, see community template:\n# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore\n#\n# Binaries for programs and plugins\n*.exe\n*.exe~\n*.dll\n*.so\n*.dylib\n\n# Test binary, built with go test -c\n*.test\n\n# Output of the go coverage tool, specifically when used with LiteIDE\n*.out\n\n# Dependency directories (remove the comment below to include it)\n# vendor/\n\n# Go workspace file\ngo.work\ngo.work.sum\n\n# env file\n.env\n"
}`,
))
}
wantGitIgnoreTemplate := &GitIgnore{
Name: "Go",
Source: "# If you prefer the allow list template instead of the deny list, see community template:\n# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore\n#\n# Binaries for programs and plugins\n*.exe\n*.exe~\n*.dll\n*.so\n*.dylib\n\n# Test binary, built with go test -c\n*.test\n\n# Output of the go coverage tool, specifically when used with LiteIDE\n*.out\n\n# Dependency directories (remove the comment below to include it)\n# vendor/\n\n# Go workspace file\ngo.work\ngo.work.sum\n\n# env file\n.env\n",
}
reg := &httpmock.Registry{}
httpStubs(reg)
httpClient := func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
client, _ := httpClient()
defer reg.Verify(t)
gotGitIgnoreTemplate, err := RepoGitIgnoreTemplate(client, "api.github.com", gitIgnoreTemplateName)
assert.NoError(t, err, fmt.Sprintf("Expected no error while fetching /gitignore/templates/%v", gitIgnoreTemplateName))
assert.Equal(t, wantGitIgnoreTemplate, gotGitIgnoreTemplate, fmt.Sprintf("GitIgnore template \"%v\" fetched is not as expected", gitIgnoreTemplateName))
}
func TestGitIgnoreTemplateReturnsErrorWhenGitIgnoreTemplateNotFound(t *testing.T) {
gitIgnoreTemplateName := "invalid-gitignore"
httpStubs := func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", fmt.Sprintf("gitignore/templates/%v", gitIgnoreTemplateName)),
httpmock.StatusStringResponse(404, heredoc.Doc(`
{
"message": "Not Found",
"documentation_url": "https://docs.github.com/v3/gitignore",
"status": "404"
}`)),
)
}
reg := &httpmock.Registry{}
httpStubs(reg)
httpClient := func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
client, _ := httpClient()
defer reg.Verify(t)
_, err := RepoGitIgnoreTemplate(client, "api.github.com", gitIgnoreTemplateName)
assert.Error(t, err, fmt.Sprintf("Expected error while fetching /gitignore/templates/%v", gitIgnoreTemplateName))
}

View file

@ -1,276 +1,12 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
surveyCore "github.com/AlecAivazis/survey/v2/core"
"github.com/AlecAivazis/survey/v2/terminal"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/build"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/config/migration"
"github.com/cli/cli/v2/internal/update"
"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-isatty"
"github.com/mgutz/ansi"
"github.com/spf13/cobra"
)
var updaterEnabled = ""
type exitCode int
const (
exitOK exitCode = 0
exitError exitCode = 1
exitCancel exitCode = 2
exitAuth exitCode = 4
exitPending exitCode = 8
"github.com/cli/cli/v2/internal/ghcmd"
)
func main() {
code := mainRun()
code := ghcmd.Main()
os.Exit(int(code))
}
func mainRun() exitCode {
buildDate := build.Date
buildVersion := build.Version
hasDebug, _ := utils.IsDebugEnabled()
cmdFactory := factory.New(buildVersion)
stderr := cmdFactory.IOStreams.ErrOut
ctx := context.Background()
if cfg, err := cmdFactory.Config(); err == nil {
var m migration.MultiAccount
if err := cfg.Migrate(m); err != nil {
fmt.Fprintln(stderr, err)
return exitError
}
}
updateCtx, updateCancel := context.WithCancel(ctx)
defer updateCancel()
updateMessageChan := make(chan *update.ReleaseInfo)
go func() {
rel, err := checkForUpdate(updateCtx, cmdFactory, buildVersion)
if err != nil && hasDebug {
fmt.Fprintf(stderr, "warning: checking for update failed: %v", err)
}
updateMessageChan <- rel
}()
if !cmdFactory.IOStreams.ColorEnabled() {
surveyCore.DisableColor = true
ansi.DisableColors(true)
} else {
// override survey's poor choice of color
surveyCore.TemplateFuncsWithColor["color"] = func(style string) string {
switch style {
case "white":
return ansi.ColorCode("default")
default:
return ansi.ColorCode(style)
}
}
}
// Enable running gh from Windows File Explorer's address bar. Without this, the user is told to stop and run from a
// terminal. With this, a user can clone a repo (or take other actions) directly from explorer.
if len(os.Args) > 1 && os.Args[1] != "" {
cobra.MousetrapHelpText = ""
}
rootCmd, err := root.NewCmdRoot(cmdFactory, buildVersion, buildDate)
if err != nil {
fmt.Fprintf(stderr, "failed to create root command: %s\n", err)
return exitError
}
expandedArgs := []string{}
if len(os.Args) > 0 {
expandedArgs = os.Args[1:]
}
// translate `gh help <command>` to `gh <command> --help` for extensions.
if len(expandedArgs) >= 2 && expandedArgs[0] == "help" && isExtensionCommand(rootCmd, expandedArgs[1:]) {
expandedArgs = expandedArgs[1:]
expandedArgs = append(expandedArgs, "--help")
}
rootCmd.SetArgs(expandedArgs)
if cmd, err := rootCmd.ExecuteContextC(ctx); err != nil {
var pagerPipeError *iostreams.ErrClosedPagerPipe
var noResultsError cmdutil.NoResultsError
var extError *root.ExternalCommandExitError
var authError *root.AuthError
if err == cmdutil.SilentError {
return exitError
} else if err == cmdutil.PendingError {
return exitPending
} else if cmdutil.IsUserCancellation(err) {
if errors.Is(err, terminal.InterruptErr) {
// ensure the next shell prompt will start on its own line
fmt.Fprint(stderr, "\n")
}
return exitCancel
} else if errors.As(err, &authError) {
return exitAuth
} else if errors.As(err, &pagerPipeError) {
// ignore the error raised when piping to a closed pager
return exitOK
} else if errors.As(err, &noResultsError) {
if cmdFactory.IOStreams.IsStdoutTTY() {
fmt.Fprintln(stderr, noResultsError.Error())
}
// no results is not a command failure
return exitOK
} else if errors.As(err, &extError) {
// pass on exit codes from extensions and shell aliases
return exitCode(extError.ExitCode())
}
printError(stderr, err, cmd, hasDebug)
if strings.Contains(err.Error(), "Incorrect function") {
fmt.Fprintln(stderr, "You appear to be running in MinTTY without pseudo terminal support.")
fmt.Fprintln(stderr, "To learn about workarounds for this error, run: gh help mintty")
return exitError
}
var httpErr api.HTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode == 401 {
fmt.Fprintln(stderr, "Try authenticating with: gh auth login")
} 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)
}
return exitError
}
if root.HasFailed() {
return exitError
}
updateCancel() // if the update checker hasn't completed by now, abort it
newRelease := <-updateMessageChan
if newRelease != nil {
isHomebrew := isUnderHomebrew(cmdFactory.Executable())
if isHomebrew && isRecentRelease(newRelease.PublishedAt) {
// do not notify Homebrew users before the version bump had a chance to get merged into homebrew-core
return exitOK
}
fmt.Fprintf(stderr, "\n\n%s %s → %s\n",
ansi.Color("A new release of gh is available:", "yellow"),
ansi.Color(strings.TrimPrefix(buildVersion, "v"), "cyan"),
ansi.Color(strings.TrimPrefix(newRelease.Version, "v"), "cyan"))
if isHomebrew {
fmt.Fprintf(stderr, "To upgrade, run: %s\n", "brew upgrade gh")
}
fmt.Fprintf(stderr, "%s\n\n",
ansi.Color(newRelease.URL, "yellow"))
}
return exitOK
}
// isExtensionCommand returns true if args resolve to an extension command.
func isExtensionCommand(rootCmd *cobra.Command, args []string) bool {
c, _, err := rootCmd.Find(args)
return err == nil && c != nil && c.GroupID == "extension"
}
func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) {
var dnsError *net.DNSError
if errors.As(err, &dnsError) {
fmt.Fprintf(out, "error connecting to %s\n", dnsError.Name)
if debug {
fmt.Fprintln(out, dnsError)
}
fmt.Fprintln(out, "check your internet connection or https://githubstatus.com")
return
}
fmt.Fprintln(out, err)
var flagError *cmdutil.FlagError
if errors.As(err, &flagError) || strings.HasPrefix(err.Error(), "unknown command ") {
if !strings.HasSuffix(err.Error(), "\n") {
fmt.Fprintln(out)
}
fmt.Fprintln(out, cmd.UsageString())
}
}
func shouldCheckForUpdate() bool {
if os.Getenv("GH_NO_UPDATE_NOTIFIER") != "" {
return false
}
if os.Getenv("CODESPACES") != "" {
return false
}
return updaterEnabled != "" && !isCI() && isTerminal(os.Stdout) && isTerminal(os.Stderr)
}
func isTerminal(f *os.File) bool {
return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd())
}
// based on https://github.com/watson/ci-info/blob/HEAD/index.js
func isCI() bool {
return os.Getenv("CI") != "" || // GitHub Actions, Travis CI, CircleCI, Cirrus CI, GitLab CI, AppVeyor, CodeShip, dsari
os.Getenv("BUILD_NUMBER") != "" || // Jenkins, TeamCity
os.Getenv("RUN_ID") != "" // TaskCluster, dsari
}
func checkForUpdate(ctx context.Context, f *cmdutil.Factory, currentVersion string) (*update.ReleaseInfo, error) {
if !shouldCheckForUpdate() {
return nil, nil
}
httpClient, err := f.HttpClient()
if err != nil {
return nil, err
}
repo := updaterEnabled
stateFilePath := filepath.Join(config.StateDir(), "state.yml")
return update.CheckForUpdate(ctx, httpClient, stateFilePath, repo, currentVersion)
}
func isRecentRelease(publishedAt time.Time) bool {
return !publishedAt.IsZero() && time.Since(publishedAt) < time.Hour*24
}
// Check whether the gh binary was found under the Homebrew prefix
func isUnderHomebrew(ghBinary string) bool {
brewExe, err := safeexec.LookPath("brew")
if err != nil {
return false
}
brewPrefixBytes, err := exec.Command(brewExe, "--prefix").Output()
if err != nil {
return false
}
brewBinPrefix := filepath.Join(strings.TrimSpace(string(brewPrefixBytes)), "bin") + string(filepath.Separator)
return strings.HasPrefix(ghBinary, brewBinPrefix)
}

18
go.mod
View file

@ -11,13 +11,13 @@ require (
github.com/cenkalti/backoff/v4 v4.3.0
github.com/charmbracelet/glamour v0.7.0
github.com/charmbracelet/lipgloss v0.10.1-0.20240413172830-d0be07ea6b9c
github.com/cli/go-gh/v2 v2.10.0
github.com/cli/oauth v1.0.1
github.com/cli/go-gh/v2 v2.11.0
github.com/cli/oauth v1.1.1
github.com/cli/safeexec v1.0.1
github.com/cpuguy83/go-md2man/v2 v2.0.5
github.com/creack/pty v1.1.23
github.com/distribution/reference v0.5.0
github.com/gabriel-vasile/mimetype v1.4.5
github.com/gabriel-vasile/mimetype v1.4.6
github.com/gdamore/tcell/v2 v2.5.4
github.com/google/go-cmp v0.6.0
github.com/google/go-containerregistry v0.20.2
@ -43,10 +43,10 @@ require (
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.9.0
github.com/zalando/go-keyring v0.2.5
golang.org/x/crypto v0.27.0
golang.org/x/crypto v0.28.0
golang.org/x/sync v0.8.0
golang.org/x/term v0.24.0
golang.org/x/text v0.18.0
golang.org/x/term v0.25.0
golang.org/x/text v0.19.0
google.golang.org/grpc v1.64.1
google.golang.org/protobuf v1.34.2
gopkg.in/h2non/gock.v1 v1.1.2
@ -125,6 +125,7 @@ require (
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rodaine/table v1.0.1 // indirect
github.com/rogpeppe/go-internal v1.13.1
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
@ -158,8 +159,9 @@ require (
go.uber.org/zap v1.27.0 // indirect
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 // indirect
golang.org/x/mod v0.20.0 // indirect
golang.org/x/net v0.27.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/net v0.30.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/tools v0.22.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240520151616-dc85e6b867a5 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240520151616-dc85e6b867a5 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect

40
go.sum
View file

@ -95,10 +95,10 @@ github.com/charmbracelet/x/exp/term v0.0.0-20240425164147-ba2a9512b05f/go.mod h1
github.com/cli/browser v1.0.0/go.mod h1:IEWkHYbLjkhtjwwWlwTHW2lGxeS5gezEQBMLTwDHf5Q=
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
github.com/cli/go-gh/v2 v2.10.0 h1:GMflBKoErBXlLvN2euxzL+p7JaM8erlSmw0cT7uZr7M=
github.com/cli/go-gh/v2 v2.10.0/go.mod h1:MeRoKzXff3ygHu7zP+NVTT+imcHW6p3tpuxHAzRM2xE=
github.com/cli/oauth v1.0.1 h1:pXnTFl/qUegXHK531Dv0LNjW4mLx626eS42gnzfXJPA=
github.com/cli/oauth v1.0.1/go.mod h1:qd/FX8ZBD6n1sVNQO3aIdRxeu5LGw9WhKnYhIIoC2A4=
github.com/cli/go-gh/v2 v2.11.0 h1:TERLYMMWderKBO3lBff/JIu2+eSly2oFRgN2WvO+3eA=
github.com/cli/go-gh/v2 v2.11.0/go.mod h1:MeRoKzXff3ygHu7zP+NVTT+imcHW6p3tpuxHAzRM2xE=
github.com/cli/oauth v1.1.1 h1:459gD3hSjlKX9B1uXBuiAMdpXBUQ9QGf/NDcCpoQxPs=
github.com/cli/oauth v1.1.1/go.mod h1:qd/FX8ZBD6n1sVNQO3aIdRxeu5LGw9WhKnYhIIoC2A4=
github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00=
github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
@ -147,8 +147,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4=
github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4=
github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc=
github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc=
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/gdamore/tcell/v2 v2.5.4 h1:TGU4tSjD3sCL788vFNeJnTdzpNKIw1H5dgLnJRQVv/k=
@ -366,8 +366,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rodaine/table v1.0.1 h1:U/VwCnUxlVYxw8+NJiLIuCxA/xa6jL38MY3FYysVWWQ=
github.com/rodaine/table v1.0.1/go.mod h1:UVEtfBsflpeEcD56nF4F5AocNFta0ZuolpSVdPtlmP4=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
@ -486,8 +486,8 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 h1:hNQpMuAJe5CtcUqCXaWga3FHu+kQvCqcsoVaQgSV60o=
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@ -496,8 +496,8 @@ golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA=
golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -515,26 +515,26 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220906165534-d0df966e6959/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.172.0 h1:/1OcMZGPmW1rX2LCu2CmGUD1KXK1+pfzxotxyRUCCdk=
google.golang.org/api v0.172.0/go.mod h1:+fJZq6QXWfa9pXhnIzsjx4yI22d4aI9ZpLb58gvXjis=

View file

@ -16,6 +16,8 @@ import (
"github.com/cli/cli/v2/utils"
"github.com/cli/oauth"
"github.com/henvic/httpretty"
ghauth "github.com/cli/go-gh/v2/pkg/auth"
)
var (
@ -41,18 +43,16 @@ func AuthFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition
minimumScopes := []string{"repo", "read:org", "gist"}
scopes := append(minimumScopes, additionalScopes...)
callbackURI := "http://127.0.0.1/callback"
if ghinstance.IsEnterprise(oauthHost) {
// the OAuth app on Enterprise hosts is still registered with a legacy callback URL
// see https://github.com/cli/cli/pull/222, https://github.com/cli/cli/pull/650
callbackURI = "http://localhost/"
host, err := oauth.NewGitHubHost(ghinstance.HostPrefix(oauthHost))
if err != nil {
return "", "", err
}
flow := &oauth.Flow{
Host: oauth.GitHubHost(ghinstance.HostPrefix(oauthHost)),
Host: host,
ClientID: oauthClientID,
ClientSecret: oauthClientSecret,
CallbackURI: callbackURI,
CallbackURI: getCallbackURI(oauthHost),
Scopes: scopes,
DisplayCode: func(code, verificationURL string) error {
fmt.Fprintf(w, "%s First copy your one-time code: %s\n", cs.Yellow("!"), cs.Bold(code))
@ -105,6 +105,16 @@ func AuthFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition
return token.Token, userLogin, nil
}
func getCallbackURI(oauthHost string) string {
callbackURI := "http://127.0.0.1/callback"
if ghauth.IsEnterprise(oauthHost) {
// the OAuth app on Enterprise hosts is still registered with a legacy callback URL
// see https://github.com/cli/cli/pull/222, https://github.com/cli/cli/pull/650
callbackURI = "http://localhost/"
}
return callbackURI
}
type cfg struct {
token string
}

View file

@ -0,0 +1,36 @@
package authflow
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_getCallbackURI(t *testing.T) {
tests := []struct {
name string
oauthHost string
want string
}{
{
name: "dotcom",
oauthHost: "github.com",
want: "http://127.0.0.1/callback",
},
{
name: "ghes",
oauthHost: "my.server.com",
want: "http://localhost/",
},
{
name: "ghec data residency (ghe.com)",
oauthHost: "stampname.ghe.com",
want: "http://127.0.0.1/callback",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, getCallbackURI(tt.oauthHost))
})
}
}

View file

@ -10,7 +10,7 @@ import (
"github.com/cli/cli/v2/internal/gh"
"github.com/cli/cli/v2/internal/keyring"
o "github.com/cli/cli/v2/pkg/option"
ghAuth "github.com/cli/go-gh/v2/pkg/auth"
ghauth "github.com/cli/go-gh/v2/pkg/auth"
ghConfig "github.com/cli/go-gh/v2/pkg/config"
)
@ -206,7 +206,7 @@ func (c *AuthConfig) ActiveToken(hostname string) (string, string) {
if c.tokenOverride != nil {
return c.tokenOverride(hostname)
}
token, source := ghAuth.TokenFromEnvOrConfig(hostname)
token, source := ghauth.TokenFromEnvOrConfig(hostname)
if token == "" {
var err error
token, err = c.TokenFromKeyring(hostname)
@ -240,7 +240,7 @@ func (c *AuthConfig) HasEnvToken() bool {
// It has to use a hostname that is not going to be found in the hosts so that it
// can guarantee that tokens will only be returned from a set env var.
// Discussed here, but maybe worth revisiting: https://github.com/cli/cli/pull/7169#discussion_r1136979033
token, _ := ghAuth.TokenFromEnvOrConfig(hostname)
token, _ := ghauth.TokenFromEnvOrConfig(hostname)
return token != ""
}
@ -282,7 +282,7 @@ func (c *AuthConfig) Hosts() []string {
if c.hostsOverride != nil {
return c.hostsOverride()
}
return ghAuth.KnownHosts()
return ghauth.KnownHosts()
}
// SetHosts will override any hosts resolution and return the given
@ -297,7 +297,7 @@ func (c *AuthConfig) DefaultHost() (string, string) {
if c.defaultHostOverride != nil {
return c.defaultHostOverride()
}
return ghAuth.DefaultHost()
return ghauth.DefaultHost()
}
// SetDefaultHost will override any host resolution and return the given

View file

@ -4,8 +4,9 @@ import (
"net/http"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghinstance"
"golang.org/x/sync/errgroup"
ghauth "github.com/cli/go-gh/v2/pkg/auth"
)
type Detector interface {
@ -62,7 +63,7 @@ func NewDetector(httpClient *http.Client, host string) Detector {
}
func (d *detector) IssueFeatures() (IssueFeatures, error) {
if !ghinstance.IsEnterprise(d.host) {
if !ghauth.IsEnterprise(d.host) {
return allIssueFeatures, nil
}
@ -163,7 +164,7 @@ func (d *detector) PullRequestFeatures() (PullRequestFeatures, error) {
}
func (d *detector) RepositoryFeatures() (RepositoryFeatures, error) {
if !ghinstance.IsEnterprise(d.host) {
if !ghauth.IsEnterprise(d.host) {
return allRepositoryFeatures, nil
}

View file

@ -25,6 +25,14 @@ func TestIssueFeatures(t *testing.T) {
},
wantErr: false,
},
{
name: "ghec data residency (ghe.com)",
hostname: "stampname.ghe.com",
wantFeatures: IssueFeatures{
StateReason: true,
},
wantErr: false,
},
{
name: "GHE empty response",
hostname: "git.my.org",
@ -271,6 +279,16 @@ func TestRepositoryFeatures(t *testing.T) {
},
wantErr: false,
},
{
name: "ghec data residency (ghe.com)",
hostname: "stampname.ghe.com",
wantFeatures: RepositoryFeatures{
PullRequestTemplateQuery: true,
VisibilityField: true,
AutoMerge: true,
},
wantErr: false,
},
{
name: "GHE empty response",
hostname: "git.my.org",

271
internal/ghcmd/cmd.go Normal file
View file

@ -0,0 +1,271 @@
package ghcmd
import (
"context"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
surveyCore "github.com/AlecAivazis/survey/v2/core"
"github.com/AlecAivazis/survey/v2/terminal"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/build"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/config/migration"
"github.com/cli/cli/v2/internal/update"
"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-isatty"
"github.com/mgutz/ansi"
"github.com/spf13/cobra"
)
var updaterEnabled = ""
type exitCode int
const (
exitOK exitCode = 0
exitError exitCode = 1
exitCancel exitCode = 2
exitAuth exitCode = 4
exitPending exitCode = 8
)
func Main() exitCode {
buildDate := build.Date
buildVersion := build.Version
hasDebug, _ := utils.IsDebugEnabled()
cmdFactory := factory.New(buildVersion)
stderr := cmdFactory.IOStreams.ErrOut
ctx := context.Background()
if cfg, err := cmdFactory.Config(); err == nil {
var m migration.MultiAccount
if err := cfg.Migrate(m); err != nil {
fmt.Fprintln(stderr, err)
return exitError
}
}
updateCtx, updateCancel := context.WithCancel(ctx)
defer updateCancel()
updateMessageChan := make(chan *update.ReleaseInfo)
go func() {
rel, err := checkForUpdate(updateCtx, cmdFactory, buildVersion)
if err != nil && hasDebug {
fmt.Fprintf(stderr, "warning: checking for update failed: %v", err)
}
updateMessageChan <- rel
}()
if !cmdFactory.IOStreams.ColorEnabled() {
surveyCore.DisableColor = true
ansi.DisableColors(true)
} else {
// override survey's poor choice of color
surveyCore.TemplateFuncsWithColor["color"] = func(style string) string {
switch style {
case "white":
return ansi.ColorCode("default")
default:
return ansi.ColorCode(style)
}
}
}
// Enable running gh from Windows File Explorer's address bar. Without this, the user is told to stop and run from a
// terminal. With this, a user can clone a repo (or take other actions) directly from explorer.
if len(os.Args) > 1 && os.Args[1] != "" {
cobra.MousetrapHelpText = ""
}
rootCmd, err := root.NewCmdRoot(cmdFactory, buildVersion, buildDate)
if err != nil {
fmt.Fprintf(stderr, "failed to create root command: %s\n", err)
return exitError
}
expandedArgs := []string{}
if len(os.Args) > 0 {
expandedArgs = os.Args[1:]
}
// translate `gh help <command>` to `gh <command> --help` for extensions.
if len(expandedArgs) >= 2 && expandedArgs[0] == "help" && isExtensionCommand(rootCmd, expandedArgs[1:]) {
expandedArgs = expandedArgs[1:]
expandedArgs = append(expandedArgs, "--help")
}
rootCmd.SetArgs(expandedArgs)
if cmd, err := rootCmd.ExecuteContextC(ctx); err != nil {
var pagerPipeError *iostreams.ErrClosedPagerPipe
var noResultsError cmdutil.NoResultsError
var extError *root.ExternalCommandExitError
var authError *root.AuthError
if err == cmdutil.SilentError {
return exitError
} else if err == cmdutil.PendingError {
return exitPending
} else if cmdutil.IsUserCancellation(err) {
if errors.Is(err, terminal.InterruptErr) {
// ensure the next shell prompt will start on its own line
fmt.Fprint(stderr, "\n")
}
return exitCancel
} else if errors.As(err, &authError) {
return exitAuth
} else if errors.As(err, &pagerPipeError) {
// ignore the error raised when piping to a closed pager
return exitOK
} else if errors.As(err, &noResultsError) {
if cmdFactory.IOStreams.IsStdoutTTY() {
fmt.Fprintln(stderr, noResultsError.Error())
}
// no results is not a command failure
return exitOK
} else if errors.As(err, &extError) {
// pass on exit codes from extensions and shell aliases
return exitCode(extError.ExitCode())
}
printError(stderr, err, cmd, hasDebug)
if strings.Contains(err.Error(), "Incorrect function") {
fmt.Fprintln(stderr, "You appear to be running in MinTTY without pseudo terminal support.")
fmt.Fprintln(stderr, "To learn about workarounds for this error, run: gh help mintty")
return exitError
}
var httpErr api.HTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode == 401 {
fmt.Fprintln(stderr, "Try authenticating with: gh auth login")
} 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)
}
return exitError
}
if root.HasFailed() {
return exitError
}
updateCancel() // if the update checker hasn't completed by now, abort it
newRelease := <-updateMessageChan
if newRelease != nil {
isHomebrew := isUnderHomebrew(cmdFactory.Executable())
if isHomebrew && isRecentRelease(newRelease.PublishedAt) {
// do not notify Homebrew users before the version bump had a chance to get merged into homebrew-core
return exitOK
}
fmt.Fprintf(stderr, "\n\n%s %s → %s\n",
ansi.Color("A new release of gh is available:", "yellow"),
ansi.Color(strings.TrimPrefix(buildVersion, "v"), "cyan"),
ansi.Color(strings.TrimPrefix(newRelease.Version, "v"), "cyan"))
if isHomebrew {
fmt.Fprintf(stderr, "To upgrade, run: %s\n", "brew upgrade gh")
}
fmt.Fprintf(stderr, "%s\n\n",
ansi.Color(newRelease.URL, "yellow"))
}
return exitOK
}
// isExtensionCommand returns true if args resolve to an extension command.
func isExtensionCommand(rootCmd *cobra.Command, args []string) bool {
c, _, err := rootCmd.Find(args)
return err == nil && c != nil && c.GroupID == "extension"
}
func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) {
var dnsError *net.DNSError
if errors.As(err, &dnsError) {
fmt.Fprintf(out, "error connecting to %s\n", dnsError.Name)
if debug {
fmt.Fprintln(out, dnsError)
}
fmt.Fprintln(out, "check your internet connection or https://githubstatus.com")
return
}
fmt.Fprintln(out, err)
var flagError *cmdutil.FlagError
if errors.As(err, &flagError) || strings.HasPrefix(err.Error(), "unknown command ") {
if !strings.HasSuffix(err.Error(), "\n") {
fmt.Fprintln(out)
}
fmt.Fprintln(out, cmd.UsageString())
}
}
func shouldCheckForUpdate() bool {
if os.Getenv("GH_NO_UPDATE_NOTIFIER") != "" {
return false
}
if os.Getenv("CODESPACES") != "" {
return false
}
return updaterEnabled != "" && !isCI() && isTerminal(os.Stdout) && isTerminal(os.Stderr)
}
func isTerminal(f *os.File) bool {
return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd())
}
// based on https://github.com/watson/ci-info/blob/HEAD/index.js
func isCI() bool {
return os.Getenv("CI") != "" || // GitHub Actions, Travis CI, CircleCI, Cirrus CI, GitLab CI, AppVeyor, CodeShip, dsari
os.Getenv("BUILD_NUMBER") != "" || // Jenkins, TeamCity
os.Getenv("RUN_ID") != "" // TaskCluster, dsari
}
func checkForUpdate(ctx context.Context, f *cmdutil.Factory, currentVersion string) (*update.ReleaseInfo, error) {
if !shouldCheckForUpdate() {
return nil, nil
}
httpClient, err := f.HttpClient()
if err != nil {
return nil, err
}
repo := updaterEnabled
stateFilePath := filepath.Join(config.StateDir(), "state.yml")
return update.CheckForUpdate(ctx, httpClient, stateFilePath, repo, currentVersion)
}
func isRecentRelease(publishedAt time.Time) bool {
return !publishedAt.IsZero() && time.Since(publishedAt) < time.Hour*24
}
// Check whether the gh binary was found under the Homebrew prefix
func isUnderHomebrew(ghBinary string) bool {
brewExe, err := safeexec.LookPath("brew")
if err != nil {
return false
}
brewPrefixBytes, err := exec.Command(brewExe, "--prefix").Output()
if err != nil {
return false
}
brewBinPrefix := filepath.Join(strings.TrimSpace(string(brewPrefixBytes)), "bin") + string(filepath.Separator)
return strings.HasPrefix(ghBinary, brewBinPrefix)
}

View file

@ -1,4 +1,4 @@
package main
package ghcmd
import (
"bytes"

View file

@ -4,6 +4,8 @@ import (
"errors"
"fmt"
"strings"
ghauth "github.com/cli/go-gh/v2/pkg/auth"
)
// DefaultHostname is the domain name of the default GitHub instance.
@ -20,22 +22,10 @@ func Default() string {
return defaultHostname
}
// IsEnterprise reports whether a non-normalized host name looks like a GHE instance.
func IsEnterprise(h string) bool {
normalizedHostName := NormalizeHostname(h)
return normalizedHostName != defaultHostname && normalizedHostName != localhost
}
// IsTenancy reports whether a non-normalized host name looks like a tenancy instance.
func IsTenancy(h string) bool {
normalizedHostName := NormalizeHostname(h)
return strings.HasSuffix(normalizedHostName, "."+tenancyHost)
}
// TenantName extracts the tenant name from tenancy host name and
// reports whether it found the tenant name.
func TenantName(h string) (string, bool) {
normalizedHostName := NormalizeHostname(h)
normalizedHostName := ghauth.NormalizeHostname(h)
return cutSuffix(normalizedHostName, "."+tenancyHost)
}
@ -43,22 +33,6 @@ func isGarage(h string) bool {
return strings.EqualFold(h, "garage.github.com")
}
// NormalizeHostname returns the canonical host name of a GitHub instance.
func NormalizeHostname(h string) string {
hostname := strings.ToLower(h)
if strings.HasSuffix(hostname, "."+defaultHostname) {
return defaultHostname
}
if strings.HasSuffix(hostname, "."+localhost) {
return localhost
}
if before, found := cutSuffix(hostname, "."+tenancyHost); found {
idx := strings.LastIndex(before, ".")
return fmt.Sprintf("%s.%s", before[idx+1:], tenancyHost)
}
return hostname
}
func HostnameValidator(hostname string) error {
if len(strings.TrimSpace(hostname)) < 1 {
return errors.New("a value is required")
@ -73,14 +47,7 @@ func GraphQLEndpoint(hostname string) string {
if isGarage(hostname) {
return fmt.Sprintf("https://%s/api/graphql", hostname)
}
// Once we change Tenancy to no longer be treated as Enterprise, this
// conditional can be removed as the flow will fall through to the bottom.
// However, we can't do that until we've investigated all places in which
// Tenancy is currently treated as Enterprise.
if IsTenancy(hostname) {
return fmt.Sprintf("https://api.%s/graphql", hostname)
}
if IsEnterprise(hostname) {
if ghauth.IsEnterprise(hostname) {
return fmt.Sprintf("https://%s/api/graphql", hostname)
}
if strings.EqualFold(hostname, localhost) {
@ -93,14 +60,7 @@ func RESTPrefix(hostname string) string {
if isGarage(hostname) {
return fmt.Sprintf("https://%s/api/v3/", hostname)
}
// Once we change Tenancy to no longer be treated as Enterprise, this
// conditional can be removed as the flow will fall through to the bottom.
// However, we can't do that until we've investigated all places in which
// Tenancy is currently treated as Enterprise.
if IsTenancy(hostname) {
return fmt.Sprintf("https://api.%s/", hostname)
}
if IsEnterprise(hostname) {
if ghauth.IsEnterprise(hostname) {
return fmt.Sprintf("https://%s/api/v3/", hostname)
}
if strings.EqualFold(hostname, localhost) {
@ -121,7 +81,7 @@ func GistHost(hostname string) string {
if isGarage(hostname) {
return fmt.Sprintf("%s/gist/", hostname)
}
if IsEnterprise(hostname) {
if ghauth.IsEnterprise(hostname) {
return fmt.Sprintf("%s/gist/", hostname)
}
if strings.EqualFold(hostname, localhost) {

View file

@ -6,88 +6,6 @@ import (
"github.com/stretchr/testify/assert"
)
func TestIsEnterprise(t *testing.T) {
tests := []struct {
host string
want bool
}{
{
host: "github.com",
want: false,
},
{
host: "api.github.com",
want: false,
},
{
host: "github.localhost",
want: false,
},
{
host: "api.github.localhost",
want: false,
},
{
host: "garage.github.com",
want: false,
},
{
host: "ghe.io",
want: true,
},
{
host: "example.com",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.host, func(t *testing.T) {
if got := IsEnterprise(tt.host); got != tt.want {
t.Errorf("IsEnterprise() = %v, want %v", got, tt.want)
}
})
}
}
func TestIsTenancy(t *testing.T) {
tests := []struct {
host string
want bool
}{
{
host: "github.com",
want: false,
},
{
host: "github.localhost",
want: false,
},
{
host: "garage.github.com",
want: false,
},
{
host: "ghe.com",
want: false,
},
{
host: "tenant.ghe.com",
want: true,
},
{
host: "api.tenant.ghe.com",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.host, func(t *testing.T) {
if got := IsTenancy(tt.host); got != tt.want {
t.Errorf("IsTenancy() = %v, want %v", got, tt.want)
}
})
}
}
func TestTenantName(t *testing.T) {
tests := []struct {
host string
@ -130,69 +48,6 @@ func TestTenantName(t *testing.T) {
}
}
func TestNormalizeHostname(t *testing.T) {
tests := []struct {
host string
want string
}{
{
host: "GitHub.com",
want: "github.com",
},
{
host: "api.github.com",
want: "github.com",
},
{
host: "ssh.github.com",
want: "github.com",
},
{
host: "upload.github.com",
want: "github.com",
},
{
host: "GitHub.localhost",
want: "github.localhost",
},
{
host: "api.github.localhost",
want: "github.localhost",
},
{
host: "garage.github.com",
want: "github.com",
},
{
host: "GHE.IO",
want: "ghe.io",
},
{
host: "git.my.org",
want: "git.my.org",
},
{
host: "ghe.com",
want: "ghe.com",
},
{
host: "tenant.ghe.com",
want: "tenant.ghe.com",
},
{
host: "api.tenant.ghe.com",
want: "tenant.ghe.com",
},
}
for _, tt := range tests {
t.Run(tt.host, func(t *testing.T) {
if got := NormalizeHostname(tt.host); got != tt.want {
t.Errorf("NormalizeHostname() = %v, want %v", got, tt.want)
}
})
}
}
func TestHostnameValidator(t *testing.T) {
tests := []struct {
name string

View file

@ -6,7 +6,7 @@ import (
"strings"
"github.com/cli/cli/v2/internal/ghinstance"
ghAuth "github.com/cli/go-gh/v2/pkg/auth"
ghauth "github.com/cli/go-gh/v2/pkg/auth"
"github.com/cli/go-gh/v2/pkg/repository"
)
@ -37,7 +37,7 @@ func FullName(r Interface) string {
}
func defaultHost() string {
host, _ := ghAuth.DefaultHost()
host, _ := ghauth.DefaultHost()
return host
}

View file

@ -3,15 +3,13 @@ package auth
import (
"errors"
"github.com/cli/cli/v2/internal/ghinstance"
ghauth "github.com/cli/go-gh/v2/pkg/auth"
)
var ErrUnsupportedHost = errors.New("An unsupported host was detected. Note that gh attestation does not currently support GHES")
func IsHostSupported(host string) error {
// Note that this check is slightly redundant as Tenancy should not be considered Enterprise
// but the ghinstance package has not been updated to reflect this yet.
if ghinstance.IsEnterprise(host) && !ghinstance.IsTenancy(host) {
if ghauth.IsEnterprise(host) {
return ErrUnsupportedHost
}
return nil

View file

@ -85,7 +85,7 @@ func NewInspectCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
Logger: opts.Logger,
}
// Prepare for tenancy if detected
if ghinstance.IsTenancy(opts.Hostname) {
if ghauth.IsTenancy(opts.Hostname) {
hc, err := f.HttpClient()
if err != nil {
return err

View file

@ -6,7 +6,6 @@ import (
"fmt"
"os"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/pkg/cmd/attestation/api"
"github.com/cli/cli/v2/pkg/cmd/attestation/auth"
"github.com/cli/cli/v2/pkg/cmd/attestation/io"
@ -68,7 +67,7 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com
return err
}
if ghinstance.IsTenancy(opts.Hostname) {
if ghauth.IsTenancy(opts.Hostname) {
c, err := f.Config()
if err != nil {
return err

View file

@ -138,7 +138,7 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
}
// Prepare for tenancy if detected
if ghinstance.IsTenancy(opts.Hostname) {
if ghauth.IsTenancy(opts.Hostname) {
td, err := opts.APIClient.GetTrustDomain()
if err != nil {
return fmt.Errorf("error getting trust domain, make sure you are authenticated against the host: %w", err)

View file

@ -15,7 +15,7 @@ import (
"github.com/cli/cli/v2/pkg/cmd/auth/shared/gitcredentials"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
ghAuth "github.com/cli/go-gh/v2/pkg/auth"
ghauth "github.com/cli/go-gh/v2/pkg/auth"
"github.com/spf13/cobra"
)
@ -123,7 +123,7 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm
}
if opts.Hostname == "" && (!opts.Interactive || opts.Web) {
opts.Hostname, _ = ghAuth.DefaultHost()
opts.Hostname, _ = ghauth.DefaultHost()
}
opts.MainExecutable = f.Executable()

View file

@ -8,11 +8,12 @@ import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/gh"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
ghauth "github.com/cli/go-gh/v2/pkg/auth"
)
type CloneOptions struct {
@ -93,7 +94,7 @@ func cloneRun(opts *CloneOptions) error {
}
func formatRemoteURL(hostname string, gistID string, protocol string) string {
if ghinstance.IsEnterprise(hostname) {
if ghauth.IsEnterprise(hostname) {
if protocol == "ssh" {
return fmt.Sprintf("git@%s:gist/%s.git", hostname, gistID)
}

View file

@ -3,9 +3,11 @@ package list
import (
"fmt"
"net/http"
"regexp"
"strings"
"time"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/gh"
"github.com/cli/cli/v2/internal/tableprinter"
"github.com/cli/cli/v2/internal/text"
@ -20,8 +22,10 @@ type ListOptions struct {
Config func() (gh.Config, error)
HttpClient func() (*http.Client, error)
Limit int
Visibility string // all, secret, public
Limit int
Filter *regexp.Regexp
IncludeContent bool
Visibility string // all, secret, public
}
func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
@ -33,10 +37,36 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
var flagPublic bool
var flagSecret bool
var flagFilter string
cmd := &cobra.Command{
Use: "list",
Short: "List your gists",
Use: "list",
Short: "List your gists",
Long: heredoc.Docf(`
List gists from your user account.
You can use a regular expression to filter the description, file names,
or even the content of files in the gist using %[1]s--filter%[1]s.
For supported regular expression syntax, see <https://pkg.go.dev/regexp/syntax>.
Use %[1]s--include-content%[1]s to include content of files, noting that
this will be slower and increase the rate limit used. Instead of printing a table,
code will be printed with highlights similar to %[1]sgh search code%[1]s:
{{gist ID}} {{file name}}
{{description}}
{{matching lines from content}}
No highlights or other color is printed when output is redirected.
`, "`"),
Example: heredoc.Doc(`
# list all secret gists from your user account
$ gh gist list --secret
# find all gists from your user account mentioning "octo" anywhere
$ gh gist list --filter octo --include-content
`),
Aliases: []string{"ls"},
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
@ -44,6 +74,18 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
return cmdutil.FlagErrorf("invalid limit: %v", opts.Limit)
}
if flagFilter == "" {
if opts.IncludeContent {
return cmdutil.FlagErrorf("cannot use --include-content without --filter")
}
} else {
if filter, err := regexp.CompilePOSIX(flagFilter); err != nil {
return err
} else {
opts.Filter = filter
}
}
opts.Visibility = "all"
if flagSecret {
opts.Visibility = "secret"
@ -61,6 +103,8 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 10, "Maximum number of gists to fetch")
cmd.Flags().BoolVar(&flagPublic, "public", false, "Show only public gists")
cmd.Flags().BoolVar(&flagSecret, "secret", false, "Show only secret gists")
cmd.Flags().StringVar(&flagFilter, "filter", "", "Filter gists using a regular `expression`")
cmd.Flags().BoolVar(&opts.IncludeContent, "include-content", false, "Include gists' file content when filtering")
return cmd
}
@ -78,7 +122,13 @@ func listRun(opts *ListOptions) error {
host, _ := cfg.Authentication().DefaultHost()
gists, err := shared.ListGists(client, host, opts.Limit, opts.Visibility)
// Filtering can take a while so start the progress indicator. StopProgressIndicator will no-op if not running.
if opts.Filter != nil {
opts.IO.StartProgressIndicator()
}
gists, err := shared.ListGists(client, host, opts.Limit, opts.Filter, opts.IncludeContent, opts.Visibility)
opts.IO.StopProgressIndicator()
if err != nil {
return err
}
@ -93,8 +143,38 @@ func listRun(opts *ListOptions) error {
fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err)
}
cs := opts.IO.ColorScheme()
tp := tableprinter.New(opts.IO, tableprinter.WithHeader("ID", "DESCRIPTION", "FILES", "VISIBILITY", "UPDATED"))
if opts.Filter != nil && opts.IncludeContent {
return printContent(opts.IO, gists, opts.Filter)
}
return printTable(opts.IO, gists, opts.Filter)
}
func printTable(io *iostreams.IOStreams, gists []shared.Gist, filter *regexp.Regexp) error {
cs := io.ColorScheme()
tp := tableprinter.New(io, tableprinter.WithHeader("ID", "DESCRIPTION", "FILES", "VISIBILITY", "UPDATED"))
// Highlight filter matches in the description when printing the table.
highlightDescription := func(s string) string {
if filter != nil {
if str, err := highlightMatch(s, filter, nil, cs.Bold, cs.Highlight); err == nil {
return str
}
}
return cs.Bold(s)
}
// Highlight the files column when any file name matches the filter.
highlightFilesFunc := func(gist *shared.Gist) func(string) string {
if filter != nil {
for _, file := range gist.Files {
if filter.MatchString(file.Filename) {
return cs.Highlight
}
}
}
return normal
}
for _, gist := range gists {
fileCount := len(gist.Files)
@ -119,9 +199,12 @@ func listRun(opts *ListOptions) error {
tp.AddField(gist.ID)
tp.AddField(
text.RemoveExcessiveWhitespace(description),
tableprinter.WithColor(cs.Bold),
tableprinter.WithColor(highlightDescription),
)
tp.AddField(
text.Pluralize(fileCount, "file"),
tableprinter.WithColor(highlightFilesFunc(&gist)),
)
tp.AddField(text.Pluralize(fileCount, "file"))
tp.AddField(visibility, tableprinter.WithColor(visColor))
tp.AddTimeField(time.Now(), gist.UpdatedAt, cs.Gray)
tp.EndRow()
@ -129,3 +212,98 @@ func listRun(opts *ListOptions) error {
return tp.Render()
}
// printContent prints a gist with optional description and content similar to `gh search code`
// including highlighted matches in the form:
//
// {{gist ID}} {{file name}}
// {{description, if any}}
// {{content lines with matches, if any}}
//
// If printing to a non-TTY stream the format will be the same but without highlights.
func printContent(io *iostreams.IOStreams, gists []shared.Gist, filter *regexp.Regexp) error {
const tab string = " "
cs := io.ColorScheme()
out := &strings.Builder{}
var filename, description string
var err error
split := func(r rune) bool {
return r == '\n' || r == '\r'
}
for _, gist := range gists {
for _, file := range gist.Files {
matched := false
out.Reset()
if filename, err = highlightMatch(file.Filename, filter, &matched, cs.Green, cs.Highlight); err != nil {
return err
}
fmt.Fprintln(out, cs.Blue(gist.ID), filename)
if gist.Description != "" {
if description, err = highlightMatch(gist.Description, filter, &matched, cs.Bold, cs.Highlight); err != nil {
return err
}
fmt.Fprintf(out, "%s%s\n", tab, description)
}
if file.Content != "" {
for _, line := range strings.FieldsFunc(file.Content, split) {
if filter.MatchString(line) {
if line, err = highlightMatch(line, filter, &matched, normal, cs.Highlight); err != nil {
return err
}
fmt.Fprintf(out, "%[1]s%[1]s%[2]s\n", tab, line)
}
}
}
if matched {
fmt.Fprintln(io.Out, out.String())
}
}
}
return nil
}
func highlightMatch(s string, filter *regexp.Regexp, matched *bool, color, highlight func(string) string) (string, error) {
matches := filter.FindAllStringIndex(s, -1)
if matches == nil {
return color(s), nil
}
out := strings.Builder{}
// Color up to the first match. If an empty string, no ANSI color sequence is added.
if _, err := out.WriteString(color(s[:matches[0][0]])); err != nil {
return "", err
}
// Highlight each match, then color the remaining text which, if an empty string, no ANSI color sequence is added.
for i, match := range matches {
if _, err := out.WriteString(highlight(s[match[0]:match[1]])); err != nil {
return "", err
}
text := s[match[1]:]
if i+1 < len(matches) {
text = s[match[1]:matches[i+1][0]]
}
if _, err := out.WriteString(color(text)); err != nil {
return "", nil
}
}
if matched != nil {
*matched = *matched || true
}
return out.String(), nil
}
func normal(s string) string {
return s
}

View file

@ -4,6 +4,7 @@ import (
"bytes"
"fmt"
"net/http"
"regexp"
"testing"
"time"
@ -19,9 +20,10 @@ import (
func TestNewCmdList(t *testing.T) {
tests := []struct {
name string
cli string
wants ListOptions
name string
cli string
wants ListOptions
wantsErr bool
}{
{
name: "no arguments",
@ -70,6 +72,31 @@ func TestNewCmdList(t *testing.T) {
Visibility: "all",
},
},
{
name: "invalid limit",
cli: "--limit 0",
wantsErr: true,
},
{
name: "filter and include-content",
cli: "--filter octo --include-content",
wants: ListOptions{
Limit: 10,
Filter: regexp.MustCompilePOSIX("octo"),
IncludeContent: true,
Visibility: "all",
},
},
{
name: "invalid filter",
cli: "--filter octo(",
wantsErr: true,
},
{
name: "include content without filter",
cli: "--include-content",
wantsErr: true,
},
}
for _, tt := range tests {
@ -90,6 +117,10 @@ func TestNewCmdList(t *testing.T) {
cmd.SetErr(&bytes.Buffer{})
_, err = cmd.ExecuteC()
if tt.wantsErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wants.Visibility, gotOpts.Visibility)
@ -110,6 +141,7 @@ func Test_listRun(t *testing.T) {
wantErr bool
wantOut string
stubs func(*httpmock.Registry)
color bool
nontty bool
}{
{
@ -358,6 +390,225 @@ func Test_listRun(t *testing.T) {
`),
nontty: true,
},
{
name: "filtered",
opts: &ListOptions{
Filter: regexp.MustCompile("octo"),
Visibility: "all",
},
nontty: true,
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(query),
httpmock.StringResponse(fmt.Sprintf(
`{ "data": { "viewer": { "gists": { "nodes": [
{
"name": "1234",
"files": [
{ "name": "main.txt", "text": "foo" }
],
"description": "octo match in the description",
"updatedAt": "%[1]v",
"isPublic": true
},
{
"name": "2345",
"files": [
{ "name": "main.txt", "text": "foo" },
{ "name": "octo.txt", "text": "bar" }
],
"description": "match in the file name",
"updatedAt": "%[1]v",
"isPublic": false
},
{
"name": "3456",
"files": [
{ "name": "main.txt", "text": "octo in the text" }
],
"description": "match in the file text",
"updatedAt": "%[1]v",
"isPublic": true
}
] } } } }`,
absTime.Format(time.RFC3339),
)),
)
},
wantOut: heredoc.Docf(`
1234%[1]socto match in the description%[1]s1 file%[1]spublic%[1]s2020-07-30T15:24:28Z
2345%[1]smatch in the file name%[1]s2 files%[1]ssecret%[1]s2020-07-30T15:24:28Z
`, "\t"),
},
{
name: "filtered (tty)",
opts: &ListOptions{
Filter: regexp.MustCompile("octo"),
Visibility: "all",
},
color: true,
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(query),
httpmock.StringResponse(fmt.Sprintf(
`{ "data": { "viewer": { "gists": { "nodes": [
{
"name": "1234",
"files": [
{ "name": "main.txt", "text": "foo" }
],
"description": "octo match in the description",
"updatedAt": "%[1]v",
"isPublic": true
},
{
"name": "2345",
"files": [
{ "name": "main.txt", "text": "foo" },
{ "name": "octo.txt", "text": "bar" }
],
"description": "match in the file name",
"updatedAt": "%[1]v",
"isPublic": false
},
{
"name": "3456",
"files": [
{ "name": "main.txt", "text": "octo in the text" }
],
"description": "match in the file text",
"updatedAt": "%[1]v",
"isPublic": true
}
] } } } }`,
sixHoursAgo.Format(time.RFC3339),
)),
)
},
wantOut: heredoc.Docf(`
%[1]s[0;2;4;37mID %[1]s[0m %[1]s[0;2;4;37mDESCRIPTION %[1]s[0m %[1]s[0;2;4;37mFILES %[1]s[0m %[1]s[0;2;4;37mVISIBILITY%[1]s[0m %[1]s[0;2;4;37mUPDATED %[1]s[0m
1234 %[1]s[0;30;43mocto%[1]s[0m%[1]s[0;1;39m match in the description%[1]s[0m 1 file %[1]s[0;32mpublic %[1]s[0m %[1]s[38;5;242mabout 6 hours ago%[1]s[m
2345 %[1]s[0;1;39mmatch in the file name %[1]s[0m %[1]s[0;30;43m2 files%[1]s[0m %[1]s[0;31msecret %[1]s[0m %[1]s[38;5;242mabout 6 hours ago%[1]s[m
`, "\x1b"),
},
{
name: "filtered with content",
opts: &ListOptions{
Filter: regexp.MustCompile("octo"),
IncludeContent: true,
Visibility: "all",
},
nontty: true,
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(query),
httpmock.StringResponse(fmt.Sprintf(
`{ "data": { "viewer": { "gists": { "nodes": [
{
"name": "1234",
"files": [
{ "name": "main.txt", "text": "foo" }
],
"description": "octo match in the description",
"updatedAt": "%[1]v",
"isPublic": true
},
{
"name": "2345",
"files": [
{ "name": "main.txt", "text": "foo" },
{ "name": "octo.txt", "text": "bar" }
],
"description": "match in the file name",
"updatedAt": "%[1]v",
"isPublic": false
},
{
"name": "3456",
"files": [
{ "name": "main.txt", "text": "octo in the text" }
],
"description": "match in the file text",
"updatedAt": "%[1]v",
"isPublic": true
}
] } } } }`,
absTime.Format(time.RFC3339),
)),
)
},
wantOut: heredoc.Doc(`
1234 main.txt
octo match in the description
2345 octo.txt
match in the file name
3456 main.txt
match in the file text
octo in the text
`),
},
{
name: "filtered with content (tty)",
opts: &ListOptions{
Filter: regexp.MustCompile("octo"),
IncludeContent: true,
Visibility: "all",
},
color: true,
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(query),
httpmock.StringResponse(fmt.Sprintf(
`{ "data": { "viewer": { "gists": { "nodes": [
{
"name": "1234",
"files": [
{ "name": "main.txt", "text": "foo" }
],
"description": "octo match in the description",
"updatedAt": "%[1]v",
"isPublic": true
},
{
"name": "2345",
"files": [
{ "name": "main.txt", "text": "foo" },
{ "name": "octo.txt", "text": "bar" }
],
"description": "match in the file name",
"updatedAt": "%[1]v",
"isPublic": false
},
{
"name": "3456",
"files": [
{ "name": "main.txt", "text": "octo in the text" }
],
"description": "match in the file text",
"updatedAt": "%[1]v",
"isPublic": true
}
] } } } }`,
sixHoursAgo.Format(time.RFC3339),
)),
)
},
wantOut: heredoc.Docf(`
%[1]s[0;34m1234%[1]s[0m %[1]s[0;32mmain.txt%[1]s[0m
%[1]s[0;30;43mocto%[1]s[0m%[1]s[0;1;39m match in the description%[1]s[0m
%[1]s[0;34m2345%[1]s[0m %[1]s[0;30;43mocto%[1]s[0m%[1]s[0;32m.txt%[1]s[0m
%[1]s[0;1;39mmatch in the file name%[1]s[0m
%[1]s[0;34m3456%[1]s[0m %[1]s[0;32mmain.txt%[1]s[0m
%[1]s[0;1;39mmatch in the file text%[1]s[0m
%[1]s[0;30;43mocto%[1]s[0m in the text
`, "\x1b"),
},
}
for _, tt := range tests {
@ -373,6 +624,7 @@ func Test_listRun(t *testing.T) {
}
ios, _, stdout, _ := iostreams.Test()
ios.SetColorEnabled(tt.color)
ios.SetStdoutTTY(!tt.nontty)
tt.opts.IO = ios
@ -396,3 +648,59 @@ func Test_listRun(t *testing.T) {
})
}
}
func Test_highlightMatch(t *testing.T) {
regex := regexp.MustCompilePOSIX(`[Oo]cto`)
tests := []struct {
name string
input string
color bool
want string
}{
{
name: "single match",
input: "Octo",
want: "Octo",
},
{
name: "single match (color)",
input: "Octo",
color: true,
want: "\x1b[0;30;43mOcto\x1b[0m",
},
{
name: "single match with extra",
input: "Hello, Octocat!",
want: "Hello, Octocat!",
},
{
name: "single match with extra (color)",
input: "Hello, Octocat!",
color: true,
want: "\x1b[0;34mHello, \x1b[0m\x1b[0;30;43mOcto\x1b[0m\x1b[0;34mcat!\x1b[0m",
},
{
name: "multiple matches",
input: "Octocat/octo",
want: "Octocat/octo",
},
{
name: "multiple matches (color)",
input: "Octocat/octo",
color: true,
want: "\x1b[0;30;43mOcto\x1b[0m\x1b[0;34mcat/\x1b[0m\x1b[0;30;43mocto\x1b[0m",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cs := iostreams.NewColorScheme(tt.color, false, false)
matched := false
got, err := highlightMatch(tt.input, regex, &matched, cs.Blue, cs.Highlight)
assert.NoError(t, err)
assert.True(t, matched)
assert.Equal(t, tt.want, got)
})
}
}

View file

@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/url"
"regexp"
"sort"
"strings"
"time"
@ -74,7 +75,9 @@ func GistIDFromURL(gistURL string) (string, error) {
return "", fmt.Errorf("Invalid gist URL %s", u)
}
func ListGists(client *http.Client, hostname string, limit int, visibility string) ([]Gist, error) {
const maxPerPage = 100
func ListGists(client *http.Client, hostname string, limit int, filter *regexp.Regexp, includeContent bool, visibility string) ([]Gist, error) {
type response struct {
Viewer struct {
Gists struct {
@ -82,6 +85,7 @@ func ListGists(client *http.Client, hostname string, limit int, visibility strin
Description string
Files []struct {
Name string
Text string `graphql:"text @include(if: $includeContent)"`
}
IsPublic bool
Name string
@ -96,14 +100,33 @@ func ListGists(client *http.Client, hostname string, limit int, visibility strin
}
perPage := limit
if perPage > 100 {
perPage = 100
if perPage > maxPerPage {
perPage = maxPerPage
}
variables := map[string]interface{}{
"per_page": githubv4.Int(perPage),
"endCursor": (*githubv4.String)(nil),
"visibility": githubv4.GistPrivacy(strings.ToUpper(visibility)),
"per_page": githubv4.Int(perPage),
"endCursor": (*githubv4.String)(nil),
"visibility": githubv4.GistPrivacy(strings.ToUpper(visibility)),
"includeContent": githubv4.Boolean(includeContent),
}
filterFunc := func(gist *Gist) bool {
if filter.MatchString(gist.Description) {
return true
}
for _, file := range gist.Files {
if filter.MatchString(file.Filename) {
return true
}
if includeContent && filter.MatchString(file.Content) {
return true
}
}
return false
}
gql := api.NewClientFromHTTP(client)
@ -122,19 +145,22 @@ pagination:
for _, file := range gist.Files {
files[file.Name] = &GistFile{
Filename: file.Name,
Content: file.Text,
}
}
gists = append(
gists,
Gist{
ID: gist.Name,
Description: gist.Description,
Files: files,
UpdatedAt: gist.UpdatedAt,
Public: gist.IsPublic,
},
)
gist := Gist{
ID: gist.Name,
Description: gist.Description,
Files: files,
UpdatedAt: gist.UpdatedAt,
Public: gist.IsPublic,
}
if filter == nil || filterFunc(&gist) {
gists = append(gists, gist)
}
if len(gists) == limit {
break pagination
}
@ -177,7 +203,7 @@ func IsBinaryContents(contents []byte) bool {
}
func PromptGists(prompter prompter.Prompter, client *http.Client, host string, cs *iostreams.ColorScheme) (gistID string, err error) {
gists, err := ListGists(client, host, 10, "all")
gists, err := ListGists(client, host, 10, nil, false, "all")
if err != nil {
return "", err
}

View file

@ -222,7 +222,7 @@ func createRun(opts *CreateOptions) (err error) {
defer prShared.PreserveInput(opts.IO, &tb, &err)()
if opts.Title == "" {
err = prShared.TitleSurvey(opts.Prompter, &tb)
err = prShared.TitleSurvey(opts.Prompter, opts.IO, &tb)
if err != nil {
return
}

View file

@ -611,7 +611,7 @@ func TestIssueCreate_recover(t *testing.T) {
pm := &prompter.PrompterMock{}
pm.InputFunc = func(p, d string) (string, error) {
if p == "Title" {
if p == "Title (required)" {
return d, nil
} else {
return "", prompter.NoSuchPromptErr(p)
@ -736,7 +736,7 @@ func TestIssueCreate_continueInBrowser(t *testing.T) {
pm := &prompter.PrompterMock{}
pm.InputFunc = func(p, d string) (string, error) {
if p == "Title" {
if p == "Title (required)" {
return "hello", nil
} else {
return "", prompter.NoSuchPromptErr(p)

View file

@ -379,7 +379,7 @@ func createRun(opts *CreateOptions) (err error) {
} else {
if !opts.TitleProvided {
err = shared.TitleSurvey(opts.Prompter, state)
err = shared.TitleSurvey(opts.Prompter, opts.IO, state)
if err != nil {
return
}

View file

@ -1210,7 +1210,7 @@ func Test_createRun(t *testing.T) {
},
promptStubs: func(pm *prompter.PrompterMock) {
pm.InputFunc = func(p, d string) (string, error) {
if p == "Title" {
if p == "Title (required)" {
return d, nil
} else {
return "", prompter.NoSuchPromptErr(p)
@ -1316,7 +1316,7 @@ func Test_createRun(t *testing.T) {
}
pm.InputFunc = func(p, d string) (string, error) {
if p == "Title" {
if p == "Title (required)" {
return d, nil
} else if p == "Body" {
return d, nil

View file

@ -110,10 +110,17 @@ func BodySurvey(p Prompt, state *IssueMetadataState, templateContent string) err
return nil
}
func TitleSurvey(p Prompt, state *IssueMetadataState) error {
result, err := p.Input("Title", state.Title)
if err != nil {
return err
func TitleSurvey(p Prompt, io *iostreams.IOStreams, state *IssueMetadataState) error {
var err error
result := ""
for result == "" {
result, err = p.Input("Title (required)", state.Title)
if err != nil {
return err
}
if result == "" {
fmt.Fprintf(io.ErrOut, "%s Title cannot be blank\n", io.ColorScheme().FailureIcon())
}
}
if result != state.Title {

View file

@ -158,3 +158,44 @@ type testEditor struct {
func (e testEditor) Edit(filename, text string) (string, error) {
return e.edit(text)
}
func TestTitleSurvey(t *testing.T) {
tests := []struct {
name string
prompterMockInputs []string
expectedTitle string
expectStderr bool
}{
{
name: "title provided",
prompterMockInputs: []string{"title"},
expectedTitle: "title",
},
{
name: "first input empty",
prompterMockInputs: []string{"", "title"},
expectedTitle: "title",
expectStderr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, _, _, stderr := iostreams.Test()
pm := prompter.NewMockPrompter(t)
for _, input := range tt.prompterMockInputs {
pm.RegisterInput("Title (required)", func(string, string) (string, error) {
return input, nil
})
}
state := &IssueMetadataState{}
err := TitleSurvey(pm, io, state)
assert.NoError(t, err)
assert.Equal(t, tt.expectedTitle, state.Title)
if tt.expectStderr {
assert.Equal(t, "X Title cannot be blank\n", stderr.String())
}
})
}
}

View file

@ -6,9 +6,10 @@ import (
"strings"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/set"
ghauth "github.com/cli/go-gh/v2/pkg/auth"
)
type requestOptions struct {
@ -120,13 +121,10 @@ func pullRequestStatus(httpClient *http.Client, repo ghrepo.Interface, options r
}
`
currentUsername := options.Username
if currentUsername == "@me" && ghinstance.IsEnterprise(repo.RepoHost()) {
var err error
currentUsername, err = api.CurrentLoginName(apiClient, repo.RepoHost())
if err != nil {
return nil, err
}
currentUsername, err := getCurrentUsername(options.Username, repo.RepoHost(), apiClient)
if err != nil {
return nil, err
}
viewerQuery := fmt.Sprintf("repo:%s state:open is:pr author:%s", ghrepo.FullName(repo), currentUsername)
@ -188,6 +186,17 @@ func pullRequestStatus(httpClient *http.Client, repo ghrepo.Interface, options r
return &payload, nil
}
func getCurrentUsername(username string, hostname string, apiClient *api.Client) (string, error) {
if username == "@me" && ghauth.IsEnterprise(hostname) {
var err error
username, err = api.CurrentLoginName(apiClient, hostname)
if err != nil {
return "", err
}
}
return username, nil
}
func pullRequestFragment(conflictStatus bool, statusCheckRollupWithCountByState bool) (string, error) {
fields := []string{
"number", "title", "state", "url", "isDraft", "isCrossRepository",

View file

@ -0,0 +1,62 @@
package status
import (
"net/http"
"testing"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/stretchr/testify/assert"
)
func Test_getCurrentUsername(t *testing.T) {
tests := []struct {
name string
username string
hostname string
serverUsername string
currentUsername string
}{
{
name: "dotcom",
username: "@me",
hostname: "github.com",
currentUsername: "@me",
},
{
name: "ghec data residency (ghe.com)",
username: "@me",
hostname: "stampname.ghe.com",
currentUsername: "@me",
},
{
name: "ghes",
username: "@me",
hostname: "my.server.com",
serverUsername: "@serverUserName",
currentUsername: "@serverUserName",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
clientStub := &http.Client{}
if tt.serverUsername != "" {
reg := &httpmock.Registry{}
defer reg.Verify(t)
reg.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data":{"viewer":{"login":"`+tt.serverUsername+`"}}}`),
)
clientStub.Transport = reg
}
apiClientStub := api.NewClientFromHTTP(clientStub)
currentUsername, err := getCurrentUsername(tt.username, tt.hostname, apiClientStub)
assert.NoError(t, err)
assert.Equal(t, tt.currentUsername, currentUsername)
})
}
}

View file

@ -98,7 +98,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
For language or platform .gitignore templates to use with %[1]s--gitignore%[1]s, <https://github.com/github/gitignore>.
For license keywords to use with %[1]s--license%[1]s, <https://choosealicense.com/>.
For license keywords to use with %[1]s--license%[1]s, run %[1]sgh repo license list%[1]s or visit <https://choosealicense.com>.
`, "`"),
Example: heredoc.Doc(`
# create a repository interactively
@ -227,7 +227,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
return nil, cobra.ShellCompDirectiveError
}
hostname, _ := cfg.Authentication().DefaultHost()
results, err := listGitIgnoreTemplates(httpClient, hostname)
results, err := api.RepoGitIgnoreTemplates(httpClient, hostname)
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
@ -244,7 +244,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
return nil, cobra.ShellCompDirectiveError
}
hostname, _ := cfg.Authentication().DefaultHost()
licenses, err := listLicenseTemplates(httpClient, hostname)
licenses, err := api.RepoLicenses(httpClient, hostname)
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
@ -811,7 +811,7 @@ func interactiveGitIgnore(client *http.Client, hostname string, prompter iprompt
return "", nil
}
templates, err := listGitIgnoreTemplates(client, hostname)
templates, err := api.RepoGitIgnoreTemplates(client, hostname)
if err != nil {
return "", err
}
@ -830,7 +830,7 @@ func interactiveLicense(client *http.Client, hostname string, prompter iprompter
return "", nil
}
licenses, err := listLicenseTemplates(client, hostname)
licenses, err := api.RepoLicenses(client, hostname)
if err != nil {
return "", err
}

View file

@ -339,28 +339,6 @@ func listTemplateRepositories(client *http.Client, hostname, owner string) ([]ap
return templateRepositories, nil
}
// listGitIgnoreTemplates uses API v3 here because gitignore template isn't supported by GraphQL yet.
func listGitIgnoreTemplates(httpClient *http.Client, hostname string) ([]string, error) {
var gitIgnoreTemplates []string
client := api.NewClientFromHTTP(httpClient)
err := client.REST(hostname, "GET", "gitignore/templates", nil, &gitIgnoreTemplates)
if err != nil {
return []string{}, err
}
return gitIgnoreTemplates, nil
}
// listLicenseTemplates uses API v3 here because license template isn't supported by GraphQL yet.
func listLicenseTemplates(httpClient *http.Client, hostname string) ([]api.License, error) {
var licenseTemplates []api.License
client := api.NewClientFromHTTP(httpClient)
err := client.REST(hostname, "GET", "licenses", nil, &licenseTemplates)
if err != nil {
return nil, err
}
return licenseTemplates, nil
}
// Returns the current username and any orgs that user is a member of.
func userAndOrgs(httpClient *http.Client, hostname string) (string, []string, error) {
client := api.NewClientFromHTTP(httpClient)

View file

@ -10,7 +10,7 @@ import (
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
ghAuth "github.com/cli/go-gh/v2/pkg/auth"
ghauth "github.com/cli/go-gh/v2/pkg/auth"
"github.com/spf13/cobra"
)
@ -85,7 +85,7 @@ func deleteRun(opts *DeleteOptions) error {
} else {
repoSelector := opts.RepoArg
if !strings.Contains(repoSelector, "/") {
defaultHost, _ := ghAuth.DefaultHost()
defaultHost, _ := ghauth.DefaultHost()
currentUser, err := api.CurrentLoginName(apiClient, defaultHost)
if err != nil {
return err

View file

@ -0,0 +1,20 @@
package gitignore
import (
cmdList "github.com/cli/cli/v2/pkg/cmd/repo/gitignore/list"
cmdView "github.com/cli/cli/v2/pkg/cmd/repo/gitignore/view"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/spf13/cobra"
)
func NewCmdGitIgnore(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "gitignore <command>",
Short: "List and view available repository gitignore templates",
}
cmd.AddCommand(cmdList.NewCmdList(f, nil))
cmd.AddCommand(cmdView.NewCmdView(f, nil))
return cmd
}

View file

@ -0,0 +1,81 @@
package list
import (
"fmt"
"net/http"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/gh"
"github.com/cli/cli/v2/internal/tableprinter"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/spf13/cobra"
)
type ListOptions struct {
IO *iostreams.IOStreams
HTTPClient func() (*http.Client, error)
Config func() (gh.Config, error)
}
func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
opts := &ListOptions{
IO: f.IOStreams,
HTTPClient: f.HttpClient,
Config: f.Config,
}
cmd := &cobra.Command{
Use: "list",
Short: "List available repository gitignore templates",
Aliases: []string{"ls"},
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
}
return listRun(opts)
},
}
return cmd
}
func listRun(opts *ListOptions) error {
client, err := opts.HTTPClient()
if err != nil {
return err
}
cfg, err := opts.Config()
if err != nil {
return err
}
if err := opts.IO.StartPager(); err != nil {
fmt.Fprintf(opts.IO.ErrOut, "starting pager failed: %v\n", err)
}
defer opts.IO.StopPager()
hostname, _ := cfg.Authentication().DefaultHost()
gitIgnoreTemplates, err := api.RepoGitIgnoreTemplates(client, hostname)
if err != nil {
return err
}
if len(gitIgnoreTemplates) == 0 {
return cmdutil.NewNoResultsError("No gitignore templates found")
}
return renderGitIgnoreTemplatesTable(gitIgnoreTemplates, opts)
}
func renderGitIgnoreTemplatesTable(gitIgnoreTemplates []string, opts *ListOptions) error {
t := tableprinter.New(opts.IO, tableprinter.WithHeader("GITIGNORE"))
for _, gt := range gitIgnoreTemplates {
t.AddField(gt)
t.EndRow()
}
return t.Render()
}

View file

@ -0,0 +1,190 @@
package list
import (
"bytes"
"net/http"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/gh"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewCmdList(t *testing.T) {
tests := []struct {
name string
args []string
wantErr bool
tty bool
}{
{
name: "happy path no arguments",
args: []string{},
wantErr: false,
tty: false,
},
{
name: "too many arguments",
args: []string{"foo"},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, _, _, _ := iostreams.Test()
ios.SetStdinTTY(tt.tty)
ios.SetStdoutTTY(tt.tty)
ios.SetStderrTTY(tt.tty)
f := &cmdutil.Factory{
IOStreams: ios,
}
cmd := NewCmdList(f, func(*ListOptions) error {
return nil
})
cmd.SetArgs(tt.args)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
_, err := cmd.ExecuteC()
if tt.wantErr {
assert.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}
func TestListRun(t *testing.T) {
tests := []struct {
name string
opts *ListOptions
isTTY bool
httpStubs func(t *testing.T, reg *httpmock.Registry)
wantStdout string
wantStderr string
wantErr bool
errMsg string
}{
{
name: "gitignore list tty",
isTTY: true,
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "gitignore/templates"),
httpmock.StringResponse(`[
"AL",
"Actionscript",
"Ada",
"Agda",
"Android",
"AppEngine",
"AppceleratorTitanium",
"ArchLinuxPackages",
"Autotools",
"Ballerina",
"C",
"C++",
"CFWheels",
"CMake",
"CUDA",
"CakePHP",
"ChefCookbook",
"Clojure",
"CodeIgniter",
"CommonLisp",
"Composer",
"Concrete5",
"Coq",
"CraftCMS",
"D"
]`,
))
},
wantStdout: heredoc.Doc(`
GITIGNORE
AL
Actionscript
Ada
Agda
Android
AppEngine
AppceleratorTitanium
ArchLinuxPackages
Autotools
Ballerina
C
C++
CFWheels
CMake
CUDA
CakePHP
ChefCookbook
Clojure
CodeIgniter
CommonLisp
Composer
Concrete5
Coq
CraftCMS
D
`),
wantStderr: "",
opts: &ListOptions{},
},
{
name: "gitignore list no .gitignore templates tty",
isTTY: true,
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "gitignore/templates"),
httpmock.StringResponse(`[]`),
)
},
wantStdout: "",
wantStderr: "",
wantErr: true,
errMsg: "No gitignore templates found",
opts: &ListOptions{},
},
}
for _, tt := range tests {
reg := &httpmock.Registry{}
if tt.httpStubs != nil {
tt.httpStubs(t, reg)
}
tt.opts.Config = func() (gh.Config, error) {
return config.NewBlankConfig(), nil
}
tt.opts.HTTPClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
ios, _, stdout, stderr := iostreams.Test()
ios.SetStdoutTTY(tt.isTTY)
ios.SetStdinTTY(tt.isTTY)
ios.SetStderrTTY(tt.isTTY)
tt.opts.IO = ios
t.Run(tt.name, func(t *testing.T) {
defer reg.Verify(t)
err := listRun(tt.opts)
if tt.wantErr {
assert.Error(t, err)
assert.Equal(t, tt.errMsg, err.Error())
return
}
assert.Equal(t, tt.wantStdout, stdout.String())
assert.Equal(t, tt.wantStderr, stderr.String())
})
}
}

View file

@ -0,0 +1,118 @@
package view
import (
"errors"
"fmt"
"net/http"
"strings"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/gh"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/spf13/cobra"
)
type ViewOptions struct {
IO *iostreams.IOStreams
HTTPClient func() (*http.Client, error)
Config func() (gh.Config, error)
Template string
}
func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command {
opts := &ViewOptions{
IO: f.IOStreams,
HTTPClient: f.HttpClient,
Config: f.Config,
Template: "",
}
cmd := &cobra.Command{
Use: "view <template>",
Short: "View an available repository gitignore template",
Long: heredoc.Docf(`
View an available repository %[1]s.gitignore%[1]s template.
%[1]s<template>%[1]s is a case-sensitive %[1]s.gitignore%[1]s template name.
For a list of available templates, run %[1]sgh repo gitignore list%[1]s.
`, "`"),
Example: heredoc.Doc(`
# View the Go gitignore template
gh repo gitignore view Go
# View the Python gitignore template
gh repo gitignore view Python
# Create a new .gitignore file using the Go template
gh repo gitignore view Go > .gitignore
# Create a new .gitignore file using the Python template
gh repo gitignore view Python > .gitignore
`),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
opts.Template = args[0]
if runF != nil {
return runF(opts)
}
return viewRun(opts)
},
}
return cmd
}
func viewRun(opts *ViewOptions) error {
if opts.Template == "" {
return errors.New("no template provided")
}
client, err := opts.HTTPClient()
if err != nil {
return err
}
cfg, err := opts.Config()
if err != nil {
return err
}
if err := opts.IO.StartPager(); err != nil {
fmt.Fprintf(opts.IO.ErrOut, "starting pager failed: %v\n", err)
}
defer opts.IO.StopPager()
hostname, _ := cfg.Authentication().DefaultHost()
gitIgnore, err := api.RepoGitIgnoreTemplate(client, hostname, opts.Template)
if err != nil {
var httpErr api.HTTPError
if errors.As(err, &httpErr) {
if httpErr.StatusCode == 404 {
return fmt.Errorf("'%s' is not a valid gitignore template. Run `gh repo gitignore list` for options", opts.Template)
}
}
return err
}
return renderGitIgnore(gitIgnore, opts)
}
func renderGitIgnore(licenseTemplate *api.GitIgnore, opts *ViewOptions) error {
// I wanted to render this in a markdown code block and benefit
// from .gitignore syntax highlighting. But, the upstream syntax highlighter
// does not currently support .gitignore.
// So, I just add a newline and print the content as is instead.
// Ref: https://github.com/alecthomas/chroma/pull/755
var out strings.Builder
if opts.IO.IsStdoutTTY() {
out.WriteString("\n")
}
out.WriteString(licenseTemplate.Source)
_, err := opts.IO.Out.Write([]byte(out.String()))
if err != nil {
return err
}
return nil
}

View file

@ -0,0 +1,186 @@
package view
import (
"bytes"
"net/http"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/gh"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestViewRun(t *testing.T) {
tests := []struct {
name string
opts *ViewOptions
isTTY bool
httpStubs func(t *testing.T, reg *httpmock.Registry)
wantStdout string
wantStderr string
wantErr bool
errMsg string
}{
{
name: "No template",
opts: &ViewOptions{},
wantErr: true,
errMsg: "no template provided",
},
{
name: "happy path with template",
opts: &ViewOptions{Template: "Go"},
wantErr: false,
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "gitignore/templates/Go"),
httpmock.StringResponse(`{
"name": "Go",
"source": "# If you prefer the allow list template instead of the deny list, see community template:\n# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore\n#\n# Binaries for programs and plugins\n*.exe\n*.exe~\n*.dll\n*.so\n*.dylib\n\n# Test binary, built with go test -c\n*.test\n\n# Output of the go coverage tool, specifically when used with LiteIDE\n*.out\n\n# Dependency directories (remove the comment below to include it)\n# vendor/\n\n# Go workspace file\ngo.work\ngo.work.sum\n\n# env file\n.env\n"
}`,
))
},
wantStdout: heredoc.Doc(`# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with go test -c
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env
`),
},
{
name: "non-existent template",
opts: &ViewOptions{Template: "non-existent"},
wantErr: true,
errMsg: "'non-existent' is not a valid gitignore template. Run `gh repo gitignore list` for options",
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "gitignore/templates/non-existent"),
httpmock.StatusStringResponse(404, `{
"message": "Not Found",
"documentation_url": "https://docs.github.com/v3/gitignore",
"status": "404"
}`,
))
},
},
}
for _, tt := range tests {
reg := &httpmock.Registry{}
if tt.httpStubs != nil {
tt.httpStubs(t, reg)
}
tt.opts.Config = func() (gh.Config, error) {
return config.NewBlankConfig(), nil
}
tt.opts.HTTPClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
ios, _, stdout, stderr := iostreams.Test()
ios.SetStdoutTTY(tt.isTTY)
ios.SetStdinTTY(tt.isTTY)
ios.SetStderrTTY(tt.isTTY)
tt.opts.IO = ios
t.Run(tt.name, func(t *testing.T) {
defer reg.Verify(t)
err := viewRun(tt.opts)
if tt.wantErr {
assert.Error(t, err)
assert.Equal(t, tt.errMsg, err.Error())
return
}
assert.Equal(t, tt.wantStdout, stdout.String())
assert.Equal(t, tt.wantStderr, stderr.String())
})
}
}
func TestNewCmdView(t *testing.T) {
tests := []struct {
name string
args []string
wantErr bool
wantOpts *ViewOptions
tty bool
}{
{
name: "No template",
args: []string{},
wantErr: true,
wantOpts: &ViewOptions{
Template: "",
},
},
{
name: "Happy path single template",
args: []string{"Go"},
wantErr: false,
wantOpts: &ViewOptions{
Template: "Go",
},
},
{
name: "Happy path too many templates",
args: []string{"Go", "Ruby"},
wantErr: true,
wantOpts: &ViewOptions{
Template: "",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, _, _, _ := iostreams.Test()
ios.SetStdinTTY(tt.tty)
ios.SetStdoutTTY(tt.tty)
ios.SetStderrTTY(tt.tty)
f := &cmdutil.Factory{
IOStreams: ios,
}
cmd := NewCmdView(f, func(*ViewOptions) error {
return nil
})
cmd.SetArgs(tt.args)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
_, err := cmd.ExecuteC()
if tt.wantErr {
assert.Error(t, err)
} else {
require.NoError(t, err)
}
assert.Equal(t, tt.wantOpts.Template, tt.wantOpts.Template)
})
}
}

View file

@ -0,0 +1,20 @@
package license
import (
cmdList "github.com/cli/cli/v2/pkg/cmd/repo/license/list"
cmdView "github.com/cli/cli/v2/pkg/cmd/repo/license/view"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/spf13/cobra"
)
func NewCmdLicense(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "license <command>",
Short: "Explore repository licenses",
}
cmd.AddCommand(cmdList.NewCmdList(f, nil))
cmd.AddCommand(cmdView.NewCmdView(f, nil))
return cmd
}

View file

@ -0,0 +1,89 @@
package list
import (
"fmt"
"net/http"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/gh"
"github.com/cli/cli/v2/internal/tableprinter"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/spf13/cobra"
)
type ListOptions struct {
IO *iostreams.IOStreams
HTTPClient func() (*http.Client, error)
Config func() (gh.Config, error)
}
func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
opts := &ListOptions{
IO: f.IOStreams,
HTTPClient: f.HttpClient,
Config: f.Config,
}
cmd := &cobra.Command{
Use: "list",
Short: "List common repository licenses",
Long: heredoc.Doc(`
List common repository licenses.
For even more licenses, visit <https://choosealicense.com/appendix>
`),
Aliases: []string{"ls"},
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
}
return listRun(opts)
},
}
return cmd
}
func listRun(opts *ListOptions) error {
client, err := opts.HTTPClient()
if err != nil {
return err
}
cfg, err := opts.Config()
if err != nil {
return err
}
if err := opts.IO.StartPager(); err != nil {
fmt.Fprintf(opts.IO.ErrOut, "starting pager failed: %v\n", err)
}
defer opts.IO.StopPager()
hostname, _ := cfg.Authentication().DefaultHost()
licenses, err := api.RepoLicenses(client, hostname)
if err != nil {
return err
}
if len(licenses) == 0 {
return cmdutil.NewNoResultsError("No repository licenses found")
}
return renderLicensesTable(licenses, opts)
}
func renderLicensesTable(licenses []api.License, opts *ListOptions) error {
t := tableprinter.New(opts.IO, tableprinter.WithHeader("LICENSE KEY", "SPDX ID", "LICENSE NAME"))
for _, l := range licenses {
t.AddField(l.Key)
t.AddField(l.SPDXID)
t.AddField(l.Name)
t.EndRow()
}
return t.Render()
}

View file

@ -0,0 +1,196 @@
package list
import (
"bytes"
"net/http"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/gh"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewCmdList(t *testing.T) {
tests := []struct {
name string
args []string
wantErr bool
tty bool
}{
{
name: "happy path no arguments",
args: []string{},
wantErr: false,
tty: false,
},
{
name: "too many arguments",
args: []string{"foo"},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, _, _, _ := iostreams.Test()
ios.SetStdinTTY(tt.tty)
ios.SetStdoutTTY(tt.tty)
ios.SetStderrTTY(tt.tty)
f := &cmdutil.Factory{
IOStreams: ios,
}
cmd := NewCmdList(f, func(*ListOptions) error {
return nil
})
cmd.SetArgs(tt.args)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
_, err := cmd.ExecuteC()
if tt.wantErr {
assert.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}
func TestListRun(t *testing.T) {
tests := []struct {
name string
opts *ListOptions
isTTY bool
httpStubs func(t *testing.T, reg *httpmock.Registry)
wantStdout string
wantStderr string
wantErr bool
errMsg string
}{
{
name: "license list tty",
isTTY: true,
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "licenses"),
httpmock.StringResponse(`[
{
"key": "mit",
"name": "MIT License",
"spdx_id": "MIT",
"url": "https://api.github.com/licenses/mit",
"node_id": "MDc6TGljZW5zZW1pdA=="
},
{
"key": "lgpl-3.0",
"name": "GNU Lesser General Public License v3.0",
"spdx_id": "LGPL-3.0",
"url": "https://api.github.com/licenses/lgpl-3.0",
"node_id": "MDc6TGljZW5zZW1pdA=="
},
{
"key": "mpl-2.0",
"name": "Mozilla Public License 2.0",
"spdx_id": "MPL-2.0",
"url": "https://api.github.com/licenses/mpl-2.0",
"node_id": "MDc6TGljZW5zZW1pdA=="
},
{
"key": "agpl-3.0",
"name": "GNU Affero General Public License v3.0",
"spdx_id": "AGPL-3.0",
"url": "https://api.github.com/licenses/agpl-3.0",
"node_id": "MDc6TGljZW5zZW1pdA=="
},
{
"key": "unlicense",
"name": "The Unlicense",
"spdx_id": "Unlicense",
"url": "https://api.github.com/licenses/unlicense",
"node_id": "MDc6TGljZW5zZW1pdA=="
},
{
"key": "apache-2.0",
"name": "Apache License 2.0",
"spdx_id": "Apache-2.0",
"url": "https://api.github.com/licenses/apache-2.0",
"node_id": "MDc6TGljZW5zZW1pdA=="
},
{
"key": "gpl-3.0",
"name": "GNU General Public License v3.0",
"spdx_id": "GPL-3.0",
"url": "https://api.github.com/licenses/gpl-3.0",
"node_id": "MDc6TGljZW5zZW1pdA=="
}
]`,
))
},
wantStdout: heredoc.Doc(`
LICENSE KEY SPDX ID LICENSE NAME
mit MIT MIT License
lgpl-3.0 LGPL-3.0 GNU Lesser General Public License v3.0
mpl-2.0 MPL-2.0 Mozilla Public License 2.0
agpl-3.0 AGPL-3.0 GNU Affero General Public License v3.0
unlicense Unlicense The Unlicense
apache-2.0 Apache-2.0 Apache License 2.0
gpl-3.0 GPL-3.0 GNU General Public License v3.0
`),
wantStderr: "",
opts: &ListOptions{},
},
{
name: "license list no license templates tty",
isTTY: true,
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "licenses"),
httpmock.StringResponse(`[]`),
)
},
wantStdout: "",
wantStderr: "",
wantErr: true,
errMsg: "No repository licenses found",
opts: &ListOptions{},
},
}
for _, tt := range tests {
reg := &httpmock.Registry{}
if tt.httpStubs != nil {
tt.httpStubs(t, reg)
}
tt.opts.Config = func() (gh.Config, error) {
return config.NewBlankConfig(), nil
}
tt.opts.HTTPClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
ios, _, stdout, stderr := iostreams.Test()
ios.SetStdoutTTY(tt.isTTY)
ios.SetStdinTTY(tt.isTTY)
ios.SetStderrTTY(tt.isTTY)
tt.opts.IO = ios
t.Run(tt.name, func(t *testing.T) {
defer reg.Verify(t)
err := listRun(tt.opts)
if tt.wantErr {
assert.Error(t, err)
assert.Equal(t, tt.errMsg, err.Error())
return
}
assert.Equal(t, tt.wantStdout, stdout.String())
assert.Equal(t, tt.wantStderr, stderr.String())
})
}
}

View file

@ -0,0 +1,132 @@
package view
import (
"errors"
"fmt"
"net/http"
"strings"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/browser"
"github.com/cli/cli/v2/internal/gh"
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/spf13/cobra"
)
type ViewOptions struct {
IO *iostreams.IOStreams
HTTPClient func() (*http.Client, error)
Config func() (gh.Config, error)
License string
Web bool
Browser browser.Browser
}
func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command {
opts := &ViewOptions{
IO: f.IOStreams,
HTTPClient: f.HttpClient,
Config: f.Config,
Browser: f.Browser,
}
cmd := &cobra.Command{
Use: "view {<license-key> | <SPDX-ID>}",
Short: "View a specific repository license",
Long: heredoc.Docf(`
View a specific repository license by license key or SPDX ID.
Run %[1]sgh repo license list%[1]s to see available commonly used licenses. For even more licenses, visit <https://choosealicense.com/appendix>.
`, "`"),
Example: heredoc.Doc(`
# View the MIT license from SPDX ID
gh repo license view MIT
# View the MIT license from license key
gh repo license view mit
# View the GNU AGPL-3.0 license from SPDX ID
gh repo license view AGPL-3.0
# View the GNU AGPL-3.0 license from license key
gh repo license view agpl-3.0
# Create a LICENSE.md with the MIT license
gh repo license view MIT > LICENSE.md
`),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
opts.License = args[0]
if runF != nil {
return runF(opts)
}
return viewRun(opts)
},
}
cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open https://choosealicense.com/ in the browser")
return cmd
}
func viewRun(opts *ViewOptions) error {
if opts.License == "" {
return errors.New("no license provided")
}
client, err := opts.HTTPClient()
if err != nil {
return err
}
cfg, err := opts.Config()
if err != nil {
return err
}
if err := opts.IO.StartPager(); err != nil {
fmt.Fprintf(opts.IO.ErrOut, "starting pager failed: %v\n", err)
}
defer opts.IO.StopPager()
hostname, _ := cfg.Authentication().DefaultHost()
license, err := api.RepoLicense(client, hostname, opts.License)
if err != nil {
var httpErr api.HTTPError
if errors.As(err, &httpErr) {
if httpErr.StatusCode == 404 {
return fmt.Errorf("'%s' is not a valid license name or SPDX ID.\n\nRun `gh repo license list` to see available commonly used licenses. For even more licenses, visit %s", opts.License, text.DisplayURL("https://choosealicense.com/appendix"))
}
}
return err
}
if opts.Web {
url := fmt.Sprintf("https://choosealicense.com/licenses/%s", license.Key)
if opts.IO.IsStdoutTTY() {
fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", text.DisplayURL(url))
}
return opts.Browser.Browse(url)
}
return renderLicense(license, opts)
}
func renderLicense(license *api.License, opts *ViewOptions) error {
cs := opts.IO.ColorScheme()
var out strings.Builder
if opts.IO.IsStdoutTTY() {
out.WriteString(fmt.Sprintf("\n%s\n", cs.Gray(license.Description)))
out.WriteString(fmt.Sprintf("\n%s\n", cs.Grayf("To implement: %s", license.Implementation)))
out.WriteString(fmt.Sprintf("\n%s\n\n", cs.Grayf("For more information, see: %s", license.HTMLURL)))
}
out.WriteString(license.Body)
_, err := opts.IO.Out.Write([]byte(out.String()))
if err != nil {
return err
}
return nil
}

View file

@ -0,0 +1,312 @@
package view
import (
"bytes"
"net/http"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/browser"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/gh"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewCmdView(t *testing.T) {
tests := []struct {
name string
args []string
wantErr bool
wantOpts *ViewOptions
tty bool
}{
{
name: "No license key or SPDX ID provided",
args: []string{},
wantErr: true,
wantOpts: &ViewOptions{
License: "",
},
},
{
name: "Happy path single license key",
args: []string{"mit"},
wantErr: false,
wantOpts: &ViewOptions{
License: "mit",
},
},
{
name: "Happy path too many license keys",
args: []string{"mit", "apache-2.0"},
wantErr: true,
wantOpts: &ViewOptions{
License: "",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, _, _, _ := iostreams.Test()
ios.SetStdinTTY(tt.tty)
ios.SetStdoutTTY(tt.tty)
ios.SetStderrTTY(tt.tty)
f := &cmdutil.Factory{
IOStreams: ios,
}
cmd := NewCmdView(f, func(*ViewOptions) error {
return nil
})
cmd.SetArgs(tt.args)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
_, err := cmd.ExecuteC()
if tt.wantErr {
assert.Error(t, err)
} else {
require.NoError(t, err)
}
assert.Equal(t, tt.wantOpts.License, tt.wantOpts.License)
})
}
}
func TestViewRun(t *testing.T) {
tests := []struct {
name string
opts *ViewOptions
isTTY bool
httpStubs func(reg *httpmock.Registry)
wantStdout string
wantStderr string
wantErr bool
errMsg string
wantBrowsedURL string
}{
{
name: "happy path with license no tty",
opts: &ViewOptions{License: "mit"},
wantErr: false,
isTTY: false,
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "licenses/mit"),
httpmock.StringResponse(`{
"key": "mit",
"name": "MIT License",
"spdx_id": "MIT",
"url": "https://api.github.com/licenses/mit",
"node_id": "MDc6TGljZW5zZTEz",
"html_url": "http://choosealicense.com/licenses/mit/",
"description": "A short and simple permissive license with conditions only requiring preservation of copyright and license notices. Licensed works, modifications, and larger works may be distributed under different terms and without source code.",
"implementation": "Create a text file (typically named LICENSE or LICENSE.txt) in the root of your source code and copy the text of the license into the file. Replace [year] with the current year and [fullname] with the name (or names) of the copyright holders.",
"permissions": [
"commercial-use",
"modifications",
"distribution",
"private-use"
],
"conditions": [
"include-copyright"
],
"limitations": [
"liability",
"warranty"
],
"body": "MIT License\n\nCopyright (c) [year] [fullname]\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n",
"featured": true
}`))
},
wantStdout: heredoc.Doc(`
MIT License
Copyright (c) [year] [fullname]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
`),
},
{
name: "happy path with license tty",
opts: &ViewOptions{License: "mit"},
wantErr: false,
isTTY: true,
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "licenses/mit"),
httpmock.StringResponse(`{
"key": "mit",
"name": "MIT License",
"spdx_id": "MIT",
"url": "https://api.github.com/licenses/mit",
"node_id": "MDc6TGljZW5zZTEz",
"html_url": "http://choosealicense.com/licenses/mit/",
"description": "A short and simple permissive license with conditions only requiring preservation of copyright and license notices. Licensed works, modifications, and larger works may be distributed under different terms and without source code.",
"implementation": "Create a text file (typically named LICENSE or LICENSE.txt) in the root of your source code and copy the text of the license into the file. Replace [year] with the current year and [fullname] with the name (or names) of the copyright holders.",
"permissions": [
"commercial-use",
"modifications",
"distribution",
"private-use"
],
"conditions": [
"include-copyright"
],
"limitations": [
"liability",
"warranty"
],
"body": "MIT License\n\nCopyright (c) [year] [fullname]\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n",
"featured": true
}`))
},
wantStdout: heredoc.Doc(`
A short and simple permissive license with conditions only requiring preservation of copyright and license notices. Licensed works, modifications, and larger works may be distributed under different terms and without source code.
To implement: Create a text file (typically named LICENSE or LICENSE.txt) in the root of your source code and copy the text of the license into the file. Replace [year] with the current year and [fullname] with the name (or names) of the copyright holders.
For more information, see: http://choosealicense.com/licenses/mit/
MIT License
Copyright (c) [year] [fullname]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
`),
},
{
name: "License not found",
opts: &ViewOptions{License: "404"},
wantErr: true,
errMsg: heredoc.Docf(`
'404' is not a valid license name or SPDX ID.
Run %[1]sgh repo license list%[1]s to see available commonly used licenses. For even more licenses, visit https://choosealicense.com/appendix`, "`"),
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "licenses/404"),
httpmock.StatusStringResponse(404, `{
"message": "Not Found",
"documentation_url": "https://docs.github.com/rest/licenses/licenses#get-a-license",
"status": "404"
}`,
))
},
},
{
name: "web flag happy path",
opts: &ViewOptions{
License: "mit",
Web: true,
},
wantErr: false,
isTTY: true,
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "licenses/mit"),
httpmock.StringResponse(`{
"key": "mit",
"name": "MIT License",
"spdx_id": "MIT",
"url": "https://api.github.com/licenses/mit",
"node_id": "MDc6TGljZW5zZTEz",
"html_url": "http://choosealicense.com/licenses/mit/",
"description": "A short and simple permissive license with conditions only requiring preservation of copyright and license notices. Licensed works, modifications, and larger works may be distributed under different terms and without source code.",
"implementation": "Create a text file (typically named LICENSE or LICENSE.txt) in the root of your source code and copy the text of the license into the file. Replace [year] with the current year and [fullname] with the name (or names) of the copyright holders.",
"permissions": [
"commercial-use",
"modifications",
"distribution",
"private-use"
],
"conditions": [
"include-copyright"
],
"limitations": [
"liability",
"warranty"
],
"body": "MIT License\n\nCopyright (c) [year] [fullname]\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n",
"featured": true
}`))
},
wantBrowsedURL: "https://choosealicense.com/licenses/mit",
wantStdout: "Opening https://choosealicense.com/licenses/mit in your browser.\n",
},
}
for _, tt := range tests {
reg := &httpmock.Registry{}
if tt.httpStubs != nil {
tt.httpStubs(reg)
}
tt.opts.Config = func() (gh.Config, error) {
return config.NewBlankConfig(), nil
}
tt.opts.HTTPClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
ios, _, stdout, stderr := iostreams.Test()
ios.SetStdoutTTY(tt.isTTY)
ios.SetStdinTTY(tt.isTTY)
ios.SetStderrTTY(tt.isTTY)
tt.opts.IO = ios
browser := &browser.Stub{}
tt.opts.Browser = browser
t.Run(tt.name, func(t *testing.T) {
defer reg.Verify(t)
err := viewRun(tt.opts)
if tt.wantErr {
assert.Error(t, err)
assert.Equal(t, tt.errMsg, err.Error())
return
}
assert.Equal(t, tt.wantStdout, stdout.String())
assert.Equal(t, tt.wantStderr, stderr.String())
assert.Equal(t, tt.wantBrowsedURL, browser.BrowsedURL())
})
}
}

View file

@ -11,6 +11,8 @@ import (
repoEditCmd "github.com/cli/cli/v2/pkg/cmd/repo/edit"
repoForkCmd "github.com/cli/cli/v2/pkg/cmd/repo/fork"
gardenCmd "github.com/cli/cli/v2/pkg/cmd/repo/garden"
gitIgnoreCmd "github.com/cli/cli/v2/pkg/cmd/repo/gitignore"
licenseCmd "github.com/cli/cli/v2/pkg/cmd/repo/license"
repoListCmd "github.com/cli/cli/v2/pkg/cmd/repo/list"
repoRenameCmd "github.com/cli/cli/v2/pkg/cmd/repo/rename"
repoDefaultCmd "github.com/cli/cli/v2/pkg/cmd/repo/setdefault"
@ -54,6 +56,8 @@ func NewCmdRepo(f *cmdutil.Factory) *cobra.Command {
repoSyncCmd.NewCmdSync(f, nil),
repoEditCmd.NewCmdEdit(f, nil),
deployKeyCmd.NewCmdDeployKey(f),
licenseCmd.NewCmdLicense(f),
gitIgnoreCmd.NewCmdGitIgnore(f),
repoRenameCmd.NewCmdRename(f, nil),
repoArchiveCmd.NewCmdArchive(f, nil),
repoUnarchiveCmd.NewCmdUnarchive(f, nil),

View file

@ -15,7 +15,7 @@ import (
"github.com/cli/cli/v2/pkg/cmd/ruleset/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
ghAuth "github.com/cli/go-gh/v2/pkg/auth"
ghauth "github.com/cli/go-gh/v2/pkg/auth"
"github.com/spf13/cobra"
)
@ -109,7 +109,7 @@ func listRun(opts *ListOptions) error {
}
}
hostname, _ := ghAuth.DefaultHost()
hostname, _ := ghauth.DefaultHost()
if opts.WebMode {
var rulesetURL string

View file

@ -15,7 +15,7 @@ import (
"github.com/cli/cli/v2/pkg/cmd/ruleset/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
ghAuth "github.com/cli/go-gh/v2/pkg/auth"
ghauth "github.com/cli/go-gh/v2/pkg/auth"
"github.com/spf13/cobra"
)
@ -124,7 +124,7 @@ func viewRun(opts *ViewOptions) error {
}
}
hostname, _ := ghAuth.DefaultHost()
hostname, _ := ghauth.DefaultHost()
cs := opts.IO.ColorScheme()
if opts.InteractiveMode {

View file

@ -180,16 +180,22 @@ func (r *Run) ExportData(fields []string) map[string]interface{} {
for _, j := range r.Jobs {
steps := make([]interface{}, 0, len(j.Steps))
for _, s := range j.Steps {
var stepCompletedAt time.Time
if !s.CompletedAt.IsZero() {
stepCompletedAt = s.CompletedAt
}
steps = append(steps, map[string]interface{}{
"name": s.Name,
"status": s.Status,
"conclusion": s.Conclusion,
"number": s.Number,
"name": s.Name,
"status": s.Status,
"conclusion": s.Conclusion,
"number": s.Number,
"startedAt": s.StartedAt,
"completedAt": stepCompletedAt,
})
}
var completedAt time.Time
var jobCompletedAt time.Time
if !j.CompletedAt.IsZero() {
completedAt = j.CompletedAt
jobCompletedAt = j.CompletedAt
}
jobs = append(jobs, map[string]interface{}{
"databaseId": j.ID,
@ -198,7 +204,7 @@ func (r *Run) ExportData(fields []string) map[string]interface{} {
"name": j.Name,
"steps": steps,
"startedAt": j.StartedAt,
"completedAt": completedAt,
"completedAt": jobCompletedAt,
"url": j.URL,
})
}
@ -225,11 +231,13 @@ type Job struct {
}
type Step struct {
Name string
Status Status
Conclusion Conclusion
Number int
Log *zip.File
Name string
Status Status
Conclusion Conclusion
Number int
StartedAt time.Time `json:"started_at"`
CompletedAt time.Time `json:"completed_at"`
Log *zip.File
}
type Steps []Step

View file

@ -110,8 +110,12 @@ func TestRun_Duration(t *testing.T) {
func TestRunExportData(t *testing.T) {
oldestStartedAt, _ := time.Parse(time.RFC3339, "2022-07-20T11:20:13Z")
oldestStepStartedAt, _ := time.Parse(time.RFC3339, "2022-07-20T11:20:15Z")
oldestStepCompletedAt, _ := time.Parse(time.RFC3339, "2022-07-20T11:21:10Z")
oldestCompletedAt, _ := time.Parse(time.RFC3339, "2022-07-20T11:21:16Z")
newestStartedAt, _ := time.Parse(time.RFC3339, "2022-07-20T11:20:55Z")
newestStepStartedAt, _ := time.Parse(time.RFC3339, "2022-07-20T11:21:01Z")
newestStepCompletedAt, _ := time.Parse(time.RFC3339, "2022-07-20T11:23:10Z")
newestCompletedAt, _ := time.Parse(time.RFC3339, "2022-07-20T11:23:16Z")
tests := []struct {
@ -132,10 +136,12 @@ func TestRunExportData(t *testing.T) {
Name: "macos",
Steps: []Step{
{
Name: "Checkout",
Status: "completed",
Conclusion: "success",
Number: 1,
Name: "Checkout",
Status: "completed",
Conclusion: "success",
Number: 1,
StartedAt: oldestStepStartedAt,
CompletedAt: oldestStepCompletedAt,
},
},
StartedAt: oldestStartedAt,
@ -144,7 +150,7 @@ func TestRunExportData(t *testing.T) {
},
},
},
output: `{"jobs":[{"completedAt":"2022-07-20T11:21:16Z","conclusion":"success","databaseId":123456,"name":"macos","startedAt":"2022-07-20T11:20:13Z","status":"completed","steps":[{"conclusion":"success","name":"Checkout","number":1,"status":"completed"}],"url":"https://example.com/OWNER/REPO/actions/runs/123456"}]}`,
output: `{"jobs":[{"completedAt":"2022-07-20T11:21:16Z","conclusion":"success","databaseId":123456,"name":"macos","startedAt":"2022-07-20T11:20:13Z","status":"completed","steps":[{"completedAt":"2022-07-20T11:21:10Z","conclusion":"success","name":"Checkout","number":1,"startedAt":"2022-07-20T11:20:15Z","status":"completed"}],"url":"https://example.com/OWNER/REPO/actions/runs/123456"}]}`,
},
{
name: "exports workflow run's multiple jobs",
@ -158,10 +164,12 @@ func TestRunExportData(t *testing.T) {
Name: "macos",
Steps: []Step{
{
Name: "Checkout",
Status: "completed",
Conclusion: "success",
Number: 1,
Name: "Checkout",
Status: "completed",
Conclusion: "success",
Number: 1,
StartedAt: oldestStepStartedAt,
CompletedAt: oldestStepCompletedAt,
},
},
StartedAt: oldestStartedAt,
@ -175,10 +183,12 @@ func TestRunExportData(t *testing.T) {
Name: "windows",
Steps: []Step{
{
Name: "Checkout",
Status: "completed",
Conclusion: "error",
Number: 2,
Name: "Checkout",
Status: "completed",
Conclusion: "error",
Number: 2,
StartedAt: newestStepStartedAt,
CompletedAt: newestStepCompletedAt,
},
},
StartedAt: newestStartedAt,
@ -187,7 +197,7 @@ func TestRunExportData(t *testing.T) {
},
},
},
output: `{"jobs":[{"completedAt":"2022-07-20T11:21:16Z","conclusion":"success","databaseId":123456,"name":"macos","startedAt":"2022-07-20T11:20:13Z","status":"completed","steps":[{"conclusion":"success","name":"Checkout","number":1,"status":"completed"}],"url":"https://example.com/OWNER/REPO/actions/runs/123456"},{"completedAt":"2022-07-20T11:23:16Z","conclusion":"error","databaseId":234567,"name":"windows","startedAt":"2022-07-20T11:20:55Z","status":"completed","steps":[{"conclusion":"error","name":"Checkout","number":2,"status":"completed"}],"url":"https://example.com/OWNER/REPO/actions/runs/234567"}]}`,
output: `{"jobs":[{"completedAt":"2022-07-20T11:21:16Z","conclusion":"success","databaseId":123456,"name":"macos","startedAt":"2022-07-20T11:20:13Z","status":"completed","steps":[{"completedAt":"2022-07-20T11:21:10Z","conclusion":"success","name":"Checkout","number":1,"startedAt":"2022-07-20T11:20:15Z","status":"completed"}],"url":"https://example.com/OWNER/REPO/actions/runs/123456"},{"completedAt":"2022-07-20T11:23:16Z","conclusion":"error","databaseId":234567,"name":"windows","startedAt":"2022-07-20T11:20:55Z","status":"completed","steps":[{"completedAt":"2022-07-20T11:23:10Z","conclusion":"error","name":"Checkout","number":2,"startedAt":"2022-07-20T11:21:01Z","status":"completed"}],"url":"https://example.com/OWNER/REPO/actions/runs/234567"}]}`,
},
{
name: "exports workflow run with attempt count",

View file

@ -143,7 +143,7 @@ func displayResults(io *iostreams.IOStreams, results search.CodeResult) error {
}
fmt.Fprintf(io.Out, "%s %s\n", cs.Blue(code.Repository.FullName), cs.GreenBold(code.Path))
for _, match := range code.TextMatches {
lines := formatMatch(match.Fragment, match.Matches, io.ColorEnabled())
lines := formatMatch(match.Fragment, match.Matches, io)
for _, line := range lines {
fmt.Fprintf(io.Out, "\t%s\n", strings.TrimSpace(line))
}
@ -153,7 +153,7 @@ func displayResults(io *iostreams.IOStreams, results search.CodeResult) error {
}
for _, code := range results.Items {
for _, match := range code.TextMatches {
lines := formatMatch(match.Fragment, match.Matches, io.ColorEnabled())
lines := formatMatch(match.Fragment, match.Matches, io)
for _, line := range lines {
fmt.Fprintf(io.Out, "%s:%s: %s\n", cs.Blue(code.Repository.FullName), cs.GreenBold(code.Path), strings.TrimSpace(line))
}
@ -162,7 +162,9 @@ func displayResults(io *iostreams.IOStreams, results search.CodeResult) error {
return nil
}
func formatMatch(t string, matches []search.Match, colorize bool) []string {
func formatMatch(t string, matches []search.Match, io *iostreams.IOStreams) []string {
cs := io.ColorScheme()
startIndices := map[int]struct{}{}
endIndices := map[int]struct{}{}
for _, m := range matches {
@ -186,14 +188,10 @@ func formatMatch(t string, matches []search.Match, colorize bool) []string {
continue
}
if _, ok := startIndices[i]; ok {
if colorize {
b.WriteString("\x1b[30;43m") // black text on yellow background
}
b.WriteString(cs.HighlightStart())
found = true
} else if _, ok := endIndices[i]; ok {
if colorize {
b.WriteString("\x1b[m") // color reset
}
b.WriteString(cs.Reset())
}
b.WriteRune(c)
}

View file

@ -8,6 +8,10 @@ import (
"github.com/mgutz/ansi"
)
const (
highlightStyle = "black:yellow"
)
var (
magenta = ansi.ColorFunc("magenta")
cyan = ansi.ColorFunc("cyan")
@ -20,6 +24,8 @@ var (
bold = ansi.ColorFunc("default+b")
cyanBold = ansi.ColorFunc("cyan+b")
greenBold = ansi.ColorFunc("green+b")
highlightStart = ansi.ColorCode(highlightStyle)
highlight = ansi.ColorFunc(highlightStyle)
gray256 = func(t string) string {
return fmt.Sprintf("\x1b[%d;5;%dm%s\x1b[m", 38, 242, t)
@ -176,6 +182,30 @@ func (c *ColorScheme) FailureIconWithColor(colo func(string) string) string {
return colo("X")
}
func (c *ColorScheme) HighlightStart() string {
if !c.enabled {
return ""
}
return highlightStart
}
func (c *ColorScheme) Highlight(t string) string {
if !c.enabled {
return t
}
return highlight(t)
}
func (c *ColorScheme) Reset() string {
if !c.enabled {
return ""
}
return ansi.Reset
}
func (c *ColorScheme) ColorFromString(s string) func(string) string {
s = strings.ToLower(s)
var fn func(string) string