diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 20bf83882..bc047d1c5 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -1,5 +1,5 @@
{
- "image": "mcr.microsoft.com/devcontainers/go:1.22",
+ "image": "mcr.microsoft.com/devcontainers/go:1.23",
"features": {
"ghcr.io/devcontainers/features/sshd:1": {}
},
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 40d0a83e3..4e2032a87 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -24,7 +24,7 @@ We accept pull requests for bug fixes and features where we've discussed the app
## Building the project
Prerequisites:
-- Go 1.22+
+- Go 1.23+
Build with:
* Unix-like systems: `make`
diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml
index efc729711..ced49a9ac 100644
--- a/.github/workflows/deployment.yml
+++ b/.github/workflows/deployment.yml
@@ -309,7 +309,7 @@ jobs:
rpmsign --addsign dist/*.rpm
- name: Attest release artifacts
if: inputs.environment == 'production'
- uses: actions/attest-build-provenance@520d128f165991a6c774bcb264f323e3d70747f4 # v2.2.0
+ uses: actions/attest-build-provenance@bd77c077858b8d561b7a36cbe48ef4cc642ca39d # v2.2.2
with:
subject-path: "dist/gh_*"
- name: Run createrepo
diff --git a/README.md b/README.md
index c227cffdc..fbd99a5a8 100644
--- a/README.md
+++ b/README.md
@@ -134,7 +134,7 @@ There are two common ways to verify a downloaded release, depending if `gh` is a
- **Option 1: Using `gh` if already installed:**
```shell
- $ % gh at verify -R cli/cli gh_2.62.0_macOS_arm64.zip
+ $ gh at verify -R cli/cli gh_2.62.0_macOS_arm64.zip
Loaded digest sha256:fdb77f31b8a6dd23c3fd858758d692a45f7fc76383e37d475bdcae038df92afc for file://gh_2.62.0_macOS_arm64.zip
Loaded 1 attestation from GitHub API
✓ Verification succeeded!
diff --git a/api/queries_user.go b/api/queries_user.go
index cf0121a8b..dd7be0105 100644
--- a/api/queries_user.go
+++ b/api/queries_user.go
@@ -31,7 +31,7 @@ func CurrentLoginNameAndOrgs(client *Client, hostname string) (string, []string,
for _, org := range query.Viewer.Organizations.Nodes {
orgNames = append(orgNames, org.Login)
}
- return query.Viewer.Login, orgNames, err
+ return query.Viewer.Login, orgNames, nil
}
func CurrentUserID(client *Client, hostname string) (string, error) {
diff --git a/context/context.go b/context/context.go
index 06ef8ca7d..7374e02bb 100644
--- a/context/context.go
+++ b/context/context.go
@@ -99,7 +99,7 @@ func (r *ResolvedRemotes) BaseRepo(io *iostreams.IOStreams) (ghrepo.Interface, e
cs := io.ColorScheme()
fmt.Fprintf(io.ErrOut,
- "%s No default remote repository has been set for this directory.\n",
+ "%s No default remote repository has been set. To learn more about the default repository, run: gh repo set-default --help\n",
cs.FailureIcon())
fmt.Fprintln(io.Out)
diff --git a/docs/release-process-deep-dive.md b/docs/release-process-deep-dive.md
new file mode 100644
index 000000000..ed9362d38
--- /dev/null
+++ b/docs/release-process-deep-dive.md
@@ -0,0 +1,682 @@
+# Release Process Deep Dive
+
+The current release workflow and associated scripts were created before the current set of maintainers, and all maintainers from that time have left. On a number of occasions (releasing a MacOS installer, moving to Azure HSM signing, updating expired GPG key) the current maintainers have spent time investigating the release workflow. This document is intended to serve as a guide for future maintainers who need to understand the release process.
+
+# High Level Overview
+
+From a high level, the [release workflow](https://github.com/cli/cli/blob/537a22228cd6b42b740d7f1c09f47c45bb1dab30/.github/workflows/deployment.yml):
+ * Is triggered by a `workflow_dispatch` event (typically a result of running `./script/release`)
+ * Builds, packages and signs artifacts in parallel for Linux, MacOS and Windows
+ * GPG signs Debian and Red Hat repository artifacts
+ * Builds and updates the [manual](https://cli.github.com/manual) and repository packages
+ * Creates GitHub Attestations for the artifacts
+ * Creates a GitHub Release and attaches the artifacts
+ * Bumps the `gh` [homebrew-core formula](https://github.com/Homebrew/homebrew-core/blob/2df031cbd8f7bc9b9a380e941ccefcf3c8f3d02b/Formula/g/gh.rb)
+
+# Jobs Deep Dive
+
+This section will deep dive into each job in the [`deployment.yml` workflow](https://github.com/cli/cli/blob/537a22228cd6b42b740d7f1c09f47c45bb1dab30/.github/workflows/deployment.yml).
+
+- [validate-tag-name](#validate-tag-name)
+- [OS Specific Builds](#os-builds)
+ - [linux](#linux)
+ - [macos](#macos)
+ - [windows](#windows)
+- [release](#release)
+
+Although this workflow is used to do our production releases for Linux, MacOS and Windows, it is also possible to run subsets of the workflow. Specifically:
+ * The workflow can be triggered with `inputs.release` set to `false`, resulting in the entire [release job](#release) being skipped. This is not exposed via `./script/release`.
+ * Many sections are guarded by `if: inputs.environment == 'production'`. These guards protect sections that require secrets (e.g. signing) or that result in mutations (e.g. creating a GitHub release). `./script/release` accepts the `--staging` flag for this purpose. This differs from the previous bullet point as some steps in the [release job](#release) print debug information such as [`git` diffs](https://github.com/cli/cli/blob/5d2eadef8cccf2671f68aad05cd93215a4c01b48/.github/workflows/deployment.yml#L380-L384).
+ * The workflow can be triggered for only `linux`, `MacOS` or `Windows` which allows for debugging single jobs. This is not exposed via `./script/release`. The [release job](#release) should not run in this case as it [requires all OS specific builds.](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.github/workflows/deployment.yml#L252)
+
+## [validate-tag-name](https://github.com/cli/cli/blob/537a22228cd6b42b740d7f1c09f47c45bb1dab30/.github/workflows/deployment.yml#L31-L39)
+
+
+
+```yml
+ validate-tag-name:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Validate tag name format
+ run: |
+ if [[ ! "${{ inputs.tag_name }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "Invalid tag name format. Must be in the form v1.2.3"
+ exit 1
+ fi
+```
+
+
+The purpose of this job is to [prevent incorrectly tagged releases](https://github.com/cli/cli/pull/10121), by ensuring they conform to the `major.minor.patch` form of semantic versioning, preceded by a `v`.
+
+> [!WARNING]
+> The `release` job can [create the GitHub release as a pre-release based on the existence of a hyphen in the tag name](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.github/workflows/deployment.yml#L362-L364), but the later addition of `validate-tag-name` disallows this.
+
+## [OS Builds](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.github/workflows/deployment.yml#L40-L248)
+
+After validating the tag name, the workflow parallelises across `ubuntu`, `macos` and `windows` runners. The primary purpose of these jobs is to build and sign release artifacts. These artifacts are made available to the `release` job via `actions/upload-artifact` and `actions/download-artifact` respectively.
+
+### [linux](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.github/workflows/deployment.yml#L40-L73)
+
+
+
+```yml
+ linux:
+ needs: validate-tag-name
+ runs-on: ubuntu-latest
+ environment: ${{ inputs.environment }}
+ if: contains(inputs.platforms, 'linux')
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: 'go.mod'
+ - name: Install GoReleaser
+ uses: goreleaser/goreleaser-action@v6
+ with:
+ version: "~1.17.1"
+ install-only: true
+ - name: Build release binaries
+ env:
+ TAG_NAME: ${{ inputs.tag_name }}
+ run: script/release --local "$TAG_NAME" --platform linux
+ - name: Generate web manual pages
+ run: |
+ go run ./cmd/gen-docs --website --doc-path dist/manual
+ tar -czvf dist/manual.tar.gz -C dist -- manual
+ - uses: actions/upload-artifact@v4
+ with:
+ name: linux
+ if-no-files-found: error
+ retention-days: 7
+ path: |
+ dist/*.tar.gz
+ dist/*.rpm
+ dist/*.deb
+```
+
+
+In addition to building release artifacts, the `linux` job builds the [CLI manual](https://cli.github.com/manual/) for use in the later `release` job.
+
+#### Building
+
+This job executes `script/release --local "$TAG_NAME" --platform linux` which uses`GoReleaser` to create the Go executables, `.zip` archives, and `.deb` / `.rpm` repository packages. See [how ./script/release works](#how-script-release-works) for further information.
+
+#### Signing
+
+There is no signing of linux artifacts in this job. See the [release job](#release) for more information on signing linux artifacts.
+
+### [macos](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.github/workflows/deployment.yml#L75-L145)
+
+
+
+```yaml
+ macos:
+ needs: validate-tag-name
+ runs-on: macos-latest
+ environment: ${{ inputs.environment }}
+ if: contains(inputs.platforms, 'macos')
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: 'go.mod'
+ - name: Configure macOS signing
+ if: inputs.environment == 'production'
+ env:
+ APPLE_DEVELOPER_ID: ${{ vars.APPLE_DEVELOPER_ID }}
+ APPLE_APPLICATION_CERT: ${{ secrets.APPLE_APPLICATION_CERT }}
+ APPLE_APPLICATION_CERT_PASSWORD: ${{ secrets.APPLE_APPLICATION_CERT_PASSWORD }}
+ run: |
+ keychain="$RUNNER_TEMP/buildagent.keychain"
+ keychain_password="password1"
+
+ security create-keychain -p "$keychain_password" "$keychain"
+ security default-keychain -s "$keychain"
+ security unlock-keychain -p "$keychain_password" "$keychain"
+
+ base64 -D <<<"$APPLE_APPLICATION_CERT" > "$RUNNER_TEMP/cert.p12"
+ security import "$RUNNER_TEMP/cert.p12" -k "$keychain" -P "$APPLE_APPLICATION_CERT_PASSWORD" -T /usr/bin/codesign
+ security set-key-partition-list -S "apple-tool:,apple:,codesign:" -s -k "$keychain_password" "$keychain"
+ rm "$RUNNER_TEMP/cert.p12"
+ - name: Install GoReleaser
+ uses: goreleaser/goreleaser-action@v6
+ with:
+ version: "~1.17.1"
+ install-only: true
+ - name: Build release binaries
+ env:
+ TAG_NAME: ${{ inputs.tag_name }}
+ APPLE_DEVELOPER_ID: ${{ vars.APPLE_DEVELOPER_ID }}
+ run: script/release --local "$TAG_NAME" --platform macos
+ - name: Notarize macOS archives
+ if: inputs.environment == 'production'
+ env:
+ APPLE_ID: ${{ vars.APPLE_ID }}
+ APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
+ APPLE_DEVELOPER_ID: ${{ vars.APPLE_DEVELOPER_ID }}
+ run: |
+ shopt -s failglob
+ script/sign dist/gh_*_macOS_*.zip
+ - name: Build universal macOS pkg installer
+ if: inputs.environment != 'production'
+ env:
+ TAG_NAME: ${{ inputs.tag_name }}
+ run: script/pkgmacos "$TAG_NAME"
+ - name: Build & notarize universal macOS pkg installer
+ if: inputs.environment == 'production'
+ env:
+ TAG_NAME: ${{ inputs.tag_name }}
+ APPLE_DEVELOPER_INSTALLER_ID: ${{ vars.APPLE_DEVELOPER_INSTALLER_ID }}
+ run: |
+ shopt -s failglob
+ script/pkgmacos "$TAG_NAME"
+ - uses: actions/upload-artifact@v4
+ with:
+ name: macos
+ if-no-files-found: error
+ retention-days: 7
+ path: |
+ dist/*.tar.gz
+ dist/*.zip
+ dist/*.pkg
+```
+
+
+#### Building
+
+This job executes `script/release --local "$TAG_NAME" --platform macos` which uses `GoReleaser` to create the Go executables and `.zip` archives. See [how ./script/release works](#how-script-release-works) for further information.
+
+This job also executes `script/pkgmacos "$TAG_NAME"` to build a Universal (architecture independent) MacOS `.pkg` installer. See [how ./script/pkgmacos works](#how-script-pkgmacos-works) for further information.
+
+#### Signing
+
+For MacOS, the "signing" section refers to both signing and notarizing.
+
+There are three levels of "signing" that occur in this job:
+ * Signing of Go executables created by `GoReleaser` is performed in a [`GoReleaser` hook](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.goreleaser.yml#L20).
+ * Notarization of `.zip` archives created by `GoReleaser` is performed by directly executing `script/sign dist/gh_*_macOS_*.zip`
+ * Signing of the `.pkg` installer in `./script/pkgmacos` when executing [`productbuild`](https://github.com/cli/cli/blob/1c74296d28cf5595008065d3ddf7061ca9388305/script/pkgmacos#L108). See warnings below.
+
+ > [!WARNING]
+> Although the job title is `Build & notarize universal macOS pkg installer`, the [`productbuild` docs](https://www.unix.com/man_page/osx/1/productbuild/) only refer to signing, thus notarization may not be the correct term here.
+
+> [!WARNING]
+> Although it appears as if signing the `.pkg` installer can occur if `inputs.environment == 'production'`, in practice, I don't believe we ever set `${{ vars.APPLE_DEVELOPER_INSTALLER_ID }}`, thus we always [skip signing](https://github.com/cli/cli/actions/runs/13271193192/job/37050749548#step:9:11).
+
+Signing of MacOS artifacts uses `codesign` and notarization uses `xcrun notarytool`, which submits the artifact to the Apple servers for additional checks.
+
+In order to perform signing, a keychain must be configured with the signing certificate. Comments have been added to provide clarity to the script:
+
+```sh
+keychain="$RUNNER_TEMP/buildagent.keychain"
+keychain_password="password1"
+
+# Create a new keychain for credentials to be stored in.
+security create-keychain -p "$keychain_password" "$keychain"
+# Mark the keychain as the system default so that a later signing step doesn't require
+# referencing the keychain by name.
+security default-keychain -s "$keychain"
+# Unlock the keychain so that future operations can access the secrets without user interaction.
+security unlock-keychain -p "$keychain_password" "$keychain"
+
+base64 -D <<<"$APPLE_APPLICATION_CERT" > "$RUNNER_TEMP/cert.p12"
+
+# Import the certificate into the keychain so that a later signing step can use it.
+# `man security` snippet:
+# -k keychain Specify keychain into which item(s) will be imported.
+# -P passphrase Specify the unwrapping passphrase immediately. The default is to obtain a secure passphrase via GUI.
+# -T appPath Specify an application which may access the imported key (multiple -T options are allowed)
+security import "$RUNNER_TEMP/cert.p12" -k "$keychain" -P "$APPLE_APPLICATION_CERT_PASSWORD" -T /usr/bin/codesign
+
+# Enforce additional security requirements that only the applications used for signing can access the keychain. This allows for signing applications to access the keychain without user interaction.
+# The three values:
+# * apple-tool: → Grants access to Apple's development tools.
+# * apple: → Grants access to Apple’s general cryptographic tools.
+# * codesign: → Grants access to the codesign tool, which is used to sign binaries and applications.
+#
+# `man security` snippet:
+# set-key-partition-list [-S partition-list] [-k password] [options...] [keychain] Sets the "partition list" for a key. The "partition list" is an extra parameter in the ACL which limits access to the key based on an application's code signature. You must present the keychain's password to change a partition list. If you'd like to run /usr/bin/codesign with the key, "apple:" must be an element of the partition
+# list.
+
+# -S partition-list
+# Comma-separated partition list. See output of "security dump-keychain" for examples.
+# -k password Password for keychain
+# -s Match keys that can sign
+security set-key-partition-list -S "apple-tool:,apple:,codesign:" -s -k "$keychain_password" "$keychain"
+# Clean up the certificate so that it's not lying around for later jobs to leak.
+rm "$RUNNER_TEMP/cert.p12"
+```
+
+When we execute `codesign --timestamp --options=runtime -s "${APPLE_DEVELOPER_ID?}" -v "$1"` in `./script/sign`, `codesign` inspects into the default keychain to find a certificate that matches the `APPLE_DEVELOPER_ID` environment variable. The `--timestamp` and `--options=runtime` flags are required for Notarization, described below.
+
+---
+
+[Code signing certifies that a `gh` executable was created by GitHub](https://developer.apple.com/documentation/security/code-signing-services). On the other hand, [Notarization](https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution) is an additional security step upon which software is submitted to Apple for automated scanning. If passed, Apple generates a `ticket` that can be `stapled` to the software, and Apple's [Gatekeeper](https://support.apple.com/en-gb/guide/security/sec5599b66df/web) software is made aware of it.
+
+When we execute `xcrun notarytool submit "$1" --apple-id "${APPLE_ID?}" --team-id "${APPLE_DEVELOPER_ID?}" --password "${APPLE_ID_PASSWORD?}"` in `./script/sign` no keychain access should be required as the `APPLE_ID_PASSWORD` environment variable is used to authenticate.
+
+### [windows](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.github/workflows/deployment.yml#L147-L248)
+
+
+
+```yml
+windows:
+ needs: validate-tag-name
+ runs-on: windows-latest
+ environment: ${{ inputs.environment }}
+ if: contains(inputs.platforms, 'windows')
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: 'go.mod'
+ - name: Install GoReleaser
+ uses: goreleaser/goreleaser-action@v6
+ with:
+ version: "~1.17.1"
+ install-only: true
+ - name: Install Azure Code Signing Client
+ shell: pwsh
+ env:
+ ACS_DIR: ${{ runner.temp }}\acs
+ ACS_ZIP: ${{ runner.temp }}\acs.zip
+ CORRELATION_ID: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ METADATA_PATH: ${{ runner.temp }}\acs\metadata.json
+ run: |
+ # Download Azure Code Signing client containing the DLL needed for signtool in script/sign
+ Invoke-WebRequest -Uri https://www.nuget.org/api/v2/package/Azure.CodeSigning.Client/1.0.43 -OutFile $Env:ACS_ZIP -Verbose
+ Expand-Archive $Env:ACS_ZIP -Destination $Env:ACS_DIR -Force -Verbose
+
+ # Generate metadata file for signtool, used in signing box .exe and .msi
+ @{
+ CertificateProfileName = "GitHubInc"
+ CodeSigningAccountName = "GitHubInc"
+ CorrelationId = $Env:CORRELATION_ID
+ Endpoint = "https://wus.codesigning.azure.net/"
+ } | ConvertTo-Json | Out-File -FilePath $Env:METADATA_PATH
+
+ # Azure Code Signing leverages the environment variables for secrets that complement the metadata.json
+ # file generated above (AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID)
+ # For more information, see https://learn.microsoft.com/en-us/dotnet/api/azure.identity.defaultazurecredential?view=azure-dotnet
+ - name: Build release binaries
+ shell: bash
+ env:
+ AZURE_CLIENT_ID: ${{ secrets.SPN_GITHUB_CLI_SIGNING_CLIENT_ID }}
+ AZURE_CLIENT_SECRET: ${{ secrets.SPN_GITHUB_CLI_SIGNING }}
+ AZURE_TENANT_ID: ${{ secrets.SPN_GITHUB_CLI_SIGNING_TENANT_ID }}
+ DLIB_PATH: ${{ runner.temp }}\acs\bin\x64\Azure.CodeSigning.Dlib.dll
+ METADATA_PATH: ${{ runner.temp }}\acs\metadata.json
+ TAG_NAME: ${{ inputs.tag_name }}
+ run: script/release --local "$TAG_NAME" --platform windows
+ - name: Set up MSBuild
+ id: setupmsbuild
+ uses: microsoft/setup-msbuild@v2.0.0
+ - name: Build MSI
+ shell: bash
+ env:
+ MSBUILD_PATH: ${{ steps.setupmsbuild.outputs.msbuildPath }}
+ run: |
+ for ZIP_FILE in dist/gh_*_windows_*.zip; do
+ MSI_NAME="$(basename "$ZIP_FILE" ".zip")"
+ MSI_VERSION="$(cut -d_ -f2 <<<"$MSI_NAME" | cut -d- -f1)"
+ case "$MSI_NAME" in
+ *_386 )
+ source_dir="$PWD/dist/windows_windows_386"
+ platform="x86"
+ ;;
+ *_amd64 )
+ source_dir="$PWD/dist/windows_windows_amd64_v1"
+ platform="x64"
+ ;;
+ *_arm64 )
+ source_dir="$PWD/dist/windows_windows_arm64"
+ platform="arm64"
+ ;;
+ * )
+ printf "unsupported architecture: %s\n" "$MSI_NAME" >&2
+ exit 1
+ ;;
+ esac
+ "${MSBUILD_PATH}\MSBuild.exe" ./build/windows/gh.wixproj -p:SourceDir="$source_dir" -p:OutputPath="$PWD/dist" -p:OutputName="$MSI_NAME" -p:ProductVersion="${MSI_VERSION#v}" -p:Platform="$platform"
+ done
+ - name: Sign .msi release binaries
+ if: inputs.environment == 'production'
+ shell: pwsh
+ env:
+ AZURE_CLIENT_ID: ${{ secrets.SPN_GITHUB_CLI_SIGNING_CLIENT_ID }}
+ AZURE_CLIENT_SECRET: ${{ secrets.SPN_GITHUB_CLI_SIGNING }}
+ AZURE_TENANT_ID: ${{ secrets.SPN_GITHUB_CLI_SIGNING_TENANT_ID }}
+ DLIB_PATH: ${{ runner.temp }}\acs\bin\x64\Azure.CodeSigning.Dlib.dll
+ METADATA_PATH: ${{ runner.temp }}\acs\metadata.json
+ run: |
+ Get-ChildItem -Path .\dist -Filter *.msi | ForEach-Object {
+ .\script\sign.ps1 $_.FullName
+ }
+ - uses: actions/upload-artifact@v4
+ with:
+ name: windows
+ if-no-files-found: error
+ retention-days: 7
+ path: |
+ dist/*.zip
+ dist/*.msi
+```
+
+
+#### Building
+
+This job executes `script/release --local "$TAG_NAME" --platform windows` to use `GoReleaser` to create the Go executables and `.zip` archives. See [how ./script/release works](#how-script-release-works) for further information.
+
+This job also executes `MSBuild.exe` to build MSI Installers, wrapping each architecture dependent `.zip` produced by GoReleaser. This is done via the command:
+
+```pwsh
+"${MSBUILD_PATH}\MSBuild.exe" ./build/windows/gh.wixproj -p:SourceDir="$source_dir" -p:OutputPath="$PWD/dist" -p:OutputName="$MSI_NAME" -p:ProductVersion="${MSI_VERSION#v}" -p:Platform="$platform"
+```
+
+This references a number of [pretty inscrutable files](https://github.com/cli/cli/tree/817eeb26e567de11007c8a82c25e61c7e20e4337/build/windows) in our repository that form a kind of manifest. Some of the details and motivation for the contents of these files is described in the [PR](https://github.com/cli/cli/pull/4276) that introduced them.
+
+#### Signing
+
+There are two levels of signing that occur in this job:
+ * Signing of Go executables created by `GoReleaser` is performed in a [`GoReleaser` hook](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.goreleaser.yml#L43).
+ * Signing of the MSI installers by executing `.\script\sign.ps1 $_.FullName`
+
+Signing of the Windows artifacts uses `signtool.exe` to request signing from [Azure HSM](https://azure.microsoft.com/en-us/products/azure-dedicated-hsm). This takes the following steps:
+
+Firstly, a package is downloaded that contains a DLL to allow `signtool.exe` to interact with Azure HSM.
+
+```pwsh
+# Download Azure Code Signing client containing the DLL needed for signtool in script/sign
+Invoke-WebRequest -Uri https://www.nuget.org/api/v2/package/Azure.CodeSigning.Client/1.0.43 -OutFile $Env:ACS_ZIP -Verbose
+Expand-Archive $Env:ACS_ZIP -Destination $Env:ACS_DIR -Force -Verbose
+```
+
+Secondly, we create a JSON file containing metadata required by HSM:
+
+```pwsh
+# Generate metadata file for signtool, used in signing box .exe and .msi
+@{
+ CertificateProfileName = "GitHubInc"
+ CodeSigningAccountName = "GitHubInc"
+ CorrelationId = $Env:CORRELATION_ID
+ Endpoint = "https://wus.codesigning.azure.net/"
+} | ConvertTo-Json | Out-File -FilePath $Env:METADATA_PATH
+```
+
+Thirdly, in `./script/sign.ps1` we look for the `signtool` executable:
+
+```pwsh
+$signtool = Resolve-Path "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" | Select-Object -Last 1
+```
+
+Finally, in `./script/sign.ps`, we execute `signtool`:
+
+```pwsh
+& $signtool sign /d "GitHub CLI" /fd sha256 /td sha256 /tr http://timestamp.acs.microsoft.com /v /dlib "$Env:DLIB_PATH" /dmdf "$Env:METADATA_PATH" $Args[0]
+```
+
+Breaking this command down:
+ * `/fd` is the file digest algorithm
+ * `/td` is the timestamp digest algorithm
+ * `/tr` indicates the timestamp server so a timestamp can be added to the signature, proving when it was signed
+ * `/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)
+
+
+
+```yml
+release:
+ runs-on: ubuntu-latest
+ needs: [linux, macos, windows]
+ environment: ${{ inputs.environment }}
+ if: inputs.release
+ steps:
+ - name: Checkout cli/cli
+ uses: actions/checkout@v4
+ - name: Merge built artifacts
+ uses: actions/download-artifact@v4
+ - name: Checkout documentation site
+ uses: actions/checkout@v4
+ with:
+ repository: github/cli.github.com
+ path: site
+ fetch-depth: 0
+ token: ${{ secrets.SITE_DEPLOY_PAT }}
+ - name: Update site man pages
+ env:
+ GIT_COMMITTER_NAME: cli automation
+ GIT_AUTHOR_NAME: cli automation
+ GIT_COMMITTER_EMAIL: noreply@github.com
+ GIT_AUTHOR_EMAIL: noreply@github.com
+ TAG_NAME: ${{ inputs.tag_name }}
+ run: |
+ git -C site rm 'manual/gh*.md' 2>/dev/null || true
+ tar -xzvf linux/manual.tar.gz -C site
+ git -C site add 'manual/gh*.md'
+ sed -i.bak -E "s/(assign version = )\".+\"/\1\"${TAG_NAME#v}\"/" site/index.html
+ rm -f site/index.html.bak
+ git -C site add index.html
+ git -C site diff --quiet --cached || git -C site commit -m "gh ${TAG_NAME#v}"
+ - name: Prepare release assets
+ env:
+ TAG_NAME: ${{ inputs.tag_name }}
+ run: |
+ shopt -s failglob
+ rm -rf dist
+ mkdir dist
+ mv -v {linux,macos,windows}/gh_* dist/
+ - name: Install packaging dependencies
+ run: sudo apt-get install -y rpm reprepro
+ - name: Set up GPG
+ if: inputs.environment == 'production'
+ env:
+ GPG_PUBKEY: ${{ secrets.GPG_PUBKEY }}
+ GPG_KEY: ${{ secrets.GPG_KEY }}
+ GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
+ GPG_KEYGRIP: ${{ secrets.GPG_KEYGRIP }}
+ run: |
+ base64 -d <<<"$GPG_PUBKEY" | gpg --import --no-tty --batch --yes
+ 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"
+ - name: Sign RPMs
+ if: inputs.environment == 'production'
+ run: |
+ cp script/rpmmacros ~/.rpmmacros
+ rpmsign --addsign dist/*.rpm
+ - name: Attest release artifacts
+ if: inputs.environment == 'production'
+ uses: actions/attest-build-provenance@520d128f165991a6c774bcb264f323e3d70747f4 # v2.2.0
+ with:
+ subject-path: "dist/gh_*"
+ - name: Run createrepo
+ env:
+ GPG_SIGN: ${{ inputs.environment == 'production' }}
+ run: |
+ mkdir -p site/packages/rpm
+ cp dist/*.rpm site/packages/rpm/
+ ./script/createrepo.sh
+ cp -r dist/repodata site/packages/rpm/
+ pushd site/packages/rpm
+ [ "$GPG_SIGN" = "false" ] || gpg --yes --detach-sign --armor repodata/repomd.xml
+ popd
+ - name: Run reprepro
+ env:
+ GPG_SIGN: ${{ inputs.environment == 'production' }}
+ # We are no longer adding to the distribution list.
+ # All apt distributions should use "stable" according to our install documentation.
+ # In the future we will remove legacy distributions listed here.
+ RELEASES: "cosmic eoan disco groovy focal stable oldstable testing sid unstable buster bullseye stretch jessie bionic trusty precise xenial hirsute impish kali-rolling"
+ run: |
+ mkdir -p upload
+ [ "$GPG_SIGN" = "true" ] || sed -i.bak '/^SignWith:/d' script/distributions
+ for release in $RELEASES; do
+ for file in dist/*.deb; do
+ reprepro --confdir="+b/script" includedeb "$release" "$file"
+ done
+ done
+ cp -a dists/ pool/ upload/
+ mkdir -p site/packages
+ cp -a upload/* site/packages/
+ - name: Create the release
+ env:
+ # In non-production environments, the assets will not have been signed
+ DO_PUBLISH: ${{ inputs.environment == 'production' }}
+ TAG_NAME: ${{ inputs.tag_name }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ shopt -s failglob
+ pushd dist
+ shasum -a 256 gh_* > checksums.txt
+ mv checksums.txt gh_${TAG_NAME#v}_checksums.txt
+ popd
+ release_args=(
+ "$TAG_NAME"
+ --title "GitHub CLI ${TAG_NAME#v}"
+ --target "$GITHUB_SHA"
+ --generate-notes
+ )
+ if [[ $TAG_NAME == *-* ]]; then
+ release_args+=( --prerelease )
+ fi
+ guard="echo"
+ [ "$DO_PUBLISH" = "false" ] || guard=""
+ script/label-assets dist/gh_* | xargs $guard gh release create "${release_args[@]}" --
+ - name: Publish site
+ env:
+ DO_PUBLISH: ${{ inputs.environment == 'production' && !contains(inputs.tag_name, '-') }}
+ TAG_NAME: ${{ inputs.tag_name }}
+ GIT_COMMITTER_NAME: cli automation
+ GIT_AUTHOR_NAME: cli automation
+ GIT_COMMITTER_EMAIL: noreply@github.com
+ GIT_AUTHOR_EMAIL: noreply@github.com
+ working-directory: ./site
+ run: |
+ git add packages
+ git commit -m "Add rpm and deb packages for $TAG_NAME"
+ if [ "$DO_PUBLISH" = "true" ]; then
+ git push
+ else
+ git log --oneline @{upstream}..
+ git diff --name-status @{upstream}..
+ fi
+ - name: Bump homebrew-core formula
+ uses: mislav/bump-homebrew-formula-action@v3
+ if: inputs.environment == 'production' && !contains(inputs.tag_name, '-')
+ with:
+ formula-name: gh
+ formula-path: Formula/g/gh.rb
+ tag-name: ${{ inputs.tag_name }}
+ push-to: williammartin/homebrew-core
+ env:
+ COMMITTER_TOKEN: ${{ secrets.HOMEBREW_PR_PAT }}
+```
+
+
+The following sections are not strictly in the same order as the workflow but intended to bucket the different responsibilities.
+
+### Site Manual
+
+A git commit is created in the `cli.github.com` site repository containing the contents of the CLI Manual uploaded by the [`linux`](#linux) job. This is not pushed until the package repository artifacts are set up later.
+
+### Site Package Repositories
+
+The `cli.github.com` website hosts RPM and Debian package repositories to support the [official sources installation instructions](https://github.com/cli/cli/blob/trunk/docs/install_linux.md#official-sources). In order to provide a secure installation method, artifacts in these repositories are signed by a GPG key, which must be loaded into `gpg` for use in later steps. Comments have been added to provide clarity to the script:
+
+```sh
+# Import the public and private keys into gpg non-interactively
+base64 -d <<<"$GPG_PUBKEY" | gpg --import --no-tty --batch --yes
+base64 -d <<<"$GPG_KEY" | gpg --import --no-tty --batch --yes
+# Configure gpg so that passphrases can be preset, so that they don't
+# have to be provided on every future operation.
+echo "allow-preset-passphrase" > ~/.gnupg/gpg-agent.conf
+# Inform gpg that it should reload the configuration to apply the previous step
+gpg-connect-agent RELOADAGENT /bye
+# Store the passphrase for a specific key (referenced by keygrip) in memory.
+/usr/lib/gnupg2/gpg-preset-passphrase --preset "$GPG_KEYGRIP" <<<"$GPG_PASSPHRASE"
+```
+
+#### RPM
+
+The `.rpm` files uploaded by the [`linux`](#linux) job are signed using [`rpmsign`](https://man7.org/linux/man-pages/man8/rpmsign.8.html). The [`createrepo`](https://linux.die.net/man/8/createrepo) tool is used to generate a `repomd.xml` metadata file which describes the contents of a Red Hat repository. The artifacts and `repomd.xml` file are then copied into the site repository, and the `repomd.xml` is signed using `gpg --yes --detach-sign --armor repodata/repomd.xml`, producing a signature file. Since there is only one private key imported into `gpg`, that key is used for the signing.
+
+> [!WARNING]
+> The `createrepo` tool is executed inside a [Docker container](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/script/createrepo.sh) for [package management reasons](https://github.com/cli/cli/pull/2856) that may no longer be true.
+
+#### Debian
+
+The `.deb` files uploaded by the [`linux`](#linux) job are iterated per Debian release (see warning below), using [`reprepro`](https://manpages.debian.org/bookworm/reprepro/reprepro.1.en.html) which produces a directory and file structure for a Debian package repository. The [`./script/distributions`](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/script/distributions) `SignWith` lines indicate the GPG Key ID that `reprepro` should use to sign packages in the created repository. The generated directories are then copied to the site repository.
+
+> [!WARNING]
+> There is a note that we should remove [legacy distributions from our list](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.github/workflows/deployment.yml#L329-L332) but no indication of when that would happen.
+
+### Attest Artifacts
+
+[Attestations](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations/using-artifact-attestations-to-establish-provenance-for-builds) are created for each of the release artifacts. For an example see: https://github.com/cli/cli/attestations/4920729
+
+### Publish Release
+
+After all release artifacts have been created, and signed, there are a number of steps taken to make them available to our users.
+
+#### GitHub Release
+
+`gh release create` is invoked to create a new release on GitHub, attaching all the archives, packages and installers, plus a checksum file to allow `gh` users to validate the attached artifacts. The artifact file names are provided to `gh release create` along with a label, as per the command `--help`:
+
+```
+Upload a release asset with a display label
+$ gh release create v1.2.3 '/path/to/asset.zip#My display label'
+```
+
+> [!NOTE]
+> It's unclear why human readable display labels were used, beyond comments that it was intentional
+> https://github.com/cli/cli/pull/7324
+> https://github.com/cli/cli/issues/7470#issuecomment-1556986607
+
+#### Site
+
+In previous steps, a git commit was made for the manual, and files had moved into place for the RPM and Debian package repositories. The package repository structure is committed and pushed, which kicks off a deployment workflow in site repository.
+
+Occasionally, the repository can become unwieldy due to hosting so many large binary artifacts. Instructions can be found in the README for that repository.
+
+#### Homebrew Formula
+
+Using [`mislav/bump-homebrew-formula-action`](https://github.com/mislav/bump-homebrew-formula-action), a PR for the `gh` [`homebrew-core` formula](https://github.com/Homebrew/homebrew-core/blob/master/Formula/g/gh.rb) is created. The fork repository is currently owned by `williammartin` as PRs are [not accepted from organizations.](https://github.com/cli/cli/pull/7953)
+
+`Homebrew/formulae.brew.sh` makes new formula versions available every 15 minutes through scheduled CI workflow. For more information, see https://docs.brew.sh/Formula-Cookbook#an-introduction
+
+## Deepest Dive
+
+### How script/release works
+
+[`./script/release`](https://github.com/cli/cli/blob/817eeb26e567de11007c8a82c25e61c7e20e4337/script/release) is used by `gh` maintainers to [create a new release](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/docs/releasing.md). When invoked it executes `gh workflow run` in order to kick off the workflow described in detail above. However, that workflow also calls back into `./script/release` with the `--local` flag resulting in release artifacts being created on the machine invoking it. Each OS specific job in the workflow additionally provides the `--platform` flag.
+
+The surprising behaviour in `./script/release` is that it uses `sed` to modify the base [`.goreleaser.yml` ](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.goreleaser.yml) file, so that only platform specific sections are retained. For example, in the case of of `linux` only the [`linux` build](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.goreleaser.yml#L27) and [`npmfs`](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.goreleaser.yml#L78) section would be configured for `GoReleaser`. The `archive` sections are addressed by [requirements](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.goreleaser.yml#L52) on previous platform builds.
+
+Each build entry in [`.goreleaser.yml` ](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.goreleaser.yml) specifies the platforms that are supported, for example:
+
+```yml
+ - id: linux #build:linux
+ goos: [linux]
+ goarch: [386, arm, amd64, arm64]
+```
+
+### How script/pkgmacos works
+
+[./script/pkgmacos](https://github.com/cli/cli/blob/817eeb26e567de11007c8a82c25e61c7e20e4337/script/pkgmacos) is used by the [macos job](#macos) to create a `.pkg` installer. It uses three main utilities:
+ * [`lipo`](https://developer.apple.com/documentation/apple-silicon/building-a-universal-macos-binary) to combine the `arm64` and `amd64` binaries into one
+ * [`pkgbuild`](https://www.unix.com/man_page/osx/1/pkgbuild/) to build a "component package", which is the payload to be installed by a MacOS installer. For `gh`, this is `com.github.cli.pkg`. The contents of this package is the universal binary, zsh completions and man pages.
+ * [`productbuild`](https://www.unix.com/man_page/osx/1/productbuild/) creates a "product archive" which is used by the MacOS installer. In addition to the "component package", a product archive can contain customized installation elements. For `gh`, we include a `LICENSE` file. We include a [`distribution.xml`](https://github.com/cli/cli/blob/trunk/build/macOS/distribution.xml) file in our repo. which` productbuild` uses.
+
+A good explanation of the difference between `pkgbuild` and `productbuild` can be found on [this Stackoverflow answer](https://stackoverflow.com/questions/74422992/what-is-the-difference-between-pkgbuild-vs-productbuild).
diff --git a/docs/releasing.md b/docs/releasing.md
index 5e3e48a98..b424266d4 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -1,5 +1,7 @@
# Releasing
+To read about what happens during a production deployment, see the [release process deep dive doc](release-process-deep-dive.md).
+
To initiate a new production deployment:
```sh
diff --git a/go.mod b/go.mod
index 4e88a54d5..9d1afbc38 100644
--- a/go.mod
+++ b/go.mod
@@ -43,7 +43,7 @@ require (
github.com/sigstore/protobuf-specs v0.3.3
github.com/sigstore/sigstore-go v0.7.0
github.com/spf13/cobra v1.8.1
- github.com/spf13/pflag v1.0.5
+ 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
@@ -79,7 +79,7 @@ require (
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gdamore/encoding v1.0.0 // indirect
github.com/go-chi/chi v4.1.2+incompatible // indirect
- github.com/go-jose/go-jose/v4 v4.0.2 // indirect
+ github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/analysis v0.23.0 // indirect
diff --git a/go.sum b/go.sum
index d58e8de5b..2c14dc4c3 100644
--- a/go.sum
+++ b/go.sum
@@ -165,8 +165,8 @@ github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyN
github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k=
github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
-github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
-github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
+github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
+github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -433,8 +433,9 @@ github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
-github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
+github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
diff --git a/pkg/cmd/actions/actions.go b/pkg/cmd/actions/actions.go
index 663866844..76d72b954 100644
--- a/pkg/cmd/actions/actions.go
+++ b/pkg/cmd/actions/actions.go
@@ -34,19 +34,19 @@ func actionsExplainer(cs *iostreams.ColorScheme) string {
GitHub CLI integrates with GitHub Actions to help you manage runs and workflows.
%[3]s
- gh run list: List recent workflow runs
- gh run view: View details for a workflow run or one of its jobs
- gh run watch: Watch a workflow run while it executes
- gh run rerun: Rerun a failed workflow run
+ gh run list: List recent workflow runs
+ gh run view: View details for a workflow run or one of its jobs
+ gh run watch: Watch a workflow run while it executes
+ gh run rerun: Rerun a failed workflow run
gh run download: Download artifacts generated by runs
To see more help, run %[1]sgh help run %[1]s
%[4]s
gh workflow list: List workflow files in your repository
- gh workflow view: View details for a workflow file
- gh workflow enable: Enable a workflow file
- gh workflow disable: Disable a workflow file
+ gh workflow view: View details for a workflow file
+ gh workflow enable: Enable a workflow file
+ gh workflow disable: Disable a workflow file
gh workflow run: Trigger a workflow_dispatch run for a workflow file
To see more help, run %[1]sgh help workflow %[1]s
diff --git a/pkg/cmd/alias/set/set.go b/pkg/cmd/alias/set/set.go
index c09f7c0cc..6d4e21422 100644
--- a/pkg/cmd/alias/set/set.go
+++ b/pkg/cmd/alias/set/set.go
@@ -51,7 +51,7 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
invoked. This allows for chaining multiple commands via piping and redirection.
`, "`"),
Example: heredoc.Doc(`
- # note: Command Prompt on Windows requires using double quotes for arguments
+ # Note: Command Prompt on Windows requires using double quotes for arguments
$ gh alias set pv 'pr view'
$ gh pv -w 123 #=> gh pr view -w 123
diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go
index a1308d09f..d25b4ce64 100644
--- a/pkg/cmd/api/api.go
+++ b/pkg/cmd/api/api.go
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
+ "net/url"
"os"
"path/filepath"
"regexp"
@@ -124,39 +125,39 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
into an outer JSON array.
`, "`"),
Example: heredoc.Doc(`
- # list releases in the current repository
+ # List releases in the current repository
$ gh api repos/{owner}/{repo}/releases
- # post an issue comment
+ # Post an issue comment
$ gh api repos/{owner}/{repo}/issues/123/comments -f body='Hi from CLI'
- # post nested parameter read from a file
+ # Post nested parameter read from a file
$ gh api gists -F 'files[myfile.txt][content]=@myfile.txt'
- # add parameters to a GET request
+ # Add parameters to a GET request
$ gh api -X GET search/issues -f q='repo:cli/cli is:open remote'
- # set a custom HTTP header
+ # Set a custom HTTP header
$ gh api -H 'Accept: application/vnd.github.v3.raw+json' ...
- # opt into GitHub API previews
+ # Opt into GitHub API previews
$ gh api --preview baptiste,nebula ...
- # print only specific fields from the response
+ # Print only specific fields from the response
$ gh api repos/{owner}/{repo}/issues --jq '.[].title'
- # use a template for the output
+ # Use a template for the output
$ gh api repos/{owner}/{repo}/issues --template \
'{{range .}}{{.title}} ({{.labels | pluck "name" | join ", " | color "yellow"}}){{"\n"}}{{end}}'
- # update allowed values of the "environment" custom property in a deeply nested array
- gh api -X PATCH /orgs/{org}/properties/schema \
+ # Update allowed values of the "environment" custom property in a deeply nested array
+ $ gh api -X PATCH /orgs/{org}/properties/schema \
-F 'properties[][property_name]=environment' \
-F 'properties[][default_value]=production' \
-F 'properties[][allowed_values][]=staging' \
-F 'properties[][allowed_values][]=production'
- # list releases with GraphQL
+ # List releases with GraphQL
$ gh api graphql -F owner='{owner}' -F name='{repo}' -f query='
query($name: String!, $owner: String!) {
repository(owner: $owner, name: $name) {
@@ -167,7 +168,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
}
'
- # list all repositories for a user
+ # List all repositories for a user
$ gh api graphql --paginate -f query='
query($endCursor: String) {
viewer {
@@ -182,7 +183,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
}
'
- # get the percentage of forks for the current user
+ # Get the percentage of forks for the current user
$ gh api graphql --paginate --slurp -f query='
query($endCursor: String) {
viewer {
@@ -201,12 +202,12 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
Annotations: map[string]string{
"help:environment": heredoc.Doc(`
GH_TOKEN, GITHUB_TOKEN (in order of precedence): an authentication token for
- github.com API requests.
+ API requests.
GH_ENTERPRISE_TOKEN, GITHUB_ENTERPRISE_TOKEN (in order of precedence): an
authentication token for API requests to GitHub Enterprise.
- GH_HOST: make the request to a GitHub host other than github.com.
+ GH_HOST: make the request to a GitHub host other than .
`),
},
Args: cobra.ExactArgs(1),
@@ -264,6 +265,8 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
return err
}
+ opts.RequestPath = escapePackageNameInPath(opts.RequestPath)
+
if runF != nil {
return runF(&opts)
}
@@ -691,3 +694,37 @@ 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 321f7b7c0..ee58d55d0 100644
--- a/pkg/cmd/api/api_test.go
+++ b/pkg/cmd/api/api_test.go
@@ -367,6 +367,72 @@ 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/attestation/api/mock_httpClient_test.go b/pkg/cmd/attestation/api/mock_httpClient_test.go
index 26933ae2e..b082d13d2 100644
--- a/pkg/cmd/attestation/api/mock_httpClient_test.go
+++ b/pkg/cmd/attestation/api/mock_httpClient_test.go
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
+ "sync"
"github.com/cli/cli/v2/pkg/cmd/attestation/test/data"
"github.com/golang/snappy"
@@ -58,12 +59,16 @@ func (m *reqFailHttpClient) Get(url string) (*http.Response, error) {
type failAfterNCallsHttpClient struct {
mock.Mock
+ mu sync.Mutex
FailOnCallN int
FailOnAllSubsequentCalls bool
NumCalls int
}
func (m *failAfterNCallsHttpClient) Get(url string) (*http.Response, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
m.On("OnGetFailAfterNCalls").Return()
m.NumCalls++
diff --git a/pkg/cmd/attestation/download/download.go b/pkg/cmd/attestation/download/download.go
index cdbdc0078..6913c0787 100644
--- a/pkg/cmd/attestation/download/download.go
+++ b/pkg/cmd/attestation/download/download.go
@@ -107,7 +107,7 @@ func NewDownloadCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Comman
},
}
- downloadCmd.Flags().StringVarP(&opts.Owner, "owner", "o", "", "a GitHub organization to scope attestation lookup by")
+ downloadCmd.Flags().StringVarP(&opts.Owner, "owner", "o", "", "GitHub organization to scope attestation lookup by")
downloadCmd.Flags().StringVarP(&opts.Repo, "repo", "R", "", "Repository name in the format /")
downloadCmd.MarkFlagsMutuallyExclusive("owner", "repo")
downloadCmd.MarkFlagsOneRequired("owner", "repo")
diff --git a/pkg/cmd/attestation/inspect/inspect.go b/pkg/cmd/attestation/inspect/inspect.go
index b2d0adfb7..6fbddd6da 100644
--- a/pkg/cmd/attestation/inspect/inspect.go
+++ b/pkg/cmd/attestation/inspect/inspect.go
@@ -47,7 +47,7 @@ func NewInspectCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
timestamps, and if the included signatures match the provided public key.
This command cannot be used to verify a bundle. To verify a bundle, see the
- %[1]sgh at verify%[1]s command.
+ %[1]sgh at verify%[1]s command.
By default, this command prints a condensed table. To see full results, provide the
%[1]s--format=json%[1]s flag.
diff --git a/pkg/cmd/attestation/trustedroot/trustedroot.go b/pkg/cmd/attestation/trustedroot/trustedroot.go
index 1a40aae3f..4e55e27ab 100644
--- a/pkg/cmd/attestation/trustedroot/trustedroot.go
+++ b/pkg/cmd/attestation/trustedroot/trustedroot.go
@@ -11,6 +11,7 @@ import (
"github.com/cli/cli/v2/pkg/cmd/attestation/io"
"github.com/cli/cli/v2/pkg/cmd/attestation/verification"
"github.com/cli/cli/v2/pkg/cmdutil"
+ o "github.com/cli/cli/v2/pkg/option"
ghauth "github.com/cli/go-gh/v2/pkg/auth"
"github.com/MakeNowJust/heredoc"
@@ -56,7 +57,7 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com
`, "`"),
Example: heredoc.Doc(`
# Get a trusted_root.jsonl for both Sigstore Public Good and GitHub's instance
- gh attestation trusted-root
+ $ gh attestation trusted-root
`),
RunE: func(cmd *cobra.Command, args []string) error {
if opts.Hostname == "" {
@@ -121,7 +122,7 @@ func getTrustedRoot(makeTUF tufClientInstantiator, opts *Options) error {
var tufOptions []tufConfig
var defaultTR = "trusted_root.json"
- tufOpt := verification.DefaultOptionsWithCacheSetting()
+ tufOpt := verification.DefaultOptionsWithCacheSetting(o.None[string]())
// Disable local caching, so we get up-to-date response from TUF repository
tufOpt.CacheValidity = 0
@@ -150,7 +151,7 @@ func getTrustedRoot(makeTUF tufClientInstantiator, opts *Options) error {
targets: []string{defaultTR},
})
- tufOpt = verification.GitHubTUFOptions()
+ tufOpt = verification.GitHubTUFOptions(o.None[string]())
tufOpt.CacheValidity = 0
tufOptions = append(tufOptions, tufConfig{
tufOptions: tufOpt,
diff --git a/pkg/cmd/attestation/verification/extensions.go b/pkg/cmd/attestation/verification/extensions.go
index 2958408d0..3ac9ac0a0 100644
--- a/pkg/cmd/attestation/verification/extensions.go
+++ b/pkg/cmd/attestation/verification/extensions.go
@@ -59,5 +59,15 @@ func verifyCertExtensions(given, expected certificate.Summary) error {
return fmt.Errorf("expected Issuer to be %s, got %s", expected.Issuer, given.Issuer)
}
+ if expected.BuildSignerDigest != "" && !strings.EqualFold(expected.BuildSignerDigest, given.BuildSignerDigest) {
+ return fmt.Errorf("expected BuildSignerDigest to be %s, got %s", expected.BuildSignerDigest, given.BuildSignerDigest)
+ }
+ if expected.SourceRepositoryDigest != "" && !strings.EqualFold(expected.SourceRepositoryDigest, given.SourceRepositoryDigest) {
+ return fmt.Errorf("expected SourceRepositoryDigest to be %s, got %s", expected.SourceRepositoryDigest, given.SourceRepositoryDigest)
+ }
+ if expected.SourceRepositoryRef != "" && !strings.EqualFold(expected.SourceRepositoryRef, given.SourceRepositoryRef) {
+ return fmt.Errorf("expected SourceRepositoryRef to be %s, got %s", expected.SourceRepositoryRef, given.SourceRepositoryRef)
+ }
+
return nil
}
diff --git a/pkg/cmd/attestation/verification/policy.go b/pkg/cmd/attestation/verification/policy.go
index f2bd126d0..284560466 100644
--- a/pkg/cmd/attestation/verification/policy.go
+++ b/pkg/cmd/attestation/verification/policy.go
@@ -52,7 +52,7 @@ func (c EnforcementCriteria) Valid() error {
}
func (c EnforcementCriteria) BuildPolicyInformation() string {
- policyAttr := make([][]string, 0, 6)
+ policyAttr := [][]string{}
policyAttr = appendStr(policyAttr, "- Predicate type must match", c.PredicateType)
@@ -62,6 +62,16 @@ func (c EnforcementCriteria) BuildPolicyInformation() string {
policyAttr = appendStr(policyAttr, "- Source Repository URI must match", c.Certificate.SourceRepositoryURI)
}
+ if c.Certificate.BuildSignerDigest != "" {
+ policyAttr = appendStr(policyAttr, "- Build signer digest must match", c.Certificate.BuildSignerDigest)
+ }
+ if c.Certificate.SourceRepositoryDigest != "" {
+ policyAttr = appendStr(policyAttr, "- Source repo digest digest must match", c.Certificate.SourceRepositoryDigest)
+ }
+ if c.Certificate.SourceRepositoryRef != "" {
+ policyAttr = appendStr(policyAttr, "- Source repo ref must match", c.Certificate.SourceRepositoryRef)
+ }
+
if c.SAN != "" {
policyAttr = appendStr(policyAttr, "- Subject Alternative Name must match", c.SAN)
} else if c.SANRegex != "" {
diff --git a/pkg/cmd/attestation/verification/sigstore.go b/pkg/cmd/attestation/verification/sigstore.go
index c71c600c7..6dd31dac0 100644
--- a/pkg/cmd/attestation/verification/sigstore.go
+++ b/pkg/cmd/attestation/verification/sigstore.go
@@ -10,6 +10,7 @@ import (
"github.com/cli/cli/v2/pkg/cmd/attestation/api"
"github.com/cli/cli/v2/pkg/cmd/attestation/io"
+ o "github.com/cli/cli/v2/pkg/option"
"github.com/sigstore/sigstore-go/pkg/bundle"
"github.com/sigstore/sigstore-go/pkg/root"
@@ -34,6 +35,8 @@ type SigstoreConfig struct {
NoPublicGood bool
// If tenancy mode is not used, trust domain is empty
TrustDomain string
+ // TUFMetadataDir
+ TUFMetadataDir o.Option[string]
}
type SigstoreVerifier interface {
@@ -45,7 +48,8 @@ type LiveSigstoreVerifier struct {
Logger *io.Handler
NoPublicGood bool
// If tenancy mode is not used, trust domain is empty
- TrustDomain string
+ TrustDomain string
+ TUFMetadataDir o.Option[string]
}
var ErrNoAttestationsVerified = errors.New("no attestations were verified")
@@ -55,10 +59,11 @@ var ErrNoAttestationsVerified = errors.New("no attestations were verified")
// Public Good, GitHub, or a custom trusted root.
func NewLiveSigstoreVerifier(config SigstoreConfig) *LiveSigstoreVerifier {
return &LiveSigstoreVerifier{
- TrustedRoot: config.TrustedRoot,
- Logger: config.Logger,
- NoPublicGood: config.NoPublicGood,
- TrustDomain: config.TrustDomain,
+ TrustedRoot: config.TrustedRoot,
+ Logger: config.Logger,
+ NoPublicGood: config.NoPublicGood,
+ TrustDomain: config.TrustDomain,
+ TUFMetadataDir: config.TUFMetadataDir,
}
}
@@ -89,9 +94,9 @@ func (v *LiveSigstoreVerifier) chooseVerifier(issuer string) (*verify.SignedEnti
if v.NoPublicGood {
return nil, fmt.Errorf("detected public good instance but requested verification without public good instance")
}
- return newPublicGoodVerifier()
+ return newPublicGoodVerifier(v.TUFMetadataDir)
case GitHubIssuerOrg:
- return newGitHubVerifier(v.TrustDomain)
+ return newGitHubVerifier(v.TrustDomain, v.TUFMetadataDir)
default:
return nil, fmt.Errorf("leaf certificate issuer is not recognized")
}
@@ -255,10 +260,10 @@ func newCustomVerifier(trustedRoot *root.TrustedRoot) (*verify.SignedEntityVerif
return gv, nil
}
-func newGitHubVerifier(trustDomain string) (*verify.SignedEntityVerifier, error) {
+func newGitHubVerifier(trustDomain string, tufMetadataDir o.Option[string]) (*verify.SignedEntityVerifier, error) {
var tr string
- opts := GitHubTUFOptions()
+ opts := GitHubTUFOptions(tufMetadataDir)
client, err := tuf.New(opts)
if err != nil {
return nil, fmt.Errorf("failed to create TUF client: %v", err)
@@ -289,8 +294,8 @@ func newGitHubVerifierWithTrustedRoot(trustedRoot *root.TrustedRoot) (*verify.Si
return gv, nil
}
-func newPublicGoodVerifier() (*verify.SignedEntityVerifier, error) {
- opts := DefaultOptionsWithCacheSetting()
+func newPublicGoodVerifier(tufMetadataDir o.Option[string]) (*verify.SignedEntityVerifier, error) {
+ opts := DefaultOptionsWithCacheSetting(tufMetadataDir)
client, err := tuf.New(opts)
if err != nil {
return nil, fmt.Errorf("failed to create TUF client: %v", err)
diff --git a/pkg/cmd/attestation/verification/sigstore_integration_test.go b/pkg/cmd/attestation/verification/sigstore_integration_test.go
index e14b472b0..987fb9caa 100644
--- a/pkg/cmd/attestation/verification/sigstore_integration_test.go
+++ b/pkg/cmd/attestation/verification/sigstore_integration_test.go
@@ -9,6 +9,7 @@ import (
"github.com/cli/cli/v2/pkg/cmd/attestation/artifact"
"github.com/cli/cli/v2/pkg/cmd/attestation/io"
"github.com/cli/cli/v2/pkg/cmd/attestation/test"
+ o "github.com/cli/cli/v2/pkg/option"
"github.com/sigstore/sigstore-go/pkg/verify"
"github.com/stretchr/testify/require"
@@ -48,25 +49,29 @@ func TestLiveSigstoreVerifier(t *testing.T) {
}
for _, tc := range testcases {
- verifier := NewLiveSigstoreVerifier(SigstoreConfig{
- Logger: io.NewTestHandler(),
+ t.Run(tc.name, func(t *testing.T) {
+ verifier := NewLiveSigstoreVerifier(SigstoreConfig{
+ Logger: io.NewTestHandler(),
+ TUFMetadataDir: o.Some(t.TempDir()),
+ })
+
+ results, err := verifier.Verify(tc.attestations, publicGoodPolicy(t))
+
+ if tc.expectErr {
+ require.Error(t, err)
+ require.ErrorContains(t, err, tc.errContains)
+ require.Nil(t, results)
+ } else {
+ require.NoError(t, err)
+ require.Equal(t, len(tc.attestations), len(results))
+ }
})
-
- results, err := verifier.Verify(tc.attestations, publicGoodPolicy(t))
-
- if tc.expectErr {
- require.Error(t, err, "test case: %s", tc.name)
- require.ErrorContains(t, err, tc.errContains, "test case: %s", tc.name)
- require.Nil(t, results, "test case: %s", tc.name)
- } else {
- require.Equal(t, len(tc.attestations), len(results), "test case: %s", tc.name)
- require.NoError(t, err, "test case: %s", tc.name)
- }
}
t.Run("with 2/3 verified attestations", func(t *testing.T) {
verifier := NewLiveSigstoreVerifier(SigstoreConfig{
- Logger: io.NewTestHandler(),
+ Logger: io.NewTestHandler(),
+ TUFMetadataDir: o.Some(t.TempDir()),
})
invalidBundle := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0-bundle-v0.1.json")
@@ -82,7 +87,8 @@ func TestLiveSigstoreVerifier(t *testing.T) {
t.Run("fail with 0/2 verified attestations", func(t *testing.T) {
verifier := NewLiveSigstoreVerifier(SigstoreConfig{
- Logger: io.NewTestHandler(),
+ Logger: io.NewTestHandler(),
+ TUFMetadataDir: o.Some(t.TempDir()),
})
invalidBundle := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0-bundle-v0.1.json")
@@ -105,7 +111,8 @@ func TestLiveSigstoreVerifier(t *testing.T) {
attestations := getAttestationsFor(t, "../test/data/github_provenance_demo-0.0.12-py3-none-any-bundle.jsonl")
verifier := NewLiveSigstoreVerifier(SigstoreConfig{
- Logger: io.NewTestHandler(),
+ Logger: io.NewTestHandler(),
+ TUFMetadataDir: o.Some(t.TempDir()),
})
results, err := verifier.Verify(attestations, githubPolicy)
@@ -117,8 +124,9 @@ func TestLiveSigstoreVerifier(t *testing.T) {
attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl")
verifier := NewLiveSigstoreVerifier(SigstoreConfig{
- Logger: io.NewTestHandler(),
- TrustedRoot: test.NormalizeRelativePath("../test/data/trusted_root.json"),
+ Logger: io.NewTestHandler(),
+ TrustedRoot: test.NormalizeRelativePath("../test/data/trusted_root.json"),
+ TUFMetadataDir: o.Some(t.TempDir()),
})
results, err := verifier.Verify(attestations, publicGoodPolicy(t))
diff --git a/pkg/cmd/attestation/verification/tuf.go b/pkg/cmd/attestation/verification/tuf.go
index f46a483d8..dcfdd0b32 100644
--- a/pkg/cmd/attestation/verification/tuf.go
+++ b/pkg/cmd/attestation/verification/tuf.go
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
+ o "github.com/cli/cli/v2/pkg/option"
"github.com/cli/go-gh/v2/pkg/config"
"github.com/sigstore/sigstore-go/pkg/tuf"
)
@@ -14,7 +15,7 @@ var githubRoot []byte
const GitHubTUFMirror = "https://tuf-repo.github.com"
-func DefaultOptionsWithCacheSetting() *tuf.Options {
+func DefaultOptionsWithCacheSetting(tufMetadataDir o.Option[string]) *tuf.Options {
opts := tuf.DefaultOptions()
// The CODESPACES environment variable will be set to true in a Codespaces workspace
@@ -25,8 +26,8 @@ func DefaultOptionsWithCacheSetting() *tuf.Options {
opts.DisableLocalCache = true
}
- // Set the cache path to a directory owned by the CLI
- opts.CachePath = filepath.Join(config.CacheDir(), ".sigstore", "root")
+ // Set the cache path to the provided dir, or a directory owned by the CLI
+ opts.CachePath = tufMetadataDir.UnwrapOr(filepath.Join(config.CacheDir(), ".sigstore", "root"))
// Allow TUF cache for 1 day
opts.CacheValidity = 1
@@ -34,8 +35,8 @@ func DefaultOptionsWithCacheSetting() *tuf.Options {
return opts
}
-func GitHubTUFOptions() *tuf.Options {
- opts := DefaultOptionsWithCacheSetting()
+func GitHubTUFOptions(tufMetadataDir o.Option[string]) *tuf.Options {
+ opts := DefaultOptionsWithCacheSetting(tufMetadataDir)
opts.Root = githubRoot
opts.RepositoryBaseURL = GitHubTUFMirror
diff --git a/pkg/cmd/attestation/verification/tuf_test.go b/pkg/cmd/attestation/verification/tuf_test.go
index 7d816bf82..e8b6ecf98 100644
--- a/pkg/cmd/attestation/verification/tuf_test.go
+++ b/pkg/cmd/attestation/verification/tuf_test.go
@@ -5,16 +5,22 @@ import (
"path/filepath"
"testing"
+ o "github.com/cli/cli/v2/pkg/option"
"github.com/cli/go-gh/v2/pkg/config"
"github.com/stretchr/testify/require"
)
-func TestGitHubTUFOptions(t *testing.T) {
+func TestGitHubTUFOptionsNoMetadataDir(t *testing.T) {
os.Setenv("CODESPACES", "true")
- opts := GitHubTUFOptions()
+ opts := GitHubTUFOptions(o.None[string]())
require.Equal(t, GitHubTUFMirror, opts.RepositoryBaseURL)
require.NotNil(t, opts.Root)
require.True(t, opts.DisableLocalCache)
require.Equal(t, filepath.Join(config.CacheDir(), ".sigstore", "root"), opts.CachePath)
}
+
+func TestGitHubTUFOptionsWithMetadataDir(t *testing.T) {
+ opts := GitHubTUFOptions(o.Some("anything"))
+ require.Equal(t, "anything", opts.CachePath)
+}
diff --git a/pkg/cmd/attestation/verify/attestation_integration_test.go b/pkg/cmd/attestation/verify/attestation_integration_test.go
index 630eb1dc5..9ff174141 100644
--- a/pkg/cmd/attestation/verify/attestation_integration_test.go
+++ b/pkg/cmd/attestation/verify/attestation_integration_test.go
@@ -10,6 +10,7 @@ import (
"github.com/cli/cli/v2/pkg/cmd/attestation/io"
"github.com/cli/cli/v2/pkg/cmd/attestation/test"
"github.com/cli/cli/v2/pkg/cmd/attestation/verification"
+ o "github.com/cli/cli/v2/pkg/option"
"github.com/sigstore/sigstore-go/pkg/fulcio/certificate"
"github.com/stretchr/testify/require"
)
@@ -25,7 +26,8 @@ func getAttestationsFor(t *testing.T, bundlePath string) []*api.Attestation {
func TestVerifyAttestations(t *testing.T) {
sgVerifier := verification.NewLiveSigstoreVerifier(verification.SigstoreConfig{
- Logger: io.NewTestHandler(),
+ Logger: io.NewTestHandler(),
+ TUFMetadataDir: o.Some(t.TempDir()),
})
certSummary := certificate.Summary{}
diff --git a/pkg/cmd/attestation/verify/options.go b/pkg/cmd/attestation/verify/options.go
index 4296cb8ec..0fbbec55a 100644
--- a/pkg/cmd/attestation/verify/options.go
+++ b/pkg/cmd/attestation/verify/options.go
@@ -31,8 +31,11 @@ type Options struct {
Repo string
SAN string
SANRegex string
+ SignerDigest string
SignerRepo string
SignerWorkflow string
+ SourceDigest string
+ SourceRef string
APIClient api.Client
Logger *io.Handler
OCIClient oci.Client
diff --git a/pkg/cmd/attestation/verify/policy.go b/pkg/cmd/attestation/verify/policy.go
index 1e1dcd4d8..1060a781e 100644
--- a/pkg/cmd/attestation/verify/policy.go
+++ b/pkg/cmd/attestation/verify/policy.go
@@ -66,7 +66,7 @@ func newEnforcementCriteria(opts *Options) (verification.EnforcementCriteria, er
// then we default to the repo option
c.SANRegex = expandToGitHubURLRegex(opts.Tenant, opts.Repo)
} else {
- // if opts.Repo was not provided, we fallback to the opts.Owner value
+ // if opts.Repo was not provided, we fall back to the opts.Owner value
c.SANRegex = expandToGitHubURLRegex(opts.Tenant, owner)
}
@@ -98,6 +98,12 @@ func newEnforcementCriteria(opts *Options) (verification.EnforcementCriteria, er
c.Certificate.Issuer = opts.OIDCIssuer
}
+ // set the SourceRepositoryDigest, SourceRepositoryRef, and BuildSignerDigest
+ // extensions if the options are provided
+ c.Certificate.BuildSignerDigest = opts.SignerDigest
+ c.Certificate.SourceRepositoryDigest = opts.SourceDigest
+ c.Certificate.SourceRepositoryRef = opts.SourceRef
+
return c, nil
}
diff --git a/pkg/cmd/attestation/verify/policy_test.go b/pkg/cmd/attestation/verify/policy_test.go
index d033ba4fa..d376498b6 100644
--- a/pkg/cmd/attestation/verify/policy_test.go
+++ b/pkg/cmd/attestation/verify/policy_test.go
@@ -216,6 +216,48 @@ func TestNewEnforcementCriteria(t *testing.T) {
require.NoError(t, err)
require.Equal(t, "https://foo.com", c.Certificate.Issuer)
})
+
+ t.Run("sets Certificate.BuildSignerDigest using opts.SignerDigest", func(t *testing.T) {
+ opts := &Options{
+ ArtifactPath: artifactPath,
+ Owner: "wrong",
+ Repo: "wrong/value",
+ SignerDigest: "foo",
+ Hostname: "github.com",
+ }
+
+ c, err := newEnforcementCriteria(opts)
+ require.NoError(t, err)
+ require.Equal(t, "foo", c.Certificate.BuildSignerDigest)
+ })
+
+ t.Run("sets Certificate.SourceRepositoryDigest using opts.SourceDigest", func(t *testing.T) {
+ opts := &Options{
+ ArtifactPath: artifactPath,
+ Owner: "wrong",
+ Repo: "wrong/value",
+ SourceDigest: "foo",
+ Hostname: "github.com",
+ }
+
+ c, err := newEnforcementCriteria(opts)
+ require.NoError(t, err)
+ require.Equal(t, "foo", c.Certificate.SourceRepositoryDigest)
+ })
+
+ t.Run("sets Certificate.SourceRepositoryRef using opts.SourceRef", func(t *testing.T) {
+ opts := &Options{
+ ArtifactPath: artifactPath,
+ Owner: "wrong",
+ Repo: "wrong/value",
+ SourceRef: "refs/heads/main",
+ Hostname: "github.com",
+ }
+
+ c, err := newEnforcementCriteria(opts)
+ require.NoError(t, err)
+ require.Equal(t, "refs/heads/main", c.Certificate.SourceRepositoryRef)
+ })
}
func TestValidateSignerWorkflow(t *testing.T) {
diff --git a/pkg/cmd/attestation/verify/verify.go b/pkg/cmd/attestation/verify/verify.go
index 0a8de8b45..65ae8ca3e 100644
--- a/pkg/cmd/attestation/verify/verify.go
+++ b/pkg/cmd/attestation/verify/verify.go
@@ -195,6 +195,9 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
verifyCmd.MarkFlagsMutuallyExclusive("cert-identity", "cert-identity-regex", "signer-repo", "signer-workflow")
verifyCmd.Flags().StringVarP(&opts.OIDCIssuer, "cert-oidc-issuer", "", verification.GitHubOIDCIssuer, "Issuer of the OIDC token")
verifyCmd.Flags().StringVarP(&opts.Hostname, "hostname", "", "", "Configure host to use")
+ verifyCmd.Flags().StringVarP(&opts.SignerDigest, "signer-digest", "", "", "Digest associated with the signer workflow")
+ verifyCmd.Flags().StringVarP(&opts.SourceRef, "source-ref", "", "", "Ref associated with the source workflow")
+ verifyCmd.Flags().StringVarP(&opts.SourceDigest, "source-digest", "", "", "Digest associated with the source workflow")
return verifyCmd
}
diff --git a/pkg/cmd/attestation/verify/verify_integration_test.go b/pkg/cmd/attestation/verify/verify_integration_test.go
index f25055d22..09479995c 100644
--- a/pkg/cmd/attestation/verify/verify_integration_test.go
+++ b/pkg/cmd/attestation/verify/verify_integration_test.go
@@ -11,6 +11,7 @@ import (
"github.com/cli/cli/v2/pkg/cmd/attestation/test"
"github.com/cli/cli/v2/pkg/cmd/attestation/verification"
"github.com/cli/cli/v2/pkg/cmd/factory"
+ o "github.com/cli/cli/v2/pkg/option"
"github.com/cli/go-gh/v2/pkg/auth"
"github.com/stretchr/testify/require"
)
@@ -19,7 +20,8 @@ func TestVerifyIntegration(t *testing.T) {
logger := io.NewTestHandler()
sigstoreConfig := verification.SigstoreConfig{
- Logger: logger,
+ Logger: logger,
+ TUFMetadataDir: o.Some(t.TempDir()),
}
cmdFactory := factory.New("test")
@@ -130,7 +132,8 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) {
logger := io.NewTestHandler()
sigstoreConfig := verification.SigstoreConfig{
- Logger: logger,
+ Logger: logger,
+ TUFMetadataDir: o.Some(t.TempDir()),
}
cmdFactory := factory.New("test")
@@ -200,7 +203,8 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) {
logger := io.NewTestHandler()
sigstoreConfig := verification.SigstoreConfig{
- Logger: logger,
+ Logger: logger,
+ TUFMetadataDir: o.Some(t.TempDir()),
}
cmdFactory := factory.New("test")
@@ -289,7 +293,8 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) {
logger := io.NewTestHandler()
sigstoreConfig := verification.SigstoreConfig{
- Logger: logger,
+ Logger: logger,
+ TUFMetadataDir: o.Some(t.TempDir()),
}
cmdFactory := factory.New("test")
diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go
index 347fcdb6a..40b2fb382 100644
--- a/pkg/cmd/auth/login/login.go
+++ b/pkg/cmd/auth/login/login.go
@@ -72,7 +72,7 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm
The minimum required scopes for the token are: %[1]srepo%[1]s, %[1]sread:org%[1]s, and %[1]sgist%[1]s.
Take care when passing a fine-grained personal access token to %[1]s--with-token%[1]s
as the inherent scoping to certain resources may cause confusing behaviour when interacting with other
- resources. Favour setting %[1]sGH_TOKEN$%[1]s for fine-grained personal access token usage.
+ resources. Favour setting %[1]sGH_TOKEN%[1]s for fine-grained personal access token usage.
Alternatively, gh will use the authentication token found in environment variables.
This method is most suitable for "headless" use of gh such as in automation. See
@@ -88,13 +88,14 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm
prompting to create and upload a new key if one is not found. This can be skipped with
%[1]s--skip-ssh-key%[1]s flag.
- For more information on OAuth scopes, .
+ For more information on OAuth scopes, see
+ .
`, "`"),
Example: heredoc.Doc(`
# Start interactive setup
$ gh auth login
- # Authenticate against github.com by reading the token from a file
+ # Authenticate against by reading the token from a file
$ gh auth login --with-token < mytoken.txt
# Authenticate with specific host
diff --git a/pkg/cmd/auth/logout/logout.go b/pkg/cmd/auth/logout/logout.go
index ef978ebac..dd908a62d 100644
--- a/pkg/cmd/auth/logout/logout.go
+++ b/pkg/cmd/auth/logout/logout.go
@@ -39,7 +39,20 @@ func NewCmdLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobra.Co
for an account. The authentication configuration is only
removed locally.
- This command does not invalidate authentication tokens.
+ This command does not revoke authentication tokens.
+
+ To revoke all authentication tokens generated by the GitHub CLI:
+
+ 1. Visit
+ 2. Select the "GitHub CLI" application
+ 3. Select "Revoke Access"
+ 4. Select "I understand, revoke access"
+
+ Note: this procedure will revoke all authentication tokens ever
+ generated by the GitHub CLI across all your devices.
+
+ For more information about revoking OAuth application tokens, see:
+
`),
Example: heredoc.Doc(`
# Select what host and account to log out of via a prompt
diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go
index 7a6a39ff3..c0050b8a3 100644
--- a/pkg/cmd/auth/refresh/refresh.go
+++ b/pkg/cmd/auth/refresh/refresh.go
@@ -74,20 +74,21 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.
inactive account, you will have to use %[1]sgh auth switch%[1]s to that account first before using
this command, and then switch back when you are done.
- For more information on OAuth scopes, .
+ For more information on OAuth scopes, see
+ .
`, "`"),
Example: heredoc.Doc(`
+ # Open a browser to add write:org and read:public_key scopes
$ gh auth refresh --scopes write:org,read:public_key
- # => open a browser to add write:org and read:public_key scopes
+ # Open a browser to ensure your authentication credentials have the correct minimum scopes
$ gh auth refresh
- # => open a browser to ensure your authentication credentials have the correct minimum scopes
+ # Open a browser to idempotently remove the delete_repo scope
$ gh auth refresh --remove-scopes delete_repo
- # => open a browser to idempotently remove the delete_repo scope
+ # Open a browser to re-authenticate with the default minimum scopes
$ gh auth refresh --reset-scopes
- # => open a browser to re-authenticate with the default minimum scopes
`),
RunE: func(cmd *cobra.Command, args []string) error {
opts.Interactive = opts.IO.CanPrompt()
diff --git a/pkg/cmd/browse/browse.go b/pkg/cmd/browse/browse.go
index f4e3d8fec..68c97526a 100644
--- a/pkg/cmd/browse/browse.go
+++ b/pkg/cmd/browse/browse.go
@@ -69,32 +69,32 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co
- Repository home page
- Repository settings
`),
- Use: "browse [ | | ]",
+ Use: "browse [ | | ]",
Args: cobra.MaximumNArgs(1),
Example: heredoc.Doc(`
+ # Open the home page of the current repository
$ gh browse
- #=> Open the home page of the current repository
+ # Open the script directory of the current repository
$ gh browse script/
- #=> Open the script directory of the current repository
+ # Open issue or pull request 217
$ gh browse 217
- #=> Open issue or pull request 217
+ # Open commit page
$ gh browse 77507cd94ccafcf568f8560cfecde965fcfa63
- #=> Open commit page
+ # Open repository settings
$ gh browse --settings
- #=> Open repository settings
+ # Open main.go at line 312
$ gh browse main.go:312
- #=> Open main.go at line 312
+ # Open main.go with the repository at head of bug-fix branch
$ gh browse main.go --branch bug-fix
- #=> Open main.go with the repository at head of bug-fix branch
+ # Open main.go with the repository at commit 775007cd
$ gh browse main.go --commit=77507cd94ccafcf568f8560cfecde965fcfa63
- #=> Open main.go with the repository at commit 775007cd
`),
Annotations: map[string]string{
"help:arguments": heredoc.Doc(`
diff --git a/pkg/cmd/cache/delete/delete.go b/pkg/cmd/cache/delete/delete.go
index 65a9d696a..ab76368ad 100644
--- a/pkg/cmd/cache/delete/delete.go
+++ b/pkg/cmd/cache/delete/delete.go
@@ -22,8 +22,9 @@ type DeleteOptions struct {
HttpClient func() (*http.Client, error)
IO *iostreams.IOStreams
- DeleteAll bool
- Identifier string
+ DeleteAll bool
+ SucceedOnNoCaches bool
+ Identifier string
}
func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command {
@@ -33,7 +34,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
}
cmd := &cobra.Command{
- Use: "delete [| | --all]",
+ Use: "delete [ | | --all]",
Short: "Delete GitHub Actions caches",
Long: heredoc.Docf(`
Delete GitHub Actions caches.
@@ -50,8 +51,11 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
# Delete a cache by id in a specific repo
$ gh cache delete 1234 --repo cli/cli
- # Delete all caches
+ # Delete all caches (exit code 1 on no caches)
$ gh cache delete --all
+
+ # Delete all caches (exit code 0 on no caches)
+ $ gh cache delete --all --succeed-on-no-caches
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
@@ -65,6 +69,10 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
return err
}
+ if !opts.DeleteAll && opts.SucceedOnNoCaches {
+ return cmdutil.FlagErrorf("--succeed-on-no-caches must be used in conjunction with --all")
+ }
+
if !opts.DeleteAll && len(args) == 0 {
return cmdutil.FlagErrorf("must provide either cache id, cache key, or use --all")
}
@@ -82,6 +90,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
}
cmd.Flags().BoolVarP(&opts.DeleteAll, "all", "a", false, "Delete all caches")
+ cmd.Flags().BoolVar(&opts.SucceedOnNoCaches, "succeed-on-no-caches", false, "Return exit code 0 if no caches found. Must be used in conjunction with `--all`")
return cmd
}
@@ -100,12 +109,21 @@ func deleteRun(opts *DeleteOptions) error {
var toDelete []string
if opts.DeleteAll {
+ opts.IO.StartProgressIndicator()
caches, err := shared.GetCaches(client, repo, shared.GetCachesOptions{Limit: -1})
+ opts.IO.StopProgressIndicator()
if err != nil {
return err
}
if len(caches.ActionsCaches) == 0 {
- return fmt.Errorf("%s No caches to delete", opts.IO.ColorScheme().FailureIcon())
+ if opts.SucceedOnNoCaches {
+ if opts.IO.IsStdoutTTY() {
+ fmt.Fprintf(opts.IO.Out, "%s No caches to delete\n", opts.IO.ColorScheme().SuccessIcon())
+ }
+ return nil
+ } else {
+ return fmt.Errorf("%s No caches to delete", opts.IO.ColorScheme().FailureIcon())
+ }
}
for _, cache := range caches.ActionsCaches {
toDelete = append(toDelete, strconv.Itoa(cache.Id))
diff --git a/pkg/cmd/cache/delete/delete_test.go b/pkg/cmd/cache/delete/delete_test.go
index 43fc7d213..8660d693b 100644
--- a/pkg/cmd/cache/delete/delete_test.go
+++ b/pkg/cmd/cache/delete/delete_test.go
@@ -43,6 +43,21 @@ func TestNewCmdDelete(t *testing.T) {
cli: "--all",
wants: DeleteOptions{DeleteAll: true},
},
+ {
+ name: "delete all and succeed-on-no-caches flags",
+ cli: "--all --succeed-on-no-caches",
+ wants: DeleteOptions{DeleteAll: true, SucceedOnNoCaches: true},
+ },
+ {
+ name: "succeed-on-no-caches flag",
+ cli: "--succeed-on-no-caches",
+ wantsErr: "--succeed-on-no-caches must be used in conjunction with --all",
+ },
+ {
+ name: "succeed-on-no-caches flag and id argument",
+ cli: "--succeed-on-no-caches 123",
+ wantsErr: "--succeed-on-no-caches must be used in conjunction with --all",
+ },
{
name: "id argument and delete all flag",
cli: "1 --all",
@@ -72,6 +87,7 @@ func TestNewCmdDelete(t *testing.T) {
}
assert.NoError(t, err)
assert.Equal(t, tt.wants.DeleteAll, gotOpts.DeleteAll)
+ assert.Equal(t, tt.wants.SucceedOnNoCaches, gotOpts.SucceedOnNoCaches)
assert.Equal(t, tt.wants.Identifier, gotOpts.Identifier)
})
}
@@ -160,6 +176,19 @@ func TestDeleteRun(t *testing.T) {
tty: true,
wantStdout: "✓ Deleted 2 caches from OWNER/REPO\n",
},
+ {
+ name: "attempts to delete all caches but api errors",
+ opts: DeleteOptions{DeleteAll: true},
+ stubs: func(reg *httpmock.Registry) {
+ reg.Register(
+ httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"),
+ httpmock.StatusStringResponse(500, ""),
+ )
+ },
+ tty: true,
+ wantErr: true,
+ wantErrMsg: "HTTP 500 (https://api.github.com/repos/OWNER/REPO/actions/caches?per_page=100)",
+ },
{
name: "displays delete error",
opts: DeleteOptions{Identifier: "123"},
@@ -186,6 +215,54 @@ func TestDeleteRun(t *testing.T) {
tty: true,
wantStdout: "✓ Deleted 1 cache from OWNER/REPO\n",
},
+ {
+ name: "no caches to delete when deleting all",
+ opts: DeleteOptions{DeleteAll: true},
+ stubs: func(reg *httpmock.Registry) {
+ reg.Register(
+ httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"),
+ httpmock.JSONResponse(shared.CachePayload{
+ ActionsCaches: []shared.Cache{},
+ TotalCount: 0,
+ }),
+ )
+ },
+ tty: false,
+ wantErr: true,
+ wantErrMsg: "X No caches to delete",
+ },
+ {
+ name: "no caches to delete when deleting all but succeed on no cache tty",
+ opts: DeleteOptions{DeleteAll: true, SucceedOnNoCaches: true},
+ stubs: func(reg *httpmock.Registry) {
+ reg.Register(
+ httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"),
+ httpmock.JSONResponse(shared.CachePayload{
+ ActionsCaches: []shared.Cache{},
+ TotalCount: 0,
+ }),
+ )
+ },
+ tty: true,
+ wantErr: false,
+ wantStdout: "✓ No caches to delete\n",
+ },
+ {
+ name: "no caches to delete when deleting all but succeed on no cache non-tty",
+ opts: DeleteOptions{DeleteAll: true, SucceedOnNoCaches: true},
+ stubs: func(reg *httpmock.Registry) {
+ reg.Register(
+ httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"),
+ httpmock.JSONResponse(shared.CachePayload{
+ ActionsCaches: []shared.Cache{},
+ TotalCount: 0,
+ }),
+ )
+ },
+ tty: false,
+ wantErr: false,
+ wantStdout: "",
+ },
}
for _, tt := range tests {
diff --git a/pkg/cmd/cache/list/list.go b/pkg/cmd/cache/list/list.go
index 902285df6..9f39876cb 100644
--- a/pkg/cmd/cache/list/list.go
+++ b/pkg/cmd/cache/list/list.go
@@ -41,23 +41,23 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
Use: "list",
Short: "List GitHub Actions caches",
Example: heredoc.Doc(`
- # List caches for current repository
- $ gh cache list
+ # List caches for current repository
+ $ gh cache list
- # List caches for specific repository
- $ gh cache list --repo cli/cli
+ # List caches for specific repository
+ $ gh cache list --repo cli/cli
- # List caches sorted by least recently accessed
- $ gh cache list --sort last_accessed_at --order asc
+ # List caches sorted by least recently accessed
+ $ gh cache list --sort last_accessed_at --order asc
- # List caches that have keys matching a prefix (or that match exactly)
- $ gh cache list --key key-prefix
+ # List caches that have keys matching a prefix (or that match exactly)
+ $ gh cache list --key key-prefix
- # To list caches for a specific branch, replace with the actual branch name
- $ gh cache list --ref refs/heads/
+ # List caches for a specific branch, replace with the actual branch name
+ $ gh cache list --ref refs/heads/
- # To list caches for a specific pull request, replace with the actual pull request number
- $ gh cache list --ref refs/pull//merge
+ # List caches for a specific pull request, replace with the actual pull request number
+ $ gh cache list --ref refs/pull//merge
`),
Aliases: []string{"ls"},
Args: cobra.NoArgs,
diff --git a/pkg/cmd/codespace/create.go b/pkg/cmd/codespace/create.go
index 67eb5461e..bd2eb4623 100644
--- a/pkg/cmd/codespace/create.go
+++ b/pkg/cmd/codespace/create.go
@@ -99,22 +99,22 @@ func newCreateCmd(app *App) *cobra.Command {
},
}
- createCmd.Flags().BoolVarP(&opts.useWeb, "web", "w", false, "create codespace from browser, cannot be used with --display-name, --idle-timeout, or --retention-period")
+ createCmd.Flags().BoolVarP(&opts.useWeb, "web", "w", false, "Create codespace from browser, cannot be used with --display-name, --idle-timeout, or --retention-period")
- createCmd.Flags().StringVarP(&opts.repo, "repo", "R", "", "repository name with owner: user/repo")
+ createCmd.Flags().StringVarP(&opts.repo, "repo", "R", "", "Repository name with owner: user/repo")
if err := addDeprecatedRepoShorthand(createCmd, &opts.repo); err != nil {
fmt.Fprintf(app.io.ErrOut, "%v\n", err)
}
- createCmd.Flags().StringVarP(&opts.branch, "branch", "b", "", "repository branch")
- createCmd.Flags().StringVarP(&opts.location, "location", "l", "", "location: {EastUs|SouthEastAsia|WestEurope|WestUs2} (determined automatically if not provided)")
- createCmd.Flags().StringVarP(&opts.machine, "machine", "m", "", "hardware specifications for the VM")
- createCmd.Flags().BoolVarP(&opts.permissionsOptOut, "default-permissions", "", false, "do not prompt to accept additional permissions requested by the codespace")
- createCmd.Flags().BoolVarP(&opts.showStatus, "status", "s", false, "show status of post-create command and dotfiles")
- createCmd.Flags().DurationVar(&opts.idleTimeout, "idle-timeout", 0, "allowed inactivity before codespace is stopped, e.g. \"10m\", \"1h\"")
- createCmd.Flags().Var(&opts.retentionPeriod, "retention-period", "allowed time after shutting down before the codespace is automatically deleted (maximum 30 days), e.g. \"1h\", \"72h\"")
- createCmd.Flags().StringVar(&opts.devContainerPath, "devcontainer-path", "", "path to the devcontainer.json file to use when creating codespace")
- createCmd.Flags().StringVarP(&opts.displayName, "display-name", "d", "", fmt.Sprintf("display name for the codespace (%d characters or less)", displayNameMaxLength))
+ createCmd.Flags().StringVarP(&opts.branch, "branch", "b", "", "Repository branch")
+ createCmd.Flags().StringVarP(&opts.location, "location", "l", "", "Location: {EastUs|SouthEastAsia|WestEurope|WestUs2} (determined automatically if not provided)")
+ createCmd.Flags().StringVarP(&opts.machine, "machine", "m", "", "Hardware specifications for the VM")
+ createCmd.Flags().BoolVarP(&opts.permissionsOptOut, "default-permissions", "", false, "Do not prompt to accept additional permissions requested by the codespace")
+ createCmd.Flags().BoolVarP(&opts.showStatus, "status", "s", false, "Show status of post-create command and dotfiles")
+ createCmd.Flags().DurationVar(&opts.idleTimeout, "idle-timeout", 0, "Allowed inactivity before codespace is stopped, e.g. \"10m\", \"1h\"")
+ createCmd.Flags().Var(&opts.retentionPeriod, "retention-period", "Allowed time after shutting down before the codespace is automatically deleted (maximum 30 days), e.g. \"1h\", \"72h\"")
+ createCmd.Flags().StringVar(&opts.devContainerPath, "devcontainer-path", "", "Path to the devcontainer.json file to use when creating codespace")
+ createCmd.Flags().StringVarP(&opts.displayName, "display-name", "d", "", fmt.Sprintf("Display name for the codespace (%d characters or less)", displayNameMaxLength))
return createCmd
}
diff --git a/pkg/cmd/codespace/edit.go b/pkg/cmd/codespace/edit.go
index c84b271c4..3fff28855 100644
--- a/pkg/cmd/codespace/edit.go
+++ b/pkg/cmd/codespace/edit.go
@@ -34,7 +34,7 @@ func newEditCmd(app *App) *cobra.Command {
opts.selector = AddCodespaceSelector(editCmd, app.apiClient)
editCmd.Flags().StringVarP(&opts.displayName, "display-name", "d", "", "Set the display name")
- editCmd.Flags().StringVar(&opts.displayName, "displayName", "", "display name")
+ editCmd.Flags().StringVar(&opts.displayName, "displayName", "", "Display name")
if err := editCmd.Flags().MarkDeprecated("displayName", "use `--display-name` instead"); err != nil {
fmt.Fprintf(app.io.ErrOut, "error marking flag as deprecated: %v\n", err)
}
diff --git a/pkg/cmd/codespace/ports.go b/pkg/cmd/codespace/ports.go
index 0608f0732..cd6656cf4 100644
--- a/pkg/cmd/codespace/ports.go
+++ b/pkg/cmd/codespace/ports.go
@@ -10,6 +10,7 @@ import (
"strings"
"time"
+ "github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/codespaces"
"github.com/cli/cli/v2/internal/codespaces/api"
"github.com/cli/cli/v2/internal/codespaces/portforwarder"
@@ -217,10 +218,12 @@ func getDevContainer(ctx context.Context, apiClient apiClient, codespace *api.Co
func newPortsVisibilityCmd(app *App, selector *CodespaceSelector) *cobra.Command {
return &cobra.Command{
- Use: "visibility :{public|private|org}...",
- Short: "Change the visibility of the forwarded port",
- Example: "gh codespace ports visibility 80:org 3000:private 8000:public",
- Args: cobra.MinimumNArgs(1),
+ Use: "visibility :{public|private|org}...",
+ Short: "Change the visibility of the forwarded port",
+ Example: heredoc.Doc(`
+ $ gh codespace ports visibility 80:org 3000:private 8000:public
+ `),
+ Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return app.UpdatePortVisibility(cmd.Context(), selector, args)
},
diff --git a/pkg/cmd/codespace/rebuild.go b/pkg/cmd/codespace/rebuild.go
index 565406edc..a7a9b9ba0 100644
--- a/pkg/cmd/codespace/rebuild.go
+++ b/pkg/cmd/codespace/rebuild.go
@@ -23,7 +23,7 @@ func newRebuildCmd(app *App) *cobra.Command {
Short: "Rebuild a codespace",
Long: heredoc.Doc(`
Rebuilding recreates your codespace.
-
+
Your code and any current changes will be preserved. Your codespace will be rebuilt using
your working directory's dev container. A full rebuild also removes cached Docker images.
`),
@@ -35,7 +35,7 @@ func newRebuildCmd(app *App) *cobra.Command {
selector = AddCodespaceSelector(rebuildCmd, app.apiClient)
- rebuildCmd.Flags().BoolVar(&fullRebuild, "full", false, "perform a full rebuild")
+ rebuildCmd.Flags().BoolVar(&fullRebuild, "full", false, "Perform a full rebuild")
return rebuildCmd
}
diff --git a/pkg/cmd/codespace/ssh.go b/pkg/cmd/codespace/ssh.go
index b79ec68c9..70e7ceb42 100644
--- a/pkg/cmd/codespace/ssh.go
+++ b/pkg/cmd/codespace/ssh.go
@@ -55,7 +55,7 @@ func newSSHCmd(app *App) *cobra.Command {
Long: heredoc.Docf(`
The %[1]sssh%[1]s command is used to SSH into a codespace. In its simplest form, you can
run %[1]sgh cs ssh%[1]s, select a codespace interactively, and connect.
-
+
The %[1]sssh%[1]s command will automatically create a public/private ssh key pair in the
%[1]s~/.ssh%[1]s directory if you do not have an existing valid key pair. When selecting the
key pair to use, the preferred order is:
@@ -732,8 +732,8 @@ func newCpCmd(app *App) *cobra.Command {
be evaluated on the remote machine, subject to expansion of tildes, braces, globs,
environment variables, and backticks. For security, do not use this flag with arguments
provided by untrusted users; see for discussion.
-
- By default, the %[1]scp%[1]s command will create a public/private ssh key pair to authenticate with
+
+ By default, the %[1]scp%[1]s command will create a public/private ssh key pair to authenticate with
the codespace inside the %[1]s~/.ssh directory%[1]s.
`, "`"),
Example: heredoc.Doc(`
diff --git a/pkg/cmd/codespace/view.go b/pkg/cmd/codespace/view.go
index e212f0820..0bcbc8b5b 100644
--- a/pkg/cmd/codespace/view.go
+++ b/pkg/cmd/codespace/view.go
@@ -29,16 +29,16 @@ func newViewCmd(app *App) *cobra.Command {
Use: "view",
Short: "View details about a codespace",
Example: heredoc.Doc(`
- # select a codespace from a list of all codespaces you own
- $ gh cs view
+ # Select a codespace from a list of all codespaces you own
+ $ gh cs view
- # view the details of a specific codespace
+ # View the details of a specific codespace
$ gh cs view -c codespace-name-12345
- # view the list of all available fields for a codespace
+ # View the list of all available fields for a codespace
$ gh cs view --json
- # view specific fields for a codespace
+ # View specific fields for a codespace
$ gh cs view --json displayName,machineDisplayName,state
`),
Args: noArgsConstraint,
diff --git a/pkg/cmd/completion/completion.go b/pkg/cmd/completion/completion.go
index d83dd31ef..b34bf7abf 100644
--- a/pkg/cmd/completion/completion.go
+++ b/pkg/cmd/completion/completion.go
@@ -33,7 +33,7 @@ func NewCmdCompletion(io *iostreams.IOStreams) *cobra.Command {
After, add this to your %[1]s~/.bash_profile%[1]s:
eval "$(gh completion -s bash)"
-
+
### zsh
Generate a %[1]s_gh%[1]s completion script and put it somewhere in your %[1]s$fpath%[1]s:
@@ -44,7 +44,7 @@ func NewCmdCompletion(io *iostreams.IOStreams) *cobra.Command {
autoload -U compinit
compinit -i
-
+
Zsh version 5.7 or later is recommended.
### fish
@@ -59,7 +59,7 @@ func NewCmdCompletion(io *iostreams.IOStreams) *cobra.Command {
mkdir -Path (Split-Path -Parent $profile) -ErrorAction SilentlyContinue
notepad $profile
-
+
Add the line and save the file:
Invoke-Expression -Command $(gh completion -s powershell | Out-String)
diff --git a/pkg/cmd/config/get/get.go b/pkg/cmd/config/get/get.go
index bec22a979..17892485c 100644
--- a/pkg/cmd/config/get/get.go
+++ b/pkg/cmd/config/get/get.go
@@ -29,7 +29,6 @@ func NewCmdConfigGet(f *cmdutil.Factory, runF func(*GetOptions) error) *cobra.Co
Short: "Print the value of a given configuration key",
Example: heredoc.Doc(`
$ gh config get git_protocol
- https
`),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go
index 93c385374..9431c0a92 100644
--- a/pkg/cmd/extension/command.go
+++ b/pkg/cmd/extension/command.go
@@ -300,7 +300,7 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
For GitHub repositories, the repository argument can be specified in
%[1]sOWNER/REPO%[1]s format or as a full repository URL.
- The URL format is useful when the repository is not hosted on github.com.
+ The URL format is useful when the repository is not hosted on .
For remote repositories, the GitHub CLI first looks for the release artifacts assuming
that it's a binary extension i.e. prebuilt binaries provided as part of the release.
@@ -411,8 +411,8 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
return nil
},
}
- cmd.Flags().BoolVar(&forceFlag, "force", false, "force upgrade extension, or ignore if latest already installed")
- cmd.Flags().StringVar(&pinFlag, "pin", "", "pin extension to a release tag or commit ref")
+ cmd.Flags().BoolVar(&forceFlag, "force", false, "Force upgrade extension, or ignore if latest already installed")
+ cmd.Flags().StringVar(&pinFlag, "pin", "", "Pin extension to a release tag or commit ref")
return cmd
}(),
func() *cobra.Command {
@@ -526,7 +526,7 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
return browse.ExtBrowse(opts)
},
}
- cmd.Flags().BoolVar(&debug, "debug", false, "log to /tmp/extBrowse-*")
+ cmd.Flags().BoolVar(&debug, "debug", false, "Log to /tmp/extBrowse-*")
cmd.Flags().BoolVarP(&singleColumn, "single-column", "s", false, "Render TUI with only one column of text")
return cmd
}(),
@@ -542,7 +542,7 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
of the extension.
`, "`"),
Example: heredoc.Doc(`
- # execute a label extension instead of the core gh label command
+ # Execute a label extension instead of the core gh label command
$ gh extension exec label
`),
Args: cobra.MinimumNArgs(1),
@@ -573,16 +573,16 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
Short: "Create a new extension",
Example: heredoc.Doc(`
# Use interactively
- gh extension create
+ $ gh extension create
# Create a script-based extension
- gh extension create foobar
+ $ gh extension create foobar
# Create a Go extension
- gh extension create --precompiled=go foobar
+ $ gh extension create --precompiled=go foobar
# Create a non-Go precompiled extension
- gh extension create --precompiled=other foobar
+ $ gh extension create --precompiled=other foobar
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
diff --git a/pkg/cmd/factory/remote_resolver.go b/pkg/cmd/factory/remote_resolver.go
index b008ade7e..b5cad393e 100644
--- a/pkg/cmd/factory/remote_resolver.go
+++ b/pkg/cmd/factory/remote_resolver.go
@@ -21,25 +21,24 @@ type remoteResolver struct {
readRemotes func() (git.RemoteSet, error)
getConfig func() (gh.Config, error)
urlTranslator context.Translator
+ cachedRemotes context.Remotes
+ remotesError error
}
func (rr *remoteResolver) Resolver() func() (context.Remotes, error) {
- var cachedRemotes context.Remotes
- var remotesError error
-
return func() (context.Remotes, error) {
- if cachedRemotes != nil || remotesError != nil {
- return cachedRemotes, remotesError
+ if rr.cachedRemotes != nil || rr.remotesError != nil {
+ return rr.cachedRemotes, rr.remotesError
}
gitRemotes, err := rr.readRemotes()
if err != nil {
- remotesError = err
+ rr.remotesError = err
return nil, err
}
if len(gitRemotes) == 0 {
- remotesError = errors.New("no git remotes found")
- return nil, remotesError
+ rr.remotesError = errors.New("no git remotes found")
+ return nil, rr.remotesError
}
sshTranslate := rr.urlTranslator
@@ -68,30 +67,31 @@ func (rr *remoteResolver) Resolver() func() (context.Remotes, error) {
// Sort remotes
sort.Sort(resolvedRemotes)
- // Filter remotes by hosts
- // Note that this is not caching correctly: https://github.com/cli/cli/issues/10103
- cachedRemotes := resolvedRemotes.FilterByHosts(hosts)
+ rr.cachedRemotes = resolvedRemotes.FilterByHosts(hosts)
// Filter again by default host if one is set
// For config file default host fallback to cachedRemotes if none match
// For environment default host (GH_HOST) do not fallback to cachedRemotes if none match
if src != "default" {
- filteredRemotes := cachedRemotes.FilterByHosts([]string{defaultHost})
+ filteredRemotes := rr.cachedRemotes.FilterByHosts([]string{defaultHost})
if isHostEnv(src) || len(filteredRemotes) > 0 {
- cachedRemotes = filteredRemotes
+ rr.cachedRemotes = filteredRemotes
}
}
- if len(cachedRemotes) == 0 {
+ if len(rr.cachedRemotes) == 0 {
if isHostEnv(src) {
- return nil, fmt.Errorf("none of the git remotes configured for this repository correspond to the %s environment variable. Try adding a matching remote or unsetting the variable.", src)
+ rr.remotesError = fmt.Errorf("none of the git remotes configured for this repository correspond to the %s environment variable. Try adding a matching remote or unsetting the variable", src)
+ return nil, rr.remotesError
} else if cfg.Authentication().HasEnvToken() {
- return nil, errors.New("set the GH_HOST environment variable to specify which GitHub host to use")
+ rr.remotesError = errors.New("set the GH_HOST environment variable to specify which GitHub host to use")
+ return nil, rr.remotesError
}
- return nil, errors.New("none of the git remotes configured for this repository point to a known GitHub host. To tell gh about a new GitHub host, please use `gh auth login`")
+ rr.remotesError = errors.New("none of the git remotes configured for this repository point to a known GitHub host. To tell gh about a new GitHub host, please use `gh auth login`")
+ return nil, rr.remotesError
}
- return cachedRemotes, nil
+ return rr.cachedRemotes, nil
}
}
diff --git a/pkg/cmd/factory/remote_resolver_test.go b/pkg/cmd/factory/remote_resolver_test.go
index 8d537826e..77d4a9d65 100644
--- a/pkg/cmd/factory/remote_resolver_test.go
+++ b/pkg/cmd/factory/remote_resolver_test.go
@@ -1,14 +1,17 @@
package factory
import (
+ "errors"
"net/url"
"testing"
+ "github.com/cli/cli/v2/context"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/gh"
ghmock "github.com/cli/cli/v2/internal/gh/mock"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
type identityTranslator struct{}
@@ -288,3 +291,95 @@ func Test_remoteResolver(t *testing.T) {
})
}
}
+
+func Test_remoteResolver_Caching(t *testing.T) {
+ t.Run("cache remotes", func(t *testing.T) {
+ var readRemotesCalled bool
+
+ rr := &remoteResolver{
+ readRemotes: func() (git.RemoteSet, error) {
+ if readRemotesCalled {
+ return git.RemoteSet{}, errors.New("readRemotes should only be called once")
+ }
+
+ readRemotesCalled = true
+ return git.RemoteSet{
+ git.NewRemote("origin", "https://github.com/owner/repo.git"),
+ }, nil
+ },
+ getConfig: func() (gh.Config, error) {
+ cfg := &ghmock.ConfigMock{}
+ cfg.AuthenticationFunc = func() gh.AuthConfig {
+ authCfg := &config.AuthConfig{}
+ authCfg.SetHosts([]string{"github.com"})
+ authCfg.SetDefaultHost("github.com", "default")
+ return authCfg
+ }
+ return cfg, nil
+ },
+ urlTranslator: identityTranslator{},
+ }
+
+ resolver := rr.Resolver()
+
+ expectedRemoteNames := []string{"origin"}
+ remotes, err := resolver()
+ require.NoError(t, err)
+ require.Equal(t, expectedRemoteNames, mapRemotesToNames(remotes))
+
+ require.Equal(t, readRemotesCalled, true)
+
+ cachedRemotes, err := resolver()
+ require.NoError(t, err, "expected no error to be cached")
+ require.Equal(t, expectedRemoteNames, mapRemotesToNames(cachedRemotes), "expected the remotes to be cached")
+ })
+
+ t.Run("cache error", func(t *testing.T) {
+ var readRemotesCalled bool
+
+ rr := &remoteResolver{
+ readRemotes: func() (git.RemoteSet, error) {
+ if readRemotesCalled {
+ return git.RemoteSet{
+ git.NewRemote("origin", "https://github.com/owner/repo.git"),
+ }, nil
+ }
+
+ readRemotesCalled = true
+ return git.RemoteSet{}, errors.New("error to be cached")
+ },
+ getConfig: func() (gh.Config, error) {
+ cfg := &ghmock.ConfigMock{}
+ cfg.AuthenticationFunc = func() gh.AuthConfig {
+ authCfg := &config.AuthConfig{}
+ authCfg.SetHosts([]string{"github.com"})
+ authCfg.SetDefaultHost("github.com", "default")
+ return authCfg
+ }
+ return cfg, nil
+ },
+ urlTranslator: identityTranslator{},
+ }
+
+ resolver := rr.Resolver()
+
+ expectedErr := errors.New("error to be cached")
+ remotes, err := resolver()
+ require.Equal(t, expectedErr, err)
+ require.Empty(t, remotes, "should return no remotes")
+
+ require.Equal(t, readRemotesCalled, true)
+
+ cachedRemotes, err := resolver()
+ require.Equal(t, expectedErr, err, "expected the error to be cached")
+ require.Empty(t, cachedRemotes, "should return no remotes")
+ })
+}
+
+func mapRemotesToNames(remotes context.Remotes) []string {
+ names := make([]string, len(remotes))
+ for i, r := range remotes {
+ names[i] = r.Name
+ }
+ return names
+}
diff --git a/pkg/cmd/gist/create/create.go b/pkg/cmd/gist/create/create.go
index 4403df9be..e9d9f2c14 100644
--- a/pkg/cmd/gist/create/create.go
+++ b/pkg/cmd/gist/create/create.go
@@ -59,19 +59,19 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
By default, gists are secret; use %[1]s--public%[1]s to make publicly listed ones.
`, "`"),
Example: heredoc.Doc(`
- # publish file 'hello.py' as a public gist
+ # Publish file 'hello.py' as a public gist
$ gh gist create --public hello.py
- # create a gist with a description
+ # Create a gist with a description
$ gh gist create hello.py -d "my Hello-World program in Python"
- # create a gist containing several files
+ # Create a gist containing several files
$ gh gist create hello.py world.py cool.txt
- # read from standard input to create a gist
+ # Read from standard input to create a gist
$ gh gist create -
- # create a gist from output piped from another command
+ # Create a gist from output piped from another command
$ cat cool.txt | gh gist create
`),
Args: func(cmd *cobra.Command, args []string) error {
diff --git a/pkg/cmd/gist/delete/delete.go b/pkg/cmd/gist/delete/delete.go
index 8b8a47540..319f9265e 100644
--- a/pkg/cmd/gist/delete/delete.go
+++ b/pkg/cmd/gist/delete/delete.go
@@ -38,19 +38,19 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
Use: "delete { | }",
Short: "Delete a gist",
Long: heredoc.Docf(`
- Delete a GitHub gist.
+ Delete a GitHub gist.
- To delete a gist interactively, use %[1]sgh gist delete%[1]s with no arguments.
+ To delete a gist interactively, use %[1]sgh gist delete%[1]s with no arguments.
- To delete a gist non-interactively, supply the gist id or url.
- `, "`"),
+ To delete a gist non-interactively, supply the gist id or url.
+ `, "`"),
Example: heredoc.Doc(`
- # delete a gist interactively
- gh gist delete
+ # Delete a gist interactively
+ $ gh gist delete
- # delete a gist non-interactively
- gh gist delete 1234
- `),
+ # Delete a gist non-interactively
+ $ gh gist delete 1234
+ `),
Args: cobra.MaximumNArgs(1),
RunE: func(c *cobra.Command, args []string) error {
if !opts.IO.CanPrompt() && !opts.Confirmed {
@@ -71,7 +71,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
return deleteRun(&opts)
},
}
- cmd.Flags().BoolVar(&opts.Confirmed, "yes", false, "confirm deletion without prompting")
+ cmd.Flags().BoolVar(&opts.Confirmed, "yes", false, "Confirm deletion without prompting")
return cmd
}
diff --git a/pkg/cmd/gist/list/list.go b/pkg/cmd/gist/list/list.go
index 00a401be4..d46d4da02 100644
--- a/pkg/cmd/gist/list/list.go
+++ b/pkg/cmd/gist/list/list.go
@@ -61,10 +61,10 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
No highlights or other color is printed when output is redirected.
`, "`"),
Example: heredoc.Doc(`
- # list all secret gists from your user account
+ # List all secret gists from your user account
$ gh gist list --secret
- # find all gists from your user account mentioning "octo" anywhere
+ # Find all gists from your user account mentioning "octo" anywhere
$ gh gist list --filter octo --include-content
`),
Aliases: []string{"ls"},
diff --git a/pkg/cmd/gist/list/list_test.go b/pkg/cmd/gist/list/list_test.go
index e6cb35d53..0acfae109 100644
--- a/pkg/cmd/gist/list/list_test.go
+++ b/pkg/cmd/gist/list/list_test.go
@@ -540,14 +540,14 @@ func Test_listRun(t *testing.T) {
wantOut: heredoc.Doc(`
1234 main.txt
octo match in the description
-
+
2345 octo.txt
match in the file name
3456 main.txt
match in the file text
octo in the text
-
+
`),
},
{
@@ -599,14 +599,14 @@ func Test_listRun(t *testing.T) {
wantOut: heredoc.Docf(`
%[1]s[0;34m1234%[1]s[0m %[1]s[0;32mmain.txt%[1]s[0m
%[1]s[0;30;43mocto%[1]s[0m%[1]s[0;1;39m match in the description%[1]s[0m
-
+
%[1]s[0;34m2345%[1]s[0m %[1]s[0;30;43mocto%[1]s[0m%[1]s[0;32m.txt%[1]s[0m
%[1]s[0;1;39mmatch in the file name%[1]s[0m
-
+
%[1]s[0;34m3456%[1]s[0m %[1]s[0;32mmain.txt%[1]s[0m
%[1]s[0;1;39mmatch in the file text%[1]s[0m
%[1]s[0;30;43mocto%[1]s[0m in the text
-
+
`, "\x1b"),
},
}
diff --git a/pkg/cmd/gist/rename/rename.go b/pkg/cmd/gist/rename/rename.go
index 630e67019..96f630c02 100644
--- a/pkg/cmd/gist/rename/rename.go
+++ b/pkg/cmd/gist/rename/rename.go
@@ -35,7 +35,7 @@ func NewCmdRename(f *cmdutil.Factory, runf func(*RenameOptions) error) *cobra.Co
}
cmd := &cobra.Command{
- Use: "rename { | } ",
+ Use: "rename { | } ",
Short: "Rename a file in a gist",
Long: heredoc.Doc(`Rename a file in the given gist ID / URL.`),
Args: cobra.ExactArgs(3),
diff --git a/pkg/cmd/issue/comment/comment.go b/pkg/cmd/issue/comment/comment.go
index c91cf79c9..090b0748c 100644
--- a/pkg/cmd/issue/comment/comment.go
+++ b/pkg/cmd/issue/comment/comment.go
@@ -11,12 +11,13 @@ import (
func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) error) *cobra.Command {
opts := &prShared.CommentableOptions{
- IO: f.IOStreams,
- HttpClient: f.HttpClient,
- EditSurvey: prShared.CommentableEditSurvey(f.Config, f.IOStreams),
- InteractiveEditSurvey: prShared.CommentableInteractiveEditSurvey(f.Config, f.IOStreams),
- ConfirmSubmitSurvey: prShared.CommentableConfirmSubmitSurvey(f.Prompter),
- OpenInBrowser: f.Browser.Browse,
+ IO: f.IOStreams,
+ HttpClient: f.HttpClient,
+ EditSurvey: prShared.CommentableEditSurvey(f.Config, f.IOStreams),
+ InteractiveEditSurvey: prShared.CommentableInteractiveEditSurvey(f.Config, f.IOStreams),
+ ConfirmSubmitSurvey: prShared.CommentableConfirmSubmitSurvey(f.Prompter),
+ ConfirmCreateIfNoneSurvey: prShared.CommentableInteractiveCreateIfNoneSurvey(f.Prompter),
+ OpenInBrowser: f.Browser.Browse,
}
var bodyFile string
@@ -69,6 +70,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) e
cmd.Flags().BoolP("editor", "e", false, "Skip prompts and open the text editor to write the body in")
cmd.Flags().BoolP("web", "w", false, "Open the web browser to write the comment")
cmd.Flags().BoolVar(&opts.EditLast, "edit-last", false, "Edit the last comment of the same author")
+ cmd.Flags().BoolVar(&opts.CreateIfNone, "create-if-none", false, "Create a new comment if no comments are found. Can be used only with --edit-last")
return cmd
}
diff --git a/pkg/cmd/issue/comment/comment_test.go b/pkg/cmd/issue/comment/comment_test.go
index 668d758e5..461ae1065 100644
--- a/pkg/cmd/issue/comment/comment_test.go
+++ b/pkg/cmd/issue/comment/comment_test.go
@@ -109,6 +109,29 @@ func TestNewCmdComment(t *testing.T) {
},
wantsErr: false,
},
+ {
+ name: "edit last flag",
+ input: "1 --edit-last",
+ output: shared.CommentableOptions{
+ Interactive: true,
+ InputType: shared.InputTypeEditor,
+ Body: "",
+ EditLast: true,
+ },
+ wantsErr: false,
+ },
+ {
+ name: "edit last flag with create if none",
+ input: "1 --edit-last --create-if-none",
+ output: shared.CommentableOptions{
+ Interactive: true,
+ InputType: shared.InputTypeEditor,
+ Body: "",
+ EditLast: true,
+ CreateIfNone: true,
+ },
+ wantsErr: false,
+ },
{
name: "body and body-file flags",
input: "1 --body 'test' --body-file 'test-file.txt'",
@@ -139,6 +162,12 @@ func TestNewCmdComment(t *testing.T) {
output: shared.CommentableOptions{},
wantsErr: true,
},
+ {
+ name: "create-if-none flag without edit-last",
+ input: "1 --create-if-none",
+ output: shared.CommentableOptions{},
+ wantsErr: true,
+ },
}
for _, tt := range tests {
@@ -188,11 +217,12 @@ func TestNewCmdComment(t *testing.T) {
func Test_commentRun(t *testing.T) {
tests := []struct {
- name string
- input *shared.CommentableOptions
- httpStubs func(*testing.T, *httpmock.Registry)
- stdout string
- stderr string
+ name string
+ input *shared.CommentableOptions
+ emptyComments bool
+ httpStubs func(*testing.T, *httpmock.Registry)
+ stdout string
+ stderr string
}{
{
name: "interactive editor",
@@ -225,6 +255,24 @@ func Test_commentRun(t *testing.T) {
},
stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n",
},
+ {
+ name: "interactive editor with edit last and create if none",
+ input: &shared.CommentableOptions{
+ Interactive: true,
+ InputType: 0,
+ Body: "",
+ EditLast: true,
+ CreateIfNone: true,
+
+ InteractiveEditSurvey: func(string) (string, error) { return "comment body", nil },
+ ConfirmCreateIfNoneSurvey: func() (bool, error) { return true, nil },
+ ConfirmSubmitSurvey: func() (bool, error) { return true, nil },
+ },
+ httpStubs: func(t *testing.T, reg *httpmock.Registry) {
+ mockCommentUpdate(t, reg)
+ },
+ stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n",
+ },
{
name: "non-interactive web",
input: &shared.CommentableOptions{
@@ -248,6 +296,39 @@ func Test_commentRun(t *testing.T) {
},
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",
+ },
{
name: "non-interactive editor",
input: &shared.CommentableOptions{
@@ -277,6 +358,23 @@ func Test_commentRun(t *testing.T) {
},
stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n",
},
+ {
+ name: "non-interactive editor with edit last and create if none",
+ input: &shared.CommentableOptions{
+ Interactive: false,
+ InputType: shared.InputTypeEditor,
+ Body: "",
+ EditLast: true,
+ CreateIfNone: true,
+
+ EditSurvey: func(string) (string, error) { return "comment body", 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: "non-interactive inline",
input: &shared.CommentableOptions{
@@ -319,14 +417,21 @@ func Test_commentRun(t *testing.T) {
tt.input.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
+
+ comments := api.Comments{Nodes: []api.Comment{
+ {ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/issues/123#issuecomment-111", ViewerDidAuthor: true},
+ {ID: "id2", Author: api.CommentAuthor{Login: "monalisa"}, URL: "https://github.com/OWNER/REPO/issues/123#issuecomment-222"},
+ }}
+
+ if tt.emptyComments {
+ comments.Nodes = []api.Comment{}
+ }
+
tt.input.RetrieveCommentable = func() (shared.Commentable, ghrepo.Interface, error) {
return &api.Issue{
- ID: "ISSUE-ID",
- URL: "https://github.com/OWNER/REPO/issues/123",
- Comments: api.Comments{Nodes: []api.Comment{
- {ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/issues/123#issuecomment-111", ViewerDidAuthor: true},
- {ID: "id2", Author: api.CommentAuthor{Login: "monalisa"}, URL: "https://github.com/OWNER/REPO/issues/123#issuecomment-222"},
- }},
+ ID: "ISSUE-ID",
+ URL: "https://github.com/OWNER/REPO/issues/123",
+ Comments: comments,
}, ghrepo.New("OWNER", "REPO"), nil
}
diff --git a/pkg/cmd/issue/delete/delete.go b/pkg/cmd/issue/delete/delete.go
index a70c6abb2..fb41f288e 100644
--- a/pkg/cmd/issue/delete/delete.go
+++ b/pkg/cmd/issue/delete/delete.go
@@ -57,9 +57,9 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
},
}
- cmd.Flags().BoolVar(&opts.Confirmed, "confirm", false, "confirm deletion without prompting")
+ cmd.Flags().BoolVar(&opts.Confirmed, "confirm", false, "Confirm deletion without prompting")
_ = cmd.Flags().MarkDeprecated("confirm", "use `--yes` instead")
- cmd.Flags().BoolVar(&opts.Confirmed, "yes", false, "confirm deletion without prompting")
+ cmd.Flags().BoolVar(&opts.Confirmed, "yes", false, "Confirm deletion without prompting")
return cmd
}
diff --git a/pkg/cmd/label/clone.go b/pkg/cmd/label/clone.go
index 81ab76958..a02c4764a 100644
--- a/pkg/cmd/label/clone.go
+++ b/pkg/cmd/label/clone.go
@@ -47,10 +47,10 @@ func newCmdClone(f *cmdutil.Factory, runF func(*cloneOptions) error) *cobra.Comm
destination repository using the %[1]s--force%[1]s flag.
`, "`"),
Example: heredoc.Doc(`
- # clone and overwrite labels from cli/cli repository into the current repository
+ # Clone and overwrite labels from cli/cli repository into the current repository
$ gh label clone cli/cli --force
- # clone labels from cli/cli repository into a octocat/cli repository
+ # Clone labels from cli/cli repository into a octocat/cli repository
$ gh label clone cli/cli --repo octocat/cli
`),
Args: cmdutil.ExactArgs(1, "cannot clone labels: source-repository argument required"),
diff --git a/pkg/cmd/label/create.go b/pkg/cmd/label/create.go
index 8db57a415..9d6b2e9ee 100644
--- a/pkg/cmd/label/create.go
+++ b/pkg/cmd/label/create.go
@@ -66,7 +66,7 @@ func newCmdCreate(f *cmdutil.Factory, runF func(*createOptions) error) *cobra.Co
The label color needs to be 6 character hex value.
`, "`"),
Example: heredoc.Doc(`
- # create new bug label
+ # Create new bug label
$ gh label create bug --description "Something isn't working" --color E99695
`),
Args: cmdutil.ExactArgs(1, "cannot create label: name argument required"),
diff --git a/pkg/cmd/label/edit.go b/pkg/cmd/label/edit.go
index beae14045..79eb633f5 100644
--- a/pkg/cmd/label/edit.go
+++ b/pkg/cmd/label/edit.go
@@ -42,10 +42,10 @@ func newCmdEdit(f *cmdutil.Factory, runF func(*editOptions) error) *cobra.Comman
The label color needs to be 6 character hex value.
`, "`"),
Example: heredoc.Doc(`
- # update the color of the bug label
+ # Update the color of the bug label
$ gh label edit bug --color FF0000
- # rename and edit the description of the bug label
+ # Rename and edit the description of the bug label
$ gh label edit bug --name big-bug --description "Bigger than normal bug"
`),
Args: cmdutil.ExactArgs(1, "cannot update label: name argument required"),
diff --git a/pkg/cmd/label/list.go b/pkg/cmd/label/list.go
index 7b4c95ed1..fe1e9cdb7 100644
--- a/pkg/cmd/label/list.go
+++ b/pkg/cmd/label/list.go
@@ -42,10 +42,10 @@ func newCmdList(f *cmdutil.Factory, runF func(*listOptions) error) *cobra.Comman
This behavior cannot be configured with the %[1]s--order%[1]s or %[1]s--sort%[1]s flags.
`, "`"),
Example: heredoc.Doc(`
- # sort labels by name
+ # Sort labels by name
$ gh label list --sort name
- # find labels with "bug" in the name or description
+ # Find labels with "bug" in the name or description
$ gh label list --search bug
`),
Args: cobra.NoArgs,
diff --git a/pkg/cmd/label/list_test.go b/pkg/cmd/label/list_test.go
index 6f066da32..8a1bc7c7a 100644
--- a/pkg/cmd/label/list_test.go
+++ b/pkg/cmd/label/list_test.go
@@ -218,7 +218,7 @@ func TestListRun(t *testing.T) {
wantStdout: heredoc.Doc(`
Showing 2 of 2 labels in OWNER/REPO
-
+
NAME DESCRIPTION COLOR
bug This is a bug label #d73a4a
docs This is a docs label #ffa8da
diff --git a/pkg/cmd/pr/checkout/checkout.go b/pkg/cmd/pr/checkout/checkout.go
index c73d90339..32189fda1 100644
--- a/pkg/cmd/pr/checkout/checkout.go
+++ b/pkg/cmd/pr/checkout/checkout.go
@@ -27,13 +27,8 @@ type CheckoutOptions struct {
Remotes func() (cliContext.Remotes, error)
Branch func() (string, error)
- Finder shared.PRFinder
- Prompter shared.Prompt
- Lister shared.PRLister
+ PRResolver PRResolver
- Interactive bool
- BaseRepo func() (ghrepo.Interface, error)
- SelectorArg string
RecurseSubmodules bool
Force bool
Detach bool
@@ -48,8 +43,6 @@ func NewCmdCheckout(f *cmdutil.Factory, runF func(*CheckoutOptions) error) *cobr
Config: f.Config,
Remotes: f.Remotes,
Branch: f.Branch,
- Prompter: f.Prompter,
- BaseRepo: f.BaseRepo,
}
cmd := &cobra.Command{
@@ -66,15 +59,30 @@ func NewCmdCheckout(f *cmdutil.Factory, runF func(*CheckoutOptions) error) *cobr
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
- opts.Finder = shared.NewFinder(f)
- opts.Lister = shared.NewLister(f)
-
if len(args) > 0 {
- opts.SelectorArg = args[0]
- } else if !opts.IO.CanPrompt() {
- return cmdutil.FlagErrorf("pull request number, URL, or branch required when not running interactively")
+ opts.PRResolver = &specificPRResolver{
+ prFinder: shared.NewFinder(f),
+ selector: args[0],
+ }
+ } else if opts.IO.CanPrompt() {
+ baseRepo, err := f.BaseRepo()
+ if err != nil {
+ return err
+ }
+
+ httpClient, err := f.HttpClient()
+ if err != nil {
+ return err
+ }
+
+ opts.PRResolver = &promptingPRResolver{
+ io: opts.IO,
+ prompter: f.Prompter,
+ prLister: shared.NewLister(httpClient),
+ baseRepo: baseRepo,
+ }
} else {
- opts.Interactive = true
+ return cmdutil.FlagErrorf("pull request number, URL, or branch required when not running interactively")
}
if runF != nil {
@@ -93,12 +101,7 @@ func NewCmdCheckout(f *cmdutil.Factory, runF func(*CheckoutOptions) error) *cobr
}
func checkoutRun(opts *CheckoutOptions) error {
- baseRepo, err := opts.BaseRepo()
- if err != nil {
- return err
- }
-
- pr, err := resolvePR(baseRepo, opts.Prompter, opts.SelectorArg, opts.Interactive, opts.Finder, opts.Lister, opts.IO)
+ pr, baseRepo, err := opts.PRResolver.Resolve()
if err != nil {
return err
}
@@ -172,7 +175,7 @@ func cmdsForExistingRemote(remote *cliContext.Remote, pr *api.PullRequest, opts
refSpec += fmt.Sprintf(":refs/remotes/%s", remoteBranch)
}
- cmds = append(cmds, []string{"fetch", remote.Name, refSpec})
+ cmds = append(cmds, []string{"fetch", remote.Name, refSpec, "--no-tags"})
localBranch := pr.HeadRefName
if opts.BranchName != "" {
@@ -202,7 +205,7 @@ func cmdsForMissingRemote(pr *api.PullRequest, baseURLOrName, repoHost, defaultB
ref := fmt.Sprintf("refs/pull/%d/head", pr.Number)
if opts.Detach {
- cmds = append(cmds, []string{"fetch", baseURLOrName, ref})
+ cmds = append(cmds, []string{"fetch", baseURLOrName, ref, "--no-tags"})
cmds = append(cmds, []string{"checkout", "--detach", "FETCH_HEAD"})
return cmds
}
@@ -218,7 +221,7 @@ func cmdsForMissingRemote(pr *api.PullRequest, baseURLOrName, repoHost, defaultB
currentBranch, _ := opts.Branch()
if localBranch == currentBranch {
// PR head matches currently checked out branch
- cmds = append(cmds, []string{"fetch", baseURLOrName, ref})
+ cmds = append(cmds, []string{"fetch", baseURLOrName, ref, "--no-tags"})
if opts.Force {
cmds = append(cmds, []string{"reset", "--hard", "FETCH_HEAD"})
} else {
@@ -226,13 +229,12 @@ func cmdsForMissingRemote(pr *api.PullRequest, baseURLOrName, repoHost, defaultB
cmds = append(cmds, []string{"merge", "--ff-only", "FETCH_HEAD"})
}
} else {
+ // TODO: check if non-fast-forward and suggest to use `--force`
+ fetchCmd := []string{"fetch", baseURLOrName, fmt.Sprintf("%s:%s", ref, localBranch), "--no-tags"}
if opts.Force {
- cmds = append(cmds, []string{"fetch", baseURLOrName, fmt.Sprintf("%s:%s", ref, localBranch), "--force"})
- } else {
- // TODO: check if non-fast-forward and suggest to use `--force`
- cmds = append(cmds, []string{"fetch", baseURLOrName, fmt.Sprintf("%s:%s", ref, localBranch)})
+ fetchCmd = append(fetchCmd, "--force")
}
-
+ cmds = append(cmds, fetchCmd)
cmds = append(cmds, []string{"checkout", localBranch})
}
@@ -287,32 +289,47 @@ func executeCmds(client *git.Client, credentialPattern git.CredentialPattern, cm
return nil
}
-func resolvePR(baseRepo ghrepo.Interface, prompter shared.Prompt, pullRequestSelector string, isInteractive bool, pullRequestFinder shared.PRFinder, prLister shared.PRLister, io *iostreams.IOStreams) (*api.PullRequest, error) {
- // When non-interactive
- if pullRequestSelector != "" {
- pr, _, err := pullRequestFinder.Find(shared.FindOptions{
- Selector: pullRequestSelector,
- Fields: []string{
- "number",
- "headRefName",
- "headRepository",
- "headRepositoryOwner",
- "isCrossRepository",
- "maintainerCanModify",
- },
- })
- if err != nil {
- return nil, err
- }
- return pr, nil
+type PRResolver interface {
+ Resolve() (*api.PullRequest, ghrepo.Interface, error)
+}
+
+type specificPRResolver struct {
+ prFinder shared.PRFinder
+ selector string
+}
+
+func (r *specificPRResolver) Resolve() (*api.PullRequest, ghrepo.Interface, error) {
+ pr, baseRepo, err := r.prFinder.Find(shared.FindOptions{
+ Selector: r.selector,
+ Fields: []string{
+ "number",
+ "headRefName",
+ "headRepository",
+ "headRepositoryOwner",
+ "isCrossRepository",
+ "maintainerCanModify",
+ },
+ })
+ if err != nil {
+ return nil, nil, err
}
- if !isInteractive {
- return nil, cmdutil.FlagErrorf("pull request number, URL, or branch required when not running interactively")
- }
- // When interactive
- io.StartProgressIndicator()
- listResult, err := prLister.List(shared.ListOptions{
- State: "open",
+ return pr, baseRepo, nil
+}
+
+type promptingPRResolver struct {
+ io *iostreams.IOStreams
+ prompter shared.Prompt
+
+ prLister shared.PRLister
+
+ baseRepo ghrepo.Interface
+}
+
+func (r *promptingPRResolver) Resolve() (*api.PullRequest, ghrepo.Interface, error) {
+ r.io.StartProgressIndicator()
+ listResult, err := r.prLister.List(shared.ListOptions{
+ BaseRepo: r.baseRepo,
+ State: "open",
Fields: []string{
"number",
"title",
@@ -326,21 +343,16 @@ func resolvePR(baseRepo ghrepo.Interface, prompter shared.Prompt, pullRequestSel
"maintainerCanModify",
},
LimitResults: 10})
- io.StopProgressIndicator()
+ r.io.StopProgressIndicator()
if err != nil {
- return nil, err
+ return nil, nil, err
}
if len(listResult.PullRequests) == 0 {
- return nil, shared.ListNoResults(ghrepo.FullName(baseRepo), "pull request", false)
+ return nil, nil, shared.ListNoResults(ghrepo.FullName(r.baseRepo), "pull request", false)
}
- pr, err := promptForPR(prompter, *listResult)
- return pr, err
-}
-
-func promptForPR(prompter shared.Prompt, jobs api.PullRequestAndTotalCount) (*api.PullRequest, error) {
candidates := []string{}
- for _, pr := range jobs.PullRequests {
+ for _, pr := range listResult.PullRequests {
candidates = append(candidates, fmt.Sprintf("%d\t%s %s [%s]",
pr.Number,
shared.PrStateWithDraft(&pr),
@@ -349,14 +361,10 @@ func promptForPR(prompter shared.Prompt, jobs api.PullRequestAndTotalCount) (*ap
))
}
- selected, err := prompter.Select("Select a pull request", "", candidates)
+ selected, err := r.prompter.Select("Select a pull request", "", candidates)
if err != nil {
- return nil, err
+ return nil, nil, err
}
- if selected >= 0 {
- return &jobs.PullRequests[selected], nil
- }
-
- return nil, nil
+ return &listResult.PullRequests[selected], r.baseRepo, nil
}
diff --git a/pkg/cmd/pr/checkout/checkout_test.go b/pkg/cmd/pr/checkout/checkout_test.go
index bd8bc3984..40917fd76 100644
--- a/pkg/cmd/pr/checkout/checkout_test.go
+++ b/pkg/cmd/pr/checkout/checkout_test.go
@@ -23,8 +23,87 @@ import (
"github.com/cli/cli/v2/test"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
+func TestNewCmdCheckout(t *testing.T) {
+ tests := []struct {
+ name string
+ args string
+ wantsOpts CheckoutOptions
+ wantErr error
+ }{
+ {
+ name: "recurse submodules",
+ args: "--recurse-submodules 123",
+ wantsOpts: CheckoutOptions{
+ RecurseSubmodules: true,
+ },
+ },
+ {
+ name: "force",
+ args: "--force 123",
+ wantsOpts: CheckoutOptions{
+ Force: true,
+ },
+ },
+ {
+ name: "detach",
+ args: "--detach 123",
+ wantsOpts: CheckoutOptions{
+ Detach: true,
+ },
+ },
+ {
+ name: "branch",
+ args: "--branch test-branch 123",
+ wantsOpts: CheckoutOptions{
+ BranchName: "test-branch",
+ },
+ },
+ {
+ name: "when there is no selector and no TTY, returns an error",
+ args: "",
+ wantErr: cmdutil.FlagErrorf("pull request number, URL, or branch required when not running interactively"),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ios, _, _, _ := iostreams.Test()
+ f := &cmdutil.Factory{
+ IOStreams: ios,
+ }
+
+ ios.SetStdinTTY(false)
+
+ argv, err := shlex.Split(tt.args)
+ assert.NoError(t, err)
+
+ var spiedOpts *CheckoutOptions
+ cmd := NewCmdCheckout(f, func(opts *CheckoutOptions) error {
+ spiedOpts = opts
+ return nil
+ })
+ cmd.SetArgs(argv)
+ cmd.SetIn(&bytes.Buffer{})
+ cmd.SetOut(&bytes.Buffer{})
+ cmd.SetErr(&bytes.Buffer{})
+
+ _, err = cmd.ExecuteC()
+ if tt.wantErr != nil {
+ require.Equal(t, tt.wantErr, err)
+ return
+ }
+ require.NoError(t, err)
+ require.Equal(t, tt.wantsOpts.RecurseSubmodules, spiedOpts.RecurseSubmodules)
+ require.Equal(t, tt.wantsOpts.Force, spiedOpts.Force)
+ require.Equal(t, tt.wantsOpts.Detach, spiedOpts.Detach)
+ require.Equal(t, tt.wantsOpts.BranchName, spiedOpts.BranchName)
+ })
+ }
+}
+
// repo: either "baseOwner/baseRepo" or "baseOwner/baseRepo:defaultBranch"
// prHead: "headOwner/headRepo:headBranch"
func stubPR(repo, prHead string) (ghrepo.Interface, *api.PullRequest) {
@@ -70,6 +149,20 @@ func _stubPR(repo, prHead string, number int, title string, state string, isDraf
}
}
+type stubPRResolver struct {
+ pr *api.PullRequest
+ baseRepo ghrepo.Interface
+
+ err error
+}
+
+func (s *stubPRResolver) Resolve() (*api.PullRequest, ghrepo.Interface, error) {
+ if s.err != nil {
+ return nil, nil, s.err
+ }
+ return s.pr, s.baseRepo, nil
+}
+
func Test_checkoutRun(t *testing.T) {
tests := []struct {
name string
@@ -88,16 +181,13 @@ func Test_checkoutRun(t *testing.T) {
{
name: "checkout with ssh remote URL",
opts: &CheckoutOptions{
- SelectorArg: "123",
- Finder: func() shared.PRFinder {
+ PRResolver: func() PRResolver {
baseRepo, pr := stubPR("OWNER/REPO:master", "OWNER/REPO:feature")
- finder := shared.NewMockFinder("123", pr, baseRepo)
- return finder
+ return &stubPRResolver{
+ pr: pr,
+ baseRepo: baseRepo,
+ }
}(),
- BaseRepo: func() (ghrepo.Interface, error) {
- baseRepo, _ := stubPR("OWNER/REPO:master", "OWNER/REPO:feature")
- return baseRepo, nil
- },
Config: func() (gh.Config, error) {
return config.NewBlankConfig(), nil
},
@@ -110,25 +200,22 @@ func Test_checkoutRun(t *testing.T) {
},
runStubs: func(cs *run.CommandStubber) {
cs.Register(`git show-ref --verify -- refs/heads/feature`, 1, "")
- cs.Register(`git fetch origin \+refs/heads/feature:refs/remotes/origin/feature`, 0, "")
+ cs.Register(`git fetch origin \+refs/heads/feature:refs/remotes/origin/feature --no-tags`, 0, "")
cs.Register(`git checkout -b feature --track origin/feature`, 0, "")
},
},
{
name: "fork repo was deleted",
opts: &CheckoutOptions{
- SelectorArg: "123",
- Finder: func() shared.PRFinder {
- baseRepo, pr := stubPR("OWNER/REPO:master", "hubot/REPO:feature")
+ PRResolver: func() PRResolver {
+ baseRepo, pr := stubPR("OWNER/REPO:master", "OWNER/REPO:feature")
pr.MaintainerCanModify = true
pr.HeadRepository = nil
- finder := shared.NewMockFinder("123", pr, baseRepo)
- return finder
+ return &stubPRResolver{
+ pr: pr,
+ baseRepo: baseRepo,
+ }
}(),
- BaseRepo: func() (ghrepo.Interface, error) {
- baseRepo, _ := stubPR("OWNER/REPO:master", "OWNER/REPO:feature")
- return baseRepo, nil
- },
Config: func() (gh.Config, error) {
return config.NewBlankConfig(), nil
},
@@ -140,7 +227,7 @@ func Test_checkoutRun(t *testing.T) {
"origin": "OWNER/REPO",
},
runStubs: func(cs *run.CommandStubber) {
- cs.Register(`git fetch origin refs/pull/123/head:feature`, 0, "")
+ cs.Register(`git fetch origin refs/pull/123/head:feature --no-tags`, 0, "")
cs.Register(`git config branch\.feature\.merge`, 1, "")
cs.Register(`git checkout feature`, 0, "")
cs.Register(`git config branch\.feature\.remote origin`, 0, "")
@@ -151,17 +238,14 @@ func Test_checkoutRun(t *testing.T) {
{
name: "with local branch rename and existing git remote",
opts: &CheckoutOptions{
- SelectorArg: "123",
- BranchName: "foobar",
- Finder: func() shared.PRFinder {
+ BranchName: "foobar",
+ PRResolver: func() PRResolver {
baseRepo, pr := stubPR("OWNER/REPO:master", "OWNER/REPO:feature")
- finder := shared.NewMockFinder("123", pr, baseRepo)
- return finder
+ return &stubPRResolver{
+ pr: pr,
+ baseRepo: baseRepo,
+ }
}(),
- BaseRepo: func() (ghrepo.Interface, error) {
- baseRepo, _ := stubPR("OWNER/REPO:master", "OWNER/REPO:feature")
- return baseRepo, nil
- },
Config: func() (gh.Config, error) {
return config.NewBlankConfig(), nil
},
@@ -174,25 +258,22 @@ func Test_checkoutRun(t *testing.T) {
},
runStubs: func(cs *run.CommandStubber) {
cs.Register(`git show-ref --verify -- refs/heads/foobar`, 1, "")
- cs.Register(`git fetch origin \+refs/heads/feature:refs/remotes/origin/feature`, 0, "")
+ cs.Register(`git fetch origin \+refs/heads/feature:refs/remotes/origin/feature --no-tags`, 0, "")
cs.Register(`git checkout -b foobar --track origin/feature`, 0, "")
},
},
{
name: "with local branch name, no existing git remote",
opts: &CheckoutOptions{
- SelectorArg: "123",
- BranchName: "foobar",
- Finder: func() shared.PRFinder {
+ BranchName: "foobar",
+ PRResolver: func() PRResolver {
baseRepo, pr := stubPR("OWNER/REPO:master", "hubot/REPO:feature")
pr.MaintainerCanModify = true
- finder := shared.NewMockFinder("123", pr, baseRepo)
- return finder
+ return &stubPRResolver{
+ pr: pr,
+ baseRepo: baseRepo,
+ }
}(),
- BaseRepo: func() (ghrepo.Interface, error) {
- baseRepo, _ := stubPR("OWNER/REPO:master", "hubot/REPO:feature")
- return baseRepo, nil
- },
Config: func() (gh.Config, error) {
return config.NewBlankConfig(), nil
},
@@ -205,7 +286,7 @@ func Test_checkoutRun(t *testing.T) {
},
runStubs: func(cs *run.CommandStubber) {
cs.Register(`git config branch\.foobar\.merge`, 1, "")
- cs.Register(`git fetch origin refs/pull/123/head:foobar`, 0, "")
+ cs.Register(`git fetch origin refs/pull/123/head:foobar --no-tags`, 0, "")
cs.Register(`git checkout foobar`, 0, "")
cs.Register(`git config branch\.foobar\.remote https://github.com/hubot/REPO.git`, 0, "")
cs.Register(`git config branch\.foobar\.pushRemote https://github.com/hubot/REPO.git`, 0, "")
@@ -213,78 +294,14 @@ func Test_checkoutRun(t *testing.T) {
},
},
{
- name: "with no selected PR args and non tty, return error",
+ name: "when the PR resolver errors, then that error is bubbled up",
opts: &CheckoutOptions{
- SelectorArg: "",
- Interactive: false,
- BaseRepo: func() (ghrepo.Interface, error) {
- return ghrepo.New("OWNER", "REPO"), nil
+ PRResolver: &stubPRResolver{
+ err: errors.New("expected test error"),
},
},
- remotes: map[string]string{
- "origin": "OWNER/REPO",
- },
wantErr: true,
- errMsg: "pull request number, URL, or branch required when not running interactively",
- },
- {
- name: "with no selected PR args and stdin tty, prompts for choice",
- opts: &CheckoutOptions{
- SelectorArg: "",
- Interactive: true,
- Lister: func() shared.PRLister {
- _, pr1 := _stubPR("OWNER/REPO:master", "OWNER/REPO:feature", 32, "New feature", "OPEN", false)
- _, pr2 := _stubPR("OWNER/REPO:master", "OWNER/REPO:bug-fix", 29, "Fixed bad bug", "OPEN", false)
- _, pr3 := _stubPR("OWNER/REPO:master", "OWNER/REPO:docs", 28, "Improve documentation", "OPEN", true)
- lister := shared.NewMockLister(&api.PullRequestAndTotalCount{
- TotalCount: 3,
- PullRequests: []api.PullRequest{
- *pr1, *pr2, *pr3,
- }, SearchCapped: false}, nil)
- lister.ExpectFields([]string{"number", "title", "state", "isDraft", "headRefName", "headRepository", "headRepositoryOwner", "isCrossRepository", "maintainerCanModify"})
- return lister
- }(),
- BaseRepo: func() (ghrepo.Interface, error) {
- return ghrepo.New("OWNER", "REPO"), nil
- },
- Config: func() (gh.Config, error) {
- return config.NewBlankConfig(), nil
- },
- },
- promptStubs: func(pm *prompter.MockPrompter) {
- pm.RegisterSelect("Select a pull request",
- []string{"32\tOPEN New feature [feature]", "29\tOPEN Fixed bad bug [bug-fix]", "28\tDRAFT Improve documentation [docs]"},
- func(_, _ string, opts []string) (int, error) {
- return prompter.IndexFor(opts, "32\tOPEN New feature [feature]")
- })
- },
- runStubs: func(cs *run.CommandStubber) {
- cs.Register(`git show-ref --verify -- refs/heads/feature`, 1, "")
- cs.Register(`git fetch origin \+refs/heads/feature:refs/remotes/origin/feature`, 0, "")
- cs.Register(`git checkout -b feature --track origin/feature`, 0, "")
- },
- remotes: map[string]string{
- "origin": "OWNER/REPO",
- },
- },
- {
- name: "with no select PR args and no open PR, return error",
- opts: &CheckoutOptions{
- SelectorArg: "",
- Interactive: true,
- BaseRepo: func() (ghrepo.Interface, error) {
- return ghrepo.New("OWNER", "REPO"), nil
- },
- Lister: shared.NewMockLister(&api.PullRequestAndTotalCount{
- TotalCount: 0,
- PullRequests: []api.PullRequest{},
- }, nil),
- },
- remotes: map[string]string{
- "origin": "OWNER/REPO",
- },
- wantErr: true,
- errMsg: "no open pull requests in OWNER/REPO",
+ errMsg: "expected test error",
},
}
for _, tt := range tests {
@@ -309,12 +326,6 @@ func Test_checkoutRun(t *testing.T) {
tt.runStubs(cmdStubs)
}
- pm := prompter.NewMockPrompter(t)
- tt.opts.Prompter = pm
- if tt.promptStubs != nil {
- tt.promptStubs(pm)
- }
-
opts.Remotes = func() (context.Remotes, error) {
if len(tt.remotes) == 0 {
return nil, errors.New("no remotes")
@@ -351,6 +362,102 @@ func Test_checkoutRun(t *testing.T) {
}
}
+func TestSpecificPRResolver(t *testing.T) {
+ t.Run("when the PR Finder returns results, those are returned", func(t *testing.T) {
+ t.Parallel()
+
+ baseRepo, pr := stubPR("OWNER/REPO:master", "OWNER/REPO:feature")
+ mockFinder := shared.NewMockFinder("123", pr, baseRepo)
+ mockFinder.ExpectFields([]string{"number", "headRefName", "headRepository", "headRepositoryOwner", "isCrossRepository", "maintainerCanModify"})
+
+ resolver := &specificPRResolver{
+ prFinder: mockFinder,
+ selector: "123",
+ }
+
+ resolvedPR, resolvedBaseRepo, err := resolver.Resolve()
+ require.NoError(t, err)
+ require.Equal(t, pr, resolvedPR)
+ require.True(t, ghrepo.IsSame(baseRepo, resolvedBaseRepo), "expected repos to be the same")
+ })
+
+ t.Run("when the PR Finder errors, that error is returned", func(t *testing.T) {
+ t.Parallel()
+
+ mockFinder := shared.NewMockFinder("123", nil, nil)
+
+ resolver := &specificPRResolver{
+ prFinder: mockFinder,
+ selector: "123",
+ }
+
+ _, _, err := resolver.Resolve()
+ var notFoundErr *shared.NotFoundError
+ require.ErrorAs(t, err, ¬FoundErr)
+ })
+}
+
+func TestPromptingPRResolver(t *testing.T) {
+ t.Run("when the PR Lister has results, then we prompt for a choice", func(t *testing.T) {
+ t.Parallel()
+
+ ios, _, _, _ := iostreams.Test()
+
+ baseRepo, pr1 := _stubPR("OWNER/REPO:master", "OWNER/REPO:feature", 32, "New feature", "OPEN", false)
+ _, pr2 := _stubPR("OWNER/REPO:master", "OWNER/REPO:bug-fix", 29, "Fixed bad bug", "OPEN", false)
+ _, pr3 := _stubPR("OWNER/REPO:master", "OWNER/REPO:docs", 28, "Improve documentation", "OPEN", true)
+ lister := shared.NewMockLister(&api.PullRequestAndTotalCount{
+ TotalCount: 3,
+ PullRequests: []api.PullRequest{
+ *pr1, *pr2, *pr3,
+ }, SearchCapped: false}, nil)
+ lister.ExpectFields([]string{"number", "title", "state", "isDraft", "headRefName", "headRepository", "headRepositoryOwner", "isCrossRepository", "maintainerCanModify"})
+
+ pm := prompter.NewMockPrompter(t)
+ pm.RegisterSelect("Select a pull request",
+ []string{"32\tOPEN New feature [feature]", "29\tOPEN Fixed bad bug [bug-fix]", "28\tDRAFT Improve documentation [docs]"},
+ func(_, _ string, opts []string) (int, error) {
+ return prompter.IndexFor(opts, "32\tOPEN New feature [feature]")
+ })
+
+ resolver := &promptingPRResolver{
+ io: ios,
+ prompter: pm,
+
+ prLister: lister,
+
+ baseRepo: baseRepo,
+ }
+
+ resolvedPR, resolvedBaseRepo, err := resolver.Resolve()
+ require.NoError(t, err)
+ require.Equal(t, pr1, resolvedPR)
+ require.True(t, ghrepo.IsSame(baseRepo, resolvedBaseRepo), "expected repos to be the same")
+ })
+
+ t.Run("when the PR lister has no results, then we return an error", func(t *testing.T) {
+ t.Parallel()
+
+ ios, _, _, _ := iostreams.Test()
+
+ lister := shared.NewMockLister(&api.PullRequestAndTotalCount{
+ TotalCount: 0,
+ PullRequests: []api.PullRequest{},
+ }, nil)
+
+ resolver := &promptingPRResolver{
+ io: ios,
+ prLister: lister,
+ baseRepo: ghrepo.New("OWNER", "REPO"),
+ }
+
+ _, _, err := resolver.Resolve()
+ var noResultsErr cmdutil.NoResultsError
+ require.ErrorAs(t, err, &noResultsErr)
+ require.Equal(t, "no open pull requests in OWNER/REPO", noResultsErr.Error())
+ })
+}
+
/** LEGACY TESTS **/
func runCommand(rt http.RoundTripper, remotes context.Remotes, branch string, cli string, baseRepo ghrepo.Interface) (*test.CmdOut, error) {
@@ -417,7 +524,7 @@ func TestPRCheckout_sameRepo(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
- cs.Register(`git fetch origin \+refs/heads/feature:refs/remotes/origin/feature`, 0, "")
+ cs.Register(`git fetch origin \+refs/heads/feature:refs/remotes/origin/feature --no-tags`, 0, "")
cs.Register(`git show-ref --verify -- refs/heads/feature`, 1, "")
cs.Register(`git checkout -b feature --track origin/feature`, 0, "")
@@ -436,7 +543,7 @@ func TestPRCheckout_existingBranch(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
- cs.Register(`git fetch origin \+refs/heads/feature:refs/remotes/origin/feature`, 0, "")
+ cs.Register(`git fetch origin \+refs/heads/feature:refs/remotes/origin/feature --no-tags`, 0, "")
cs.Register(`git show-ref --verify -- refs/heads/feature`, 0, "")
cs.Register(`git checkout feature`, 0, "")
cs.Register(`git merge --ff-only refs/remotes/origin/feature`, 0, "")
@@ -468,7 +575,7 @@ func TestPRCheckout_differentRepo_remoteExists(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
- cs.Register(`git fetch robot-fork \+refs/heads/feature:refs/remotes/robot-fork/feature`, 0, "")
+ cs.Register(`git fetch robot-fork \+refs/heads/feature:refs/remotes/robot-fork/feature --no-tags`, 0, "")
cs.Register(`git show-ref --verify -- refs/heads/feature`, 1, "")
cs.Register(`git checkout -b feature --track robot-fork/feature`, 0, "")
@@ -488,7 +595,7 @@ func TestPRCheckout_differentRepo(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
- cs.Register(`git fetch origin refs/pull/123/head:feature`, 0, "")
+ cs.Register(`git fetch origin refs/pull/123/head:feature --no-tags`, 0, "")
cs.Register(`git config branch\.feature\.merge`, 1, "")
cs.Register(`git checkout feature`, 0, "")
cs.Register(`git config branch\.feature\.remote origin`, 0, "")
@@ -501,6 +608,29 @@ func TestPRCheckout_differentRepo(t *testing.T) {
assert.Equal(t, "", output.Stderr())
}
+func TestPRCheckout_differentRepoForce(t *testing.T) {
+ http := &httpmock.Registry{}
+ defer http.Verify(t)
+
+ baseRepo, pr := stubPR("OWNER/REPO:master", "hubot/REPO:feature")
+ finder := shared.RunCommandFinder("123", pr, baseRepo)
+ finder.ExpectFields([]string{"number", "headRefName", "headRepository", "headRepositoryOwner", "isCrossRepository", "maintainerCanModify"})
+
+ cs, cmdTeardown := run.Stub()
+ defer cmdTeardown(t)
+ cs.Register(`git fetch origin refs/pull/123/head:feature --no-tags --force`, 0, "")
+ cs.Register(`git config branch\.feature\.merge`, 1, "")
+ cs.Register(`git checkout feature`, 0, "")
+ cs.Register(`git config branch\.feature\.remote origin`, 0, "")
+ cs.Register(`git config branch\.feature\.pushRemote origin`, 0, "")
+ cs.Register(`git config branch\.feature\.merge refs/pull/123/head`, 0, "")
+
+ output, err := runCommand(http, nil, "master", `123 --force`, baseRepo)
+ assert.NoError(t, err)
+ assert.Equal(t, "", output.String())
+ assert.Equal(t, "", output.Stderr())
+}
+
func TestPRCheckout_differentRepo_existingBranch(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
@@ -510,7 +640,7 @@ func TestPRCheckout_differentRepo_existingBranch(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
- cs.Register(`git fetch origin refs/pull/123/head:feature`, 0, "")
+ cs.Register(`git fetch origin refs/pull/123/head:feature --no-tags`, 0, "")
cs.Register(`git config branch\.feature\.merge`, 0, "refs/heads/feature\n")
cs.Register(`git checkout feature`, 0, "")
@@ -529,7 +659,7 @@ func TestPRCheckout_detachedHead(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
- cs.Register(`git fetch origin refs/pull/123/head:feature`, 0, "")
+ cs.Register(`git fetch origin refs/pull/123/head:feature --no-tags`, 0, "")
cs.Register(`git config branch\.feature\.merge`, 0, "refs/heads/feature\n")
cs.Register(`git checkout feature`, 0, "")
@@ -548,7 +678,7 @@ func TestPRCheckout_differentRepo_currentBranch(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
- cs.Register(`git fetch origin refs/pull/123/head`, 0, "")
+ cs.Register(`git fetch origin refs/pull/123/head --no-tags`, 0, "")
cs.Register(`git config branch\.feature\.merge`, 0, "refs/heads/feature\n")
cs.Register(`git merge --ff-only FETCH_HEAD`, 0, "")
@@ -585,7 +715,7 @@ func TestPRCheckout_maintainerCanModify(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
- cs.Register(`git fetch origin refs/pull/123/head:feature`, 0, "")
+ cs.Register(`git fetch origin refs/pull/123/head:feature --no-tags`, 0, "")
cs.Register(`git config branch\.feature\.merge`, 1, "")
cs.Register(`git checkout feature`, 0, "")
cs.Register(`git config branch\.feature\.remote https://github\.com/hubot/REPO\.git`, 0, "")
@@ -606,7 +736,7 @@ func TestPRCheckout_recurseSubmodules(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
- cs.Register(`git fetch origin \+refs/heads/feature:refs/remotes/origin/feature`, 0, "")
+ cs.Register(`git fetch origin \+refs/heads/feature:refs/remotes/origin/feature --no-tags`, 0, "")
cs.Register(`git show-ref --verify -- refs/heads/feature`, 0, "")
cs.Register(`git checkout feature`, 0, "")
cs.Register(`git merge --ff-only refs/remotes/origin/feature`, 0, "")
@@ -627,7 +757,7 @@ func TestPRCheckout_force(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
- cs.Register(`git fetch origin \+refs/heads/feature:refs/remotes/origin/feature`, 0, "")
+ cs.Register(`git fetch origin \+refs/heads/feature:refs/remotes/origin/feature --no-tags`, 0, "")
cs.Register(`git show-ref --verify -- refs/heads/feature`, 0, "")
cs.Register(`git checkout feature`, 0, "")
cs.Register(`git reset --hard refs/remotes/origin/feature`, 0, "")
@@ -649,7 +779,7 @@ func TestPRCheckout_detach(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git checkout --detach FETCH_HEAD`, 0, "")
- cs.Register(`git fetch origin refs/pull/123/head`, 0, "")
+ cs.Register(`git fetch origin refs/pull/123/head --no-tags`, 0, "")
output, err := runCommand(http, nil, "", `123 --detach`, baseRepo)
assert.NoError(t, err)
diff --git a/pkg/cmd/pr/close/close.go b/pkg/cmd/pr/close/close.go
index ec5132377..063501ef7 100644
--- a/pkg/cmd/pr/close/close.go
+++ b/pkg/cmd/pr/close/close.go
@@ -143,7 +143,7 @@ func closeRun(opts *CloseOptions) error {
branchSwitchString = fmt.Sprintf(" and switched to branch %s", cs.Cyan(branchToSwitchTo))
}
} else {
- fmt.Fprintf(opts.IO.ErrOut, "%s Skipped deleting the local branch since current directory is not a git repository \n", cs.WarningIcon())
+ fmt.Fprintf(opts.IO.ErrOut, "%s Skipped deleting the local branch since current directory is not a git repository\n", cs.WarningIcon())
}
}
diff --git a/pkg/cmd/pr/close/close_test.go b/pkg/cmd/pr/close/close_test.go
index f957581f8..959af0e04 100644
--- a/pkg/cmd/pr/close/close_test.go
+++ b/pkg/cmd/pr/close/close_test.go
@@ -271,7 +271,7 @@ func TestPrClose_deleteBranch_notInGitRepo(t *testing.T) {
assert.Equal(t, "", output.String())
assert.Equal(t, heredoc.Doc(`
✓ Closed pull request OWNER/REPO#96 (The title of the PR)
- ! Skipped deleting the local branch since current directory is not a git repository
+ ! Skipped deleting the local branch since current directory is not a git repository
✓ Deleted branch trunk
`), output.Stderr())
}
diff --git a/pkg/cmd/pr/comment/comment.go b/pkg/cmd/pr/comment/comment.go
index 31a2bc25c..a2ab4bf9e 100644
--- a/pkg/cmd/pr/comment/comment.go
+++ b/pkg/cmd/pr/comment/comment.go
@@ -10,12 +10,13 @@ import (
func NewCmdComment(f *cmdutil.Factory, runF func(*shared.CommentableOptions) error) *cobra.Command {
opts := &shared.CommentableOptions{
- IO: f.IOStreams,
- HttpClient: f.HttpClient,
- EditSurvey: shared.CommentableEditSurvey(f.Config, f.IOStreams),
- InteractiveEditSurvey: shared.CommentableInteractiveEditSurvey(f.Config, f.IOStreams),
- ConfirmSubmitSurvey: shared.CommentableConfirmSubmitSurvey(f.Prompter),
- OpenInBrowser: f.Browser.Browse,
+ IO: f.IOStreams,
+ HttpClient: f.HttpClient,
+ EditSurvey: shared.CommentableEditSurvey(f.Config, f.IOStreams),
+ InteractiveEditSurvey: shared.CommentableInteractiveEditSurvey(f.Config, f.IOStreams),
+ ConfirmSubmitSurvey: shared.CommentableConfirmSubmitSurvey(f.Prompter),
+ ConfirmCreateIfNoneSurvey: shared.CommentableInteractiveCreateIfNoneSurvey(f.Prompter),
+ OpenInBrowser: f.Browser.Browse,
}
var bodyFile string
@@ -75,6 +76,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*shared.CommentableOptions) err
cmd.Flags().BoolP("editor", "e", false, "Skip prompts and open the text editor to write the body in")
cmd.Flags().BoolP("web", "w", false, "Open the web browser to write the comment")
cmd.Flags().BoolVar(&opts.EditLast, "edit-last", false, "Edit the last comment of the same author")
+ cmd.Flags().BoolVar(&opts.CreateIfNone, "create-if-none", false, "Create a new comment if no comments are found. Can be used only with --edit-last")
return cmd
}
diff --git a/pkg/cmd/pr/comment/comment_test.go b/pkg/cmd/pr/comment/comment_test.go
index 56cf58d21..a6cc36abf 100644
--- a/pkg/cmd/pr/comment/comment_test.go
+++ b/pkg/cmd/pr/comment/comment_test.go
@@ -129,6 +129,29 @@ func TestNewCmdComment(t *testing.T) {
},
wantsErr: false,
},
+ {
+ name: "edit last flag",
+ input: "1 --edit-last",
+ output: shared.CommentableOptions{
+ Interactive: true,
+ InputType: shared.InputTypeEditor,
+ Body: "",
+ EditLast: true,
+ },
+ wantsErr: false,
+ },
+ {
+ name: "edit last flag with create if none",
+ input: "1 --edit-last --create-if-none",
+ output: shared.CommentableOptions{
+ Interactive: true,
+ InputType: shared.InputTypeEditor,
+ Body: "",
+ EditLast: true,
+ CreateIfNone: true,
+ },
+ wantsErr: false,
+ },
{
name: "body and body-file flags",
input: "1 --body 'test' --body-file 'test-file.txt'",
@@ -159,6 +182,12 @@ func TestNewCmdComment(t *testing.T) {
output: shared.CommentableOptions{},
wantsErr: true,
},
+ {
+ name: "create-if-none flag without edit-last",
+ input: "1 --create-if-none",
+ output: shared.CommentableOptions{},
+ wantsErr: true,
+ },
}
for _, tt := range tests {
@@ -208,11 +237,12 @@ func TestNewCmdComment(t *testing.T) {
func Test_commentRun(t *testing.T) {
tests := []struct {
- name string
- input *shared.CommentableOptions
- httpStubs func(*testing.T, *httpmock.Registry)
- stdout string
- stderr string
+ name string
+ input *shared.CommentableOptions
+ emptyComments bool
+ httpStubs func(*testing.T, *httpmock.Registry)
+ stdout string
+ stderr string
}{
{
name: "interactive editor",
@@ -245,6 +275,24 @@ func Test_commentRun(t *testing.T) {
},
stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n",
},
+ {
+ name: "interactive editor with edit last and create if none",
+ input: &shared.CommentableOptions{
+ Interactive: true,
+ InputType: 0,
+ Body: "",
+ EditLast: true,
+ CreateIfNone: true,
+
+ InteractiveEditSurvey: func(string) (string, error) { return "comment body", nil },
+ ConfirmCreateIfNoneSurvey: func() (bool, error) { return true, nil },
+ ConfirmSubmitSurvey: func() (bool, error) { return true, nil },
+ },
+ httpStubs: func(t *testing.T, reg *httpmock.Registry) {
+ mockCommentUpdate(t, reg)
+ },
+ stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n",
+ },
{
name: "non-interactive web",
input: &shared.CommentableOptions{
@@ -264,7 +312,43 @@ func Test_commentRun(t *testing.T) {
Body: "",
EditLast: true,
- OpenInBrowser: func(string) error { return nil },
+ OpenInBrowser: func(u string) error {
+ assert.Contains(t, u, "#issuecomment-111")
+ 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,
+ stderr: "Opening https://github.com/OWNER/REPO/pull/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/pull/123 in your browser.\n",
},
@@ -297,6 +381,23 @@ 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",
+ input: &shared.CommentableOptions{
+ Interactive: false,
+ InputType: shared.InputTypeEditor,
+ Body: "",
+ EditLast: true,
+ CreateIfNone: true,
+
+ EditSurvey: func(string) (string, error) { return "comment body", nil },
+ },
+ emptyComments: true,
+ httpStubs: func(t *testing.T, reg *httpmock.Registry) {
+ mockCommentCreate(t, reg)
+ },
+ stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
+ },
{
name: "non-interactive inline",
input: &shared.CommentableOptions{
@@ -339,14 +440,20 @@ func Test_commentRun(t *testing.T) {
tt.input.IO = ios
tt.input.HttpClient = httpClient
+
+ comments := api.Comments{Nodes: []api.Comment{
+ {ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-111", ViewerDidAuthor: true},
+ {ID: "id2", Author: api.CommentAuthor{Login: "monalisa"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-222"},
+ }}
+ if tt.emptyComments {
+ comments.Nodes = []api.Comment{}
+ }
+
tt.input.RetrieveCommentable = func() (shared.Commentable, ghrepo.Interface, error) {
return &api.PullRequest{
- Number: 123,
- URL: "https://github.com/OWNER/REPO/pull/123",
- Comments: api.Comments{Nodes: []api.Comment{
- {ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-111", ViewerDidAuthor: true},
- {ID: "id2", Author: api.CommentAuthor{Login: "monalisa"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-222"},
- }},
+ Number: 123,
+ URL: "https://github.com/OWNER/REPO/pull/123",
+ Comments: comments,
}, ghrepo.New("OWNER", "REPO"), nil
}
diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go
index b2abe0938..0066bbc6e 100644
--- a/pkg/cmd/pr/create/create.go
+++ b/pkg/cmd/pr/create/create.go
@@ -935,6 +935,7 @@ func handlePush(opts CreateOptions, ctx CreateContext) error {
headRepo := ctx.HeadRepo
headRemote := ctx.HeadRemote
client := ctx.Client
+ gitClient := ctx.GitClient
var err error
// if a head repository could not be determined so far, automatically create
@@ -976,7 +977,8 @@ func handlePush(opts CreateOptions, ctx CreateContext) error {
headRepoURL := ghrepo.FormatRemoteURL(headRepo, cloneProtocol)
gitClient := ctx.GitClient
origin, _ := remotes.FindByName("origin")
- upstream, _ := remotes.FindByName("upstream")
+ upstreamName := "upstream"
+ upstream, _ := remotes.FindByName(upstreamName)
remoteName := "origin"
if origin != nil {
@@ -984,7 +986,7 @@ func handlePush(opts CreateOptions, ctx CreateContext) error {
}
if origin != nil && upstream == nil && ghrepo.IsSame(origin, ctx.BaseRepo) {
- renameCmd, err := gitClient.Command(context.Background(), "remote", "rename", "origin", "upstream")
+ renameCmd, err := gitClient.Command(context.Background(), "remote", "rename", "origin", upstreamName)
if err != nil {
return err
}
@@ -992,7 +994,7 @@ func handlePush(opts CreateOptions, ctx CreateContext) error {
return fmt.Errorf("error renaming origin remote: %w", err)
}
remoteName = "origin"
- fmt.Fprintf(opts.IO.ErrOut, "Changed %s remote to %q\n", ghrepo.FullName(ctx.BaseRepo), "upstream")
+ fmt.Fprintf(opts.IO.ErrOut, "Changed %s remote to %q\n", ghrepo.FullName(ctx.BaseRepo), upstreamName)
}
gitRemote, err := gitClient.AddRemote(context.Background(), remoteName, headRepoURL, []string{})
@@ -1002,6 +1004,19 @@ func handlePush(opts CreateOptions, ctx CreateContext) error {
fmt.Fprintf(opts.IO.ErrOut, "Added %s as remote %q\n", ghrepo.FullName(headRepo), remoteName)
+ // Only mark `upstream` remote as default if `gh pr create` created the remote.
+ if didForkRepo {
+ err := gitClient.SetRemoteResolution(context.Background(), upstreamName, "base")
+ if err != nil {
+ return fmt.Errorf("error setting upstream as default: %w", err)
+ }
+
+ if opts.IO.IsStdoutTTY() {
+ cs := opts.IO.ColorScheme()
+ fmt.Fprintf(opts.IO.ErrOut, "%s Repository %s set as the default repository. To learn more about the default repository, run: gh repo set-default --help\n", cs.WarningIcon(), cs.Bold(ghrepo.FullName(headRepo)))
+ }
+ }
+
headRemote = &ghContext.Remote{
Remote: gitRemote,
Repo: headRepo,
@@ -1013,7 +1028,6 @@ func handlePush(opts CreateOptions, ctx CreateContext) error {
pushBranch := func() error {
w := NewRegexpWriter(opts.IO.ErrOut, gitPushRegexp, "")
defer w.Flush()
- gitClient := ctx.GitClient
ref := fmt.Sprintf("HEAD:refs/heads/%s", ctx.HeadBranch)
bo := backoff.NewConstantBackOff(2 * time.Second)
ctx := context.Background()
diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go
index 6df3f9880..4d7c294db 100644
--- a/pkg/cmd/pr/create/create_test.go
+++ b/pkg/cmd/pr/create/create_test.go
@@ -798,6 +798,7 @@ func Test_createRun(t *testing.T) {
cs.Register("git remote rename origin upstream", 0, "")
cs.Register(`git remote add origin https://github.com/monalisa/REPO.git`, 0, "")
cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "")
+ cs.Register(`git config --add remote.upstream.gh-resolved base`, 0, "")
},
promptStubs: func(pm *prompter.PrompterMock) {
pm.SelectFunc = func(p, _ string, opts []string) (int, error) {
@@ -809,7 +810,7 @@ func Test_createRun(t *testing.T) {
}
},
expectedOut: "https://github.com/OWNER/REPO/pull/12\n",
- expectedErrOut: "\nCreating pull request for monalisa:feature into master in OWNER/REPO\n\nChanged OWNER/REPO remote to \"upstream\"\nAdded monalisa/REPO as remote \"origin\"\n",
+ expectedErrOut: "\nCreating pull request for monalisa:feature into master in OWNER/REPO\n\nChanged OWNER/REPO remote to \"upstream\"\nAdded monalisa/REPO as remote \"origin\"\n! Repository monalisa/REPO set as the default repository. To learn more about the default repository, run: gh repo set-default --help\n",
},
{
name: "pushed to non base repo",
diff --git a/pkg/cmd/pr/list/http.go b/pkg/cmd/pr/list/http.go
index 3836b9eb9..4c69af708 100644
--- a/pkg/cmd/pr/list/http.go
+++ b/pkg/cmd/pr/list/http.go
@@ -7,7 +7,6 @@ import (
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghrepo"
prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared"
- "github.com/cli/cli/v2/pkg/cmdutil"
)
func shouldUseSearch(filters prShared.FilterOptions) bool {
@@ -19,10 +18,8 @@ func listPullRequests(httpClient *http.Client, repo ghrepo.Interface, filters pr
return searchPullRequests(httpClient, repo, filters, limit)
}
- return prShared.NewLister(&cmdutil.Factory{
- HttpClient: func() (*http.Client, error) { return httpClient, nil },
- BaseRepo: func() (ghrepo.Interface, error) { return repo, nil },
- }).List(prShared.ListOptions{
+ return prShared.NewLister(httpClient).List(prShared.ListOptions{
+ BaseRepo: repo,
LimitResults: limit,
State: filters.State,
BaseBranch: filters.BaseBranch,
diff --git a/pkg/cmd/pr/list/list.go b/pkg/cmd/pr/list/list.go
index 61be95998..ee708f161 100644
--- a/pkg/cmd/pr/list/list.go
+++ b/pkg/cmd/pr/list/list.go
@@ -61,16 +61,16 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
`),
Example: heredoc.Doc(`
- List PRs authored by you
+ # List PRs authored by you
$ gh pr list --author "@me"
- List only PRs with all of the given labels
+ # List only PRs with all of the given labels
$ gh pr list --label bug --label "priority 1"
- Filter PRs using search syntax
+ # Filter PRs using search syntax
$ gh pr list --search "status:success review:required"
- Find a PR that introduced a given commit
+ # Find a PR that introduced a given commit
$ gh pr list --search "" --state merged
`),
Aliases: []string{"ls"},
diff --git a/pkg/cmd/pr/review/review.go b/pkg/cmd/pr/review/review.go
index 4fa973a87..25f81d973 100644
--- a/pkg/cmd/pr/review/review.go
+++ b/pkg/cmd/pr/review/review.go
@@ -56,16 +56,16 @@ func NewCmdReview(f *cmdutil.Factory, runF func(*ReviewOptions) error) *cobra.Co
Without an argument, the pull request that belongs to the current branch is reviewed.
`),
Example: heredoc.Doc(`
- # approve the pull request of the current branch
+ # Approve the pull request of the current branch
$ gh pr review --approve
- # leave a review comment for the current branch
+ # Leave a review comment for the current branch
$ gh pr review --comment -b "interesting"
- # add a review for a specific pull request
+ # Add a review for a specific pull request
$ gh pr review 123
- # request changes on a specific pull request
+ # Request changes on a specific pull request
$ gh pr review 123 -r -b "needs more ASCII art"
`),
Args: cobra.MaximumNArgs(1),
diff --git a/pkg/cmd/pr/shared/commentable.go b/pkg/cmd/pr/shared/commentable.go
index 7a38286d2..46e3072e2 100644
--- a/pkg/cmd/pr/shared/commentable.go
+++ b/pkg/cmd/pr/shared/commentable.go
@@ -17,6 +17,8 @@ import (
"github.com/spf13/cobra"
)
+var errNoUserComments = errors.New("no comments found for current user")
+
type InputType int
const (
@@ -32,19 +34,21 @@ type Commentable interface {
}
type CommentableOptions struct {
- IO *iostreams.IOStreams
- HttpClient func() (*http.Client, error)
- RetrieveCommentable func() (Commentable, ghrepo.Interface, error)
- EditSurvey func(string) (string, error)
- InteractiveEditSurvey func(string) (string, error)
- ConfirmSubmitSurvey func() (bool, error)
- OpenInBrowser func(string) error
- Interactive bool
- InputType InputType
- Body string
- EditLast bool
- Quiet bool
- Host string
+ IO *iostreams.IOStreams
+ HttpClient func() (*http.Client, error)
+ RetrieveCommentable func() (Commentable, ghrepo.Interface, error)
+ EditSurvey func(string) (string, error)
+ InteractiveEditSurvey func(string) (string, error)
+ ConfirmSubmitSurvey func() (bool, error)
+ ConfirmCreateIfNoneSurvey func() (bool, error)
+ OpenInBrowser func(string) error
+ Interactive bool
+ InputType InputType
+ Body string
+ EditLast bool
+ CreateIfNone bool
+ Quiet bool
+ Host string
}
func CommentablePreRun(cmd *cobra.Command, opts *CommentableOptions) error {
@@ -66,6 +70,10 @@ func CommentablePreRun(cmd *cobra.Command, opts *CommentableOptions) error {
inputFlags++
}
+ if opts.CreateIfNone && !opts.EditLast {
+ return cmdutil.FlagErrorf("`--create-if-none` can only be used with `--edit-last`")
+ }
+
if inputFlags == 0 {
if !opts.IO.CanPrompt() {
return cmdutil.FlagErrorf("flags required when not running interactively")
@@ -85,7 +93,24 @@ func CommentableRun(opts *CommentableOptions) error {
}
opts.Host = repo.RepoHost()
if opts.EditLast {
- return updateComment(commentable, opts)
+ err := updateComment(commentable, opts)
+ if !errors.Is(err, errNoUserComments) {
+ 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
+ }
+ }
+ }
}
return createComment(commentable, opts)
}
@@ -144,7 +169,7 @@ func createComment(commentable Commentable, opts *CommentableOptions) error {
func updateComment(commentable Commentable, opts *CommentableOptions) error {
comments := commentable.CurrentUserComments()
if len(comments) == 0 {
- return fmt.Errorf("no comments found for current user")
+ return errNoUserComments
}
lastComment := &comments[len(comments)-1]
@@ -219,6 +244,12 @@ func CommentableInteractiveEditSurvey(cf func() (gh.Config, error), io *iostream
}
}
+func CommentableInteractiveCreateIfNoneSurvey(p Prompt) func() (bool, error) {
+ return func() (bool, error) {
+ return p.Confirm("No comments found. Create one?", true)
+ }
+}
+
func CommentableEditSurvey(cf func() (gh.Config, error), io *iostreams.IOStreams) func(string) (string, error) {
return func(initialValue string) (string, error) {
editorCommand, err := cmdutil.DetermineEditor(cf)
diff --git a/pkg/cmd/pr/shared/lister.go b/pkg/cmd/pr/shared/lister.go
index 454ecbfcf..cd140a950 100644
--- a/pkg/cmd/pr/shared/lister.go
+++ b/pkg/cmd/pr/shared/lister.go
@@ -5,7 +5,6 @@ import (
"net/http"
"github.com/cli/cli/v2/internal/ghrepo"
- "github.com/cli/cli/v2/pkg/cmdutil"
api "github.com/cli/cli/v2/api"
)
@@ -15,6 +14,8 @@ type PRLister interface {
}
type ListOptions struct {
+ BaseRepo ghrepo.Interface
+
LimitResults int
State string
@@ -25,32 +26,16 @@ type ListOptions struct {
}
type lister struct {
- baseRepoFn func() (ghrepo.Interface, error)
- httpClient func() (*http.Client, error)
+ httpClient *http.Client
}
-func NewLister(factory *cmdutil.Factory) PRLister {
+func NewLister(httpClient *http.Client) PRLister {
return &lister{
- baseRepoFn: factory.BaseRepo,
- httpClient: factory.HttpClient,
+ httpClient: httpClient,
}
}
func (l *lister) List(opts ListOptions) (*api.PullRequestAndTotalCount, error) {
- repo, err := l.baseRepoFn()
- if err != nil {
- return nil, err
- }
-
- client, err := l.httpClient()
- if err != nil {
- return nil, err
- }
-
- return listPullRequests(client, repo, opts)
-}
-
-func listPullRequests(httpClient *http.Client, repo ghrepo.Interface, filters ListOptions) (*api.PullRequestAndTotalCount, error) {
type response struct {
Repository struct {
PullRequests struct {
@@ -63,8 +48,8 @@ func listPullRequests(httpClient *http.Client, repo ghrepo.Interface, filters Li
}
}
}
- limit := filters.LimitResults
- fragment := fmt.Sprintf("fragment pr on PullRequest{%s}", api.PullRequestGraphQL(filters.Fields))
+ limit := opts.LimitResults
+ fragment := fmt.Sprintf("fragment pr on PullRequest{%s}", api.PullRequestGraphQL(opts.Fields))
query := fragment + `
query PullRequestList(
$owner: String!,
@@ -98,11 +83,11 @@ func listPullRequests(httpClient *http.Client, repo ghrepo.Interface, filters Li
pageLimit := min(limit, 100)
variables := map[string]interface{}{
- "owner": repo.RepoOwner(),
- "repo": repo.RepoName(),
+ "owner": opts.BaseRepo.RepoOwner(),
+ "repo": opts.BaseRepo.RepoName(),
}
- switch filters.State {
+ switch opts.State {
case "open":
variables["state"] = []string{"OPEN"}
case "closed":
@@ -112,25 +97,25 @@ func listPullRequests(httpClient *http.Client, repo ghrepo.Interface, filters Li
case "all":
variables["state"] = []string{"OPEN", "CLOSED", "MERGED"}
default:
- return nil, fmt.Errorf("invalid state: %s", filters.State)
+ return nil, fmt.Errorf("invalid state: %s", opts.State)
}
- if filters.BaseBranch != "" {
- variables["baseBranch"] = filters.BaseBranch
+ if opts.BaseBranch != "" {
+ variables["baseBranch"] = opts.BaseBranch
}
- if filters.HeadBranch != "" {
- variables["headBranch"] = filters.HeadBranch
+ if opts.HeadBranch != "" {
+ variables["headBranch"] = opts.HeadBranch
}
res := api.PullRequestAndTotalCount{}
var check = make(map[int]struct{})
- client := api.NewClientFromHTTP(httpClient)
+ client := api.NewClientFromHTTP(l.httpClient)
loop:
for {
variables["limit"] = pageLimit
var data response
- err := client.GraphQL(repo.RepoHost(), query, variables, &data)
+ err := client.GraphQL(opts.BaseRepo.RepoHost(), query, variables, &data)
if err != nil {
return nil, err
}
diff --git a/pkg/cmd/project/close/close.go b/pkg/cmd/project/close/close.go
index d24278a23..3e85596c0 100644
--- a/pkg/cmd/project/close/close.go
+++ b/pkg/cmd/project/close/close.go
@@ -40,11 +40,11 @@ func NewCmdClose(f *cmdutil.Factory, runF func(config closeConfig) error) *cobra
Short: "Close a project",
Use: "close []",
Example: heredoc.Doc(`
- # close project "1" owned by monalisa
- gh project close 1 --owner monalisa
+ # Close project "1" owned by monalisa
+ $ gh project close 1 --owner monalisa
- # reopen closed project "1" owned by github
- gh project close 1 --owner github --undo
+ # Reopen closed project "1" owned by github
+ $ gh project close 1 --owner github --undo
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
diff --git a/pkg/cmd/project/copy/copy.go b/pkg/cmd/project/copy/copy.go
index 63047f0a3..c0f255f96 100644
--- a/pkg/cmd/project/copy/copy.go
+++ b/pkg/cmd/project/copy/copy.go
@@ -42,8 +42,8 @@ func NewCmdCopy(f *cmdutil.Factory, runF func(config copyConfig) error) *cobra.C
Short: "Copy a project",
Use: "copy []",
Example: heredoc.Doc(`
- # copy project "1" owned by monalisa to github
- gh project copy 1 --source-owner monalisa --target-owner github --title "a new project"
+ # Copy project "1" owned by monalisa to github
+ $ gh project copy 1 --source-owner monalisa --target-owner github --title "a new project"
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
diff --git a/pkg/cmd/project/create/create.go b/pkg/cmd/project/create/create.go
index a261cb600..268a25550 100644
--- a/pkg/cmd/project/create/create.go
+++ b/pkg/cmd/project/create/create.go
@@ -37,8 +37,8 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(config createConfig) error) *cob
Short: "Create a project",
Use: "create",
Example: heredoc.Doc(`
- # create a new project owned by login monalisa
- gh project create --owner monalisa --title "a new project"
+ # Create a new project owned by login monalisa
+ $ gh project create --owner monalisa --title "a new project"
`),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := client.New(f)
diff --git a/pkg/cmd/project/delete/delete.go b/pkg/cmd/project/delete/delete.go
index 4848a52ae..993b94d30 100644
--- a/pkg/cmd/project/delete/delete.go
+++ b/pkg/cmd/project/delete/delete.go
@@ -38,8 +38,8 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(config deleteConfig) error) *cob
Short: "Delete a project",
Use: "delete []",
Example: heredoc.Doc(`
- # delete the current user's project "1"
- gh project delete 1 --owner "@me"
+ # Delete the current user's project "1"
+ $ gh project delete 1 --owner "@me"
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
diff --git a/pkg/cmd/project/edit/edit.go b/pkg/cmd/project/edit/edit.go
index f303ce357..4eb41f98f 100644
--- a/pkg/cmd/project/edit/edit.go
+++ b/pkg/cmd/project/edit/edit.go
@@ -45,8 +45,8 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(config editConfig) error) *cobra.C
Short: "Edit a project",
Use: "edit []",
Example: heredoc.Doc(`
- # edit the title of monalisa's project "1"
- gh project edit 1 --owner monalisa --title "New title"
+ # Edit the title of monalisa's project "1"
+ $ gh project edit 1 --owner monalisa --title "New title"
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
diff --git a/pkg/cmd/project/field-create/field_create.go b/pkg/cmd/project/field-create/field_create.go
index 56a305480..143719f47 100644
--- a/pkg/cmd/project/field-create/field_create.go
+++ b/pkg/cmd/project/field-create/field_create.go
@@ -41,11 +41,11 @@ func NewCmdCreateField(f *cmdutil.Factory, runF func(config createFieldConfig) e
Short: "Create a field in a project",
Use: "field-create []",
Example: heredoc.Doc(`
- # create a field in the current user's project "1"
- gh project field-create 1 --owner "@me" --name "new field" --data-type "text"
+ # Create a field in the current user's project "1"
+ $ gh project field-create 1 --owner "@me" --name "new field" --data-type "text"
- # create a field with three options to select from for owner monalisa
- gh project field-create 1 --owner monalisa --name "new field" --data-type "SINGLE_SELECT" --single-select-options "one,two,three"
+ # Create a field with three options to select from for owner monalisa
+ $ gh project field-create 1 --owner monalisa --name "new field" --data-type "SINGLE_SELECT" --single-select-options "one,two,three"
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
diff --git a/pkg/cmd/project/field-list/field_list.go b/pkg/cmd/project/field-list/field_list.go
index 11964cb52..51340c687 100644
--- a/pkg/cmd/project/field-list/field_list.go
+++ b/pkg/cmd/project/field-list/field_list.go
@@ -30,10 +30,10 @@ func NewCmdList(f *cmdutil.Factory, runF func(config listConfig) error) *cobra.C
opts := listOpts{}
listCmd := &cobra.Command{
Short: "List the fields in a project",
- Use: "field-list number",
+ Use: "field-list []",
Example: heredoc.Doc(`
- # list fields in the current user's project "1"
- gh project field-list 1 --owner "@me"
+ # List fields in the current user's project "1"
+ $ gh project field-list 1 --owner "@me"
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
diff --git a/pkg/cmd/project/item-add/item_add.go b/pkg/cmd/project/item-add/item_add.go
index 5e4eeff10..00c610f0d 100644
--- a/pkg/cmd/project/item-add/item_add.go
+++ b/pkg/cmd/project/item-add/item_add.go
@@ -40,8 +40,8 @@ func NewCmdAddItem(f *cmdutil.Factory, runF func(config addItemConfig) error) *c
Short: "Add a pull request or an issue to a project",
Use: "item-add []",
Example: heredoc.Doc(`
- # add an item to monalisa's project "1"
- gh project item-add 1 --owner monalisa --url https://github.com/monalisa/myproject/issues/23
+ # Add an item to monalisa's project "1"
+ $ gh project item-add 1 --owner monalisa --url https://github.com/monalisa/myproject/issues/23
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
diff --git a/pkg/cmd/project/item-archive/item_archive.go b/pkg/cmd/project/item-archive/item_archive.go
index 18a820e86..ec19b4fcc 100644
--- a/pkg/cmd/project/item-archive/item_archive.go
+++ b/pkg/cmd/project/item-archive/item_archive.go
@@ -46,8 +46,8 @@ func NewCmdArchiveItem(f *cmdutil.Factory, runF func(config archiveItemConfig) e
Short: "Archive an item in a project",
Use: "item-archive []",
Example: heredoc.Doc(`
- # archive an item in the current user's project "1"
- gh project item-archive 1 --owner "@me" --id
+ # Archive an item in the current user's project "1"
+ $ gh project item-archive 1 --owner "@me" --id
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
diff --git a/pkg/cmd/project/item-create/item_create.go b/pkg/cmd/project/item-create/item_create.go
index d1775172d..dc70fe8a4 100644
--- a/pkg/cmd/project/item-create/item_create.go
+++ b/pkg/cmd/project/item-create/item_create.go
@@ -40,8 +40,8 @@ func NewCmdCreateItem(f *cmdutil.Factory, runF func(config createItemConfig) err
Short: "Create a draft issue item in a project",
Use: "item-create []",
Example: heredoc.Doc(`
- # create a draft issue in the current user's project "1"
- gh project item-create 1 --owner "@me" --title "new item" --body "new item body"
+ # Create a draft issue in the current user's project "1"
+ $ gh project item-create 1 --owner "@me" --title "new item" --body "new item body"
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
diff --git a/pkg/cmd/project/item-delete/item_delete.go b/pkg/cmd/project/item-delete/item_delete.go
index fc0d7c5ea..df5df20e9 100644
--- a/pkg/cmd/project/item-delete/item_delete.go
+++ b/pkg/cmd/project/item-delete/item_delete.go
@@ -39,8 +39,8 @@ func NewCmdDeleteItem(f *cmdutil.Factory, runF func(config deleteItemConfig) err
Short: "Delete an item from a project by ID",
Use: "item-delete []",
Example: heredoc.Doc(`
- # delete an item in the current user's project "1"
- gh project item-delete 1 --owner "@me" --id
+ # Delete an item in the current user's project "1"
+ $ gh project item-delete 1 --owner "@me" --id
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
diff --git a/pkg/cmd/project/item-edit/item_edit.go b/pkg/cmd/project/item-edit/item_edit.go
index 657af1f54..43aff835a 100644
--- a/pkg/cmd/project/item-edit/item_edit.go
+++ b/pkg/cmd/project/item-edit/item_edit.go
@@ -24,6 +24,7 @@ type editItemOpts struct {
projectID string
text string
number float64
+ numberChanged bool
date string
singleSelectOptionID string
iterationID string
@@ -63,23 +64,24 @@ func NewCmdEditItem(f *cmdutil.Factory, runF func(config editItemConfig) error)
Short: "Edit an item in a project",
Long: heredoc.Docf(`
Edit either a draft issue or a project item. Both usages require the ID of the item to edit.
-
+
For non-draft issues, the ID of the project is also required, and only a single field value can be updated per invocation.
Remove project item field value using %[1]s--clear%[1]s flag.
`, "`"),
Example: heredoc.Doc(`
- # edit an item's text field value
- gh project item-edit --id --field-id --project-id --text "new text"
+ # Edit an item's text field value
+ $ gh project item-edit --id --field-id --project-id --text "new text"
- # clear an item's field value
- gh project item-edit --id --field-id --project-id --clear
+ # Clear an item's field value
+ $ gh project item-edit --id --field-id --project-id --clear
`),
RunE: func(cmd *cobra.Command, args []string) error {
+ opts.numberChanged = cmd.Flags().Changed("number")
if err := cmdutil.MutuallyExclusive(
"only one of `--text`, `--number`, `--date`, `--single-select-option-id` or `--iteration-id` may be used",
opts.text != "",
- opts.number != 0,
+ opts.numberChanged,
opts.date != "",
opts.singleSelectOptionID != "",
opts.iterationID != "",
@@ -89,7 +91,7 @@ func NewCmdEditItem(f *cmdutil.Factory, runF func(config editItemConfig) error)
if err := cmdutil.MutuallyExclusive(
"cannot use `--text`, `--number`, `--date`, `--single-select-option-id` or `--iteration-id` in conjunction with `--clear`",
- opts.text != "" || opts.number != 0 || opts.date != "" || opts.singleSelectOptionID != "" || opts.iterationID != "",
+ opts.text != "" || opts.numberChanged || opts.date != "" || opts.singleSelectOptionID != "" || opts.iterationID != "",
opts.clear,
); err != nil {
return err
@@ -146,7 +148,7 @@ func runEditItem(config editItemConfig) error {
}
// update item values
- if config.opts.text != "" || config.opts.number != 0 || config.opts.date != "" || config.opts.singleSelectOptionID != "" || config.opts.iterationID != "" {
+ if config.opts.text != "" || config.opts.numberChanged || config.opts.date != "" || config.opts.singleSelectOptionID != "" || config.opts.iterationID != "" {
return updateItemValues(config)
}
@@ -172,7 +174,7 @@ func buildUpdateItem(config editItemConfig, date time.Time) (*UpdateProjectV2Fie
value = githubv4.ProjectV2FieldValue{
Text: githubv4.NewString(githubv4.String(config.opts.text)),
}
- } else if config.opts.number != 0 {
+ } else if config.opts.numberChanged {
value = githubv4.ProjectV2FieldValue{
Number: githubv4.NewFloat(githubv4.Float(config.opts.number)),
}
diff --git a/pkg/cmd/project/item-edit/item_edit_test.go b/pkg/cmd/project/item-edit/item_edit_test.go
index 2f9a16df6..916bb5899 100644
--- a/pkg/cmd/project/item-edit/item_edit_test.go
+++ b/pkg/cmd/project/item-edit/item_edit_test.go
@@ -55,6 +55,14 @@ func TestNewCmdeditItem(t *testing.T) {
itemID: "123",
},
},
+ {
+ name: "number zero",
+ cli: "--number 0 --id 123",
+ wants: editItemOpts{
+ number: 0,
+ itemID: "123",
+ },
+ },
{
name: "field-id",
cli: "--field-id FIELD_ID --id 123",
@@ -292,10 +300,64 @@ func TestRunItemEdit_Number(t *testing.T) {
config := editItemConfig{
io: ios,
opts: editItemOpts{
- number: 123.45,
- itemID: "item_id",
- projectID: "project_id",
- fieldID: "field_id",
+ number: 123.45,
+ numberChanged: true,
+ itemID: "item_id",
+ projectID: "project_id",
+ fieldID: "field_id",
+ },
+ client: client,
+ }
+
+ err := runEditItem(config)
+ assert.NoError(t, err)
+ assert.Equal(
+ t,
+ "Edited item \"title\"\n",
+ stdout.String())
+}
+
+func TestRunItemEdit_NumberZero(t *testing.T) {
+ defer gock.Off()
+ // gock.Observe(gock.DumpRequest)
+
+ // edit item
+ gock.New("https://api.github.com").
+ Post("/graphql").
+ BodyString(`{"query":"mutation UpdateItemValues.*","variables":{"input":{"projectId":"project_id","itemId":"item_id","fieldId":"field_id","value":{"number":0}}}}`).
+ Reply(200).
+ JSON(map[string]interface{}{
+ "data": map[string]interface{}{
+ "updateProjectV2ItemFieldValue": map[string]interface{}{
+ "projectV2Item": map[string]interface{}{
+ "ID": "item_id",
+ "content": map[string]interface{}{
+ "__typename": "Issue",
+ "body": "body",
+ "title": "title",
+ "number": 1,
+ "repository": map[string]interface{}{
+ "nameWithOwner": "my-repo",
+ },
+ },
+ },
+ },
+ },
+ })
+
+ client := queries.NewTestClient()
+
+ ios, _, stdout, _ := iostreams.Test()
+ ios.SetStdoutTTY(true)
+
+ config := editItemConfig{
+ io: ios,
+ opts: editItemOpts{
+ number: 0,
+ numberChanged: true,
+ itemID: "item_id",
+ projectID: "project_id",
+ fieldID: "field_id",
},
client: client,
}
diff --git a/pkg/cmd/project/item-list/item_list.go b/pkg/cmd/project/item-list/item_list.go
index d62eb2716..3f792f3bd 100644
--- a/pkg/cmd/project/item-list/item_list.go
+++ b/pkg/cmd/project/item-list/item_list.go
@@ -32,8 +32,8 @@ func NewCmdList(f *cmdutil.Factory, runF func(config listConfig) error) *cobra.C
Short: "List the items in a project",
Use: "item-list []",
Example: heredoc.Doc(`
- # list the items in the current users's project "1"
- gh project item-list 1 --owner "@me"
+ # List the items in the current users's project "1"
+ $ gh project item-list 1 --owner "@me"
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
diff --git a/pkg/cmd/project/link/link.go b/pkg/cmd/project/link/link.go
index 1e334af8c..f2477dde4 100644
--- a/pkg/cmd/project/link/link.go
+++ b/pkg/cmd/project/link/link.go
@@ -41,16 +41,16 @@ func NewCmdLink(f *cmdutil.Factory, runF func(config linkConfig) error) *cobra.C
opts := linkOpts{}
linkCmd := &cobra.Command{
Short: "Link a project to a repository or a team",
- Use: "link [] [flag]",
+ Use: "link []",
Example: heredoc.Doc(`
- # link monalisa's project 1 to her repository "my_repo"
- gh project link 1 --owner monalisa --repo my_repo
+ # Link monalisa's project 1 to her repository "my_repo"
+ $ gh project link 1 --owner monalisa --repo my_repo
- # link monalisa's organization's project 1 to her team "my_team"
- gh project link 1 --owner my_organization --team my_team
+ # Link monalisa's organization's project 1 to her team "my_team"
+ $ gh project link 1 --owner my_organization --team my_team
- # link monalisa's project 1 to the repository of current directory if neither --repo nor --team is specified
- gh project link 1
+ # Link monalisa's project 1 to the repository of current directory if neither --repo nor --team is specified
+ $ gh project link 1
`),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := client.New(f)
diff --git a/pkg/cmd/project/list/list.go b/pkg/cmd/project/list/list.go
index ba6e1d747..01868b4c3 100644
--- a/pkg/cmd/project/list/list.go
+++ b/pkg/cmd/project/list/list.go
@@ -35,11 +35,11 @@ func NewCmdList(f *cmdutil.Factory, runF func(config listConfig) error) *cobra.C
Use: "list",
Short: "List the projects for an owner",
Example: heredoc.Doc(`
- # list the current user's projects
- gh project list
+ # List the current user's projects
+ $ gh project list
- # list the projects for org github including closed projects
- gh project list --owner github --closed
+ # List the projects for org github including closed projects
+ $ gh project list --owner github --closed
`),
Aliases: []string{"ls"},
RunE: func(cmd *cobra.Command, args []string) error {
diff --git a/pkg/cmd/project/mark-template/mark_template.go b/pkg/cmd/project/mark-template/mark_template.go
index 025fe2838..616f8428d 100644
--- a/pkg/cmd/project/mark-template/mark_template.go
+++ b/pkg/cmd/project/mark-template/mark_template.go
@@ -44,11 +44,11 @@ func NewCmdMarkTemplate(f *cmdutil.Factory, runF func(config markTemplateConfig)
Short: "Mark a project as a template",
Use: "mark-template []",
Example: heredoc.Doc(`
- # mark the github org's project "1" as a template
- gh project mark-template 1 --owner "github"
+ # Mark the github org's project "1" as a template
+ $ gh project mark-template 1 --owner "github"
- # unmark the github org's project "1" as a template
- gh project mark-template 1 --owner "github" --undo
+ # Unmark the github org's project "1" as a template
+ $ gh project mark-template 1 --owner "github" --undo
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
diff --git a/pkg/cmd/project/project.go b/pkg/cmd/project/project.go
index 6dae4a897..cddbad940 100644
--- a/pkg/cmd/project/project.go
+++ b/pkg/cmd/project/project.go
@@ -29,7 +29,13 @@ func NewCmdProject(f *cmdutil.Factory) *cobra.Command {
var cmd = &cobra.Command{
Use: "project ",
Short: "Work with GitHub Projects.",
- Long: "Work with GitHub Projects. Note that the token you are using must have 'project' scope, which is not set by default. You can verify your token scope by running 'gh auth status' and add the project scope by running 'gh auth refresh -s project'.",
+ Long: heredoc.Docf(`
+ Work with GitHub Projects.
+
+ The minimum required scope for the token is: %[1]sproject%[1]s.
+ You can verify your token scope by running %[1]sgh auth status%[1]s and
+ add the %[1]sproject%[1]s scope by running %[1]sgh auth refresh -s project%[1]s.
+ `, "`"),
Example: heredoc.Doc(`
$ gh project create --owner monalisa --title "Roadmap"
$ gh project view 1 --owner cli --web
diff --git a/pkg/cmd/project/unlink/unlink.go b/pkg/cmd/project/unlink/unlink.go
index 7a75db6d1..8cc2c0747 100644
--- a/pkg/cmd/project/unlink/unlink.go
+++ b/pkg/cmd/project/unlink/unlink.go
@@ -41,16 +41,16 @@ func NewCmdUnlink(f *cmdutil.Factory, runF func(config unlinkConfig) error) *cob
opts := unlinkOpts{}
linkCmd := &cobra.Command{
Short: "Unlink a project from a repository or a team",
- Use: "unlink [] [flag]",
+ Use: "unlink []",
Example: heredoc.Doc(`
- # unlink monalisa's project 1 from her repository "my_repo"
- gh project unlink 1 --owner monalisa --repo my_repo
+ # Unlink monalisa's project 1 from her repository "my_repo"
+ $ gh project unlink 1 --owner monalisa --repo my_repo
- # unlink monalisa's organization's project 1 from her team "my_team"
- gh project unlink 1 --owner my_organization --team my_team
+ # Unlink monalisa's organization's project 1 from her team "my_team"
+ $ gh project unlink 1 --owner my_organization --team my_team
- # unlink monalisa's project 1 from the repository of current directory if neither --repo nor --team is specified
- gh project unlink 1
+ # Unlink monalisa's project 1 from the repository of current directory if neither --repo nor --team is specified
+ $ gh project unlink 1
`),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := client.New(f)
diff --git a/pkg/cmd/project/view/view.go b/pkg/cmd/project/view/view.go
index 3d94b5af3..49056b8d7 100644
--- a/pkg/cmd/project/view/view.go
+++ b/pkg/cmd/project/view/view.go
@@ -34,11 +34,11 @@ func NewCmdView(f *cmdutil.Factory, runF func(config viewConfig) error) *cobra.C
Short: "View a project",
Use: "view []",
Example: heredoc.Doc(`
- # view the current user's project "1"
- gh project view 1
+ # View the current user's project "1"
+ $ gh project view 1
- # open user monalisa's project "1" in the browser
- gh project view 1 --owner monalisa --web
+ # Open user monalisa's project "1" in the browser
+ $ gh project view 1 --owner monalisa --web
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go
index 8e77546d0..be6d0f65a 100644
--- a/pkg/cmd/release/create/create.go
+++ b/pkg/cmd/release/create/create.go
@@ -60,6 +60,7 @@ type CreateOptions struct {
NotesStartTag string
VerifyTag bool
NotesFromTag bool
+ FailOnNoCommits bool
}
func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command {
@@ -99,37 +100,45 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
When using automatically generated release notes, a release title will also be automatically
generated unless a title was explicitly passed. Additional release notes can be prepended to
automatically generated notes by using the %[1]s--notes%[1]s flag.
+
+ By default, the release is created even if there are no new commits since the last release.
+ This may result in the same or duplicate release which may not be desirable in some cases.
+ Use %[1]s--fail-on-no-commits%[1]s to fail if no new commits are available. This flag has no
+ effect if there are no existing releases or this is the very first release.
`, "`"),
Example: heredoc.Doc(`
- Interactively create a release
+ # Interactively create a release
$ gh release create
- Interactively create a release from specific tag
+ # Interactively create a release from specific tag
$ gh release create v1.2.3
- Non-interactively create a release
+ # Non-interactively create a release
$ gh release create v1.2.3 --notes "bugfix release"
- Use automatically generated release notes
+ # Use automatically generated release notes
$ gh release create v1.2.3 --generate-notes
- Use release notes from a file
+ # Use release notes from a file
$ gh release create v1.2.3 -F release-notes.md
- Use annotated tag notes
+ # Use annotated tag notes
$ gh release create v1.2.3 --notes-from-tag
- Don't mark the release as latest
- $ gh release create v1.2.3 --latest=false
+ # Don't mark the release as latest
+ $ gh release create v1.2.3 --latest=false
- Upload all tarballs in a directory as release assets
+ # Upload all tarballs in a directory as release assets
$ gh release create v1.2.3 ./dist/*.tgz
- Upload a release asset with a display label
+ # Upload a release asset with a display label
$ gh release create v1.2.3 '/path/to/asset.zip#My display label'
- Create a release and start a discussion
+ # Create a release and start a discussion
$ gh release create v1.2.3 --discussion-category "General"
+
+ # Create a release only if there are new commits available since the last release
+ $ gh release create v1.2.3 --fail-on-no-commits
`),
Aliases: []string{"new"},
RunE: func(cmd *cobra.Command, args []string) error {
@@ -194,6 +203,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
cmdutil.NilBoolFlag(cmd, &opts.IsLatest, "latest", "", "Mark this release as \"Latest\" (default [automatic based on date and version]). --latest=false to explicitly NOT set as latest")
cmd.Flags().BoolVarP(&opts.VerifyTag, "verify-tag", "", false, "Abort in case the git tag doesn't already exist in the remote repository")
cmd.Flags().BoolVarP(&opts.NotesFromTag, "notes-from-tag", "", false, "Automatically generate notes from annotated tag")
+ cmd.Flags().BoolVar(&opts.FailOnNoCommits, "fail-on-no-commits", false, "Fail if there are no commits since the last release (no impact on the first release)")
_ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "target")
@@ -211,6 +221,16 @@ func createRun(opts *CreateOptions) error {
return err
}
+ if opts.FailOnNoCommits {
+ isNew, err := isNewRelease(httpClient, baseRepo)
+ if err != nil {
+ return fmt.Errorf("failed to check whether there were new commits since last release: %v", err)
+ }
+ if !isNew {
+ return fmt.Errorf("no new commits since the last release")
+ }
+ }
+
var existingTag bool
if opts.TagName == "" {
tags, err := getTags(httpClient, baseRepo, 5)
diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go
index c9e9a8c8a..a6e6cd7c6 100644
--- a/pkg/cmd/release/create/create_test.go
+++ b/pkg/cmd/release/create/create_test.go
@@ -52,17 +52,18 @@ func Test_NewCmdCreate(t *testing.T) {
args: "",
isTTY: true,
want: CreateOptions{
- TagName: "",
- Target: "",
- Name: "",
- Body: "",
- BodyProvided: false,
- Draft: false,
- Prerelease: false,
- RepoOverride: "",
- Concurrency: 5,
- Assets: []*shared.AssetForUpload(nil),
- VerifyTag: false,
+ TagName: "",
+ Target: "",
+ Name: "",
+ Body: "",
+ BodyProvided: false,
+ Draft: false,
+ Prerelease: false,
+ RepoOverride: "",
+ Concurrency: 5,
+ Assets: []*shared.AssetForUpload(nil),
+ VerifyTag: false,
+ FailOnNoCommits: false,
},
},
{
@@ -76,17 +77,18 @@ func Test_NewCmdCreate(t *testing.T) {
args: "v1.2.3",
isTTY: true,
want: CreateOptions{
- TagName: "v1.2.3",
- Target: "",
- Name: "",
- Body: "",
- BodyProvided: false,
- Draft: false,
- Prerelease: false,
- RepoOverride: "",
- Concurrency: 5,
- Assets: []*shared.AssetForUpload(nil),
- VerifyTag: false,
+ TagName: "v1.2.3",
+ Target: "",
+ Name: "",
+ Body: "",
+ BodyProvided: false,
+ Draft: false,
+ Prerelease: false,
+ RepoOverride: "",
+ Concurrency: 5,
+ Assets: []*shared.AssetForUpload(nil),
+ VerifyTag: false,
+ FailOnNoCommits: false,
},
},
{
@@ -347,6 +349,19 @@ func Test_NewCmdCreate(t *testing.T) {
isTTY: false,
wantErr: "using `--notes-from-tag` with `--generate-notes` or `--notes-start-tag` is not supported",
},
+ {
+ name: "with --fail-on-no-commits",
+ args: "v1.2.3 --fail-on-no-commits",
+ isTTY: false,
+ want: CreateOptions{
+ TagName: "v1.2.3",
+ BodyProvided: false,
+ Concurrency: 5,
+ Assets: []*shared.AssetForUpload(nil),
+ NotesFromTag: false,
+ FailOnNoCommits: true,
+ },
+ },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -402,6 +417,7 @@ func Test_NewCmdCreate(t *testing.T) {
assert.Equal(t, tt.want.IsLatest, opts.IsLatest)
assert.Equal(t, tt.want.VerifyTag, opts.VerifyTag)
assert.Equal(t, tt.want.NotesFromTag, opts.NotesFromTag)
+ assert.Equal(t, tt.want.FailOnNoCommits, opts.FailOnNoCommits)
require.Equal(t, len(tt.want.Assets), len(opts.Assets))
for i := range tt.want.Assets {
@@ -460,6 +476,100 @@ func Test_createRun(t *testing.T) {
wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n",
wantStderr: ``,
},
+ {
+ name: "create a release if there are new commits and the last release does not exist",
+ isTTY: true,
+ opts: CreateOptions{
+ TagName: "v1.2.3",
+ Name: "The Big 1.2",
+ Body: "* Fixed bugs",
+ BodyProvided: true,
+ Target: "",
+ FailOnNoCommits: true,
+ },
+ runStubs: defaultRunStubs,
+ httpStubs: func(t *testing.T, reg *httpmock.Registry) {
+ reg.Register(httpmock.REST("GET", "repos/OWNER/REPO/releases/latest"), httpmock.StatusStringResponse(404, `{
+ "message": "Not Found",
+ "documentation_url": "https://docs.github.com/rest/releases/releases#get-the-latest-release",
+ "status": "404"
+ }`))
+ reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases"), httpmock.RESTPayload(201, `{
+ "url": "https://api.github.com/releases/123",
+ "upload_url": "https://api.github.com/assets/upload",
+ "html_url": "https://github.com/OWNER/REPO/releases/tag/v1.2.3"
+ }`, func(params map[string]interface{}) {
+ assert.Equal(t, map[string]interface{}{
+ "tag_name": "v1.2.3",
+ "name": "The Big 1.2",
+ "body": "* Fixed bugs",
+ "draft": false,
+ "prerelease": false,
+ }, params)
+ }))
+ },
+ wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n",
+ wantStderr: ``,
+ },
+ {
+ name: "create a release if there are new commits and the last release exists",
+ isTTY: true,
+ opts: CreateOptions{
+ TagName: "v1.2.3",
+ Name: "The Big 1.2",
+ Body: "* Fixed bugs",
+ BodyProvided: true,
+ Target: "",
+ FailOnNoCommits: true,
+ },
+ runStubs: defaultRunStubs,
+ httpStubs: func(t *testing.T, reg *httpmock.Registry) {
+ reg.Register(httpmock.REST("GET", "repos/OWNER/REPO/releases/latest"), httpmock.StatusStringResponse(200, `{
+ "tag_name": "v1.2.2"
+ }`))
+ reg.Register(httpmock.REST("GET", "repos/OWNER/REPO/compare/v1.2.2...HEAD"), httpmock.StatusStringResponse(200, `{
+ "status": "ahead"
+ }`))
+ reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases"), httpmock.RESTPayload(201, `{
+ "url": "https://api.github.com/releases/123",
+ "upload_url": "https://api.github.com/assets/upload",
+ "html_url": "https://github.com/OWNER/REPO/releases/tag/v1.2.3"
+ }`, func(params map[string]interface{}) {
+ assert.Equal(t, map[string]interface{}{
+ "tag_name": "v1.2.3",
+ "name": "The Big 1.2",
+ "body": "* Fixed bugs",
+ "draft": false,
+ "prerelease": false,
+ }, params)
+ }))
+ },
+ wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n",
+ wantStderr: ``,
+ },
+ {
+ name: "create a release if there are no new commits but the last release exists",
+ isTTY: true,
+ opts: CreateOptions{
+ TagName: "v1.2.3",
+ Name: "The Big 1.2",
+ Body: "* Fixed bugs",
+ BodyProvided: true,
+ Target: "",
+ FailOnNoCommits: true,
+ },
+ httpStubs: func(t *testing.T, reg *httpmock.Registry) {
+ reg.Register(httpmock.REST("GET", "repos/OWNER/REPO/releases/latest"), httpmock.StatusStringResponse(200, `{
+ "tag_name": "v1.2.2"
+ }`))
+ reg.Register(httpmock.REST("GET", "repos/OWNER/REPO/compare/v1.2.2...HEAD"), httpmock.StatusStringResponse(200, `{
+ "status": "identical"
+ }`))
+ },
+ wantErr: "no new commits since the last release",
+ wantStdout: "",
+ wantStderr: ``,
+ },
{
name: "with discussion category",
isTTY: true,
diff --git a/pkg/cmd/release/create/http.go b/pkg/cmd/release/create/http.go
index 3bb55f39e..4311a3896 100644
--- a/pkg/cmd/release/create/http.go
+++ b/pkg/cmd/release/create/http.go
@@ -2,6 +2,7 @@ package create
import (
"bytes"
+ "context"
"encoding/json"
"errors"
"fmt"
@@ -299,3 +300,31 @@ func tokenHasWorkflowScope(resp *http.Response) bool {
return slices.Contains(strings.Split(scopes, ","), "workflow")
}
+
+// isNewRelease checks if there are new commits since the latest release.
+func isNewRelease(httpClient *http.Client, repo ghrepo.Interface) (bool, error) {
+ ctx := context.Background()
+ release, err := shared.FetchLatestRelease(ctx, httpClient, repo)
+ if err != nil {
+ if errors.Is(err, shared.ErrReleaseNotFound) {
+ return true, nil
+ } else {
+ return false, err
+ }
+ }
+
+ tagName := release.TagName
+ path := fmt.Sprintf("repos/%s/%s/compare/%s...HEAD?per_page=1", repo.RepoOwner(), repo.RepoName(), tagName)
+
+ var comparisonStatus struct {
+ Status string `json:"status"`
+ }
+
+ apiClient := api.NewClientFromHTTP(httpClient)
+ if err := apiClient.REST(repo.RepoHost(), "GET", path, nil, &comparisonStatus); err != nil {
+ return false, err
+ }
+
+ isNew := comparisonStatus.Status == "ahead"
+ return isNew, nil
+}
diff --git a/pkg/cmd/release/download/download.go b/pkg/cmd/release/download/download.go
index bb0df7243..cdf6135b6 100644
--- a/pkg/cmd/release/download/download.go
+++ b/pkg/cmd/release/download/download.go
@@ -57,16 +57,16 @@ func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobr
is required.
`, "`"),
Example: heredoc.Doc(`
- # download all assets from a specific release
+ # Download all assets from a specific release
$ gh release download v1.2.3
- # download only Debian packages for the latest release
+ # Download only Debian packages for the latest release
$ gh release download --pattern '*.deb'
- # specify multiple file patterns
+ # Specify multiple file patterns
$ gh release download -p '*.deb' -p '*.rpm'
- # download the archive of the source code for a release
+ # Download the archive of the source code for a release
$ gh release download v1.2.3 --archive=zip
`),
Args: cobra.MaximumNArgs(1),
diff --git a/pkg/cmd/release/edit/edit.go b/pkg/cmd/release/edit/edit.go
index 245041bae..95abf5e20 100644
--- a/pkg/cmd/release/edit/edit.go
+++ b/pkg/cmd/release/edit/edit.go
@@ -43,10 +43,10 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
Use: "edit ",
Short: "Edit a release",
Example: heredoc.Doc(`
- Publish a release that was previously a draft
+ # Publish a release that was previously a draft
$ gh release edit v1.0 --draft=false
- Update the release notes from the content of a file
+ # Update the release notes from the content of a file
$ gh release edit v1.0 --notes-file /path/to/release_notes.md
`),
Args: cobra.ExactArgs(1),
diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go
index c9cf7a6a4..8db7e502a 100644
--- a/pkg/cmd/release/shared/fetch.go
+++ b/pkg/cmd/release/shared/fetch.go
@@ -124,7 +124,7 @@ func (rel *Release) ExportData(fields []string) map[string]interface{} {
return data
}
-var errNotFound = errors.New("release not found")
+var ErrReleaseNotFound = errors.New("release not found")
type fetchResult struct {
release *Release
@@ -150,7 +150,7 @@ func FetchRelease(ctx context.Context, httpClient *http.Client, repo ghrepo.Inte
}()
res := <-results
- if errors.Is(res.error, errNotFound) {
+ if errors.Is(res.error, ErrReleaseNotFound) {
res = <-results
cancel() // satisfy the linter even though no goroutines are running anymore
} else {
@@ -190,7 +190,7 @@ func fetchDraftRelease(ctx context.Context, httpClient *http.Client, repo ghrepo
}
if query.Repository.Release == nil || !query.Repository.Release.IsDraft {
- return nil, errNotFound
+ return nil, ErrReleaseNotFound
}
// Then, use REST to get information about the draft release. In theory, we could have fetched
@@ -213,7 +213,7 @@ func fetchReleasePath(ctx context.Context, httpClient *http.Client, host string,
if resp.StatusCode == 404 {
_, _ = io.Copy(io.Discard, resp.Body)
- return nil, errNotFound
+ return nil, ErrReleaseNotFound
} else if resp.StatusCode > 299 {
return nil, api.HandleHTTPError(resp)
}
diff --git a/pkg/cmd/release/view/view_test.go b/pkg/cmd/release/view/view_test.go
index 6fc747f69..1fed0fdc8 100644
--- a/pkg/cmd/release/view/view_test.go
+++ b/pkg/cmd/release/view/view_test.go
@@ -144,15 +144,15 @@ func Test_viewRun(t *testing.T) {
wantStdout: heredoc.Doc(`
v1.2.3
MonaLisa released this about 1 day ago
-
+
• Fixed bugs
-
-
+
+
Assets
windows.zip 12 B
linux.tgz 34 B
-
+
View on GitHub: https://github.com/OWNER/REPO/releases/tags/v1.2.3
`),
wantStderr: ``,
@@ -168,15 +168,15 @@ func Test_viewRun(t *testing.T) {
wantStdout: heredoc.Doc(`
v1.2.3
MonaLisa released this about 1 day ago
-
+
• Fixed bugs
-
-
+
+
Assets
windows.zip 12 B
linux.tgz 34 B
-
+
View on GitHub: https://github.com/OWNER/REPO/releases/tags/v1.2.3
`),
wantStderr: ``,
diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go
index ba33156c8..cd7c56ea8 100644
--- a/pkg/cmd/repo/create/create.go
+++ b/pkg/cmd/repo/create/create.go
@@ -103,17 +103,17 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
The repo is created with the configured repository default branch, see .
`, "`"),
Example: heredoc.Doc(`
- # create a repository interactively
- gh repo create
+ # Create a repository interactively
+ $ gh repo create
- # create a new remote repository and clone it locally
- gh repo create my-project --public --clone
+ # Create a new remote repository and clone it locally
+ $ gh repo create my-project --public --clone
- # create a new remote repository in a different organization
- gh repo create my-org/my-project --public
+ # Create a new remote repository in a different organization
+ $ gh repo create my-org/my-project --public
- # create a remote repository from the current directory
- gh repo create my-project --private --source=. --remote=upstream
+ # Create a remote repository from the current directory
+ $ gh repo create my-project --private --source=. --remote=upstream
`),
Args: cobra.MaximumNArgs(1),
Aliases: []string{"new"},
diff --git a/pkg/cmd/repo/credits/credits.go b/pkg/cmd/repo/credits/credits.go
index 7db08a231..a26b6a731 100644
--- a/pkg/cmd/repo/credits/credits.go
+++ b/pkg/cmd/repo/credits/credits.go
@@ -43,13 +43,13 @@ func NewCmdCredits(f *cmdutil.Factory, runF func(*CreditsOptions) error) *cobra.
Short: "View credits for this tool",
Long: `View animated credits for gh, the tool you are currently using :)`,
Example: heredoc.Doc(`
- # see a credits animation for this project
+ # See a credits animation for this project
$ gh credits
- # display a non-animated thank you
+ # Display a non-animated thank you
$ gh credits -s
- # just print the contributors, one per line
+ # Just print the contributors, one per line
$ gh credits | cat
`),
Args: cobra.ExactArgs(0),
@@ -79,16 +79,16 @@ func NewCmdRepoCredits(f *cmdutil.Factory, runF func(*CreditsOptions) error) *co
Use: "credits []",
Short: "View credits for a repository",
Example: heredoc.Doc(`
- # view credits for the current repository
+ # View credits for the current repository
$ gh repo credits
- # view credits for a specific repository
+ # View credits for a specific repository
$ gh repo credits cool/repo
- # print a non-animated thank you
+ # Print a non-animated thank you
$ gh repo credits -s
- # pipe to just print the contributors, one per line
+ # Pipe to just print the contributors, one per line
$ gh repo credits | cat
`),
Args: cobra.MaximumNArgs(1),
diff --git a/pkg/cmd/repo/delete/delete.go b/pkg/cmd/repo/delete/delete.go
index 7c6476f1d..96cfe5e95 100644
--- a/pkg/cmd/repo/delete/delete.go
+++ b/pkg/cmd/repo/delete/delete.go
@@ -41,10 +41,10 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
Short: "Delete a repository",
Long: heredoc.Docf(`
Delete a GitHub repository.
-
+
With no argument, deletes the current repository. Otherwise, deletes the specified repository.
- Deletion requires authorization with the %[1]sdelete_repo%[1]s scope.
+ Deletion requires authorization with the %[1]sdelete_repo%[1]s scope.
To authorize, run %[1]sgh auth refresh -s delete_repo%[1]s
`, "`"),
Args: cobra.MaximumNArgs(1),
@@ -65,9 +65,9 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
},
}
- cmd.Flags().BoolVar(&opts.Confirmed, "confirm", false, "confirm deletion without prompting")
+ cmd.Flags().BoolVar(&opts.Confirmed, "confirm", false, "Confirm deletion without prompting")
_ = cmd.Flags().MarkDeprecated("confirm", "use `--yes` instead")
- cmd.Flags().BoolVar(&opts.Confirmed, "yes", false, "confirm deletion without prompting")
+ cmd.Flags().BoolVar(&opts.Confirmed, "yes", false, "Confirm deletion without prompting")
return cmd
}
diff --git a/pkg/cmd/repo/deploy-key/add/add.go b/pkg/cmd/repo/deploy-key/add/add.go
index 473d42498..2466ec98a 100644
--- a/pkg/cmd/repo/deploy-key/add/add.go
+++ b/pkg/cmd/repo/deploy-key/add/add.go
@@ -34,13 +34,13 @@ func NewCmdAdd(f *cmdutil.Factory, runF func(*AddOptions) error) *cobra.Command
Short: "Add a deploy key to a GitHub repository",
Long: heredoc.Doc(`
Add a deploy key to a GitHub repository.
-
+
Note that any key added by gh will be associated with the current authentication token.
If you de-authorize the GitHub CLI app or authentication token from your account, any
deploy keys added by GitHub CLI will be removed as well.
`),
Example: heredoc.Doc(`
- # generate a passwordless SSH key and add it as a deploy key to a repository
+ # Generate a passwordless SSH key and add it as a deploy key to a repository
$ ssh-keygen -t ed25519 -C "my description" -N "" -f ~/.ssh/gh-test
$ gh repo deploy-key add ~/.ssh/gh-test.pub
`),
diff --git a/pkg/cmd/repo/edit/edit.go b/pkg/cmd/repo/edit/edit.go
index daa700cfb..94ad8868a 100644
--- a/pkg/cmd/repo/edit/edit.go
+++ b/pkg/cmd/repo/edit/edit.go
@@ -119,15 +119,15 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(options *EditOptions) error) *cobr
When the %[1]s--visibility%[1]s flag is used, %[1]s--accept-visibility-change-consequences%[1]s flag is required.
- For information on all the potential consequences, see
+ For information on all the potential consequences, see .
`, "`"),
Args: cobra.MaximumNArgs(1),
Example: heredoc.Doc(`
- # enable issues and wiki
- gh repo edit --enable-issues --enable-wiki
+ # Enable issues and wiki
+ $ gh repo edit --enable-issues --enable-wiki
- # disable projects
- gh repo edit --enable-projects=false
+ # Disable projects
+ $ gh repo edit --enable-projects=false
`),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
diff --git a/pkg/cmd/repo/gitignore/view/view.go b/pkg/cmd/repo/gitignore/view/view.go
index 46e674632..fcf83700a 100644
--- a/pkg/cmd/repo/gitignore/view/view.go
+++ b/pkg/cmd/repo/gitignore/view/view.go
@@ -34,23 +34,23 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
Short: "View an available repository gitignore template",
Long: heredoc.Docf(`
View an available repository %[1]s.gitignore%[1]s template.
-
+
%[1]s%[1]s is a case-sensitive %[1]s.gitignore%[1]s template name.
For a list of available templates, run %[1]sgh repo gitignore list%[1]s.
`, "`"),
Example: heredoc.Doc(`
# View the Go gitignore template
- gh repo gitignore view Go
+ $ gh repo gitignore view Go
# View the Python gitignore template
- gh repo gitignore view Python
+ $ gh repo gitignore view Python
# Create a new .gitignore file using the Go template
- gh repo gitignore view Go > .gitignore
+ $ gh repo gitignore view Go > .gitignore
# Create a new .gitignore file using the Python template
- gh repo gitignore view Python > .gitignore
+ $ gh repo gitignore view Python > .gitignore
`),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
diff --git a/pkg/cmd/repo/license/list/list.go b/pkg/cmd/repo/license/list/list.go
index 6431f58c0..ec003ad21 100644
--- a/pkg/cmd/repo/license/list/list.go
+++ b/pkg/cmd/repo/license/list/list.go
@@ -31,7 +31,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
Short: "List common repository licenses",
Long: heredoc.Doc(`
List common repository licenses.
-
+
For even more licenses, visit
`),
Aliases: []string{"ls"},
diff --git a/pkg/cmd/repo/license/view/view.go b/pkg/cmd/repo/license/view/view.go
index 48601faa9..d3e63c241 100644
--- a/pkg/cmd/repo/license/view/view.go
+++ b/pkg/cmd/repo/license/view/view.go
@@ -34,7 +34,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
}
cmd := &cobra.Command{
- Use: "view { | }",
+ Use: "view { | }",
Short: "View a specific repository license",
Long: heredoc.Docf(`
View a specific repository license by license key or SPDX ID.
@@ -43,19 +43,19 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
`, "`"),
Example: heredoc.Doc(`
# View the MIT license from SPDX ID
- gh repo license view MIT
+ $ gh repo license view MIT
# View the MIT license from license key
- gh repo license view mit
+ $ gh repo license view mit
# View the GNU AGPL-3.0 license from SPDX ID
- gh repo license view AGPL-3.0
+ $ gh repo license view AGPL-3.0
# View the GNU AGPL-3.0 license from license key
- gh repo license view agpl-3.0
+ $ gh repo license view agpl-3.0
# Create a LICENSE.md with the MIT license
- gh repo license view MIT > LICENSE.md
+ $ gh repo license view MIT > LICENSE.md
`),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
diff --git a/pkg/cmd/repo/license/view/view_test.go b/pkg/cmd/repo/license/view/view_test.go
index 40439d1f0..0a282693d 100644
--- a/pkg/cmd/repo/license/view/view_test.go
+++ b/pkg/cmd/repo/license/view/view_test.go
@@ -220,7 +220,7 @@ func TestViewRun(t *testing.T) {
wantErr: true,
errMsg: heredoc.Docf(`
'404' is not a valid license name or SPDX ID.
-
+
Run %[1]sgh repo license list%[1]s to see available commonly used licenses. For even more licenses, visit https://choosealicense.com/appendix`, "`"),
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
diff --git a/pkg/cmd/repo/rename/rename.go b/pkg/cmd/repo/rename/rename.go
index e50095d76..cbb89342d 100644
--- a/pkg/cmd/repo/rename/rename.go
+++ b/pkg/cmd/repo/rename/rename.go
@@ -52,14 +52,14 @@ func NewCmdRename(f *cmdutil.Factory, runf func(*RenameOptions) error) *cobra.Co
Short: "Rename a repository",
Long: heredoc.Docf(`
Rename a GitHub repository.
-
+
%[1]s%[1]s is the desired repository name without the owner.
-
+
By default, the current repository is renamed. Otherwise, the repository specified
with %[1]s--repo%[1]s is renamed.
-
+
To transfer repository ownership to another user account or organization,
- you must follow additional steps on GitHub.com
+ you must follow additional steps on .
For more information on transferring repository ownership, see:
@@ -126,7 +126,7 @@ func renameRun(opts *RenameOptions) error {
}
if strings.Contains(newRepoName, "/") {
- return fmt.Errorf("New repository name cannot contain '/' character - to transfer a repository to a new owner, you must follow additional steps on GitHub.com. For more information on transferring repository ownership, see .")
+ return fmt.Errorf("New repository name cannot contain '/' character - to transfer a repository to a new owner, you must follow additional steps on . For more information on transferring repository ownership, see .")
}
if opts.DoConfirm {
diff --git a/pkg/cmd/repo/rename/rename_test.go b/pkg/cmd/repo/rename/rename_test.go
index e33e14438..d6aa2993f 100644
--- a/pkg/cmd/repo/rename/rename_test.go
+++ b/pkg/cmd/repo/rename/rename_test.go
@@ -227,7 +227,7 @@ func TestRenameRun(t *testing.T) {
newRepoSelector: "org/new-name",
},
wantErr: true,
- errMsg: "New repository name cannot contain '/' character - to transfer a repository to a new owner, you must follow additional steps on GitHub.com. For more information on transferring repository ownership, see .",
+ errMsg: "New repository name cannot contain '/' character - to transfer a repository to a new owner, you must follow additional steps on . For more information on transferring repository ownership, see .",
},
}
diff --git a/pkg/cmd/repo/setdefault/setdefault.go b/pkg/cmd/repo/setdefault/setdefault.go
index 3e1554a69..f2b4b5267 100644
--- a/pkg/cmd/repo/setdefault/setdefault.go
+++ b/pkg/cmd/repo/setdefault/setdefault.go
@@ -64,16 +64,16 @@ func NewCmdSetDefault(f *cmdutil.Factory, runF func(*SetDefaultOptions) error) *
Short: "Configure default repository for this directory",
Long: explainer(),
Example: heredoc.Doc(`
- Interactively select a default repository:
+ # Interactively select a default repository
$ gh repo set-default
- Set a repository explicitly:
+ # Set a repository explicitly
$ gh repo set-default owner/repo
- View the current default repository:
+ # View the current default repository
$ gh repo set-default --view
- Show more repository options in the interactive picker:
+ # Show more repository options in the interactive picker
$ git remote add newrepo https://github.com/owner/repo
$ gh repo set-default
`),
@@ -105,8 +105,8 @@ func NewCmdSetDefault(f *cmdutil.Factory, runF func(*SetDefaultOptions) error) *
},
}
- cmd.Flags().BoolVarP(&opts.ViewMode, "view", "v", false, "view the current default repository")
- cmd.Flags().BoolVarP(&opts.UnsetMode, "unset", "u", false, "unset the current default repository")
+ cmd.Flags().BoolVarP(&opts.ViewMode, "view", "v", false, "View the current default repository")
+ cmd.Flags().BoolVarP(&opts.UnsetMode, "unset", "u", false, "Unset the current default repository")
return cmd
}
@@ -119,15 +119,18 @@ func setDefaultRun(opts *SetDefaultOptions) error {
currentDefaultRepo, _ := remotes.ResolvedRemote()
+ cs := opts.IO.ColorScheme()
+
if opts.ViewMode {
if currentDefaultRepo != nil {
fmt.Fprintln(opts.IO.Out, displayRemoteRepoName(currentDefaultRepo))
} else {
- fmt.Fprintln(opts.IO.ErrOut, "no default repository has been set; use `gh repo set-default` to select one")
+ fmt.Fprintf(opts.IO.ErrOut,
+ "%s No default remote repository has been set. To learn more about the default repository, run: gh repo set-default --help\n",
+ cs.FailureIcon())
}
return nil
}
- cs := opts.IO.ColorScheme()
if opts.UnsetMode {
var msg string
diff --git a/pkg/cmd/repo/setdefault/setdefault_test.go b/pkg/cmd/repo/setdefault/setdefault_test.go
index dfd72d83e..0d2e2ddaa 100644
--- a/pkg/cmd/repo/setdefault/setdefault_test.go
+++ b/pkg/cmd/repo/setdefault/setdefault_test.go
@@ -176,7 +176,7 @@ func TestDefaultRun(t *testing.T) {
Repo: repo1,
},
},
- wantStderr: "no default repository has been set; use `gh repo set-default` to select one\n",
+ wantStderr: "X No default remote repository has been set. To learn more about the default repository, run: gh repo set-default --help\n",
},
{
name: "view mode no current default",
@@ -188,7 +188,7 @@ func TestDefaultRun(t *testing.T) {
Repo: repo1,
},
},
- wantStderr: "no default repository has been set; use `gh repo set-default` to select one\n",
+ wantStderr: "X No default remote repository has been set. To learn more about the default repository, run: gh repo set-default --help\n",
},
{
name: "view mode with base resolved current default",
diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go
index 9ae2c5c86..2b6432ef9 100644
--- a/pkg/cmd/root/help_topic.go
+++ b/pkg/cmd/root/help_topic.go
@@ -43,7 +43,7 @@ var HelpTopics = []helpTopic{
short: "Environment variables that can be used with gh",
long: heredoc.Docf(`
%[1]sGH_TOKEN%[1]s, %[1]sGITHUB_TOKEN%[1]s (in order of precedence): an authentication token that will be used when
- a command targets either github.com or a subdomain of ghe.com. Setting this avoids being prompted to
+ a command targets either or a subdomain of . Setting this avoids being prompted to
authenticate and takes precedence over previously stored credentials.
%[1]sGH_ENTERPRISE_TOKEN%[1]s, %[1]sGITHUB_ENTERPRISE_TOKEN%[1]s (in order of precedence): an authentication
@@ -154,7 +154,7 @@ var HelpTopics = []helpTopic{
To learn more about Go templates, see: .
`, "`"),
example: heredoc.Doc(`
- # default output format
+ # Default output format
$ gh pr list
Showing 23 of 23 open pull requests in cli/cli
@@ -163,7 +163,7 @@ var HelpTopics = []helpTopic{
#125 An exciting new feature feature-branch about 2 days ago
- # adding the --json flag with a list of field names
+ # Adding the --json flag with a list of field names
$ gh pr list --json number,title,author
[
{
@@ -190,13 +190,14 @@ var HelpTopics = []helpTopic{
]
- # adding the --jq flag and selecting fields from the array
+ # Adding the --jq flag and selecting fields from the array
$ gh pr list --json author --jq '.[].author.login'
monalisa
codercat
cli-maintainer
- # --jq can be used to implement more complex filtering and output changes:
+
+ # --jq can be used to implement more complex filtering and output changes
$ gh issue list --json number,title,labels --jq \
'map(select((.labels | length) > 0)) # must have labels
| map(.labels = (.labels | map(.name))) # show only the label names
@@ -227,11 +228,13 @@ var HelpTopics = []helpTopic{
"title": "An exciting new feature"
}
]
- # using the --template flag with the hyperlink helper
- gh issue list --json title,url --template '{{range .}}{{hyperlink .url .title}}{{"\n"}}{{end}}'
- # adding the --template flag and modifying the display format
+ # Using the --template flag with the hyperlink helper
+ $ gh issue list --json title,url --template '{{range .}}{{hyperlink .url .title}}{{"\n"}}{{end}}'
+
+
+ # Adding the --template flag and modifying the display format
$ gh pr list --json number,title,headRefName,updatedAt --template \
'{{range .}}{{tablerow (printf "#%v" .number | autocolor "green") .title .headRefName (timeago .updatedAt)}}{{end}}'
@@ -240,7 +243,7 @@ var HelpTopics = []helpTopic{
#125 An exciting new feature feature-branch about 2 days ago
- # a more complex example with the --template flag which formats a pull request using multiple tables with headers:
+ # A more complex example with the --template flag which formats a pull request using multiple tables with headers
$ gh pr view 3519 --json number,title,body,reviews,assignees --template \
'{{printf "#%v" .number}} {{.title}}
diff --git a/pkg/cmd/ruleset/list/list.go b/pkg/cmd/ruleset/list/list.go
index 2b945c906..8c79b5d96 100644
--- a/pkg/cmd/ruleset/list/list.go
+++ b/pkg/cmd/ruleset/list/list.go
@@ -49,7 +49,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
Use the %[1]s--parents%[1]s flag to control whether rulesets configured at higher levels that also apply to the provided
repository or organization should be returned. The default is %[1]strue%[1]s.
-
+
Your access token must have the %[1]sadmin:org%[1]s scope to use the %[1]s--org%[1]s flag, which can be granted by running %[1]sgh auth refresh -s admin:org%[1]s.
`, "`"),
Example: heredoc.Doc(`
diff --git a/pkg/cmd/ruleset/view/view.go b/pkg/cmd/ruleset/view/view.go
index cefd926ab..b535a1033 100644
--- a/pkg/cmd/ruleset/view/view.go
+++ b/pkg/cmd/ruleset/view/view.go
@@ -49,7 +49,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
If no ID is provided, an interactive prompt will be used to choose
the ruleset to view.
-
+
Use the %[1]s--parents%[1]s flag to control whether rulesets configured at higher
levels that also apply to the provided repository or organization should
be returned. The default is %[1]strue%[1]s.
diff --git a/pkg/cmd/ruleset/view/view_test.go b/pkg/cmd/ruleset/view/view_test.go
index e463a9ac4..9aa4b1c00 100644
--- a/pkg/cmd/ruleset/view/view_test.go
+++ b/pkg/cmd/ruleset/view/view_test.go
@@ -158,14 +158,14 @@ func Test_viewRun(t *testing.T) {
Source: my-owner/repo-name (Repository)
Enforcement: Active
You can bypass: pull requests only
-
+
Bypass List
- OrganizationAdmin (ID: 1), mode: always
- RepositoryRole (ID: 5), mode: always
-
+
Conditions
- ref_name: [exclude: []] [include: [~ALL]]
-
+
Rules
- commit_author_email_pattern: [name: ] [negate: false] [operator: ends_with] [pattern: @example.com]
- commit_message_pattern: [name: ] [negate: false] [operator: contains] [pattern: asdf]
@@ -212,14 +212,14 @@ func Test_viewRun(t *testing.T) {
ID: 74
Source: my-owner (Organization)
Enforcement: Evaluate Mode (not enforced)
-
+
Bypass List
This ruleset cannot be bypassed
-
+
Conditions
- ref_name: [exclude: []] [include: [~ALL]]
- repository_name: [exclude: []] [include: [~ALL]] [protected: true]
-
+
Rules
- commit_author_email_pattern: [name: ] [negate: false] [operator: ends_with] [pattern: @example.com]
- commit_message_pattern: [name: ] [negate: false] [operator: contains] [pattern: asdf]
diff --git a/pkg/cmd/run/rerun/rerun.go b/pkg/cmd/run/rerun/rerun.go
index 20e94b68f..66d5d6f64 100644
--- a/pkg/cmd/run/rerun/rerun.go
+++ b/pkg/cmd/run/rerun/rerun.go
@@ -50,7 +50,7 @@ func NewCmdRerun(f *cmdutil.Factory, runF func(*RerunOptions) error) *cobra.Comm
However, this %[1]s%[1]s should not be used with the %[1]s--job%[1]s flag and will result in the
API returning %[1]s404 NOT FOUND%[1]s. Instead, you can get the correct job IDs using the following command:
-
+
gh run view --json jobs --jq '.jobs[] | {name, databaseId}'
You will need to use databaseId field for triggering job re-runs.
diff --git a/pkg/cmd/run/watch/watch.go b/pkg/cmd/run/watch/watch.go
index 9f4f37c2f..57be01f64 100644
--- a/pkg/cmd/run/watch/watch.go
+++ b/pkg/cmd/run/watch/watch.go
@@ -53,10 +53,10 @@ func NewCmdWatch(f *cmdutil.Factory, runF func(*WatchOptions) error) *cobra.Comm
`, "`"),
Example: heredoc.Doc(`
# Watch a run until it's done
- gh run watch
+ $ gh run watch
# Run some other command when the run is finished
- gh run watch && notify-send 'run is done!'
+ $ gh run watch && notify-send 'run is done!'
`),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
diff --git a/pkg/cmd/search/code/code.go b/pkg/cmd/search/code/code.go
index 761d2602c..d51ec8fa6 100644
--- a/pkg/cmd/search/code/code.go
+++ b/pkg/cmd/search/code/code.go
@@ -40,26 +40,26 @@ func NewCmdCode(f *cmdutil.Factory, runF func(*CodeOptions) error) *cobra.Comman
Note that these search results are powered by what is now a legacy GitHub code search engine.
- The results might not match what is seen on github.com, and new features like regex search
+ The results might not match what is seen on , and new features like regex search
are not yet available via the GitHub API.
`),
Example: heredoc.Doc(`
- # search code matching "react" and "lifecycle"
+ # Search code matching "react" and "lifecycle"
$ gh search code react lifecycle
- # search code matching "error handling"
+ # Search code matching "error handling"
$ gh search code "error handling"
-
- # search code matching "deque" in Python files
+
+ # Search code matching "deque" in Python files
$ gh search code deque --language=python
- # search code matching "cli" in repositories owned by microsoft organization
+ # Search code matching "cli" in repositories owned by microsoft organization
$ gh search code cli --owner=microsoft
- # search code matching "panic" in the GitHub CLI repository
+ # Search code matching "panic" in the GitHub CLI repository
$ gh search code panic --repo cli/cli
- # search code matching keyword "lint" in package.json files
+ # Search code matching keyword "lint" in package.json files
$ gh search code lint --filename package.json
`),
RunE: func(c *cobra.Command, args []string) error {
diff --git a/pkg/cmd/search/commits/commits.go b/pkg/cmd/search/commits/commits.go
index 6a9f58b86..bc37684a2 100644
--- a/pkg/cmd/search/commits/commits.go
+++ b/pkg/cmd/search/commits/commits.go
@@ -47,22 +47,22 @@ func NewCmdCommits(f *cmdutil.Factory, runF func(*CommitsOptions) error) *cobra.
`),
Example: heredoc.Doc(`
- # search commits matching set of keywords "readme" and "typo"
+ # Search commits matching set of keywords "readme" and "typo"
$ gh search commits readme typo
- # search commits matching phrase "bug fix"
+ # Search commits matching phrase "bug fix"
$ gh search commits "bug fix"
- # search commits committed by user "monalisa"
+ # Search commits committed by user "monalisa"
$ gh search commits --committer=monalisa
- # search commits authored by users with name "Jane Doe"
+ # Search commits authored by users with name "Jane Doe"
$ gh search commits --author-name="Jane Doe"
- # search commits matching hash "8dd03144ffdc6c0d486d6b705f9c7fba871ee7c3"
+ # Search commits matching hash "8dd03144ffdc6c0d486d6b705f9c7fba871ee7c3"
$ gh search commits --hash=8dd03144ffdc6c0d486d6b705f9c7fba871ee7c3
- # search commits authored before February 1st, 2022
+ # Search commits authored before February 1st, 2022
$ gh search commits --author-date="<2022-02-01"
`),
RunE: func(c *cobra.Command, args []string) error {
diff --git a/pkg/cmd/search/issues/issues.go b/pkg/cmd/search/issues/issues.go
index 793eecc61..e1f4105ae 100644
--- a/pkg/cmd/search/issues/issues.go
+++ b/pkg/cmd/search/issues/issues.go
@@ -36,25 +36,25 @@ func NewCmdIssues(f *cmdutil.Factory, runF func(*shared.IssuesOptions) error) *c
`),
Example: heredoc.Doc(`
- # search issues matching set of keywords "readme" and "typo"
+ # Search issues matching set of keywords "readme" and "typo"
$ gh search issues readme typo
- # search issues matching phrase "broken feature"
+ # Search issues matching phrase "broken feature"
$ gh search issues "broken feature"
- # search issues and pull requests in cli organization
+ # Search issues and pull requests in cli organization
$ gh search issues --include-prs --owner=cli
- # search open issues assigned to yourself
+ # Search open issues assigned to yourself
$ gh search issues --assignee=@me --state=open
- # search issues with numerous comments
+ # Search issues with numerous comments
$ gh search issues --comments=">100"
- # search issues without label "bug"
+ # Search issues without label "bug"
$ gh search issues -- -label:bug
- # search issues only from un-archived repositories (default is all repositories)
+ # Search issues only from un-archived repositories (default is all repositories)
$ gh search issues --owner github --archived=false
`),
RunE: func(c *cobra.Command, args []string) error {
diff --git a/pkg/cmd/search/prs/prs.go b/pkg/cmd/search/prs/prs.go
index fa00677e6..f7a96c5bf 100644
--- a/pkg/cmd/search/prs/prs.go
+++ b/pkg/cmd/search/prs/prs.go
@@ -38,25 +38,25 @@ func NewCmdPrs(f *cmdutil.Factory, runF func(*shared.IssuesOptions) error) *cobr
`),
Example: heredoc.Doc(`
- # search pull requests matching set of keywords "fix" and "bug"
+ # Search pull requests matching set of keywords "fix" and "bug"
$ gh search prs fix bug
- # search draft pull requests in cli repository
+ # Search draft pull requests in cli repository
$ gh search prs --repo=cli/cli --draft
- # search open pull requests requesting your review
+ # Search open pull requests requesting your review
$ gh search prs --review-requested=@me --state=open
- # search merged pull requests assigned to yourself
+ # Search merged pull requests assigned to yourself
$ gh search prs --assignee=@me --merged
- # search pull requests with numerous reactions
+ # Search pull requests with numerous reactions
$ gh search prs --reactions=">100"
- # search pull requests without label "bug"
+ # Search pull requests without label "bug"
$ gh search prs -- -label:bug
- # search pull requests only from un-archived repositories (default is all repositories)
+ # Search pull requests only from un-archived repositories (default is all repositories)
$ gh search prs --owner github --archived=false
`),
RunE: func(c *cobra.Command, args []string) error {
diff --git a/pkg/cmd/search/repos/repos.go b/pkg/cmd/search/repos/repos.go
index 88b8db13f..0bad650d3 100644
--- a/pkg/cmd/search/repos/repos.go
+++ b/pkg/cmd/search/repos/repos.go
@@ -48,25 +48,25 @@ func NewCmdRepos(f *cmdutil.Factory, runF func(*ReposOptions) error) *cobra.Comm
`),
Example: heredoc.Doc(`
- # search repositories matching set of keywords "cli" and "shell"
+ # Search repositories matching set of keywords "cli" and "shell"
$ gh search repos cli shell
- # search repositories matching phrase "vim plugin"
+ # Search repositories matching phrase "vim plugin"
$ gh search repos "vim plugin"
- # search repositories public repos in the microsoft organization
+ # Search repositories public repos in the microsoft organization
$ gh search repos --owner=microsoft --visibility=public
- # search repositories with a set of topics
+ # Search repositories with a set of topics
$ gh search repos --topic=unix,terminal
- # search repositories by coding language and number of good first issues
+ # Search repositories by coding language and number of good first issues
$ gh search repos --language=go --good-first-issues=">=10"
- # search repositories without topic "linux"
+ # Search repositories without topic "linux"
$ gh search repos -- -topic:linux
- # search repositories excluding archived repositories
+ # Search repositories excluding archived repositories
$ gh search repos --archived=false
`),
RunE: func(c *cobra.Command, args []string) error {
diff --git a/pkg/cmd/workflow/run/run.go b/pkg/cmd/workflow/run/run.go
index b7ab53098..def3cf9e4 100644
--- a/pkg/cmd/workflow/run/run.go
+++ b/pkg/cmd/workflow/run/run.go
@@ -128,7 +128,7 @@ func NewCmdRun(f *cmdutil.Factory, runF func(*RunOptions) error) *cobra.Command
return runRun(opts)
},
}
- cmd.Flags().StringVarP(&opts.Ref, "ref", "r", "", "The branch or tag name which contains the version of the workflow file you'd like to run")
+ cmd.Flags().StringVarP(&opts.Ref, "ref", "r", "", "Branch or tag name which contains the version of the workflow file you'd like to run")
cmd.Flags().StringArrayVarP(&opts.MagicFields, "field", "F", nil, "Add a string parameter in `key=value` format, respecting @ syntax (see \"gh help api\").")
cmd.Flags().StringArrayVarP(&opts.RawFields, "raw-field", "f", nil, "Add a string parameter in `key=value` format")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "Read workflow inputs as JSON via STDIN")
diff --git a/pkg/cmd/workflow/shared/shared.go b/pkg/cmd/workflow/shared/shared.go
index 61511ed7d..04b5fa199 100644
--- a/pkg/cmd/workflow/shared/shared.go
+++ b/pkg/cmd/workflow/shared/shared.go
@@ -132,9 +132,13 @@ func FindWorkflow(client *api.Client, repo ghrepo.Interface, workflowSelector st
workflow, err := getWorkflowByID(client, repo, workflowSelector)
if err != nil {
var httpErr api.HTTPError
- if errors.As(err, &httpErr) && httpErr.StatusCode == 404 {
- return nil, fmt.Errorf("workflow %s not found on the default branch", workflowSelector)
+ if errors.As(err, &httpErr) {
+ if httpErr.StatusCode == 404 {
+ httpErr.Message = fmt.Sprintf("workflow %s not found on the default branch", workflowSelector)
+ }
+ return nil, httpErr
}
+ return nil, err
}
return []Workflow{*workflow}, nil
}
diff --git a/pkg/cmd/workflow/shared/shared_test.go b/pkg/cmd/workflow/shared/shared_test.go
new file mode 100644
index 000000000..cd53b667c
--- /dev/null
+++ b/pkg/cmd/workflow/shared/shared_test.go
@@ -0,0 +1,422 @@
+package shared
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/cli/cli/v2/api"
+ "github.com/cli/cli/v2/internal/ghrepo"
+ "github.com/cli/cli/v2/pkg/httpmock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ ghAPI "github.com/cli/go-gh/v2/pkg/api"
+)
+
+func TestFindWorkflow(t *testing.T) {
+ badRequestURL, err := url.Parse("https://api.github.com/repos/OWNER/REPO/actions/workflows/nonexistentWorkflow.yml")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ tests := []struct {
+ name string
+ workflowSelector string
+ repo ghrepo.Interface
+ httpStubs func(*httpmock.Registry)
+ states []WorkflowState
+ expectedWorkflow Workflow
+ expectedHTTPError *api.HTTPError
+ expectedError error
+ }{
+ {
+ name: "When the workflow selector is empty, it returns an error",
+ workflowSelector: "",
+ repo: ghrepo.New("OWNER", "REPO"),
+ expectedError: errors.New("empty workflow selector"),
+ },
+ {
+ name: "When the workflow selector is a number, it returns the workflow with that ID",
+ workflowSelector: "1",
+ repo: ghrepo.New("OWNER", "REPO"),
+ httpStubs: func(reg *httpmock.Registry) {
+ reg.Register(
+ httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/1"),
+ httpmock.StatusJSONResponse(200, Workflow{
+ ID: 1,
+ }),
+ )
+ },
+ expectedWorkflow: Workflow{
+ ID: 1,
+ },
+ },
+ {
+ name: "When the workflow selector is a file, it returns the workflow with that path",
+ workflowSelector: "workflowFile.yml",
+ repo: ghrepo.New("OWNER", "REPO"),
+ httpStubs: func(reg *httpmock.Registry) {
+ reg.Register(
+ httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/workflowFile.yml"),
+ httpmock.StatusJSONResponse(200, Workflow{
+ ID: 1,
+ Name: "Some Workflow",
+ Path: ".github/workflows/workflowFile.yml",
+ }),
+ )
+ },
+ expectedWorkflow: Workflow{
+ ID: 1,
+ Name: "Some Workflow",
+ Path: ".github/workflows/workflowFile.yml",
+ },
+ },
+ {
+ name: "When the workflow selector is a workflow that doesn't exist, it returns the workflow not found error",
+ workflowSelector: "nonexistentWorkflow.yml",
+ repo: ghrepo.New("OWNER", "REPO"),
+ httpStubs: func(reg *httpmock.Registry) {
+ reg.Register(
+ httpmock.REST("GET", strings.TrimPrefix(badRequestURL.Path, "/")),
+ httpmock.StatusJSONResponse(404, Workflow{}),
+ )
+ },
+ expectedHTTPError: &api.HTTPError{
+ HTTPError: &ghAPI.HTTPError{
+ Message: "workflow nonexistentWorkflow.yml not found on the default branch",
+ StatusCode: 404,
+ RequestURL: badRequestURL,
+ },
+ },
+ },
+ {
+ name: "When the workflow selector is a file but the server errors, it returns that error",
+ workflowSelector: "nonexistentWorkflow.yml",
+ repo: ghrepo.New("OWNER", "REPO"),
+ httpStubs: func(reg *httpmock.Registry) {
+ reg.Register(
+ httpmock.REST("GET", strings.TrimPrefix(badRequestURL.Path, "/")),
+ httpmock.StatusStringResponse(500, "server error"),
+ )
+ },
+ expectedHTTPError: &api.HTTPError{
+ HTTPError: &ghAPI.HTTPError{
+ Errors: []ghAPI.HTTPErrorItem{
+ {
+ Message: "server error",
+ },
+ },
+ StatusCode: 500,
+ RequestURL: badRequestURL,
+ },
+ },
+ },
+ {
+ name: "When the workflow selector is a name and the state is active, it returns that workflow",
+ workflowSelector: "Workflow Name",
+ repo: ghrepo.New("OWNER", "REPO"),
+ states: []WorkflowState{Active},
+ httpStubs: func(reg *httpmock.Registry) {
+ reg.Register(
+ httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+ httpmock.StatusJSONResponse(200, WorkflowsPayload{
+ Workflows: []Workflow{
+ {
+ ID: 1,
+ Name: "Workflow Name",
+ State: Active,
+ },
+ }}),
+ )
+ },
+ expectedWorkflow: Workflow{
+ ID: 1,
+ Name: "Workflow Name",
+ State: "active",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ reg := &httpmock.Registry{}
+ if tt.httpStubs != nil {
+ tt.httpStubs(reg)
+ }
+ client := api.NewClientFromHTTP(&http.Client{Transport: reg})
+
+ workflow, err := FindWorkflow(client, tt.repo, tt.workflowSelector, tt.states)
+ if tt.expectedError != nil {
+ require.Error(t, err)
+ assert.Equal(t, tt.expectedError, err)
+ } else if tt.expectedHTTPError != nil {
+ var httpErr api.HTTPError
+ require.ErrorAs(t, err, &httpErr)
+ assert.Equal(t, tt.expectedHTTPError.Error(), httpErr.Error())
+ } else {
+ require.NoError(t, err)
+ assert.Equal(t, tt.expectedWorkflow, workflow[0])
+ }
+ })
+ }
+}
+
+type ErrorTransport struct {
+ Err error
+}
+
+func (t *ErrorTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ return nil, t.Err
+}
+
+func TestFindWorkflow_nonHTTPError(t *testing.T) {
+ t.Run("When the client fails to instantiate, it returns the error", func(t *testing.T) {
+ client := api.NewClientFromHTTP(&http.Client{Transport: &ErrorTransport{Err: errors.New("non-HTTP error")}})
+ repo := ghrepo.New("OWNER", "REPO")
+ workflow, err := FindWorkflow(client, repo, "1", nil)
+
+ require.Error(t, err)
+ assert.ErrorContains(t, err, "non-HTTP error")
+ assert.Nil(t, workflow)
+ })
+}
+
+func Test_getWorkflowsByName_filtering(t *testing.T) {
+ tests := []struct {
+ name string
+ workflowName string
+ repo ghrepo.Interface
+ states []WorkflowState
+ httpStubs func(*httpmock.Registry)
+ expectedWorkflows []Workflow
+ expectedErrorMsg string
+ }{
+ {
+ name: "When no workflows match, no workflows are returned",
+ workflowName: "Unmatched Workflow Name",
+ repo: ghrepo.New("OWNER", "REPO"),
+ states: []WorkflowState{Active},
+ httpStubs: func(reg *httpmock.Registry) {
+ reg.Register(
+ httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+ httpmock.StatusJSONResponse(200, WorkflowsPayload{
+ Workflows: []Workflow{
+ {
+ ID: 1,
+ Name: "Workflow Name",
+ State: Active,
+ },
+ {
+ ID: 2,
+ Name: "Workflow Name",
+ State: DisabledInactivity,
+ },
+ {
+ ID: 3,
+ Name: "Workflow Name",
+ State: Active,
+ },
+ },
+ }),
+ )
+ },
+ expectedWorkflows: []Workflow(nil),
+ },
+ {
+ name: "When there are more than one workflow with the same name, only the ones matching the provided state are returned",
+ workflowName: "Workflow Name",
+ repo: ghrepo.New("OWNER", "REPO"),
+ states: []WorkflowState{Active},
+ httpStubs: func(reg *httpmock.Registry) {
+ reg.Register(
+ httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+ httpmock.StatusJSONResponse(200, WorkflowsPayload{
+ Workflows: []Workflow{
+ {
+ ID: 1,
+ Name: "Workflow Name",
+ State: Active,
+ },
+ {
+ ID: 2,
+ Name: "Workflow Name",
+ State: DisabledInactivity,
+ },
+ {
+ ID: 3,
+ Name: "Workflow Name",
+ State: Active,
+ },
+ },
+ }),
+ )
+ },
+ expectedWorkflows: []Workflow{
+ {
+ ID: 1,
+ Name: "Workflow Name",
+ State: Active,
+ },
+ {
+ ID: 3,
+ Name: "Workflow Name",
+ State: Active,
+ },
+ },
+ },
+ {
+ name: "When GetWorkflows errors",
+ repo: ghrepo.New("OWNER", "REPO"),
+ httpStubs: func(reg *httpmock.Registry) {
+ reg.Register(
+ httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+ httpmock.StatusStringResponse(500, ""),
+ )
+ },
+ expectedErrorMsg: "couldn't fetch workflows for OWNER/REPO",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ reg := &httpmock.Registry{}
+ if tt.httpStubs != nil {
+ tt.httpStubs(reg)
+ }
+ client := api.NewClientFromHTTP(&http.Client{Transport: reg})
+
+ workflows, err := getWorkflowsByName(client, tt.repo, tt.workflowName, tt.states)
+ if tt.expectedErrorMsg != "" {
+ require.Error(t, err)
+ assert.ErrorContains(t, err, tt.expectedErrorMsg)
+ } else {
+ require.NoError(t, err)
+ assert.Equal(t, tt.expectedWorkflows, workflows)
+ }
+ })
+ }
+}
+
+func TestGetWorkflows(t *testing.T) {
+ tests := []struct {
+ name string
+ repo ghrepo.Interface
+ limit int
+ httpStubs func(*httpmock.Registry)
+ expectedWorkflows []Workflow
+ expectedError error
+ }{
+ {
+ name: "When the repo has no workflows, it returns an empty slice",
+ repo: ghrepo.New("OWNER", "REPO"),
+ httpStubs: func(reg *httpmock.Registry) {
+ reg.Register(
+ httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+ httpmock.StatusJSONResponse(200, WorkflowsPayload{
+ Workflows: []Workflow{},
+ }),
+ )
+ },
+ expectedWorkflows: []Workflow{},
+ },
+ {
+ name: "When the api returns workflows, it returns those workflows",
+ repo: ghrepo.New("OWNER", "REPO"),
+ httpStubs: func(reg *httpmock.Registry) {
+ reg.Register(
+ httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+ httpmock.StatusJSONResponse(200, WorkflowsPayload{
+ Workflows: []Workflow{
+ {
+ Name: "Workflow 1",
+ },
+ {
+ Name: "Workflow 2",
+ },
+ },
+ }),
+ )
+ },
+ expectedWorkflows: []Workflow{
+ {
+ Name: "Workflow 1",
+ },
+ {
+ Name: "Workflow 2",
+ },
+ },
+ },
+ {
+ name: "When the api return paginates, it returns the workflows from all the pages",
+ repo: ghrepo.New("OWNER", "REPO"),
+ limit: 0,
+ httpStubs: func(reg *httpmock.Registry) {
+ reg.Register(
+ httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+ httpmock.StatusJSONResponse(200, WorkflowsPayload{
+ Workflows: generateWorkflows(t, 100, 1),
+ }),
+ )
+ reg.Register(
+ httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+ httpmock.StatusJSONResponse(200, WorkflowsPayload{
+ Workflows: generateWorkflows(t, 50, 2),
+ }),
+ )
+ },
+ expectedWorkflows: append(generateWorkflows(t, 100, 1), generateWorkflows(t, 50, 2)...),
+ },
+ {
+ name: "When the limit is set to fewer workflows than the api returns, it returns the number of workflows specified by the limit",
+ repo: ghrepo.New("OWNER", "REPO"),
+ limit: 2,
+ httpStubs: func(reg *httpmock.Registry) {
+ reg.Register(
+ httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
+ httpmock.StatusJSONResponse(200, WorkflowsPayload{
+ Workflows: generateWorkflows(t, 100, 1),
+ }),
+ )
+ },
+ expectedWorkflows: generateWorkflows(t, 2, 1),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ reg := &httpmock.Registry{}
+ if tt.httpStubs != nil {
+ tt.httpStubs(reg)
+ }
+ client := api.NewClientFromHTTP(&http.Client{Transport: reg})
+
+ workflows, err := GetWorkflows(client, tt.repo, tt.limit)
+ if tt.expectedError != nil {
+ require.Error(t, err)
+ assert.Equal(t, tt.expectedError, err)
+ } else {
+ require.NoError(t, err)
+ assert.Equal(t, tt.expectedWorkflows, workflows)
+ }
+ })
+ }
+}
+
+// generateWorkflows returns an slice of workflows with the given count, labeled
+// with the page number of testing pagination.
+// The page number is used to generate unique Names and IDs for each workflow.
+func generateWorkflows(t *testing.T, workflowCount int, pageNum int) []Workflow {
+ t.Helper()
+ workflows := []Workflow{}
+ for i := 0; i < workflowCount; i++ {
+ workflows = append(workflows, Workflow{
+ Name: fmt.Sprintf("Workflow-%d-%d", pageNum, i),
+ ID: int64(i) + int64(pageNum-1)*100,
+ })
+ }
+ return workflows
+}