diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 4e2032a87..a1ed27d99 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -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.
diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml
index ced49a9ac..60354a953 100644
--- a/.github/workflows/deployment.yml
+++ b/.github/workflows/deployment.yml
@@ -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: |
diff --git a/.github/workflows/issueauto.yml b/.github/workflows/issueauto.yml
index 40c4b36e7..cfdcff764 100644
--- a/.github/workflows/issueauto.yml
+++ b/.github/workflows/issueauto.yml
@@ -10,6 +10,7 @@ permissions:
jobs:
issue-auto:
runs-on: ubuntu-latest
+ environment: cli-automation
steps:
- name: label incoming issue
env:
diff --git a/.github/workflows/prauto.yml b/.github/workflows/prauto.yml
index b7ed90183..40dfee846 100644
--- a/.github/workflows/prauto.yml
+++ b/.github/workflows/prauto.yml
@@ -11,6 +11,7 @@ permissions:
jobs:
pr-auto:
runs-on: ubuntu-latest
+ environment: cli-automation
steps:
- name: lint pr
env:
diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml
index 849beebad..3ed27a5f2 100644
--- a/.github/workflows/triage.yml
+++ b/.github/workflows/triage.yml
@@ -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
diff --git a/.goreleaser.yml b/.goreleaser.yml
index a7b293d6e..6ef1ecc8b 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -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
diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go
index b22929ed5..b8eff8389 100644
--- a/acceptance/acceptance_test.go
+++ b/acceptance/acceptance_test.go
@@ -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) {
diff --git a/acceptance/testdata/issue/issue-comment-edit-last-with-comments.txtar b/acceptance/testdata/issue/issue-comment-edit-last-with-comments.txtar
new file mode 100644
index 000000000..a30286ce3
--- /dev/null
+++ b/acceptance/testdata/issue/issue-comment-edit-last-with-comments.txtar
@@ -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!'
diff --git a/acceptance/testdata/issue/issue-comment-edit-last-without-comments-creates.txtar b/acceptance/testdata/issue/issue-comment-edit-last-without-comments-creates.txtar
new file mode 100644
index 000000000..44532680b
--- /dev/null
+++ b/acceptance/testdata/issue/issue-comment-edit-last-without-comments-creates.txtar
@@ -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!'
diff --git a/acceptance/testdata/issue/issue-comment-edit-last-without-comments-errors.txtar b/acceptance/testdata/issue/issue-comment-edit-last-without-comments-errors.txtar
new file mode 100644
index 000000000..300131180
--- /dev/null
+++ b/acceptance/testdata/issue/issue-comment-edit-last-without-comments-errors.txtar
@@ -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'
diff --git a/acceptance/testdata/issue/issue-comment-new.txtar b/acceptance/testdata/issue/issue-comment-new.txtar
new file mode 100644
index 000000000..12524b6d5
--- /dev/null
+++ b/acceptance/testdata/issue/issue-comment-new.txtar
@@ -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!'
diff --git a/acceptance/testdata/issue/issue-comment.txtar b/acceptance/testdata/issue/issue-comment.txtar
deleted file mode 100644
index f47bf619c..000000000
--- a/acceptance/testdata/issue/issue-comment.txtar
+++ /dev/null
@@ -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!'
diff --git a/acceptance/testdata/pr/pr-comment-edit-last-with-comments.txtar b/acceptance/testdata/pr/pr-comment-edit-last-with-comments.txtar
new file mode 100644
index 000000000..be4650ffa
--- /dev/null
+++ b/acceptance/testdata/pr/pr-comment-edit-last-with-comments.txtar
@@ -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!'
diff --git a/acceptance/testdata/pr/pr-comment-edit-last-without-comments-creates.txtar b/acceptance/testdata/pr/pr-comment-edit-last-without-comments-creates.txtar
new file mode 100644
index 000000000..e6205737f
--- /dev/null
+++ b/acceptance/testdata/pr/pr-comment-edit-last-without-comments-creates.txtar
@@ -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!'
diff --git a/acceptance/testdata/pr/pr-comment-edit-last-without-comments-errors.txtar b/acceptance/testdata/pr/pr-comment-edit-last-without-comments-errors.txtar
new file mode 100644
index 000000000..3a70adb72
--- /dev/null
+++ b/acceptance/testdata/pr/pr-comment-edit-last-without-comments-errors.txtar
@@ -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'
diff --git a/acceptance/testdata/pr/pr-comment.txtar b/acceptance/testdata/pr/pr-comment-new.txtar
similarity index 58%
rename from acceptance/testdata/pr/pr-comment.txtar
rename to acceptance/testdata/pr/pr-comment-new.txtar
index 2c4d488b5..c5b80314c 100644
--- a/acceptance/testdata/pr/pr-comment.txtar
+++ b/acceptance/testdata/pr/pr-comment-new.txtar
@@ -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!'
diff --git a/docs/release-process-deep-dive.md b/docs/release-process-deep-dive.md
index ed9362d38..31f44f6ef 100644
--- a/docs/release-process-deep-dive.md
+++ b/docs/release-process-deep-dive.md
@@ -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.
-
## [release](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.github/workflows/deployment.yml#L250-L395)
diff --git a/go.mod b/go.mod
index 9d1afbc38..433156789 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index 2c14dc4c3..b5db03d9c 100644
--- a/go.sum
+++ b/go.sum
@@ -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=
diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go
index d25b4ce64..586aeae93 100644
--- a/pkg/cmd/api/api.go
+++ b/pkg/cmd/api/api.go
@@ -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)\/(?.*?)(?:\/(?: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
-}
diff --git a/pkg/cmd/api/api_test.go b/pkg/cmd/api/api_test.go
index ee58d55d0..321f7b7c0 100644
--- a/pkg/cmd/api/api_test.go
+++ b/pkg/cmd/api/api_test.go
@@ -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) {
diff --git a/pkg/cmd/gist/create/create.go b/pkg/cmd/gist/create/create.go
index e9d9f2c14..5392b997e 100644
--- a/pkg/cmd/gist/create/create.go
+++ b/pkg/cmd/gist/create/create.go
@@ -48,13 +48,13 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
}
cmd := &cobra.Command{
- Use: "create [... | -]",
+ Use: "create [... | ... | -]",
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)
}
diff --git a/pkg/cmd/gist/create/create_test.go b/pkg/cmd/gist/create/create_test.go
index 237486eb3..44f0ba284 100644
--- a/pkg/cmd/gist/create/create_test.go
+++ b/pkg/cmd/gist/create/create_test.go
@@ -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{
diff --git a/pkg/cmd/issue/comment/comment_test.go b/pkg/cmd/issue/comment/comment_test.go
index 461ae1065..794dafda4 100644
--- a/pkg/cmd/issue/comment/comment_test.go
+++ b/pkg/cmd/issue/comment/comment_test.go
@@ -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())
diff --git a/pkg/cmd/pr/comment/comment_test.go b/pkg/cmd/pr/comment/comment_test.go
index a6cc36abf..0941f2533 100644
--- a/pkg/cmd/pr/comment/comment_test.go
+++ b/pkg/cmd/pr/comment/comment_test.go
@@ -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())
diff --git a/pkg/cmd/pr/shared/commentable.go b/pkg/cmd/pr/shared/commentable.go
index 46e3072e2..f909c7559 100644
--- a/pkg/cmd/pr/shared/commentable.go
+++ b/pkg/cmd/pr/shared/commentable.go
@@ -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)
}
diff --git a/pkg/cmd/pr/update-branch/update_branch.go b/pkg/cmd/pr/update-branch/update_branch.go
index 023fdae50..ca3b2c910 100644
--- a/pkg/cmd/pr/update-branch/update_branch.go
+++ b/pkg/cmd/pr/update-branch/update_branch.go
@@ -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(`
diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go
index be6d0f65a..7e3a1d43a 100644
--- a/pkg/cmd/release/create/create.go
+++ b/pkg/cmd/release/create/create.go
@@ -78,7 +78,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
cmd := &cobra.Command{
DisableFlagsInUseLine: true,
- Use: "create [] [...]",
+ Use: "create [] [... | ...]",
Short: "Create a new release",
Long: heredoc.Docf(`
Create a new GitHub Release for a repository.
diff --git a/pkg/cmd/release/shared/upload.go b/pkg/cmd/release/shared/upload.go
index 38add7425..ab7533320 100644
--- a/pkg/cmd/release/shared/upload.go
+++ b/pkg/cmd/release/shared/upload.go
@@ -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
diff --git a/pkg/cmd/repo/sync/http.go b/pkg/cmd/repo/sync/http.go
index 3e193daeb..27e9a6351 100644
--- a/pkg/cmd/repo/sync/http.go
+++ b/pkg/cmd/repo/sync/http.go
@@ -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
diff --git a/pkg/cmd/repo/sync/sync_test.go b/pkg/cmd/repo/sync/sync_test.go
index 227e2a6a2..60e6ae392 100644
--- a/pkg/cmd/repo/sync/sync_test.go
+++ b/pkg/cmd/repo/sync/sync_test.go
@@ -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 {
diff --git a/pkg/cmd/secret/delete/delete.go b/pkg/cmd/secret/delete/delete.go
index 1cbfbca9d..b73b59848 100644
--- a/pkg/cmd/secret/delete/delete.go
+++ b/pkg/cmd/secret/delete/delete.go
@@ -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)
diff --git a/pkg/cmd/secret/delete/delete_test.go b/pkg/cmd/secret/delete/delete_test.go
index 2f6cad431..48200b881 100644
--- a/pkg/cmd/secret/delete/delete_test.go
+++ b/pkg/cmd/secret/delete/delete_test.go
@@ -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
},
}
diff --git a/pkg/cmd/secret/list/list.go b/pkg/cmd/secret/list/list.go
index c976bd60e..06476a86d 100644
--- a/pkg/cmd/secret/list/list.go
+++ b/pkg/cmd/secret/list/list.go
@@ -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)
diff --git a/pkg/cmd/secret/list/list_test.go b/pkg/cmd/secret/list/list_test.go
index f4ecde73e..5c4dd4874 100644
--- a/pkg/cmd/secret/list/list_test.go
+++ b/pkg/cmd/secret/list/list_test.go
@@ -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
},
}
diff --git a/pkg/cmd/secret/set/set.go b/pkg/cmd/secret/set/set.go
index 89f11f1cf..0a6581559 100644
--- a/pkg/cmd/secret/set/set.go
+++ b/pkg/cmd/secret/set/set.go
@@ -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)
diff --git a/pkg/cmd/secret/set/set_test.go b/pkg/cmd/secret/set/set_test.go
index 22c7aaab8..0b305eda6 100644
--- a/pkg/cmd/secret/set/set_test.go
+++ b/pkg/cmd/secret/set/set_test.go
@@ -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
},
}
diff --git a/pkg/cmdutil/args.go b/pkg/cmdutil/args.go
index 0f03a07bd..b0a22e9b5 100644
--- a/pkg/cmdutil/args.go
+++ b/pkg/cmdutil/args.go
@@ -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
+}
diff --git a/pkg/cmdutil/args_test.go b/pkg/cmdutil/args_test.go
index db0b96510..4e880dd27 100644
--- a/pkg/cmdutil/args_test.go
+++ b/pkg/cmdutil/args_test.go
@@ -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
+}
diff --git a/script/sign b/script/sign
index 1630a06b5..f07a7d2d4 100755
--- a/script/sign
+++ b/script/sign
@@ -1,36 +1,12 @@
#!/bin/bash
# usage: script/sign
#
-# 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 " >&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
\ No newline at end of file
+ sign_macos "$input_file"
+done