Merge branch 'trunk' into move-predicate-type-filtering

This commit is contained in:
Meredith Lancaster 2025-03-24 17:29:24 -06:00
commit cbe80fd169
40 changed files with 874 additions and 348 deletions

View file

@ -62,9 +62,9 @@ To propose a design:
- Include a link to the issue that the design is for.
- Describe the design you are proposing to resolve the issue, leveraging the [CLI Design System][].
- Mock up the design you are proposing using our [Google Docs Template][] or code blocks.
- Mock ups should cleary illustrate the command(s) being run and the expected output(s).
- Mock ups should clearly illustrate the command(s) being run and the expected output(s).
### (core team only) Revewing a design
### (core team only) Reviewing a design
A member of the core team will [triage](../docs/triage.md) the design proposal. Once a member of the core team has reviewed the design, they may add the [`help wanted`][hw] label to the issue, so a PR can be opened to provide the implementation.

View file

@ -301,7 +301,7 @@ jobs:
base64 -d <<<"$GPG_KEY" | gpg --import --no-tty --batch --yes
echo "allow-preset-passphrase" > ~/.gnupg/gpg-agent.conf
gpg-connect-agent RELOADAGENT /bye
/usr/lib/gnupg2/gpg-preset-passphrase --preset "$GPG_KEYGRIP" <<<"$GPG_PASSPHRASE"
base64 -d <<<"$GPG_PASSPHRASE" | /usr/lib/gnupg2/gpg-preset-passphrase --preset "$GPG_KEYGRIP"
- name: Sign RPMs
if: inputs.environment == 'production'
run: |

View file

@ -10,6 +10,7 @@ permissions:
jobs:
issue-auto:
runs-on: ubuntu-latest
environment: cli-automation
steps:
- name: label incoming issue
env:

View file

@ -11,6 +11,7 @@ permissions:
jobs:
pr-auto:
runs-on: ubuntu-latest
environment: cli-automation
steps:
- name: lint pr
env:

View file

@ -11,6 +11,7 @@ env:
TARGET_REPO: github/cli
jobs:
issue:
environment: cli-discuss-automation
runs-on: ubuntu-latest
if: github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'discuss'
steps:
@ -42,6 +43,7 @@ jobs:
pull_request:
runs-on: ubuntu-latest
environment: cli-discuss-automation
if: github.event_name == 'pull_request_target' && github.event.action == 'labeled' && github.event.label.name == 'discuss'
steps:
- name: Create issue based on source pull request

View file

@ -39,8 +39,7 @@ builds:
goarch: [386, amd64, arm64]
hooks:
post:
- cmd: >-
{{ if eq .Runtime.Goos "windows" }}pwsh .\script\sign.ps1{{ else }}./script/sign{{ end }} '{{ .Path }}'
- cmd: pwsh .\script\sign.ps1 '{{ .Path }}'
output: true
binary: bin/gh
main: ./cmd/gh

View file

@ -69,7 +69,7 @@ func TestIssues(t *testing.T) {
t.Fatal(err)
}
testscript.Run(t, testScriptParamsFor(tsEnv, "pr"))
testscript.Run(t, testScriptParamsFor(tsEnv, "issue"))
}
func TestLabels(t *testing.T) {

View file

@ -0,0 +1,31 @@
# Setup environment variables used for testscript
env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
# Create a repository with a file so it has a default branch
exec gh repo create ${ORG}/${REPO} --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes ${ORG}/${REPO}
# Clone the repo
exec gh repo clone ${ORG}/${REPO}
# Create an issue in the repo
cd ${REPO}
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!'
# Edit the last comment
exec gh issue comment ${ISSUE_URL} --edit-last --body 'Looks like an amazing feature!'
# View the issue
exec gh issue view ${ISSUE_URL} --comments
! stdout 'Looks like a great feature!'
stdout 'Looks like an amazing feature!'

View file

@ -0,0 +1,23 @@
# Setup environment variables used for testscript
env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
# Create a repository with a file so it has a default branch
exec gh repo create ${ORG}/${REPO} --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes ${ORG}/${REPO}
# Clone the repo
exec gh repo clone ${ORG}/${REPO}
# Create an issue in the repo
cd ${REPO}
exec gh issue create --title 'Feature Request' --body 'Feature Body'
stdout2env ISSUE_URL
# Comment on the issue
exec gh issue comment ${ISSUE_URL} --edit-last --create-if-none --body 'Looks like a great feature!'
# View the issue
exec gh issue view ${ISSUE_URL} --comments
stdout 'Looks like a great feature!'

View file

@ -0,0 +1,20 @@
# Setup environment variables used for testscript
env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
# Create a repository with a file so it has a default branch
exec gh repo create ${ORG}/${REPO} --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes ${ORG}/${REPO}
# Clone the repo
exec gh repo clone ${ORG}/${REPO}
# Create an issue in the repo
cd ${REPO}
exec gh issue create --title 'Feature Request' --body 'Feature Body'
stdout2env ISSUE_URL
# Comment on the issue
! exec gh issue comment ${ISSUE_URL} --edit-last --body 'Looks like a great feature!'
stderr 'no comments found for current user'

View file

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

View file

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

View file

@ -0,0 +1,40 @@
# Setup environment variables used for testscript
env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
# 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}/${REPO} --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes ${ORG}/${REPO}
# Clone the repo
exec gh repo clone ${ORG}/${REPO}
# Prepare a branch to PR
cd ${REPO}
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!'
# Edit the last comment
exec gh pr comment ${PR_URL} --edit-last --body 'Looks like an amazing feature!'
# View the PR
exec gh pr view ${PR_URL} --comments
! stdout 'Looks like a great feature!'
stdout 'Looks like an amazing feature!'

View file

@ -0,0 +1,32 @@
# Setup environment variables used for testscript
env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
# 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}/${REPO} --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes ${ORG}/${REPO}
# Clone the repo
exec gh repo clone ${ORG}/${REPO}
# Prepare a branch to PR
cd ${REPO}
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} --edit-last --create-if-none --body 'Looks like a great feature!'
# View the PR
exec gh pr view ${PR_URL} --comments
stdout 'Looks like a great feature!'

View file

@ -0,0 +1,29 @@
# Setup environment variables used for testscript
env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
# 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}/${REPO} --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes ${ORG}/${REPO}
# Clone the repo
exec gh repo clone ${ORG}/${REPO}
# Prepare a branch to PR
cd ${REPO}
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} --edit-last --body 'Looks like a great feature!'
stderr 'no comments found for current user'

View file

@ -1,17 +1,20 @@
# Setup environment variables used for testscript
env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
# 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
exec gh repo create ${ORG}/${REPO} --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
defer gh repo delete --yes ${ORG}/${REPO}
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
exec gh repo clone ${ORG}/${REPO}
# Prepare a branch to PR
cd $SCRIPT_NAME-$RANDOM_STRING
cd ${REPO}
exec git checkout -b feature-branch
exec git commit --allow-empty -m 'Empty Commit'
exec git push -u origin feature-branch
@ -21,8 +24,8 @@ 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!'
exec gh pr comment ${PR_URL} --body 'Looks like a great feature!'
# View the PR
exec gh pr view $PR_URL --comments
exec gh pr view ${PR_URL} --comments
stdout 'Looks like a great feature!'

View file

@ -428,9 +428,6 @@ Breaking this command down:
* `/dlib` points to the previously extracted DLL
* `/dmdf` points to the previously created metadata file
> [!WARNING]
> The [`GoReleaser` signing hook](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.goreleaser.yml#L43) can currently call `./script/sign` on a non-windows machine, but this is an artifact from pre-HSM that should be removed.
## <a id="release">[release](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.github/workflows/deployment.yml#L250-L395)</a>
<details>

12
go.mod
View file

@ -46,10 +46,10 @@ require (
github.com/spf13/pflag v1.0.6
github.com/stretchr/testify v1.10.0
github.com/zalando/go-keyring v0.2.5
golang.org/x/crypto v0.32.0
golang.org/x/sync v0.10.0
golang.org/x/term v0.28.0
golang.org/x/text v0.21.0
golang.org/x/crypto v0.35.0
golang.org/x/sync v0.11.0
golang.org/x/term v0.29.0
golang.org/x/text v0.22.0
google.golang.org/grpc v1.69.4
google.golang.org/protobuf v1.36.5
gopkg.in/h2non/gock.v1 v1.1.2
@ -158,8 +158,8 @@ require (
go.uber.org/zap v1.27.0 // indirect
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect
golang.org/x/mod v0.22.0 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/net v0.36.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/tools v0.29.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d // indirect

24
go.sum
View file

@ -502,8 +502,8 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw=
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@ -512,14 +512,14 @@ golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -529,19 +529,19 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.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.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View file

@ -7,7 +7,6 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
@ -265,8 +264,6 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
return err
}
opts.RequestPath = escapePackageNameInPath(opts.RequestPath)
if runF != nil {
return runF(&opts)
}
@ -694,37 +691,3 @@ func previewNamesToMIMETypes(names []string) string {
}
return strings.Join(types, ", ")
}
// The package name part in the `packages` endpoints may contain slashes and
// other characters that need to be URL encoded.
//
// The `escapePackageNameInPath` function extracts and normalizes package names
// in the path. The regex `pathWithPackageNameRE` is being used to extract the
// package name with a capture group named `package`.
//
// See https://docs.github.com/en/rest/packages/packages APIs for more details.
//
// Here's an example:
//
// The package name `orders/cache` needs to be URL encoded because it contains
// a slash `/`. The `escapePackageNameInPath` function will extract the
// `orders/cache` part, perform the URL encoding, and return the normalized API
// endpoint with `%2F` replacing the slash `/` in the package name part only.
//
// - Package name: `orders/cache`
// - API endpoint: `/users/USER/packages/container/orders/cache`
// - Normalized: `/users/USER/packages/container/orders%2Fcache`
var pathWithPackageNameRE = regexp.MustCompile(`^\/(?:orgs|user|users)(?:\/.*)?\/packages\/(?:npm|maven|rubygems|docker|nuget|container)\/(?<package>.*?)(?:\/(?:restore|versions)|$)`)
func escapePackageNameInPath(path string) string {
matches := pathWithPackageNameRE.FindStringSubmatch(path)
if len(matches) > 0 {
i := pathWithPackageNameRE.SubexpIndex("package")
packageName := matches[i]
if packageName != "" {
return strings.Replace(path, packageName, url.QueryEscape(packageName), 1)
}
}
return path
}

View file

@ -367,72 +367,6 @@ func Test_NewCmdApi(t *testing.T) {
},
wantsErr: false,
},
{
name: "request path with container package name containing slashes",
cli: "/user/packages/container/github.com/username/package_name --verbose",
wants: ApiOptions{
Hostname: "",
RequestMethod: "GET",
RequestMethodPassed: false,
RequestPath: "/user/packages/container/github.com%2Fusername%2Fpackage_name",
RequestInputFile: "",
RawFields: []string(nil),
MagicFields: []string(nil),
RequestHeaders: []string(nil),
ShowResponseHeaders: false,
Paginate: false,
Silent: false,
CacheTTL: 0,
Template: "",
FilterOutput: "",
Verbose: true,
},
wantsErr: false,
},
{
name: "request path with container package name containing slashes and restore",
cli: "/user/packages/container/github.com/username/package_name/restore --verbose",
wants: ApiOptions{
Hostname: "",
RequestMethod: "GET",
RequestMethodPassed: false,
RequestPath: "/user/packages/container/github.com%2Fusername%2Fpackage_name/restore",
RequestInputFile: "",
RawFields: []string(nil),
MagicFields: []string(nil),
RequestHeaders: []string(nil),
ShowResponseHeaders: false,
Paginate: false,
Silent: false,
CacheTTL: 0,
Template: "",
FilterOutput: "",
Verbose: true,
},
wantsErr: false,
},
{
name: "request path with container package name containing slashes and versions",
cli: "/user/packages/container/github.com/username/package_name/versions --verbose",
wants: ApiOptions{
Hostname: "",
RequestMethod: "GET",
RequestMethodPassed: false,
RequestPath: "/user/packages/container/github.com%2Fusername%2Fpackage_name/versions",
RequestInputFile: "",
RawFields: []string(nil),
MagicFields: []string(nil),
RequestHeaders: []string(nil),
ShowResponseHeaders: false,
Paginate: false,
Silent: false,
CacheTTL: 0,
Template: "",
FilterOutput: "",
Verbose: true,
},
wantsErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -48,13 +48,13 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
}
cmd := &cobra.Command{
Use: "create [<filename>... | -]",
Use: "create [<filename>... | <pattern>... | -]",
Short: "Create a new gist",
Long: heredoc.Docf(`
Create a new GitHub gist with given contents.
Gists can be created from one or multiple files. Alternatively, pass %[1]s-%[1]s as
file name to read from standard input.
filename to read from standard input.
By default, gists are secret; use %[1]s--public%[1]s to make publicly listed ones.
`, "`"),
@ -68,6 +68,9 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
# Create a gist containing several files
$ gh gist create hello.py world.py cool.txt
# Create a gist containing several files using patterns
$ gh gist create *.md *.txt artifact.*
# Read from standard input to create a gist
$ gh gist create -
@ -102,12 +105,23 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
}
func createRun(opts *CreateOptions) error {
fileArgs := opts.Filenames
if len(fileArgs) == 0 {
fileArgs = []string{"-"}
readFromStdInArg, filenames := cmdutil.Partition(opts.Filenames, func(f string) bool {
return f == "-"
})
filenames, err := cmdutil.GlobPaths(filenames)
if err != nil {
return err
}
files, err := processFiles(opts.IO.In, opts.FilenameOverride, fileArgs)
filenames = append(filenames, readFromStdInArg...)
if len(filenames) == 0 {
filenames = []string{"-"}
}
files, err := processFiles(opts.IO.In, opts.FilenameOverride, filenames)
if err != nil {
return fmt.Errorf("failed to collect files for posting: %w", err)
}

View file

@ -225,7 +225,7 @@ func Test_createRun(t *testing.T) {
responseStatus: http.StatusOK,
},
{
name: "multiple files",
name: "when both a file and the stdin '-' are provided, it matches on all the files passed in and stdin",
opts: &CreateOptions{
Filenames: []string{fixtureFile, "-"},
},
@ -248,6 +248,30 @@ func Test_createRun(t *testing.T) {
},
responseStatus: http.StatusOK,
},
{
name: "when both a file and the stdin '-' are provided, but '-' is not the last argument, it matches on all the files provided and stdin",
opts: &CreateOptions{
Filenames: []string{"-", fixtureFile},
},
stdin: "cool stdin content",
wantOut: "https://gist.github.com/aa5a315d61ae9438b18d\n",
wantStderr: "- Creating gist with multiple files\n✓ Created secret gist fixture.txt\n",
wantErr: false,
wantParams: map[string]interface{}{
"description": "",
"updated_at": "0001-01-01T00:00:00Z",
"public": false,
"files": map[string]interface{}{
"fixture.txt": map[string]interface{}{
"content": "{}",
},
"gistfile1.txt": map[string]interface{}{
"content": "cool stdin content",
},
},
},
responseStatus: http.StatusOK,
},
{
name: "file with empty content",
opts: &CreateOptions{

View file

@ -223,9 +223,10 @@ func Test_commentRun(t *testing.T) {
httpStubs func(*testing.T, *httpmock.Registry)
stdout string
stderr string
wantsErr bool
}{
{
name: "interactive editor",
name: "creating new comment with interactive editor succeeds",
input: &shared.CommentableOptions{
Interactive: true,
InputType: 0,
@ -234,13 +235,29 @@ func Test_commentRun(t *testing.T) {
InteractiveEditSurvey: func(string) (string, error) { return "comment body", nil },
ConfirmSubmitSurvey: func() (bool, error) { return true, nil },
},
emptyComments: true,
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentCreate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n",
},
{
name: "interactive editor with edit last",
name: "updating last comment with interactive editor fails if there are no comments and decline prompt to create",
input: &shared.CommentableOptions{
Interactive: true,
InputType: 0,
Body: "",
EditLast: true,
InteractiveEditSurvey: func(string) (string, error) { return "comment body", nil },
ConfirmSubmitSurvey: func() (bool, error) { return true, nil },
ConfirmCreateIfNoneSurvey: func() (bool, error) { return false, nil },
},
emptyComments: true,
wantsErr: true,
},
{
name: "updating last comment with interactive editor succeeds if there are comments",
input: &shared.CommentableOptions{
Interactive: true,
InputType: 0,
@ -253,10 +270,11 @@ func Test_commentRun(t *testing.T) {
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentUpdate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n",
emptyComments: false,
stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n",
},
{
name: "interactive editor with edit last and create if none",
name: "updating last comment with interactive editor creates new comment if there are no comments but --create-if-none",
input: &shared.CommentableOptions{
Interactive: true,
InputType: 0,
@ -269,12 +287,14 @@ func Test_commentRun(t *testing.T) {
ConfirmSubmitSurvey: func() (bool, error) { return true, nil },
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentUpdate(t, reg)
mockCommentCreate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n",
emptyComments: true,
stderr: "No comments found. Creating a new comment.\n",
stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n",
},
{
name: "non-interactive web",
name: "creating new comment with non-interactive web opens issue in browser focusing on new comment",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeWeb,
@ -282,55 +302,38 @@ func Test_commentRun(t *testing.T) {
OpenInBrowser: func(string) error { return nil },
},
stderr: "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n",
emptyComments: true,
stderr: "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n",
},
{
name: "non-interactive web with edit last",
name: "updating last comment with non-interactive web opens issue in browser focusing on the last comment",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeWeb,
Body: "",
EditLast: true,
OpenInBrowser: func(string) error { return nil },
},
stderr: "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n",
},
{
name: "non-interactive web with edit last and create if none for empty comments",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeWeb,
Body: "",
EditLast: true,
CreateIfNone: true,
OpenInBrowser: func(u string) error {
assert.Contains(t, u, "#issuecomment-new")
return nil
},
},
emptyComments: true,
stderr: "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n",
},
{
name: "non-interactive web with edit last and create if none",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeWeb,
Body: "",
EditLast: true,
CreateIfNone: true,
OpenInBrowser: func(u string) error {
assert.Contains(t, u, "#issuecomment-111")
return nil
},
},
stderr: "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n",
emptyComments: false,
stderr: "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n",
},
{
name: "non-interactive editor",
name: "updating last comment with non-interactive web errors because there are no comments",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeWeb,
Body: "",
EditLast: true,
},
emptyComments: true,
wantsErr: true,
},
{
name: "creating new comment with non-interactive editor succeeds",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeEditor,
@ -344,7 +347,20 @@ func Test_commentRun(t *testing.T) {
stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n",
},
{
name: "non-interactive editor with edit last",
name: "updating last comment with non-interactive editor fails if there are no comments",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeEditor,
Body: "",
EditLast: true,
EditSurvey: func(string) (string, error) { return "comment body", nil },
},
emptyComments: true,
wantsErr: true,
},
{
name: "updating last comment with non-interactive editor succeeds if there are comments",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeEditor,
@ -356,10 +372,11 @@ func Test_commentRun(t *testing.T) {
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentUpdate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n",
emptyComments: false,
stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n",
},
{
name: "non-interactive editor with edit last and create if none",
name: "updating last comment with non-interactive editor creates new comment if there are no comments but --create-if-none",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeEditor,
@ -376,7 +393,7 @@ func Test_commentRun(t *testing.T) {
stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n",
},
{
name: "non-interactive inline",
name: "creating new comment with non-interactive inline succeeds if comment body is provided",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeInline,
@ -388,7 +405,7 @@ func Test_commentRun(t *testing.T) {
stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n",
},
{
name: "non-interactive inline with edit last",
name: "updating last comment with non-interactive inline succeeds if there are comments and comment body is provided",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeInline,
@ -398,7 +415,23 @@ func Test_commentRun(t *testing.T) {
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentUpdate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n",
emptyComments: false,
stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n",
},
{
name: "updating last comment with non-interactive inline creates new comment if there are no comments but --create-if-none",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeInline,
Body: "comment body",
EditLast: true,
CreateIfNone: true,
},
emptyComments: true,
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentCreate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n",
},
}
for _, tt := range tests {
@ -437,6 +470,10 @@ func Test_commentRun(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
err := shared.CommentableRun(tt.input)
if tt.wantsErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.stdout, stdout.String())
assert.Equal(t, tt.stderr, stderr.String())

View file

@ -243,9 +243,10 @@ func Test_commentRun(t *testing.T) {
httpStubs func(*testing.T, *httpmock.Registry)
stdout string
stderr string
wantsErr bool
}{
{
name: "interactive editor",
name: "creating new comment with interactive editor succeeds",
input: &shared.CommentableOptions{
Interactive: true,
InputType: 0,
@ -260,7 +261,22 @@ func Test_commentRun(t *testing.T) {
stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
},
{
name: "interactive editor with edit last",
name: "updating last comment with interactive editor fails if there are no comments and decline prompt to create",
input: &shared.CommentableOptions{
Interactive: true,
InputType: 0,
Body: "",
EditLast: true,
InteractiveEditSurvey: func(string) (string, error) { return "comment body", nil },
ConfirmSubmitSurvey: func() (bool, error) { return true, nil },
ConfirmCreateIfNoneSurvey: func() (bool, error) { return false, nil },
},
emptyComments: true,
wantsErr: true,
},
{
name: "updating last comment with interactive editor succeeds if there are comments",
input: &shared.CommentableOptions{
Interactive: true,
InputType: 0,
@ -273,10 +289,11 @@ func Test_commentRun(t *testing.T) {
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentUpdate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n",
emptyComments: false,
stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n",
},
{
name: "interactive editor with edit last and create if none",
name: "updating last comment with interactive editor creates new comment if there are no comments but --create-if-none",
input: &shared.CommentableOptions{
Interactive: true,
InputType: 0,
@ -289,12 +306,14 @@ func Test_commentRun(t *testing.T) {
ConfirmSubmitSurvey: func() (bool, error) { return true, nil },
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentUpdate(t, reg)
mockCommentCreate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n",
emptyComments: true,
stderr: "No comments found. Creating a new comment.\n",
stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
},
{
name: "non-interactive web",
name: "creating new comment with non-interactive web opens pull request in browser focusing on new comment",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeWeb,
@ -302,10 +321,11 @@ func Test_commentRun(t *testing.T) {
OpenInBrowser: func(string) error { return nil },
},
stderr: "Opening https://github.com/OWNER/REPO/pull/123 in your browser.\n",
emptyComments: true,
stderr: "Opening https://github.com/OWNER/REPO/pull/123 in your browser.\n",
},
{
name: "non-interactive web with edit last",
name: "updating last comment with non-interactive web opens pull request in browser focusing on the last comment",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeWeb,
@ -317,43 +337,22 @@ func Test_commentRun(t *testing.T) {
return nil
},
},
stderr: "Opening https://github.com/OWNER/REPO/pull/123 in your browser.\n",
},
{
name: "non-interactive web with edit last and create if none for empty comments",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeWeb,
Body: "",
EditLast: true,
CreateIfNone: true,
OpenInBrowser: func(u string) error {
assert.Contains(t, u, "#issuecomment-new")
return nil
},
},
emptyComments: true,
emptyComments: false,
stderr: "Opening https://github.com/OWNER/REPO/pull/123 in your browser.\n",
},
{
name: "non-interactive web with edit last and create if none",
name: "updating last comment with non-interactive web errors because there are no comments",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeWeb,
Body: "",
EditLast: true,
CreateIfNone: true,
OpenInBrowser: func(u string) error {
assert.Contains(t, u, "#issuecomment-111")
return nil
},
Interactive: false,
InputType: shared.InputTypeWeb,
Body: "",
EditLast: true,
},
stderr: "Opening https://github.com/OWNER/REPO/pull/123 in your browser.\n",
emptyComments: true,
wantsErr: true,
},
{
name: "non-interactive editor",
name: "creating new comment with non-interactive editor succeeds",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeEditor,
@ -367,7 +366,20 @@ func Test_commentRun(t *testing.T) {
stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
},
{
name: "non-interactive editor with edit last",
name: "updating last comment with non-interactive editor fails if there are no comments",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeEditor,
Body: "",
EditLast: true,
EditSurvey: func(string) (string, error) { return "comment body", nil },
},
emptyComments: true,
wantsErr: true,
},
{
name: "updating last comment with non-interactive editor succeeds if there are comments",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeEditor,
@ -382,7 +394,7 @@ func Test_commentRun(t *testing.T) {
stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n",
},
{
name: "non-interactive editor with edit last and create if none",
name: "updating last comment with non-interactive editor creates new comment if there are no comments but --create-if-none",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeEditor,
@ -399,7 +411,7 @@ func Test_commentRun(t *testing.T) {
stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
},
{
name: "non-interactive inline",
name: "creating new comment with non-interactive inline succeeds if comment body is provided",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeInline,
@ -411,7 +423,7 @@ func Test_commentRun(t *testing.T) {
stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
},
{
name: "non-interactive inline with edit last",
name: "updating last comment with non-interactive inline succeeds if there are comments and comment body is provided",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeInline,
@ -421,7 +433,23 @@ func Test_commentRun(t *testing.T) {
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentUpdate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n",
emptyComments: false,
stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n",
},
{
name: "updating last comment with non-interactive inline creates new comment if there are no comments but --create-if-none",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeInline,
Body: "comment body",
EditLast: true,
CreateIfNone: true,
},
emptyComments: true,
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentCreate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
},
}
for _, tt := range tests {
@ -459,6 +487,10 @@ func Test_commentRun(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
err := shared.CommentableRun(tt.input)
if tt.wantsErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.stdout, stdout.String())
assert.Equal(t, tt.stderr, stderr.String())

View file

@ -92,26 +92,37 @@ func CommentableRun(opts *CommentableOptions) error {
return err
}
opts.Host = repo.RepoHost()
if opts.EditLast {
err := updateComment(commentable, opts)
if !errors.Is(err, errNoUserComments) {
// Create new comment, bail before complexities of updating the last comment
if !opts.EditLast {
return createComment(commentable, opts)
}
// Update the last comment, handling success or unexpected errors accordingly
err = updateComment(commentable, opts)
if err == nil {
return nil
}
if !errors.Is(err, errNoUserComments) {
return err
}
// Determine whether to create new comment, prompt user if interactive and missing option
if !opts.CreateIfNone && opts.Interactive {
opts.CreateIfNone, err = opts.ConfirmCreateIfNoneSurvey()
if err != nil {
return err
}
if opts.Interactive {
if opts.CreateIfNone {
fmt.Fprintln(opts.IO.ErrOut, "No comments found. Creating a new comment.")
} else {
ok, err := opts.ConfirmCreateIfNoneSurvey()
if err != nil {
return err
}
if !ok {
return errNoUserComments
}
}
}
}
if !opts.CreateIfNone {
return errNoUserComments
}
// Create new comment because updating the last comment failed due to no user comments
if opts.Interactive {
fmt.Fprintln(opts.IO.ErrOut, "No comments found. Creating a new comment.")
}
return createComment(commentable, opts)
}

View file

@ -43,7 +43,7 @@ func NewCmdUpdateBranch(f *cmdutil.Factory, runF func(*UpdateBranchOptions) erro
Without an argument, the pull request that belongs to the current branch is selected.
The default behavior is to update with a merge commit (i.e., merging the base branch
into the the PR's branch). To reconcile the changes with rebasing on top of the base
into the PR's branch). To reconcile the changes with rebasing on top of the base
branch, the %[1]s--rebase%[1]s option should be provided.
`, "`"),
Example: heredoc.Doc(`

View file

@ -78,7 +78,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
cmd := &cobra.Command{
DisableFlagsInUseLine: true,
Use: "create [<tag>] [<files>...]",
Use: "create [<tag>] [<filename>... | <pattern>...]",
Short: "Create a new release",
Long: heredoc.Docf(`
Create a new GitHub Release for a repository.

View file

@ -15,6 +15,7 @@ import (
"github.com/cenkalti/backoff/v4"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/pkg/cmdutil"
"golang.org/x/sync/errgroup"
)
@ -36,6 +37,17 @@ type AssetForUpload struct {
}
func AssetsFromArgs(args []string) (assets []*AssetForUpload, err error) {
labeledArgs, unlabeledArgs := cmdutil.Partition(args, func(arg string) bool {
return strings.Contains(arg, "#")
})
args, err = cmdutil.GlobPaths(unlabeledArgs)
if err != nil {
return nil, err
}
args = append(args, labeledArgs...)
for _, arg := range args {
var label string
fn := arg

View file

@ -32,8 +32,8 @@ func latestCommit(client *api.Client, repo ghrepo.Interface, branch string) (com
type upstreamMergeErr struct{ error }
var missingWorkflowScopeRE = regexp.MustCompile("refusing to allow.*without `workflow` scope")
var missingWorkflowScopeErr = errors.New("Upstream commits contain workflow changes, which require the `workflow` scope to merge. To request it, run: gh auth refresh -s workflow")
var missingWorkflowScopeRE = regexp.MustCompile("refusing to allow.*without `workflow(s)?` (scope|permission)")
var missingWorkflowScopeErr = errors.New("Upstream commits contain workflow changes, which require the `workflow` scope or permission to merge. To request it, run: gh auth refresh -s workflow")
func triggerUpstreamMerge(client *api.Client, repo ghrepo.Interface, branch string) (string, error) {
var payload bytes.Buffer

View file

@ -470,7 +470,7 @@ func Test_SyncRun(t *testing.T) {
errMsg: "trunk branch does not exist on OWNER/REPO-FORK repository",
},
{
name: "sync remote fork with missing workflow scope on token",
name: "sync remote fork with missing workflow scope on OAuth App token",
opts: &SyncOptions{
DestArg: "FORKOWNER/REPO-FORK",
},
@ -485,7 +485,43 @@ func Test_SyncRun(t *testing.T) {
}{Message: "refusing to allow an OAuth App to create or update workflow `.github/workflows/unimportant.yml` without `workflow` scope"}))
},
wantErr: true,
errMsg: "Upstream commits contain workflow changes, which require the `workflow` scope to merge. To request it, run: gh auth refresh -s workflow",
errMsg: "Upstream commits contain workflow changes, which require the `workflow` scope or permission to merge. To request it, run: gh auth refresh -s workflow",
},
{
name: "sync remote fork with missing workflow permission on GitHub App token",
opts: &SyncOptions{
DestArg: "FORKOWNER/REPO-FORK",
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query RepositoryInfo\b`),
httpmock.StringResponse(`{"data":{"repository":{"defaultBranchRef":{"name": "trunk"}}}}`))
reg.Register(
httpmock.REST("POST", "repos/FORKOWNER/REPO-FORK/merge-upstream"),
httpmock.StatusJSONResponse(422, struct {
Message string `json:"message"`
}{Message: "refusing to allow a GitHub App to create or update workflow `.github/workflows/unimportant.yml` without `workflows` permission"}))
},
wantErr: true,
errMsg: "Upstream commits contain workflow changes, which require the `workflow` scope or permission to merge. To request it, run: gh auth refresh -s workflow",
},
{
name: "sync remote fork with missing workflow scope on personal access token",
opts: &SyncOptions{
DestArg: "FORKOWNER/REPO-FORK",
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query RepositoryInfo\b`),
httpmock.StringResponse(`{"data":{"repository":{"defaultBranchRef":{"name": "trunk"}}}}`))
reg.Register(
httpmock.REST("POST", "repos/FORKOWNER/REPO-FORK/merge-upstream"),
httpmock.StatusJSONResponse(422, struct {
Message string `json:"message"`
}{Message: "refusing to allow a Personal Access Token to create or update workflow `.github/workflows/unimportant.yml` without `workflow` scope"}))
},
wantErr: true,
errMsg: "Upstream commits contain workflow changes, which require the `workflow` scope or permission to merge. To request it, run: gh auth refresh -s workflow",
},
}
for _, tt := range tests {

View file

@ -49,9 +49,9 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
RunE: func(cmd *cobra.Command, args []string) error {
// If the user specified a repo directly, then we're using the OverrideBaseRepoFunc set by EnableRepoOverride
// So there's no reason to use the specialised BaseRepoFunc that requires remote disambiguation.
if cmd.Flags().Changed("repo") || os.Getenv("GH_REPO") != "" {
opts.BaseRepo = f.BaseRepo
} else {
opts.BaseRepo = f.BaseRepo
isRepoUserProvided := cmd.Flags().Changed("repo") || os.Getenv("GH_REPO") != ""
if !isRepoUserProvided {
// If they haven't specified a repo directly, then we will wrap the BaseRepoFunc in one that errors if
// there might be multiple valid remotes.
opts.BaseRepo = shared.RequireNoAmbiguityBaseRepoFunc(opts.BaseRepo, f.Remotes)

View file

@ -126,7 +126,7 @@ func TestNewCmdDelete(t *testing.T) {
}
func TestNewCmdDeleteBaseRepoFuncs(t *testing.T) {
remotes := ghContext.Remotes{
multipleRemotes := ghContext.Remotes{
&ghContext.Remote{
Remote: &git.Remote{
Name: "origin",
@ -141,10 +141,20 @@ func TestNewCmdDeleteBaseRepoFuncs(t *testing.T) {
},
}
singleRemote := ghContext.Remotes{
&ghContext.Remote{
Remote: &git.Remote{
Name: "origin",
},
Repo: ghrepo.New("owner", "repo"),
},
}
tests := []struct {
name string
args string
env map[string]string
remotes ghContext.Remotes
prompterStubs func(*prompter.MockPrompter)
wantRepo ghrepo.Interface
wantErr error
@ -152,6 +162,7 @@ func TestNewCmdDeleteBaseRepoFuncs(t *testing.T) {
{
name: "when there is a repo flag provided, the factory base repo func is used",
args: "SECRET_NAME --repo owner/repo",
remotes: multipleRemotes,
wantRepo: ghrepo.New("owner", "repo"),
},
{
@ -160,18 +171,27 @@ func TestNewCmdDeleteBaseRepoFuncs(t *testing.T) {
env: map[string]string{
"GH_REPO": "owner/repo",
},
remotes: multipleRemotes,
wantRepo: ghrepo.New("owner", "repo"),
},
{
name: "when there is no repo flag or GH_REPO env var provided, and no prompting, the base func requiring no ambiguity is used",
args: "SECRET_NAME",
name: "when there is no repo flag or GH_REPO env var provided, and no prompting, the base func requiring no ambiguity is used",
args: "SECRET_NAME",
remotes: multipleRemotes,
wantErr: shared.AmbiguousBaseRepoError{
Remotes: remotes,
Remotes: multipleRemotes,
},
},
{
name: "when there is no repo flag or GH_REPO env var provided, and can prompt, the base func resolving ambiguity is used",
args: "SECRET_NAME",
name: "when there is no repo flag or GH_REPO env provided, and there is a single remote, the factory base repo func is used",
args: "SECRET_NAME",
remotes: singleRemote,
wantRepo: ghrepo.New("owner", "repo"),
},
{
name: "when there is no repo flag or GH_REPO env var provided, and can prompt, the base func resolving ambiguity is used",
args: "SECRET_NAME",
remotes: multipleRemotes,
prompterStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect(
"Select a repo",
@ -204,7 +224,7 @@ func TestNewCmdDeleteBaseRepoFuncs(t *testing.T) {
},
Prompter: pm,
Remotes: func() (ghContext.Remotes, error) {
return remotes, nil
return tt.remotes, nil
},
}

View file

@ -70,9 +70,9 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
RunE: func(cmd *cobra.Command, args []string) error {
// If the user specified a repo directly, then we're using the OverrideBaseRepoFunc set by EnableRepoOverride
// So there's no reason to use the specialised BaseRepoFunc that requires remote disambiguation.
if cmd.Flags().Changed("repo") || os.Getenv("GH_REPO") != "" {
opts.BaseRepo = f.BaseRepo
} else {
opts.BaseRepo = f.BaseRepo
isRepoUserProvided := cmd.Flags().Changed("repo") || os.Getenv("GH_REPO") != ""
if !isRepoUserProvided {
// If they haven't specified a repo directly, then we will wrap the BaseRepoFunc in one that errors if
// there might be multiple valid remotes.
opts.BaseRepo = shared.RequireNoAmbiguityBaseRepoFunc(opts.BaseRepo, f.Remotes)

View file

@ -106,7 +106,7 @@ func Test_NewCmdList(t *testing.T) {
}
func TestNewCmdListBaseRepoFuncs(t *testing.T) {
remotes := ghContext.Remotes{
multipleRemotes := ghContext.Remotes{
&ghContext.Remote{
Remote: &git.Remote{
Name: "origin",
@ -121,10 +121,20 @@ func TestNewCmdListBaseRepoFuncs(t *testing.T) {
},
}
singleRemote := ghContext.Remotes{
&ghContext.Remote{
Remote: &git.Remote{
Name: "origin",
},
Repo: ghrepo.New("owner", "repo"),
},
}
tests := []struct {
name string
args string
env map[string]string
remotes ghContext.Remotes
prompterStubs func(*prompter.MockPrompter)
wantRepo ghrepo.Interface
wantErr error
@ -132,6 +142,7 @@ func TestNewCmdListBaseRepoFuncs(t *testing.T) {
{
name: "when there is a repo flag provided, the factory base repo func is used",
args: "--repo owner/repo",
remotes: multipleRemotes,
wantRepo: ghrepo.New("owner", "repo"),
},
{
@ -139,18 +150,27 @@ func TestNewCmdListBaseRepoFuncs(t *testing.T) {
env: map[string]string{
"GH_REPO": "owner/repo",
},
remotes: multipleRemotes,
wantRepo: ghrepo.New("owner", "repo"),
},
{
name: "when there is no repo flag or GH_REPO env var provided, and no prompting, the base func requiring no ambiguity is used",
args: "",
name: "when there is no repo flag or GH_REPO env var provided, and no prompting, the base func requiring no ambiguity is used",
args: "",
remotes: multipleRemotes,
wantErr: shared.AmbiguousBaseRepoError{
Remotes: remotes,
Remotes: multipleRemotes,
},
},
{
name: "when there is no repo flag or GH_REPO env var provided, and can prompt, the base func resolving ambiguity is used",
args: "",
name: "when there is no repo flag or GH_REPO env provided, and there is a single remote, the factory base repo func is used",
args: "",
remotes: singleRemote,
wantRepo: ghrepo.New("owner", "repo"),
},
{
name: "when there is no repo flag or GH_REPO env var provided, and can prompt, the base func resolving ambiguity is used",
args: "",
remotes: multipleRemotes,
prompterStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect(
"Select a repo",
@ -183,7 +203,7 @@ func TestNewCmdListBaseRepoFuncs(t *testing.T) {
},
Prompter: pm,
Remotes: func() (ghContext.Remotes, error) {
return remotes, nil
return tt.remotes, nil
},
}

View file

@ -106,9 +106,9 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
RunE: func(cmd *cobra.Command, args []string) error {
// If the user specified a repo directly, then we're using the OverrideBaseRepoFunc set by EnableRepoOverride
// So there's no reason to use the specialised BaseRepoFunc that requires remote disambiguation.
if cmd.Flags().Changed("repo") || os.Getenv("GH_REPO") != "" {
opts.BaseRepo = f.BaseRepo
} else {
opts.BaseRepo = f.BaseRepo
isRepoUserProvided := cmd.Flags().Changed("repo") || os.Getenv("GH_REPO") != ""
if !isRepoUserProvided {
// If they haven't specified a repo directly, then we will wrap the BaseRepoFunc in one that errors if
// there might be multiple valid remotes.
opts.BaseRepo = shared.RequireNoAmbiguityBaseRepoFunc(opts.BaseRepo, f.Remotes)

View file

@ -225,7 +225,7 @@ func TestNewCmdSet(t *testing.T) {
}
func TestNewCmdSetBaseRepoFuncs(t *testing.T) {
remotes := ghContext.Remotes{
multipleRemotes := ghContext.Remotes{
&ghContext.Remote{
Remote: &git.Remote{
Name: "origin",
@ -240,10 +240,20 @@ func TestNewCmdSetBaseRepoFuncs(t *testing.T) {
},
}
singleRemote := ghContext.Remotes{
&ghContext.Remote{
Remote: &git.Remote{
Name: "origin",
},
Repo: ghrepo.New("owner", "repo"),
},
}
tests := []struct {
name string
args string
env map[string]string
remotes ghContext.Remotes
prompterStubs func(*prompter.MockPrompter)
wantRepo ghrepo.Interface
wantErr error
@ -251,6 +261,7 @@ func TestNewCmdSetBaseRepoFuncs(t *testing.T) {
{
name: "when there is a repo flag provided, the factory base repo func is used",
args: "SECRET_NAME --repo owner/repo",
remotes: multipleRemotes,
wantRepo: ghrepo.New("owner", "repo"),
},
{
@ -259,18 +270,27 @@ func TestNewCmdSetBaseRepoFuncs(t *testing.T) {
env: map[string]string{
"GH_REPO": "owner/repo",
},
remotes: multipleRemotes,
wantRepo: ghrepo.New("owner", "repo"),
},
{
name: "when there is no repo flag or GH_REPO env var provided, and no prompting, the base func requiring no ambiguity is used",
args: "SECRET_NAME",
name: "when there is no repo flag or GH_REPO env var provided, and no prompting, the base func requiring no ambiguity is used",
args: "SECRET_NAME",
remotes: multipleRemotes,
wantErr: shared.AmbiguousBaseRepoError{
Remotes: remotes,
Remotes: multipleRemotes,
},
},
{
name: "when there is no repo flag or GH_REPO env var provided, and can prompt, the base func resolving ambiguity is used",
args: "SECRET_NAME",
name: "when there is no repo flag or GH_REPO env provided, and there is a single remote, the factory base repo func is used",
args: "SECRET_NAME",
remotes: singleRemote,
wantRepo: ghrepo.New("owner", "repo"),
},
{
name: "when there is no repo flag or GH_REPO env var provided, and can prompt, the base func resolving ambiguity is used",
args: "SECRET_NAME",
remotes: multipleRemotes,
prompterStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect(
"Select a repo",
@ -303,7 +323,7 @@ func TestNewCmdSetBaseRepoFuncs(t *testing.T) {
},
Prompter: pm,
Remotes: func() (ghContext.Remotes, error) {
return remotes, nil
return tt.remotes, nil
},
}

View file

@ -2,6 +2,7 @@ package cmdutil
import (
"fmt"
"path/filepath"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
@ -57,3 +58,43 @@ func NoArgsQuoteReminder(cmd *cobra.Command, args []string) error {
return FlagErrorf("%s", errMsg)
}
// Partition takes a slice of any type T and separates it into two slices
// of the same type based on the provided predicate function. Any item
// that returns true for the predicate will be included in the first slice
// returned, and any item that returns false for the predicate will be
// included in the second slice returned.
func Partition[T any](slice []T, predicate func(T) bool) ([]T, []T) {
var matching, nonMatching []T
for _, item := range slice {
if predicate(item) {
matching = append(matching, item)
} else {
nonMatching = append(nonMatching, item)
}
}
return matching, nonMatching
}
// GlobPaths expands a list of file patterns into a list of file paths.
// If no files match a pattern, that pattern will return an error.
// If no pattern is passed, this returns an empty list and no error.
//
// For information on supported glob patterns, see
// https://pkg.go.dev/path/filepath#Match
func GlobPaths(patterns []string) ([]string, error) {
expansions := []string{}
for _, pattern := range patterns {
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, fmt.Errorf("%s: %v", pattern, err)
}
if len(matches) == 0 {
return []string{}, fmt.Errorf("no matches found for `%s`", pattern)
}
expansions = append(expansions, matches...)
}
return expansions, nil
}

View file

@ -1,6 +1,14 @@
package cmdutil
import "testing"
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMinimumArgs(t *testing.T) {
tests := []struct {
@ -48,3 +56,207 @@ func TestMinimumNs_with_error(t *testing.T) {
}
}
}
func TestPartition(t *testing.T) {
tests := []struct {
name string
slice []any
predicate func(any) bool
wantMatching []any
wantNonMatching []any
}{
{
name: "When the slice is empty, it returns two empty slices",
slice: []any{},
predicate: func(any) bool {
return true
},
wantMatching: []any{},
wantNonMatching: []any{},
},
{
name: "when the slice has one element that satisfies the predicate, it returns a slice with that element and an empty slice",
slice: []any{
"foo",
},
predicate: func(any) bool {
return true
},
wantMatching: []any{"foo"},
wantNonMatching: []any{},
},
{
name: "when the slice has one element that does not satisfy the predicate, it returns an empty slice and a slice with that element",
slice: []any{
"foo",
},
predicate: func(any) bool {
return false
},
wantMatching: []any{},
wantNonMatching: []any{"foo"},
},
{
name: "when the slice has multiple elements, it returns a slice with the elements that satisfy the predicate and a slice with the elements that do not satisfy the predicate",
slice: []any{
"foo",
"bar",
"baz",
},
predicate: func(s any) bool {
return s.(string) != "foo"
},
wantMatching: []any{"bar", "baz"},
wantNonMatching: []any{"foo"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotMatching, gotNonMatching := Partition(tt.slice, tt.predicate)
assert.ElementsMatch(t, tt.wantMatching, gotMatching)
assert.ElementsMatch(t, tt.wantNonMatching, gotNonMatching)
})
}
}
func TestGlobPaths(t *testing.T) {
tests := []struct {
name string
os string
patterns []string
wantOut []string
wantErr error
}{
{
name: "When no patterns are passed, return an empty slice",
patterns: []string{},
wantOut: []string{},
wantErr: nil,
},
{
name: "When no files match, it returns an empty expansions array with error",
patterns: []string{"foo"},
wantOut: []string{},
wantErr: errors.New("no matches found for `foo`"),
},
{
name: "When a single pattern, '*.txt' is passed with one match, it returns that match",
patterns: []string{
"*.txt",
},
wantOut: []string{
"rootFile.txt",
},
wantErr: nil,
},
{
name: "When a single pattern, '*/*.txt' is passed with multiple matches, it returns those matches",
patterns: []string{
"*/*.txt",
},
wantOut: []string{
filepath.Join("subDir1", "subDir1_file.txt"),
filepath.Join("subDir2", "subDir2_file.txt"),
},
wantErr: nil,
},
{
name: "When multiple patterns, '*/*.txt' and '*/*.go', are passed with multiple matches, it returns those matches",
patterns: []string{
"*/*.txt",
"*/*.go",
},
wantOut: []string{
filepath.Join("subDir1", "subDir1_file.txt"),
filepath.Join("subDir2", "subDir2_file.txt"),
filepath.Join("subDir2", "subDir2_file.go"),
},
wantErr: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cleanupFn := createTestDir(t)
defer cleanupFn()
got, err := GlobPaths(tt.patterns)
if tt.wantErr != nil {
assert.EqualError(t, err, tt.wantErr.Error())
} else {
require.NoError(t, err)
}
assert.Equal(t, tt.wantOut, got)
})
}
}
// Creates a temporary directory with the structure below. Returns
// a cleanup function that will remove the directory and all of its
// contents. The cleanup function should be wrapped in a defer statement.
//
// | root
// |-- rootFile.txt
// |-- subDir1
// | |-- subDir1_file.txt
// |
// |-- subDir2
// |-- subDir2_file.go
// |-- subDir2_file.txt
func createTestDir(t *testing.T) (cleanupFn func()) {
t.Helper()
// Make Directories
rootDir := t.TempDir()
// Move workspace to temporary directory
cwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
err = os.Chdir(rootDir)
if err != nil {
t.Fatal(err)
}
// Make subdirectories
err = os.Mkdir(filepath.Join(rootDir, "subDir1"), 0755)
if err != nil {
t.Fatal(err)
}
err = os.Mkdir(filepath.Join(rootDir, "subDir2"), 0755)
if err != nil {
t.Fatal(err)
}
// Make Files
err = os.WriteFile(filepath.Join(rootDir, "rootFile.txt"), []byte(""), 0644)
if err != nil {
t.Fatal(err)
}
err = os.WriteFile(filepath.Join(rootDir, "subDir1", "subDir1_file.txt"), []byte(""), 0o644)
if err != nil {
t.Fatal(err)
}
err = os.WriteFile(filepath.Join(rootDir, "subDir2", "subDir2_file.go"), []byte(""), 0o644)
if err != nil {
t.Fatal(err)
}
err = os.WriteFile(filepath.Join(rootDir, "subDir2", "subDir2_file.txt"), []byte(""), 0o644)
if err != nil {
t.Fatal(err)
}
cleanupFn = func() {
os.RemoveAll(rootDir)
err = os.Chdir(cwd)
if err != nil {
t.Fatal(err)
}
}
return cleanupFn
}

View file

@ -1,36 +1,12 @@
#!/bin/bash
# usage: script/sign <file>
#
# Signs macOS binaries using codesign, notarizes macOS zip archives using notarytool, and signs
# Windows EXE and MSI files using osslsigncode.
# Signs macOS binaries using codesign, notarizes macOS zip archives using notarytool
#
set -e
sign_windows() {
if [ -z "$CERT_FILE" ]; then
echo "skipping Windows code-signing; CERT_FILE not set" >&2
return 0
fi
if [ ! -f "$CERT_FILE" ]; then
echo "error Windows code-signing; file '$CERT_FILE' not found" >&2
return 1
fi
if [ -z "$CERT_PASSWORD" ]; then
echo "error Windows code-signing; no value for CERT_PASSWORD" >&2
return 1
fi
osslsigncode sign -n "GitHub CLI" -t http://timestamp.digicert.com \
-pkcs12 "$CERT_FILE" -readpass <(printf "%s" "$CERT_PASSWORD") -h sha256 \
-in "$1" -out "$1"~
mv "$1"~ "$1"
}
sign_macos() {
if [ -z "$APPLE_DEVELOPER_ID" ]; then
if [[ -z "$APPLE_DEVELOPER_ID" ]]; then
echo "skipping macOS code-signing; APPLE_DEVELOPER_ID not set" >&2
return 0
fi
@ -42,24 +18,17 @@ sign_macos() {
fi
}
if [ $# -eq 0 ]; then
if [[ $# -eq 0 ]]; then
echo "usage: script/sign <file>" >&2
exit 1
fi
platform="$(uname -s)"
if [[ $platform != "Darwin" ]]; then
echo "error: must run on macOS; skipping codesigning/notarization" >&2
exit 1
fi
for input_file; do
case "$input_file" in
*.exe | *.msi )
sign_windows "$input_file"
;;
* )
if [ "$platform" = "Darwin" ]; then
sign_macos "$input_file"
else
printf "warning: don't know how to sign %s on %s\n" "$1", "$platform" >&2
fi
;;
esac
done
sign_macos "$input_file"
done