Merge branch 'trunk' into jtmcg/9698
This commit is contained in:
commit
c1897f3122
59 changed files with 3028 additions and 551 deletions
7
Makefile
7
Makefile
|
|
@ -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
151
acceptance/README.md
Normal 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
|
||||
209
acceptance/acceptance_test.go
Normal file
209
acceptance/acceptance_test.go
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
//go:build acceptance
|
||||
|
||||
package acceptance_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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 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"))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
20
acceptance/testdata/issue/issue-comment.txtar
vendored
Normal file
20
acceptance/testdata/issue/issue-comment.txtar
vendored
Normal 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!'
|
||||
17
acceptance/testdata/issue/issue-create-basic.txtar
vendored
Normal file
17
acceptance/testdata/issue/issue-create-basic.txtar
vendored
Normal 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$'
|
||||
19
acceptance/testdata/issue/issue-create-with-metadata.txtar
vendored
Normal file
19
acceptance/testdata/issue/issue-create-with-metadata.txtar
vendored
Normal 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$'
|
||||
16
acceptance/testdata/issue/issue-list.txtar
vendored
Normal file
16
acceptance/testdata/issue/issue-list.txtar
vendored
Normal 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'
|
||||
17
acceptance/testdata/issue/issue-view.txtar
vendored
Normal file
17
acceptance/testdata/issue/issue-view.txtar
vendored
Normal 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$'
|
||||
30
acceptance/testdata/pr/pr-checkout.txtar
vendored
Normal file
30
acceptance/testdata/pr/pr-checkout.txtar
vendored
Normal 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
28
acceptance/testdata/pr/pr-comment.txtar
vendored
Normal 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!'
|
||||
24
acceptance/testdata/pr/pr-create-basic.txtar
vendored
Normal file
24
acceptance/testdata/pr/pr-create-basic.txtar
vendored
Normal 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'
|
||||
26
acceptance/testdata/pr/pr-create-with-metadata.txtar
vendored
Normal file
26
acceptance/testdata/pr/pr-create-with-metadata.txtar
vendored
Normal 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
24
acceptance/testdata/pr/pr-list.txtar
vendored
Normal 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'
|
||||
45
acceptance/testdata/pr/pr-merge-merge-strategy.txtar
vendored
Normal file
45
acceptance/testdata/pr/pr-merge-merge-strategy.txtar
vendored
Normal 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
|
||||
45
acceptance/testdata/pr/pr-merge-rebase-strategy.txtar
vendored
Normal file
45
acceptance/testdata/pr/pr-merge-rebase-strategy.txtar
vendored
Normal 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
25
acceptance/testdata/pr/pr-view.txtar
vendored
Normal 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'
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
268
cmd/gh/main.go
268
cmd/gh/main.go
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
8
go.mod
8
go.mod
|
|
@ -11,8 +11,8 @@ 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
|
||||
|
|
@ -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
|
||||
|
|
@ -159,7 +160,8 @@ require (
|
|||
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/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
|
||||
|
|
|
|||
20
go.sum
20
go.sum
|
|
@ -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=
|
||||
|
|
@ -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=
|
||||
|
|
@ -515,8 +515,8 @@ 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=
|
||||
|
|
@ -533,8 +533,8 @@ 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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
36
internal/authflow/flow_test.go
Normal file
36
internal/authflow/flow_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
271
internal/ghcmd/cmd.go
Normal 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)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package main
|
||||
package ghcmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
62
pkg/cmd/pr/status/http_test.go
Normal file
62
pkg/cmd/pr/status/http_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
20
pkg/cmd/repo/gitignore/gitignore.go
Normal file
20
pkg/cmd/repo/gitignore/gitignore.go
Normal 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
|
||||
}
|
||||
81
pkg/cmd/repo/gitignore/list/list.go
Normal file
81
pkg/cmd/repo/gitignore/list/list.go
Normal 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()
|
||||
}
|
||||
190
pkg/cmd/repo/gitignore/list/list_test.go
Normal file
190
pkg/cmd/repo/gitignore/list/list_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
118
pkg/cmd/repo/gitignore/view/view.go
Normal file
118
pkg/cmd/repo/gitignore/view/view.go
Normal 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
|
||||
}
|
||||
186
pkg/cmd/repo/gitignore/view/view_test.go
Normal file
186
pkg/cmd/repo/gitignore/view/view_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
20
pkg/cmd/repo/license/license.go
Normal file
20
pkg/cmd/repo/license/license.go
Normal 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
|
||||
}
|
||||
89
pkg/cmd/repo/license/list/list.go
Normal file
89
pkg/cmd/repo/license/list/list.go
Normal 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()
|
||||
}
|
||||
196
pkg/cmd/repo/license/list/list_test.go
Normal file
196
pkg/cmd/repo/license/list/list_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
132
pkg/cmd/repo/license/view/view.go
Normal file
132
pkg/cmd/repo/license/view/view.go
Normal 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
|
||||
}
|
||||
312
pkg/cmd/repo/license/view/view_test.go
Normal file
312
pkg/cmd/repo/license/view/view_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -99,6 +99,10 @@ var HelpTopics = []helpTopic{
|
|||
|
||||
%[1]sGH_PATH%[1]s: set the path to the gh executable, useful for when gh can not properly determine
|
||||
its own path such as in the cygwin terminal.
|
||||
|
||||
%[1]sGH_MDWIDTH%[1]s: default maximum width for markdown render wrapping. The max width of lines
|
||||
wrapped on the terminal will be taken as the lesser of the terminal width, this value, or 120 if
|
||||
not specified. This value is used, for example, with %[1]spr view%[1]s subcommand.
|
||||
`, "`"),
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
package markdown
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/charmbracelet/glamour"
|
||||
ghMarkdown "github.com/cli/go-gh/v2/pkg/markdown"
|
||||
)
|
||||
|
|
@ -10,11 +13,16 @@ func WithoutIndentation() glamour.TermRendererOption {
|
|||
}
|
||||
|
||||
// WithWrap is a rendering option that set the character limit for soft
|
||||
// wrapping the markdown rendering. There is a max limit of 120 characters.
|
||||
// wrapping the markdown rendering. There is a max limit of 120 characters,
|
||||
// unless the user overrides with an environment variable.
|
||||
// If 0 is passed then wrapping is disabled.
|
||||
func WithWrap(w int) glamour.TermRendererOption {
|
||||
if w > 120 {
|
||||
w = 120
|
||||
width, err := strconv.Atoi(os.Getenv("GH_MDWIDTH"))
|
||||
if err != nil {
|
||||
width = 120
|
||||
}
|
||||
if w > width {
|
||||
w = width
|
||||
}
|
||||
return ghMarkdown.WithWrap(w)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue