Merge branch 'trunk' into gh-cache-command

This commit is contained in:
Josh Kraft 2023-05-17 14:34:10 -06:00 committed by GitHub
commit 2e5a728338
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 1901 additions and 586 deletions

View file

@ -1,7 +1,11 @@
name: Deployment
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name }}
cancel-in-progress: true
permissions:
contents: read
contents: write
on:
workflow_dispatch:
@ -9,13 +13,25 @@ on:
tag_name:
required: true
type: string
environment:
default: production
type: environment
go_version:
default: "1.19"
type: string
platforms:
default: "linux,macos,windows"
type: string
release:
description: "Whether to create a GitHub Release"
type: boolean
default: true
jobs:
linux:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
if: contains(inputs.platforms, 'linux')
steps:
- name: Checkout
uses: actions/checkout@v3
@ -23,3 +39,307 @@ jobs:
uses: actions/setup-go@v4
with:
go-version: ${{ inputs.go_version }}
- name: Install GoReleaser
uses: goreleaser/goreleaser-action@v4
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@v3
with:
name: linux
if-no-files-found: error
retention-days: 7
path: |
dist/*.tar.gz
dist/*.rpm
dist/*.deb
macos:
runs-on: macos-latest
environment: ${{ inputs.environment }}
if: contains(inputs.platforms, 'macos')
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: ${{ inputs.go_version }}
- 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@v4
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
- uses: actions/upload-artifact@v3
with:
name: macos
if-no-files-found: error
retention-days: 7
path: |
dist/*.tar.gz
dist/*.zip
windows:
runs-on: windows-latest
environment: ${{ inputs.environment }}
if: contains(inputs.platforms, 'windows')
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: ${{ inputs.go_version }}
- name: Obtain signing certificate
id: obtain_cert
if: inputs.environment == 'production'
shell: bash
run: |
base64 -d <<<"$CERT_CONTENTS" > ./cert.pfx
printf "cert-file=%s\n" ".\\cert.pfx" >> $GITHUB_OUTPUT
env:
CERT_CONTENTS: ${{ secrets.WINDOWS_CERT_PFX }}
- name: Install GoReleaser
uses: goreleaser/goreleaser-action@v4
with:
version: "~1.17.1"
install-only: true
- name: Build release binaries
shell: bash
env:
TAG_NAME: ${{ inputs.tag_name }}
CERT_FILE: ${{ steps.obtain_cert.outputs.cert-file }}
CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }}
run: script/release --local "$TAG_NAME" --platform windows
- name: Set up MSBuild
id: setupmsbuild
uses: microsoft/setup-msbuild@v1.3.1
- 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 )
echo "skipping building MSI for arm64 because WiX 3.11 doesn't support it: https://github.com/wixtoolset/issues/issues/6141" >&2
continue
#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
if: inputs.environment == 'production'
shell: pwsh
run: |
Get-ChildItem -Path .\dist -Filter *.msi | ForEach-Object {
.\script\sign $_.FullName
}
env:
CERT_FILE: ${{ steps.obtain_cert.outputs.cert-file }}
CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }}
- uses: actions/upload-artifact@v3
with:
name: windows
if-no-files-found: error
retention-days: 7
path: |
dist/*.zip
dist/*.msi
release:
runs-on: ubuntu-latest
needs: [linux, macos, windows]
if: inputs.release
steps:
- name: Checkout cli/cli
uses: actions/checkout@v3
- name: Merge built artifacts
uses: actions/download-artifact@v3
- name: Checkout documentation site
uses: actions/checkout@v3
with:
repository: github/cli.github.com
path: site
fetch-depth: 0
ssh-key: ${{ secrets.SITE_SSH_KEY }}
- 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: 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 )
else
release_args+=( --discussion-category "General" )
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@v2
if: inputs.environment == 'production' && !contains(inputs.tag_name, '-')
with:
formula-name: gh
tag-name: ${{ inputs.tag_name }}
env:
COMMITTER_TOKEN: ${{ secrets.UPLOAD_GITHUB_TOKEN }}

View file

@ -1,222 +0,0 @@
name: goreleaser
on:
push:
tags:
- "v*"
permissions:
contents: write # publishing releases
repository-projects: write # move cards between columns
jobs:
goreleaser:
runs-on: ubuntu-20.04
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Go 1.19
uses: actions/setup-go@v4
with:
go-version: 1.19
- name: Generate changelog
id: changelog
run: |
echo "tag-name=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
gh api repos/$GITHUB_REPOSITORY/releases/generate-notes \
-f tag_name="${GITHUB_REF#refs/tags/}" \
-f target_commitish=trunk \
-q .body > CHANGELOG.md
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
- name: Install osslsigncode
run: sudo apt-get install -y osslsigncode
- name: Obtain signing cert
run: |
cert="$(mktemp -t cert.XXX)"
base64 -d <<<"$CERT_CONTENTS" > "$cert"
echo "CERT_FILE=$cert" >> $GITHUB_ENV
env:
CERT_CONTENTS: ${{ secrets.WINDOWS_CERT_PFX }}
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v4
with:
version: "~1.13.1"
args: release --release-notes=CHANGELOG.md
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
GORELEASER_CURRENT_TAG: ${{steps.changelog.outputs.tag-name}}
CERT_PASSWORD: ${{secrets.WINDOWS_CERT_PASSWORD}}
- name: Checkout documentation site
uses: actions/checkout@v3
with:
repository: github/cli.github.com
path: site
fetch-depth: 0
ssh-key: ${{secrets.SITE_SSH_KEY}}
- 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
run: make site-bump
- name: Move project cards
continue-on-error: true
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
PENDING_COLUMN: 8189733
DONE_COLUMN: 7110130
run: |
api() { gh api -H 'accept: application/vnd.github.inertia-preview+json' "$@"; }
api-write() { [[ $GITHUB_REF == *-* ]] && echo "skipping: api $*" || api "$@"; }
cards=$(api --paginate projects/columns/$PENDING_COLUMN/cards | jq ".[].id")
for card in $cards; do
api-write --silent projects/columns/cards/$card/moves -f position=top -F column_id=$DONE_COLUMN
done
echo "moved ${#cards[@]} cards to the Done column"
- name: Install packaging dependencies
run: sudo apt-get install -y rpm reprepro
- name: Set up GPG
run: |
echo "${{secrets.GPG_PUBKEY}}" | base64 -d | gpg --import --no-tty --batch --yes
echo "${{secrets.GPG_KEY}}" | base64 -d | gpg --import --no-tty --batch --yes
echo "allow-preset-passphrase" > ~/.gnupg/gpg-agent.conf
gpg-connect-agent RELOADAGENT /bye
echo "${{secrets.GPG_PASSPHRASE}}" | /usr/lib/gnupg2/gpg-preset-passphrase --preset "${{secrets.GPG_KEYGRIP}}"
- name: Sign RPMs
run: |
cp script/rpmmacros ~/.rpmmacros
rpmsign --addsign dist/*.rpm
- name: Run createrepo
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 --yes --detach-sign --armor repodata/repomd.xml
popd
- name: Run reprepro
env:
# 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
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: Publish site
env:
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 ${GITHUB_REF#refs/tags/}"
if [[ $GITHUB_REF == *-* ]]; then
git log --oneline @{upstream}..
git diff --name-status @{upstream}..
else
git push
fi
msi:
needs: goreleaser
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Download gh.exe
id: download_exe
shell: bash
run: |
hub release download "${GITHUB_REF#refs/tags/}" -i '*windows_amd64*.zip'
printf "zip=%s\n" *.zip >> $GITHUB_OUTPUT
unzip -o *.zip && rm -v *.zip
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
- name: Prepare PATH
id: setupmsbuild
uses: microsoft/setup-msbuild@v1.3.1
- name: Build MSI
id: buildmsi
shell: bash
env:
ZIP_FILE: ${{ steps.download_exe.outputs.zip }}
MSBUILD_PATH: ${{ steps.setupmsbuild.outputs.msbuildPath }}
run: |
name="$(basename "$ZIP_FILE" ".zip")"
version="$(echo -e ${GITHUB_REF#refs/tags/v} | sed s/-.*$//)"
"${MSBUILD_PATH}\MSBuild.exe" ./build/windows/gh.wixproj -p:SourceDir="$PWD" -p:OutputPath="$PWD" -p:OutputName="$name" -p:ProductVersion="$version"
- name: Obtain signing cert
id: obtain_cert
shell: bash
run: |
base64 -d <<<"$CERT_CONTENTS" > ./cert.pfx
printf "cert-file=%s\n" ".\\cert.pfx" >> $GITHUB_OUTPUT
env:
CERT_CONTENTS: ${{ secrets.WINDOWS_CERT_PFX }}
- name: Sign MSI
env:
CERT_FILE: ${{ steps.obtain_cert.outputs.cert-file }}
EXE_FILE: ${{ steps.buildmsi.outputs.msi }}
CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }}
run: .\script\signtool sign /d "GitHub CLI" /f $env:CERT_FILE /p $env:CERT_PASSWORD /fd sha256 /tr http://timestamp.digicert.com /v $env:EXE_FILE
- name: Upload MSI
shell: bash
run: |
tag_name="${GITHUB_REF#refs/tags/}"
hub release edit "$tag_name" -m "" -a "$MSI_FILE"
release_url="$(gh api repos/:owner/:repo/releases -q ".[]|select(.tag_name==\"${tag_name}\")|.url")"
publish_args=( -F draft=false )
if [[ $GITHUB_REF != *-* ]]; then
publish_args+=( -f discussion_category_name="$DISCUSSION_CATEGORY" )
fi
gh api -X PATCH "$release_url" "${publish_args[@]}"
env:
MSI_FILE: ${{ steps.buildmsi.outputs.msi }}
DISCUSSION_CATEGORY: General
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
- name: Bump homebrew-core formula
uses: mislav/bump-homebrew-formula-action@v2
if: "!contains(github.ref, '-')" # skip prereleases
with:
formula-name: gh
env:
COMMITTER_TOKEN: ${{ secrets.UPLOAD_GITHUB_TOKEN }}
- name: Checkout scoop bucket
uses: actions/checkout@v3
with:
repository: cli/scoop-gh
path: scoop-gh
fetch-depth: 0
token: ${{secrets.UPLOAD_GITHUB_TOKEN}}
- name: Bump scoop bucket
shell: bash
run: |
hub release download "${GITHUB_REF#refs/tags/}" -i '*_checksums.txt'
script/scoop-gen "${GITHUB_REF#refs/tags/}" ./scoop-gh/gh.json < *_checksums.txt
git -C ./scoop-gh commit -m "gh ${GITHUB_REF#refs/tags/}" gh.json
if [[ $GITHUB_REF == *-* ]]; then
git -C ./scoop-gh show -m
else
git -C ./scoop-gh push
fi
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
GIT_COMMITTER_NAME: cli automation
GIT_AUTHOR_NAME: cli automation
GIT_COMMITTER_EMAIL: noreply@github.com
GIT_AUTHOR_EMAIL: noreply@github.com

2
.gitignore vendored
View file

@ -9,7 +9,9 @@
/site
.github/**/node_modules
/CHANGELOG.md
/.goreleaser.generated.yml
/script/build
/script/build.exe
# VS Code
.vscode

View file

@ -7,57 +7,76 @@ release:
before:
hooks:
- go mod tidy
- make manpages GH_VERSION={{.Version}}
- make completions
- >-
{{ if eq .Runtime.Goos "windows" }}echo{{ end }} make manpages GH_VERSION={{.Version}}
- >-
{{ if ne .Runtime.Goos "linux" }}echo{{ end }} make completions
builds:
- <<: &build_defaults
binary: bin/gh
main: ./cmd/gh
ldflags:
- -s -w -X github.com/cli/cli/v2/internal/build.Version={{.Version}} -X github.com/cli/cli/v2/internal/build.Date={{time "2006-01-02"}}
id: macos
- id: macos #build:macos
goos: [darwin]
goarch: [amd64, arm64]
hooks:
post:
- cmd: ./script/sign '{{ .Path }}'
output: true
binary: bin/gh
main: ./cmd/gh
ldflags:
- -s -w -X github.com/cli/cli/v2/internal/build.Version={{.Version}} -X github.com/cli/cli/v2/internal/build.Date={{time "2006-01-02"}}
- <<: *build_defaults
id: linux
- id: linux #build:linux
goos: [linux]
goarch: [386, arm, amd64, arm64]
env:
- CGO_ENABLED=0
binary: bin/gh
main: ./cmd/gh
ldflags:
- -s -w -X github.com/cli/cli/v2/internal/build.Version={{.Version}} -X github.com/cli/cli/v2/internal/build.Date={{time "2006-01-02"}}
- <<: *build_defaults
id: windows
- id: windows #build:windows
goos: [windows]
goarch: [386, amd64, arm64]
hooks:
post:
- cmd: ./script/sign-windows-executable.sh '{{ .Path }}'
output: false
- cmd: >-
{{ if eq .Runtime.Goos "windows" }}.\script\sign{{ else }}./script/sign{{ end }} '{{ .Path }}'
output: true
binary: bin/gh
main: ./cmd/gh
ldflags:
- -s -w -X github.com/cli/cli/v2/internal/build.Version={{.Version}} -X github.com/cli/cli/v2/internal/build.Date={{time "2006-01-02"}}
archives:
- id: nix
builds: [macos, linux]
<<: &archive_defaults
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
- id: linux-archive
builds: [linux]
name_template: "gh_{{ .Version }}_linux_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
wrap_in_directory: true
replacements:
darwin: macOS
format: tar.gz
rlcp: true
files:
- LICENSE
- ./share/man/man1/gh*.1
- id: windows
- id: macos-archive
builds: [macos]
name_template: "gh_{{ .Version }}_macOS_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
wrap_in_directory: true
format: zip
rlcp: true
files:
- LICENSE
- ./share/man/man1/gh*.1
- id: windows-archive
builds: [windows]
<<: *archive_defaults
name_template: "gh_{{ .Version }}_windows_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
wrap_in_directory: false
format: zip
rlcp: true
files:
- LICENSE
nfpms:
nfpms: #build:linux
- license: MIT
maintainer: GitHub
homepage: https://github.com/cli/cli

View file

@ -6,33 +6,37 @@ CGO_LDFLAGS ?= $(filter -g -L% -l% -O%,${LDFLAGS})
export CGO_LDFLAGS
EXE =
ifeq ($(GOOS),windows)
ifeq ($(shell go env GOOS),windows)
EXE = .exe
endif
## The following tasks delegate to `script/build.go` so they can be run cross-platform.
.PHONY: bin/gh$(EXE)
bin/gh$(EXE): script/build
@script/build $@
bin/gh$(EXE): script/build$(EXE)
@script/build$(EXE) $@
script/build: script/build.go
script/build$(EXE): script/build.go
ifeq ($(EXE),)
GOOS= GOARCH= GOARM= GOFLAGS= CGO_ENABLED= go build -o $@ $<
else
go build -o $@ $<
endif
.PHONY: clean
clean: script/build
@script/build $@
clean: script/build$(EXE)
@$< $@
.PHONY: manpages
manpages: script/build
@script/build $@
manpages: script/build$(EXE)
@$< $@
.PHONY: completions
completions:
completions: bin/gh$(EXE)
mkdir -p ./share/bash-completion/completions ./share/fish/vendor_completions.d ./share/zsh/site-functions
go run ./cmd/gh completion -s bash > ./share/bash-completion/completions/gh
go run ./cmd/gh completion -s fish > ./share/fish/vendor_completions.d/gh.fish
go run ./cmd/gh completion -s zsh > ./share/zsh/site-functions/_gh
bin/gh$(EXE) completion -s bash > ./share/bash-completion/completions/gh
bin/gh$(EXE) completion -s fish > ./share/fish/vendor_completions.d/gh.fish
bin/gh$(EXE) completion -s zsh > ./share/zsh/site-functions/_gh
# just a convenience task around `go test`
.PHONY: test

View file

@ -2,42 +2,295 @@ package api
import (
"encoding/json"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPullRequest_ChecksStatus(t *testing.T) {
pr := PullRequest{}
func TestChecksStatus_NoCheckRunsOrStatusContexts(t *testing.T) {
t.Parallel()
payload := `
{ "statusCheckRollup": { "nodes": [] } }
`
var pr PullRequest
require.NoError(t, json.Unmarshal([]byte(payload), &pr))
expectedChecksStatus := PullRequestChecksStatus{
Pending: 0,
Failing: 0,
Passing: 0,
Total: 0,
}
require.Equal(t, expectedChecksStatus, pr.ChecksStatus())
}
func TestChecksStatus_SummarisingCheckRuns(t *testing.T) {
t.Parallel()
tests := []struct {
name string
payload string
expectedChecksStatus PullRequestChecksStatus
}{
{
name: "QUEUED is treated as Pending",
payload: singleCheckRunWithStatus("QUEUED"),
expectedChecksStatus: PullRequestChecksStatus{Pending: 1, Total: 1},
},
{
name: "IN_PROGRESS is treated as Pending",
payload: singleCheckRunWithStatus("IN_PROGRESS"),
expectedChecksStatus: PullRequestChecksStatus{Pending: 1, Total: 1},
},
{
name: "WAITING is treated as Pending",
payload: singleCheckRunWithStatus("WAITING"),
expectedChecksStatus: PullRequestChecksStatus{Pending: 1, Total: 1},
},
{
name: "PENDING is treated as Pending",
payload: singleCheckRunWithStatus("PENDING"),
expectedChecksStatus: PullRequestChecksStatus{Pending: 1, Total: 1},
},
{
name: "REQUESTED is treated as Pending",
payload: singleCheckRunWithStatus("REQUESTED"),
expectedChecksStatus: PullRequestChecksStatus{Pending: 1, Total: 1},
},
{
name: "COMPLETED / STARTUP_FAILURE is treated as Pending",
payload: singleCompletedCheckRunWithConclusion("STARTUP_FAILURE"),
expectedChecksStatus: PullRequestChecksStatus{Pending: 1, Total: 1},
},
{
name: "COMPLETED / STALE is treated as Pending",
payload: singleCompletedCheckRunWithConclusion("STALE"),
expectedChecksStatus: PullRequestChecksStatus{Pending: 1, Total: 1},
},
{
name: "COMPLETED / SUCCESS is treated as Passing",
payload: singleCompletedCheckRunWithConclusion("SUCCESS"),
expectedChecksStatus: PullRequestChecksStatus{Passing: 1, Total: 1},
},
{
name: "COMPLETED / NEUTRAL is treated as Passing",
payload: singleCompletedCheckRunWithConclusion("NEUTRAL"),
expectedChecksStatus: PullRequestChecksStatus{Passing: 1, Total: 1},
},
{
name: "COMPLETED / SKIPPED is treated as Passing",
payload: singleCompletedCheckRunWithConclusion("SKIPPED"),
expectedChecksStatus: PullRequestChecksStatus{Passing: 1, Total: 1},
},
{
name: "COMPLETED / ACTION_REQUIRED is treated as Failing",
payload: singleCompletedCheckRunWithConclusion("ACTION_REQUIRED"),
expectedChecksStatus: PullRequestChecksStatus{Failing: 1, Total: 1},
},
{
name: "COMPLETED / TIMED_OUT is treated as Failing",
payload: singleCompletedCheckRunWithConclusion("TIMED_OUT"),
expectedChecksStatus: PullRequestChecksStatus{Failing: 1, Total: 1},
},
{
name: "COMPLETED / CANCELLED is treated as Failing",
payload: singleCompletedCheckRunWithConclusion("CANCELLED"),
expectedChecksStatus: PullRequestChecksStatus{Failing: 1, Total: 1},
},
{
name: "COMPLETED / CANCELLED is treated as Failing",
payload: singleCompletedCheckRunWithConclusion("CANCELLED"),
expectedChecksStatus: PullRequestChecksStatus{Failing: 1, Total: 1},
},
{
name: "COMPLETED / FAILURE is treated as Failing",
payload: singleCompletedCheckRunWithConclusion("FAILURE"),
expectedChecksStatus: PullRequestChecksStatus{Failing: 1, Total: 1},
},
{
name: "Unrecognized Status are treated as Pending",
payload: singleCheckRunWithStatus("AnUnrecognizedStatusJustForThisTest"),
expectedChecksStatus: PullRequestChecksStatus{Pending: 1, Total: 1},
},
{
name: "Unrecognized Conclusions are treated as Pending",
payload: singleCompletedCheckRunWithConclusion("AnUnrecognizedConclusionJustForThisTest"),
expectedChecksStatus: PullRequestChecksStatus{Pending: 1, Total: 1},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var pr PullRequest
require.NoError(t, json.Unmarshal([]byte(tt.payload), &pr))
require.Equal(t, tt.expectedChecksStatus, pr.ChecksStatus())
})
}
}
func TestChecksStatus_SummarisingStatusContexts(t *testing.T) {
t.Parallel()
tests := []struct {
name string
payload string
expectedChecksStatus PullRequestChecksStatus
}{
{
name: "EXPECTED is treated as Pending",
payload: singleStatusContextWithState("EXPECTED"),
expectedChecksStatus: PullRequestChecksStatus{Pending: 1, Total: 1},
},
{
name: "PENDING is treated as Pending",
payload: singleStatusContextWithState("PENDING"),
expectedChecksStatus: PullRequestChecksStatus{Pending: 1, Total: 1},
},
{
name: "SUCCESS is treated as Passing",
payload: singleStatusContextWithState("SUCCESS"),
expectedChecksStatus: PullRequestChecksStatus{Passing: 1, Total: 1},
},
{
name: "ERROR is treated as Failing",
payload: singleStatusContextWithState("ERROR"),
expectedChecksStatus: PullRequestChecksStatus{Failing: 1, Total: 1},
},
{
name: "FAILURE is treated as Failing",
payload: singleStatusContextWithState("FAILURE"),
expectedChecksStatus: PullRequestChecksStatus{Failing: 1, Total: 1},
},
{
name: "Unrecognized States are treated as Pending",
payload: singleStatusContextWithState("AnUnrecognizedStateJustForThisTest"),
expectedChecksStatus: PullRequestChecksStatus{Pending: 1, Total: 1},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var pr PullRequest
require.NoError(t, json.Unmarshal([]byte(tt.payload), &pr))
require.Equal(t, tt.expectedChecksStatus, pr.ChecksStatus())
})
}
}
func TestChecksStatus_SummarisingCheckRunsAndStatusContexts(t *testing.T) {
t.Parallel()
// This might look a bit intimidating, but we're just inserting three nodes
// into the rollup, two completed check run nodes and one status context node.
payload := fmt.Sprintf(`
{ "statusCheckRollup": { "nodes": [{ "commit": {
"statusCheckRollup": {
"contexts": {
"nodes": [
{ "state": "SUCCESS" },
{ "state": "PENDING" },
{ "state": "FAILURE" },
{ "status": "IN_PROGRESS",
"conclusion": null },
{ "status": "COMPLETED",
"conclusion": "SUCCESS" },
{ "status": "COMPLETED",
"conclusion": "FAILURE" },
{ "status": "COMPLETED",
"conclusion": "ACTION_REQUIRED" },
{ "status": "COMPLETED",
"conclusion": "STALE" }
%s,
%s,
%s
]
}
}
} }] } }
`
err := json.Unmarshal([]byte(payload), &pr)
assert.NoError(t, err)
`,
completedCheckRunNode("SUCCESS"),
statusContextNode("PENDING"),
completedCheckRunNode("FAILURE"),
)
checks := pr.ChecksStatus()
assert.Equal(t, 8, checks.Total)
assert.Equal(t, 3, checks.Pending)
assert.Equal(t, 3, checks.Failing)
assert.Equal(t, 2, checks.Passing)
var pr PullRequest
require.NoError(t, json.Unmarshal([]byte(payload), &pr))
expectedChecksStatus := PullRequestChecksStatus{
Pending: 1,
Failing: 1,
Passing: 1,
Total: 3,
}
require.Equal(t, expectedChecksStatus, pr.ChecksStatus())
}
// Note that it would be incorrect to provide a status of COMPLETED here
// as the conclusion is always set to null. If you want a COMPLETED status,
// use `singleCompletedCheckRunWithConclusion`.
func singleCheckRunWithStatus(status string) string {
return fmt.Sprintf(`
{ "statusCheckRollup": { "nodes": [{ "commit": {
"statusCheckRollup": {
"contexts": {
"nodes": [
{
"__typename": "CheckRun",
"status": "%s",
"conclusion": null
}
]
}
}
} }] } }
`, status)
}
func singleCompletedCheckRunWithConclusion(conclusion string) string {
return fmt.Sprintf(`
{ "statusCheckRollup": { "nodes": [{ "commit": {
"statusCheckRollup": {
"contexts": {
"nodes": [
{
"__typename": "CheckRun",
"status": "COMPLETED",
"conclusion": "%s"
}
]
}
}
} }] } }
`, conclusion)
}
func singleStatusContextWithState(state string) string {
return fmt.Sprintf(`
{ "statusCheckRollup": { "nodes": [{ "commit": {
"statusCheckRollup": {
"contexts": {
"nodes": [
{
"__typename": "StatusContext",
"state": "%s"
}
]
}
}
} }] } }
`, state)
}
func completedCheckRunNode(conclusion string) string {
return fmt.Sprintf(`
{
"__typename": "CheckRun",
"status": "COMPLETED",
"conclusion": "%s"
}`, conclusion)
}
func statusContextNode(state string) string {
return fmt.Sprintf(`
{
"__typename": "StatusContext",
"state": "%s"
}`, state)
}

View file

@ -39,6 +39,8 @@ type PullRequest struct {
ClosedAt *time.Time
MergedAt *time.Time
AutoMergeRequest *AutoMergeRequest
MergeCommit *Commit
PotentialMergeCommit *Commit
@ -136,6 +138,16 @@ type PRRepository struct {
Name string `json:"name"`
}
type AutoMergeRequest struct {
AuthorEmail *string `json:"authorEmail"`
CommitBody *string `json:"commitBody"`
CommitHeadline *string `json:"commitHeadline"`
// MERGE, REBASE, SQUASH
MergeMethod string `json:"mergeMethod"`
EnabledAt time.Time `json:"enabledAt"`
EnabledBy Author `json:"enabledBy"`
}
// Commit loads just the commit SHA and nothing else
type Commit struct {
OID string `json:"oid"`
@ -249,15 +261,23 @@ type PullRequestChecksStatus struct {
Total int
}
func (pr *PullRequest) ChecksStatus() (summary PullRequestChecksStatus) {
func (pr *PullRequest) ChecksStatus() PullRequestChecksStatus {
var summary PullRequestChecksStatus
if len(pr.StatusCheckRollup.Nodes) == 0 {
return
return summary
}
commit := pr.StatusCheckRollup.Nodes[0].Commit
for _, c := range commit.StatusCheckRollup.Contexts.Nodes {
state := c.State // StatusContext
// Nodes are a discriminated union of CheckRun or StatusContext,
// but we can use the State field to disambiguate, rather than using the TypeName.
// First we try to get the State, as if we have a StatusContext
state := c.State
// But if the State is empty, then we assume we actually have a CheckRun
if state == "" {
// CheckRun
// If the Status of a CheckRun is COMPLETED, then we want to look more closely
// at the Conclusion, as that contains the relevant information.
if c.Status == "COMPLETED" {
state = c.Conclusion
} else {
@ -269,13 +289,15 @@ func (pr *PullRequest) ChecksStatus() (summary PullRequestChecksStatus) {
summary.Passing++
case "ERROR", "FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED":
summary.Failing++
default: // "EXPECTED", "REQUESTED", "WAITING", "QUEUED", "PENDING", "IN_PROGRESS", "STALE"
// We treat anything that isn't Passing or Failing as Pending, which included any future unknown states
// we might get back from the API.
default: // "EXPECTED", "REQUESTED", "WAITING", "QUEUED", "PENDING", "IN_PROGRESS", "STALE", "STARTUP_FAILURE"
summary.Pending++
}
summary.Total++
}
return
return summary
}
func (pr *PullRequest) DisplayableReviews() PullRequestReviews {

View file

@ -132,6 +132,17 @@ var prCommits = shortenQuery(`
}
`)
var autoMergeRequest = shortenQuery(`
autoMergeRequest {
authorEmail,
commitBody,
commitHeadline,
mergeMethod,
enabledAt,
enabledBy{login,...on User{id,name}}
}
`)
func StatusCheckRollupGraphQL(after string) string {
var afterClause string
if after != "" {
@ -231,6 +242,7 @@ var IssueFields = []string{
var PullRequestFields = append(IssueFields,
"additions",
"autoMergeRequest",
"baseRefName",
"changedFiles",
"commits",
@ -285,6 +297,8 @@ func IssueGraphQL(fields []string) string {
q = append(q, `mergeCommit{oid}`)
case "potentialMergeCommit":
q = append(q, `potentialMergeCommit{oid}`)
case "autoMergeRequest":
q = append(q, autoMergeRequest)
case "comments":
q = append(q, issueComments)
case "lastComment": // pseudo-field

View file

@ -30,9 +30,5 @@
<WixExtension Include="WixUIExtension"/>
<WixExtension Include="WixUtilExtension"/>
</ItemGroup>
<Target Name="SetStepOutput" AfterTargets="Build" Condition="'$(GITHUB_ACTIONS)' != ''">
<!-- Make sure the correct target path is always set as the step output -->
<Message Importance="high" Text="::set-output name=msi::$(TargetPath)"/>
</Target>
<Import Project="$(WixTargetsPath)"/>
</Project>

View file

@ -80,9 +80,6 @@ func mainRun() exitCode {
surveyCore.TemplateFuncsWithColor["color"] = func(style string) string {
switch style {
case "white":
if cmdFactory.IOStreams.ColorSupport256() {
return fmt.Sprintf("\x1b[%d;5;%dm", 38, 242)
}
return ansi.ColorCode("default")
default:
return ansi.ColorCode(style)

View file

@ -1,36 +1,40 @@
# Releasing
Our build system automatically compiles and attaches cross-platform binaries to any git tag named `vX.Y.Z`. The changelog is [generated from git commit log](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes).
To initiate a new production deployment:
```sh
script/release vX.Y.Z
```
See `script/release --help` for more information.
Users who run official builds of `gh` on their machines will get notified about the new version within a 24 hour period.
> **Note:**
> Every production release will request an approval by the select few people before it can proceed.
To test out the build system, publish a prerelease tag with a name such as `vX.Y.Z-pre.0` or `vX.Y.Z-rc.1`. Note that such a release will still be public, but it will be marked as a "prerelease", meaning that it will never show up as a "latest" release nor trigger upgrade notifications for users.
What this does is:
- Builds Linux binaries on Ubuntu;
- Builds and signs Windows binaries on Windows;
- Builds, signs, and notarizes macOS binaries on macOS;
- Uploads all release artifacts to a new GitHub Release;
- A new git tag `vX.Y.Z` is created in the remote repository;
- The changelog is [generated from the list of merged pull requests](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes);
- Updates cli.github.com with the contents of the new release;
- Updates our Homebrew formula in the homebrew-core repo.
To test out the build system while avoiding creating an actual release:
```sh
script/release --staging vX.Y.Z --branch patch-1 -p macos
```
The build artifacts will be available via `gh run download <RUN> -n macos`.
## General guidelines
* Features to be released should be reviewed and approved at least one day prior to the release.
* Feature releases should bump up the minor version number.
* Breaking releases should bump up the major version number. These should generally be rare.
## Tagging a new release
1. `git tag v1.2.3 && git push origin v1.2.3`
2. Wait several minutes for builds to run: <https://github.com/cli/cli/actions>
3. Verify release is displayed and has correct assets: <https://github.com/cli/cli/releases>
4. Scan generated release notes and optionally add a human touch by grouping items under topic sections
5. Verify the marketing site was updated: <https://cli.github.com>
6. (Optional) Delete any pre-releases related to this release
A successful build will result in changes across several repositories:
* <https://github.com/github/cli.github.com>
* <https://github.com/Homebrew/homebrew-core/pulls>
* <https://github.com/cli/scoop-gh>
If the build fails, there is not a clean way to re-run it. The easiest way would be to start over by deleting the partial release on GitHub and re-publishing the tag. Note that this might be disruptive to users or tooling that were already notified about an upgrade. If a functional release and its binaries are already out there, it might be better to try to manually fix up only the specific workflow tasks that failed. Use your best judgement depending on the failure type.
## Release locally for debugging
## Test the build system locally
A local release can be created for testing without creating anything official on the release page.
1. Make sure GoReleaser is installed: `brew install goreleaser`
2. `goreleaser --skip-validate --skip-publish --rm-dist`
2. `script/release --local`
3. Find the built products under `dist/`.

10
go.mod
View file

@ -34,8 +34,8 @@ require (
github.com/sourcegraph/jsonrpc2 v0.1.0
github.com/spf13/cobra v1.6.1
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.7.5
github.com/zalando/go-keyring v0.2.2
github.com/stretchr/testify v1.8.2
github.com/zalando/go-keyring v0.2.3
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519
golang.org/x/sync v0.1.0
golang.org/x/term v0.6.0
@ -51,7 +51,7 @@ require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/cli/browser v1.1.0 // indirect
github.com/cli/shurcooL-graphql v0.0.3 // indirect
github.com/danieljoos/wincred v1.1.2 // indirect
github.com/danieljoos/wincred v1.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.4.0 // indirect
github.com/fatih/color v1.7.0 // indirect
@ -73,13 +73,13 @@ require (
github.com/rivo/uniseg v0.4.2 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29 // indirect
github.com/stretchr/objx v0.4.0 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e // indirect
github.com/yuin/goldmark v1.4.13 // indirect
github.com/yuin/goldmark-emoji v1.0.1 // indirect
golang.org/x/net v0.8.0 // indirect
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a // indirect
golang.org/x/sys v0.6.0 // indirect
golang.org/x/sys v0.8.0 // indirect
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
)

20
go.sum
View file

@ -78,8 +78,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0=
github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0=
github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE=
github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -251,14 +251,16 @@ github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUq
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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.5 h1:s5PTfem8p8EbKQOctVV53k6jCJt3UX4IEJzwh+C324Q=
github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e h1:BuzhfgfWQbX0dWzYzT1zsORLnHRv3bcRcsaUk0VmXA8=
github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@ -271,8 +273,8 @@ github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os=
github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ=
github.com/zalando/go-keyring v0.2.2 h1:f0xmpYiSrHtSNAVgwip93Cg8tuF45HJM6rHq/A5RI/4=
github.com/zalando/go-keyring v0.2.2/go.mod h1:sI3evg9Wvpw3+n4SqplGSJUMwtDeROfD4nsFz4z9PG0=
github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms=
github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@ -393,7 +395,6 @@ golang.org/x/sys v0.0.0-20210319071255-635bc2c9138d/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -403,8 +404,9 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=

View file

@ -22,30 +22,20 @@ var allIssueFeatures = IssueFeatures{
}
type PullRequestFeatures struct {
ReviewDecision bool
StatusCheckRollup bool
BranchProtectionRule bool
MergeQueue bool
MergeQueue bool
}
var allPullRequestFeatures = PullRequestFeatures{
ReviewDecision: true,
StatusCheckRollup: true,
BranchProtectionRule: true,
MergeQueue: true,
MergeQueue: true,
}
type RepositoryFeatures struct {
IssueTemplateMutation bool
IssueTemplateQuery bool
PullRequestTemplateQuery bool
VisibilityField bool
AutoMerge bool
}
var allRepositoryFeatures = RepositoryFeatures{
IssueTemplateMutation: true,
IssueTemplateQuery: true,
PullRequestTemplateQuery: true,
VisibilityField: true,
AutoMerge: true,
@ -103,11 +93,7 @@ func (d *detector) PullRequestFeatures() (PullRequestFeatures, error) {
// return allPullRequestFeatures, nil
// }
features := PullRequestFeatures{
ReviewDecision: true,
StatusCheckRollup: true,
BranchProtectionRule: true,
}
features := PullRequestFeatures{}
var featureDetection struct {
PullRequest struct {
@ -137,10 +123,7 @@ func (d *detector) RepositoryFeatures() (RepositoryFeatures, error) {
return allRepositoryFeatures, nil
}
features := RepositoryFeatures{
IssueTemplateQuery: true,
IssueTemplateMutation: true,
}
features := RepositoryFeatures{}
var featureDetection struct {
Repository struct {

View file

@ -93,10 +93,7 @@ func TestPullRequestFeatures(t *testing.T) {
`),
},
wantFeatures: PullRequestFeatures{
ReviewDecision: true,
StatusCheckRollup: true,
BranchProtectionRule: true,
MergeQueue: true,
MergeQueue: true,
},
wantErr: false,
},
@ -110,10 +107,7 @@ func TestPullRequestFeatures(t *testing.T) {
`),
},
wantFeatures: PullRequestFeatures{
ReviewDecision: true,
StatusCheckRollup: true,
BranchProtectionRule: true,
MergeQueue: false,
MergeQueue: false,
},
wantErr: false,
},
@ -129,10 +123,7 @@ func TestPullRequestFeatures(t *testing.T) {
`),
},
wantFeatures: PullRequestFeatures{
ReviewDecision: true,
StatusCheckRollup: true,
BranchProtectionRule: true,
MergeQueue: true,
MergeQueue: true,
},
wantErr: false,
},
@ -169,8 +160,6 @@ func TestRepositoryFeatures(t *testing.T) {
name: "github.com",
hostname: "github.com",
wantFeatures: RepositoryFeatures{
IssueTemplateMutation: true,
IssueTemplateQuery: true,
PullRequestTemplateQuery: true,
VisibilityField: true,
AutoMerge: true,
@ -184,8 +173,6 @@ func TestRepositoryFeatures(t *testing.T) {
`query Repository_fields\b`: `{"data": {}}`,
},
wantFeatures: RepositoryFeatures{
IssueTemplateMutation: true,
IssueTemplateQuery: true,
PullRequestTemplateQuery: false,
},
wantErr: false,
@ -201,8 +188,6 @@ func TestRepositoryFeatures(t *testing.T) {
`),
},
wantFeatures: RepositoryFeatures{
IssueTemplateMutation: true,
IssueTemplateQuery: true,
PullRequestTemplateQuery: true,
},
wantErr: false,
@ -218,9 +203,7 @@ func TestRepositoryFeatures(t *testing.T) {
`),
},
wantFeatures: RepositoryFeatures{
IssueTemplateMutation: true,
IssueTemplateQuery: true,
VisibilityField: true,
VisibilityField: true,
},
wantErr: false,
},
@ -235,9 +218,7 @@ func TestRepositoryFeatures(t *testing.T) {
`),
},
wantFeatures: RepositoryFeatures{
IssueTemplateMutation: true,
IssueTemplateQuery: true,
AutoMerge: true,
AutoMerge: true,
},
wantErr: false,
},

View file

@ -3,6 +3,7 @@ package alias
import (
"github.com/MakeNowJust/heredoc"
deleteCmd "github.com/cli/cli/v2/pkg/cmd/alias/delete"
importCmd "github.com/cli/cli/v2/pkg/cmd/alias/imports"
listCmd "github.com/cli/cli/v2/pkg/cmd/alias/list"
setCmd "github.com/cli/cli/v2/pkg/cmd/alias/set"
"github.com/cli/cli/v2/pkg/cmdutil"
@ -23,6 +24,7 @@ func NewCmdAlias(f *cmdutil.Factory) *cobra.Command {
cmdutil.DisableAuthCheck(cmd)
cmd.AddCommand(deleteCmd.NewCmdDelete(f, nil))
cmd.AddCommand(importCmd.NewCmdImport(f, nil))
cmd.AddCommand(listCmd.NewCmdList(f, nil))
cmd.AddCommand(setCmd.NewCmdSet(f, nil))

View file

@ -0,0 +1,195 @@
package imports
import (
"fmt"
"sort"
"strings"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/pkg/cmd/alias/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
type ImportOptions struct {
Config func() (config.Config, error)
IO *iostreams.IOStreams
Filename string
OverwriteExisting bool
existingCommand func(string) bool
}
func NewCmdImport(f *cmdutil.Factory, runF func(*ImportOptions) error) *cobra.Command {
opts := &ImportOptions{
IO: f.IOStreams,
Config: f.Config,
}
cmd := &cobra.Command{
Use: "import [<filename> | -]",
Short: "Import aliases from a YAML file",
Long: heredoc.Doc(`
Import aliases from the contents of a YAML file.
Aliases should be defined as a map in YAML, where the keys represent aliases and
the values represent the corresponding expansions. An example file should look like
the following:
bugs: issue list --label=bug
igrep: '!gh issue list --label="$1" | grep "$2"'
features: |-
issue list
--label=enhancement
Use "-" to read aliases (in YAML format) from standard input.
The output from the gh command "alias list" can be used to produce a YAML file
containing your aliases, which you can use to import them from one machine to
another. Run "gh help alias list" to learn more.
`),
Example: heredoc.Doc(`
# Import aliases from a file
$ gh alias import aliases.yml
# Import aliases from standard input
$ gh alias import -
`),
Args: func(cmd *cobra.Command, args []string) error {
if len(args) > 1 {
return cmdutil.FlagErrorf("too many arguments")
}
if len(args) == 0 && opts.IO.IsStdinTTY() {
return cmdutil.FlagErrorf("no filename passed and nothing on STDIN")
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
opts.Filename = "-"
if len(args) > 0 {
opts.Filename = args[0]
}
opts.existingCommand = shared.ExistingCommandFunc(f, cmd)
if runF != nil {
return runF(opts)
}
return importRun(opts)
},
}
cmd.Flags().BoolVar(&opts.OverwriteExisting, "clobber", false, "Overwrite existing aliases of the same name")
return cmd
}
func importRun(opts *ImportOptions) error {
cs := opts.IO.ColorScheme()
cfg, err := opts.Config()
if err != nil {
return err
}
aliasCfg := cfg.Aliases()
b, err := cmdutil.ReadFile(opts.Filename, opts.IO.In)
if err != nil {
return err
}
aliasMap := map[string]string{}
if err = yaml.Unmarshal(b, &aliasMap); err != nil {
return err
}
isTerminal := opts.IO.IsStdoutTTY()
if isTerminal {
if opts.Filename == "-" {
fmt.Fprintf(opts.IO.ErrOut, "- Importing aliases from standard input\n")
} else {
fmt.Fprintf(opts.IO.ErrOut, "- Importing aliases from file %q\n", opts.Filename)
}
}
var msg strings.Builder
for _, alias := range getSortedKeys(aliasMap) {
if opts.existingCommand(alias) {
msg.WriteString(
fmt.Sprintf("%s Could not import alias %s: already a gh command\n",
cs.FailureIcon(),
cs.Bold(alias),
),
)
continue
}
expansion := aliasMap[alias]
if !(strings.HasPrefix(expansion, "!") || opts.existingCommand(expansion)) {
msg.WriteString(
fmt.Sprintf("%s Could not import alias %s: expansion does not correspond to a gh command\n",
cs.FailureIcon(),
cs.Bold(alias),
),
)
continue
}
if _, err := aliasCfg.Get(alias); err == nil {
if opts.OverwriteExisting {
aliasCfg.Add(alias, expansion)
msg.WriteString(
fmt.Sprintf("%s Changed alias %s\n",
cs.WarningIcon(),
cs.Bold(alias),
),
)
} else {
msg.WriteString(
fmt.Sprintf("%s Could not import alias %s: name already taken\n",
cs.FailureIcon(),
cs.Bold(alias),
),
)
}
} else {
aliasCfg.Add(alias, expansion)
msg.WriteString(
fmt.Sprintf("%s Added alias %s\n",
cs.SuccessIcon(),
cs.Bold(alias),
),
)
}
}
if err := cfg.Write(); err != nil {
return err
}
if isTerminal {
fmt.Fprintln(opts.IO.ErrOut, msg.String())
}
return nil
}
func getSortedKeys(m map[string]string) []string {
keys := []string{}
for key := range m {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}

View file

@ -0,0 +1,346 @@
package imports
import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/pkg/cmd/alias/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/extensions"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/google/shlex"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewCmdImport(t *testing.T) {
tests := []struct {
name string
cli string
tty bool
wants ImportOptions
wantsError string
}{
{
name: "no filename and stdin tty",
cli: "",
tty: true,
wants: ImportOptions{
Filename: "",
OverwriteExisting: false,
},
wantsError: "no filename passed and nothing on STDIN",
},
{
name: "no filename and stdin is not tty",
cli: "",
tty: false,
wants: ImportOptions{
Filename: "-",
OverwriteExisting: false,
},
},
{
name: "stdin arg",
cli: "-",
wants: ImportOptions{
Filename: "-",
OverwriteExisting: false,
},
},
{
name: "multiple filenames",
cli: "aliases1 aliases2",
wants: ImportOptions{
Filename: "aliases1 aliases2",
OverwriteExisting: false,
},
wantsError: "too many arguments",
},
{
name: "clobber flag",
cli: "aliases --clobber",
wants: ImportOptions{
Filename: "aliases",
OverwriteExisting: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, _, _, _ := iostreams.Test()
ios.SetStdinTTY(tt.tty)
f := &cmdutil.Factory{IOStreams: ios}
argv, err := shlex.Split(tt.cli)
assert.NoError(t, err)
var gotOpts *ImportOptions
cmd := NewCmdImport(f, func(opts *ImportOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
_, err = cmd.ExecuteC()
if tt.wantsError != "" {
assert.EqualError(t, err, tt.wantsError)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wants.Filename, gotOpts.Filename)
assert.Equal(t, tt.wants.OverwriteExisting, gotOpts.OverwriteExisting)
})
}
}
func TestImportRun(t *testing.T) {
tmpFile := filepath.Join(t.TempDir(), "aliases.yml")
importFileMsg := fmt.Sprintf(`- Importing aliases from file %q`, tmpFile)
importStdinMsg := "- Importing aliases from standard input"
tests := []struct {
name string
opts *ImportOptions
stdin string
fileContents string
initConfig string
wantConfig string
wantStderr string
}{
{
name: "with no existing aliases",
opts: &ImportOptions{
Filename: tmpFile,
OverwriteExisting: false,
},
fileContents: heredoc.Doc(`
co: pr checkout
igrep: '!gh issue list --label="$1" | grep "$2"'
`),
wantConfig: heredoc.Doc(`
aliases:
co: pr checkout
igrep: '!gh issue list --label="$1" | grep "$2"'
`),
wantStderr: importFileMsg + "\n✓ Added alias co\n✓ Added alias igrep\n\n",
},
{
name: "with existing aliases",
opts: &ImportOptions{
Filename: tmpFile,
OverwriteExisting: false,
},
fileContents: heredoc.Doc(`
users: |-
api graphql -F name="$1" -f query='
query ($name: String!) {
user(login: $name) {
name
}
}'
co: pr checkout
`),
initConfig: heredoc.Doc(`
aliases:
igrep: '!gh issue list --label="$1" | grep "$2"'
editor: vim
`),
wantConfig: heredoc.Doc(`
aliases:
igrep: '!gh issue list --label="$1" | grep "$2"'
co: pr checkout
users: |-
api graphql -F name="$1" -f query='
query ($name: String!) {
user(login: $name) {
name
}
}'
editor: vim
`),
wantStderr: importFileMsg + "\n✓ Added alias co\n✓ Added alias users\n\n",
},
{
name: "from stdin",
opts: &ImportOptions{
Filename: "-",
OverwriteExisting: false,
},
stdin: heredoc.Doc(`
co: pr checkout
features: |-
issue list
--label=enhancement
igrep: '!gh issue list --label="$1" | grep "$2"'
`),
wantConfig: heredoc.Doc(`
aliases:
co: pr checkout
features: |-
issue list
--label=enhancement
igrep: '!gh issue list --label="$1" | grep "$2"'
`),
wantStderr: importStdinMsg + "\n✓ Added alias co\n✓ Added alias features\n✓ Added alias igrep\n\n",
},
{
name: "already taken aliases",
opts: &ImportOptions{
Filename: tmpFile,
OverwriteExisting: false,
},
fileContents: heredoc.Doc(`
co: pr checkout -R cool/repo
igrep: '!gh issue list --label="$1" | grep "$2"'
`),
initConfig: heredoc.Doc(`
aliases:
co: pr checkout
editor: vim
`),
wantConfig: heredoc.Doc(`
aliases:
co: pr checkout
igrep: '!gh issue list --label="$1" | grep "$2"'
editor: vim
`),
wantStderr: importFileMsg + "\nX Could not import alias co: name already taken\n✓ Added alias igrep\n\n",
},
{
name: "override aliases",
opts: &ImportOptions{
Filename: tmpFile,
OverwriteExisting: true,
},
fileContents: heredoc.Doc(`
co: pr checkout -R cool/repo
igrep: '!gh issue list --label="$1" | grep "$2"'
`),
initConfig: heredoc.Doc(`
aliases:
co: pr checkout
editor: vim
`),
wantConfig: heredoc.Doc(`
aliases:
co: pr checkout -R cool/repo
igrep: '!gh issue list --label="$1" | grep "$2"'
editor: vim
`),
wantStderr: importFileMsg + "\n! Changed alias co\n✓ Added alias igrep\n\n",
},
{
name: "alias is a gh command",
opts: &ImportOptions{
Filename: tmpFile,
OverwriteExisting: false,
},
fileContents: heredoc.Doc(`
pr: pr checkout
issue: issue list
api: api graphql
`),
wantStderr: strings.Join(
[]string{
importFileMsg,
"X Could not import alias api: already a gh command",
"X Could not import alias issue: already a gh command",
"X Could not import alias pr: already a gh command\n\n",
},
"\n",
),
},
{
name: "invalid expansion",
opts: &ImportOptions{
Filename: tmpFile,
OverwriteExisting: false,
},
fileContents: heredoc.Doc(`
alias1:
alias2: ps checkout
`),
wantStderr: strings.Join(
[]string{
importFileMsg,
"X Could not import alias alias1: expansion does not correspond to a gh command",
"X Could not import alias alias2: expansion does not correspond to a gh command\n\n",
},
"\n",
),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.fileContents != "" {
err := os.WriteFile(tmpFile, []byte(tt.fileContents), 0600)
require.NoError(t, err)
}
ios, stdin, _, stderr := iostreams.Test()
ios.SetStdinTTY(true)
ios.SetStdoutTTY(true)
ios.SetStderrTTY(true)
tt.opts.IO = ios
readConfigs := config.StubWriteConfig(t)
cfg := config.NewFromString(tt.initConfig)
tt.opts.Config = func() (config.Config, error) {
return cfg, nil
}
// Create fake command factory for testing.
f := &cmdutil.Factory{
IOStreams: ios,
ExtensionManager: &extensions.ExtensionManagerMock{
ListFunc: func() []extensions.Extension {
return []extensions.Extension{}
},
},
}
// Create fake command structure for testing.
rootCmd := &cobra.Command{}
prCmd := &cobra.Command{Use: "pr"}
prCmd.AddCommand(&cobra.Command{Use: "checkout"})
prCmd.AddCommand(&cobra.Command{Use: "status"})
rootCmd.AddCommand(prCmd)
issueCmd := &cobra.Command{Use: "issue"}
issueCmd.AddCommand(&cobra.Command{Use: "list"})
rootCmd.AddCommand(issueCmd)
apiCmd := &cobra.Command{Use: "api"}
apiCmd.AddCommand(&cobra.Command{Use: "graphql"})
rootCmd.AddCommand(apiCmd)
tt.opts.existingCommand = shared.ExistingCommandFunc(f, rootCmd)
if tt.stdin != "" {
stdin.WriteString(tt.stdin)
}
err := importRun(tt.opts)
require.NoError(t, err)
configOut := bytes.Buffer{}
readConfigs(&configOut, io.Discard)
assert.Equal(t, tt.wantStderr, stderr.String())
assert.Equal(t, tt.wantConfig, configOut.String())
})
}
}

View file

@ -7,9 +7,9 @@ import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/pkg/cmd/alias/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/google/shlex"
"github.com/spf13/cobra"
)
@ -21,7 +21,7 @@ type SetOptions struct {
Expansion string
IsShell bool
validCommand func(string) bool
existingCommand func(string) bool
}
func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command {
@ -70,29 +70,12 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
opts.Name = args[0]
opts.Expansion = args[1]
opts.validCommand = func(args string) bool {
split, err := shlex.Split(args)
if err != nil {
return false
}
rootCmd := cmd.Root()
cmd, _, err := rootCmd.Traverse(split)
if err == nil && cmd != rootCmd {
return true
}
for _, ext := range f.ExtensionManager.List() {
if ext.Name() == split[0] {
return true
}
}
return false
}
opts.existingCommand = shared.ExistingCommandFunc(f, cmd)
if runF != nil {
return runF(opts)
}
return setRun(opts)
},
}
@ -127,11 +110,11 @@ func setRun(opts *SetOptions) error {
}
isShell = strings.HasPrefix(expansion, "!")
if opts.validCommand(opts.Name) {
if opts.existingCommand(opts.Name) {
return fmt.Errorf("could not create alias: %q is already a gh command", opts.Name)
}
if !isShell && !opts.validCommand(expansion) {
if !isShell && !opts.existingCommand(expansion) {
return fmt.Errorf("could not create alias: %s does not correspond to a gh command", expansion)
}

View file

@ -38,7 +38,7 @@ func runCommand(cfg config.Config, isTTY bool, cli string, in string) (*test.Cmd
cmd := NewCmdSet(factory, nil)
// fake command nesting structure needed for validCommand
// Create fake command structure for testing.
rootCmd := &cobra.Command{}
rootCmd.AddCommand(cmd)
prCmd := &cobra.Command{Use: "pr"}

View file

@ -0,0 +1,32 @@
package shared
import (
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/google/shlex"
"github.com/spf13/cobra"
)
// ExistingCommandFunc returns a function that will check if the given string
// corresponds to an existing command.
func ExistingCommandFunc(f *cmdutil.Factory, cmd *cobra.Command) func(string) bool {
return func(args string) bool {
split, err := shlex.Split(args)
if err != nil || len(split) == 0 {
return false
}
rootCmd := cmd.Root()
cmd, _, err = rootCmd.Traverse(split)
if err == nil && cmd != rootCmd {
return true
}
for _, ext := range f.ExtensionManager.List() {
if ext.Name() == split[0] {
return true
}
}
return false
}
}

View file

@ -0,0 +1,40 @@
package shared
import (
"testing"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/extensions"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
)
func TestExistingCommandFunc(t *testing.T) {
// Create fake command factory for testing.
factory := &cmdutil.Factory{
ExtensionManager: &extensions.ExtensionManagerMock{
ListFunc: func() []extensions.Extension {
return []extensions.Extension{}
},
},
}
// Create fake command structure for testing.
issueCmd := &cobra.Command{Use: "issue"}
prCmd := &cobra.Command{Use: "pr"}
prCmd.AddCommand(&cobra.Command{Use: "checkout"})
cmd := &cobra.Command{}
cmd.AddCommand(prCmd)
cmd.AddCommand(issueCmd)
f := ExistingCommandFunc(factory, cmd)
assert.True(t, f("pr"))
assert.True(t, f("pr checkout"))
assert.True(t, f("issue"))
assert.False(t, f("ps"))
assert.False(t, f("checkout"))
assert.False(t, f("repo list"))
}

View file

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"strings"
"sync/atomic"
"time"
"github.com/AlecAivazis/survey/v2"
@ -176,7 +177,8 @@ func (a *App) Delete(ctx context.Context, opts deleteOptions) (err error) {
progressLabel = "Deleting codespaces"
}
return a.RunWithProgress(progressLabel, func() error {
var deletedCodespaces uint32
err = a.RunWithProgress(progressLabel, func() error {
var g errgroup.Group
for _, c := range codespacesToDelete {
codespaceName := c.Name
@ -185,15 +187,23 @@ func (a *App) Delete(ctx context.Context, opts deleteOptions) (err error) {
a.errLogger.Printf("error deleting codespace %q: %v\n", codespaceName, err)
return err
}
atomic.AddUint32(&deletedCodespaces, 1)
return nil
})
}
if err := g.Wait(); err != nil {
return errors.New("some codespaces failed to delete")
return fmt.Errorf("%d codespace(s) failed to delete", len(codespacesToDelete)-int(deletedCodespaces))
}
return nil
})
if a.io.IsStdoutTTY() && deletedCodespaces > 0 {
successMsg := fmt.Sprintf("%d codespace(s) deleted successfully\n", deletedCodespaces)
fmt.Fprint(a.io.ErrOut, successMsg)
}
return err
}
func confirmDeletion(p prompter, apiCodespace *api.Codespace, isInteractive bool) (bool, error) {

View file

@ -26,7 +26,7 @@ func TestDelete(t *testing.T) {
codespaces []*api.Codespace
confirms map[string]bool
deleteErr error
wantErr bool
wantErr string
wantDeleted []string
wantStdout string
wantStderr string
@ -42,7 +42,7 @@ func TestDelete(t *testing.T) {
},
},
wantDeleted: []string{"hubot-robawt-abc"},
wantStdout: "",
wantStderr: "1 codespace(s) deleted successfully\n",
},
{
name: "by repo",
@ -70,7 +70,7 @@ func TestDelete(t *testing.T) {
},
},
wantDeleted: []string{"monalisa-spoonknife-123", "monalisa-spoonknife-c4f3"},
wantStdout: "",
wantStderr: "2 codespace(s) deleted successfully\n",
},
{
name: "unused",
@ -93,7 +93,7 @@ func TestDelete(t *testing.T) {
},
},
wantDeleted: []string{"hubot-robawt-abc", "monalisa-spoonknife-c4f3"},
wantStdout: "",
wantStderr: "2 codespace(s) deleted successfully\n",
},
{
name: "deletion failed",
@ -109,12 +109,13 @@ func TestDelete(t *testing.T) {
},
},
deleteErr: errors.New("aborted by test"),
wantErr: true,
wantErr: "2 codespace(s) failed to delete",
wantDeleted: []string{"hubot-robawt-abc", "monalisa-spoonknife-123"},
wantStderr: heredoc.Doc(`
error deleting codespace "hubot-robawt-abc": aborted by test
error deleting codespace "monalisa-spoonknife-123": aborted by test
`),
wantStdout: "",
},
{
name: "with confirm",
@ -149,7 +150,7 @@ func TestDelete(t *testing.T) {
"Codespace hubot-robawt-abc has unsaved changes. OK to delete?": true,
},
wantDeleted: []string{"hubot-robawt-abc", "monalisa-spoonknife-c4f3"},
wantStdout: "",
wantStderr: "2 codespace(s) deleted successfully\n",
},
{
name: "deletion for org codespace by admin succeeds",
@ -174,7 +175,7 @@ func TestDelete(t *testing.T) {
},
},
wantDeleted: []string{"monalisa-spoonknife-123"},
wantStdout: "",
wantStderr: "1 codespace(s) deleted successfully\n",
},
{
name: "deletion for org codespace by admin fails for codespace not found",
@ -200,7 +201,8 @@ func TestDelete(t *testing.T) {
},
wantDeleted: []string{},
wantStdout: "",
wantErr: true,
wantErr: "error fetching codespace information: " +
"codespace not found for user johnDoe with name monalisa-spoonknife-123",
},
{
name: "deletion for org codespace succeeds without username",
@ -215,7 +217,7 @@ func TestDelete(t *testing.T) {
},
},
wantDeleted: []string{"monalisa-spoonknife-123"},
wantStdout: "",
wantStderr: "1 codespace(s) deleted successfully\n",
},
{
name: "by repo owner",
@ -253,6 +255,7 @@ func TestDelete(t *testing.T) {
},
},
wantDeleted: []string{"octocat-spoonknife-123", "octocat-spoonknife-c4f3"},
wantStderr: "2 codespace(s) deleted successfully\n",
wantStdout: "",
},
}
@ -306,8 +309,8 @@ func TestDelete(t *testing.T) {
ios.SetStdoutTTY(true)
app := NewApp(ios, nil, apiMock, nil, nil)
err := app.Delete(context.Background(), opts)
if (err != nil) != tt.wantErr {
t.Errorf("delete() error = %v, wantErr %v", err, tt.wantErr)
if (err != nil) && tt.wantErr != err.Error() {
t.Errorf("delete() error = %v, wantErr = %v", err, tt.wantErr)
}
for _, listArgs := range apiMock.ListCodespacesCalls() {
if listArgs.Opts.OrgName != "" && listArgs.Opts.UserName == "" {

View file

@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
@ -129,6 +130,12 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
opts.TitleProvided = cmd.Flags().Changed("title")
opts.RepoOverride, _ = cmd.Flags().GetString("repo")
// Workaround: Due to the way this command is implemented, we need to manually check GH_REPO.
// Commands should use the standard BaseRepoOverride functionality to handle this behavior instead.
if opts.RepoOverride == "" {
opts.RepoOverride = os.Getenv("GH_REPO")
}
noMaintainerEdit, _ := cmd.Flags().GetBool("no-maintainer-edit")
opts.MaintainerCanModify = !noMaintainerEdit
@ -318,8 +325,6 @@ func createRun(opts *CreateOptions) (err error) {
if template != nil {
templateContent = string(template.Body())
} else {
templateContent = string(tpl.LegacyBody())
}
}

View file

@ -15,7 +15,8 @@
"headRepositoryOwner": {
"login": "OWNER"
},
"isCrossRepository": false
"isCrossRepository": false,
"autoMergeRequest": null
}
}
]
@ -31,7 +32,8 @@
"state": "OPEN",
"url": "https://github.com/cli/cli/pull/8",
"headRefName": "strawberries",
"isDraft": false
"isDraft": false,
"autoMergeRequest": null
}
}
]
@ -46,7 +48,17 @@
"state": "OPEN",
"url": "https://github.com/cli/cli/pull/9",
"headRefName": "apples",
"isDraft": false
"isDraft": false,
"autoMergeRequest": {
"authorEmail": null,
"commitBody": null,
"commitHeadline": null,
"mergeMethod": "SQUASH",
"enabledAt": "2020-08-27T19:00:12Z",
"enabledBy": {
"login": "hubot"
}
}
} }, {
"node": {
"number": 11,
@ -54,7 +66,8 @@
"state": "OPEN",
"url": "https://github.com/cli/cli/pull/1",
"headRefName": "figs",
"isDraft": true
"isDraft": true,
"autoMergeRequest": null
}
}
]

View file

@ -191,7 +191,7 @@ func pullRequestFragment(hostname string, conflictStatus bool) (string, error) {
fields := []string{
"number", "title", "state", "url", "isDraft", "isCrossRepository",
"headRefName", "headRepositoryOwner", "mergeStateStatus",
"statusCheckRollup", "requiresStrictStatusChecks",
"statusCheckRollup", "requiresStrictStatusChecks", "autoMergeRequest",
}
if conflictStatus {

View file

@ -284,6 +284,10 @@ func printPrs(io *iostreams.IOStreams, totalCount int, prs ...api.PullRequest) {
}
}
if pr.AutoMergeRequest != nil {
fmt.Fprintf(w, " %s", cs.Green("✓ Auto-merge enabled"))
}
} else {
fmt.Fprintf(w, " - %s", shared.StateTitleWithColor(cs, pr))
}

View file

@ -91,7 +91,7 @@ func TestPRStatus(t *testing.T) {
expectedPrs := []*regexp.Regexp{
regexp.MustCompile(`#8.*\[strawberries\]`),
regexp.MustCompile(`#9.*\[apples\]`),
regexp.MustCompile(`#9.*\[apples\].*✓ Auto-merge enabled`),
regexp.MustCompile(`#10.*\[blueberries\]`),
regexp.MustCompile(`#11.*\[figs\]`),
}

View file

@ -0,0 +1,55 @@
{
"data": {
"repository": {
"pullRequest": {
"number": 12,
"title": "Blueberries are from a fork",
"state": "OPEN",
"body": "**blueberries taste good**",
"url": "https://github.com/OWNER/REPO/pull/12",
"author": {
"login": "nobody"
},
"autoMergeRequest": {
"authorEmail": null,
"commitBody": null,
"commitHeadline": null,
"mergeMethod": "SQUASH",
"enabledAt": "2020-08-27T19:00:12Z",
"enabledBy": {
"login": "hubot"
}
},
"additions": 100,
"deletions": 10,
"reviewRequests": {
"nodes": [],
"totalcount": 0
},
"assignees": {
"nodes": [],
"totalcount": 0
},
"labels": {
"nodes": [],
"totalcount": 0
},
"projectcards": {
"nodes": [],
"totalcount": 0
},
"milestone": {},
"commits": {
"totalCount": 12
},
"baseRefName": "master",
"headRefName": "blueberries",
"headRepositoryOwner": {
"login": "hubot"
},
"isCrossRepository": true,
"isDraft": false
}
}
}
}

View file

@ -77,7 +77,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
}
var defaultFields = []string{
"url", "number", "title", "state", "body", "author",
"url", "number", "title", "state", "body", "author", "autoMergeRequest",
"isDraft", "maintainerCanModify", "mergeable", "additions", "deletions", "commitsCount",
"baseRefName", "headRefName", "headRepositoryOwner", "headRepository", "isCrossRepository",
"reviewRequests", "reviews", "assignees", "labels", "projectCards", "milestone",
@ -157,6 +157,15 @@ func printRawPrPreview(io *iostreams.IOStreams, pr *api.PullRequest) error {
fmt.Fprintf(out, "url:\t%s\n", pr.URL)
fmt.Fprintf(out, "additions:\t%s\n", cs.Green(strconv.Itoa(pr.Additions)))
fmt.Fprintf(out, "deletions:\t%s\n", cs.Red(strconv.Itoa(pr.Deletions)))
var autoMerge string
if pr.AutoMergeRequest == nil {
autoMerge = "disabled"
} else {
autoMerge = fmt.Sprintf("enabled\t%s\t%s",
pr.AutoMergeRequest.EnabledBy.Login,
strings.ToLower(pr.AutoMergeRequest.MergeMethod))
}
fmt.Fprintf(out, "auto-merge:\t%s\n", autoMerge)
fmt.Fprintln(out, "--")
fmt.Fprintln(out, pr.Body)
@ -223,6 +232,29 @@ func printHumanPrPreview(opts *ViewOptions, pr *api.PullRequest) error {
fmt.Fprintln(out, pr.Milestone.Title)
}
// Auto-Merge status
autoMerge := pr.AutoMergeRequest
if autoMerge != nil {
var mergeMethod string
switch autoMerge.MergeMethod {
case "MERGE":
mergeMethod = "a merge commit"
case "REBASE":
mergeMethod = "rebase and merge"
case "SQUASH":
mergeMethod = "squash and merge"
default:
mergeMethod = fmt.Sprintf("an unknown merge method (%s)", autoMerge.MergeMethod)
}
fmt.Fprintf(out,
"%s %s by %s, using %s\n",
cs.Bold("Auto-merge:"),
cs.Green("enabled"),
autoMerge.EnabledBy.Login,
mergeMethod,
)
}
// Body
var md string
var err error

View file

@ -314,6 +314,25 @@ func TestPRView_Preview_nontty(t *testing.T) {
`\*\*blueberries taste good\*\*`,
},
},
"PR with auto-merge enabled": {
branch: "master",
args: "12",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreviewWithAutoMergeEnabled.json",
},
expectedOutputs: []string{
`title:\tBlueberries are from a fork\n`,
`state:\tOPEN\n`,
`author:\tnobody\n`,
`labels:\t\n`,
`assignees:\t\n`,
`projects:\t\n`,
`milestone:\t\n`,
`additions:\t100\n`,
`deletions:\t10\n`,
`auto-merge:\tenabled\thubot\tsquash\n`,
},
},
}
for name, tc := range tests {
@ -504,6 +523,20 @@ func TestPRView_Preview(t *testing.T) {
`View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`,
},
},
"PR with auto-merge enabled": {
branch: "master",
args: "12",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreviewWithAutoMergeEnabled.json",
},
expectedOutputs: []string{
`Blueberries are from a fork #12\n`,
`Open.*nobody wants to merge 12 commits into master from blueberries . about X years ago`,
`Auto-merge:.*enabled.* by hubot, using squash and merge`,
`blueberries taste good`,
`View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`,
},
},
}
for name, tc := range tests {

View file

@ -140,7 +140,7 @@ func downloadRun(opts *DownloadOptions) error {
return err
}
opts.IO.StartProgressIndicator()
opts.IO.StartProgressIndicatorWithLabel("Finding assets to download")
defer opts.IO.StopProgressIndicator()
ctx := context.Background()
@ -196,7 +196,7 @@ func downloadRun(opts *DownloadOptions) error {
stdout: opts.IO.Out,
}
return downloadAssets(&dest, httpClient, toDownload, opts.Concurrency, isArchive)
return downloadAssets(&dest, httpClient, toDownload, opts.Concurrency, isArchive, opts.IO)
}
func matchAny(patterns []string, name string) bool {
@ -208,7 +208,7 @@ func matchAny(patterns []string, name string) bool {
return false
}
func downloadAssets(dest *destinationWriter, httpClient *http.Client, toDownload []shared.ReleaseAsset, numWorkers int, isArchive bool) error {
func downloadAssets(dest *destinationWriter, httpClient *http.Client, toDownload []shared.ReleaseAsset, numWorkers int, isArchive bool, io *iostreams.IOStreams) error {
if numWorkers == 0 {
return errors.New("the number of concurrent workers needs to be greater than 0")
}
@ -223,6 +223,7 @@ func downloadAssets(dest *destinationWriter, httpClient *http.Client, toDownload
for w := 1; w <= numWorkers; w++ {
go func() {
for a := range jobs {
io.StartProgressIndicatorWithLabel(fmt.Sprintf("Downloading %s", a.Name))
results <- downloadAsset(dest, httpClient, a.APIURL, a.Name, isArchive)
}
}()
@ -240,6 +241,8 @@ func downloadAssets(dest *destinationWriter, httpClient *http.Client, toDownload
}
}
io.StopProgressIndicator()
return downloadError
}

View file

@ -730,7 +730,7 @@ func interactiveRepoInfo(client *http.Client, hostname string, prompter iprompte
name = fmt.Sprintf("%s/%s", owner, name)
}
description, err := prompter.Input("Description", defaultName)
description, err := prompter.Input("Description", "")
if err != nil {
return "", "", "", err
}

View file

@ -86,7 +86,7 @@ func NewCmdSetDefault(f *cmdutil.Factory, runF func(*SetDefaultOptions) error) *
}
}
if !opts.IO.CanPrompt() && opts.Repo == nil {
if !opts.ViewMode && !opts.IO.CanPrompt() && opts.Repo == nil {
return cmdutil.FlagErrorf("repository required when not running interactively")
}
@ -119,10 +119,10 @@ func setDefaultRun(opts *SetDefaultOptions) error {
currentDefaultRepo, _ := remotes.ResolvedRemote()
if opts.ViewMode {
if currentDefaultRepo == nil {
fmt.Fprintln(opts.IO.Out, "no default repository has been set; use `gh repo set-default` to select one")
} else {
if currentDefaultRepo != nil {
fmt.Fprintln(opts.IO.Out, displayRemoteRepoName(currentDefaultRepo))
} else if opts.IO.IsStdoutTTY() {
fmt.Fprintln(opts.IO.Out, "no default repository has been set; use `gh repo set-default` to select one")
}
return nil
}

View file

@ -166,7 +166,8 @@ func TestDefaultRun(t *testing.T) {
wantStdout: "no default repository has been set\n",
},
{
name: "view mode no current default",
name: "tty view mode no current default",
tty: true,
opts: SetDefaultOptions{ViewMode: true},
remotes: []*context.Remote{
{
@ -176,6 +177,16 @@ func TestDefaultRun(t *testing.T) {
},
wantStdout: "no default repository has been set; use `gh repo set-default` to select one\n",
},
{
name: "view mode no current default",
opts: SetDefaultOptions{ViewMode: true},
remotes: []*context.Remote{
{
Remote: &git.Remote{Name: "origin"},
Repo: repo1,
},
},
},
{
name: "view mode with base resolved current default",
opts: SetDefaultOptions{ViewMode: true},

View file

@ -139,8 +139,8 @@ func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, args []string) {
if c := findCommand(command, "actions"); c != nil {
helpTopics = append(helpTopics, rpad(c.Name()+":", namePadding)+c.Short)
}
for topic, params := range HelpTopics {
helpTopics = append(helpTopics, rpad(topic+":", namePadding)+params["short"])
for _, helpTopic := range HelpTopics {
helpTopics = append(helpTopics, rpad(helpTopic.name+":", namePadding)+helpTopic.short)
}
sort.Strings(helpTopics)
helpEntries = append(helpEntries, helpEntry{"HELP TOPICS", strings.Join(helpTopics, "\n")})

View file

@ -11,7 +11,9 @@ import (
"github.com/spf13/cobra"
)
func referenceHelpFn(io *iostreams.IOStreams) func(*cobra.Command, []string) {
// longPager provides a pager over a commands Long message.
// It is currently only used for the reference command
func longPager(io *iostreams.IOStreams) func(*cobra.Command, []string) {
return func(cmd *cobra.Command, args []string) {
wrapWidth := 0
if io.IsStdoutTTY() {
@ -38,7 +40,7 @@ func referenceHelpFn(io *iostreams.IOStreams) func(*cobra.Command, []string) {
}
}
func referenceLong(cmd *cobra.Command) string {
func stringifyReference(cmd *cobra.Command) string {
buf := bytes.NewBufferString("# gh reference\n\n")
for _, c := range cmd.Commands() {
if c.Hidden {

View file

@ -10,10 +10,18 @@ import (
"github.com/spf13/cobra"
)
var HelpTopics = map[string]map[string]string{
"mintty": {
"short": "Information about using gh with MinTTY",
"long": heredoc.Doc(`
type helpTopic struct {
name string
short string
long string
example string
}
var HelpTopics = []helpTopic{
{
name: "mintty",
short: "Information about using gh with MinTTY",
long: heredoc.Doc(`
MinTTY is the terminal emulator that comes by default with Git
for Windows. It has known issues with gh's ability to prompt a
user for input.
@ -30,9 +38,10 @@ var HelpTopics = map[string]map[string]string{
NOTE: this can lead to some UI bugs.
`),
},
"environment": {
"short": "Environment variables that can be used with gh",
"long": heredoc.Doc(`
{
name: "environment",
short: "Environment variables that can be used with gh",
long: heredoc.Doc(`
GH_TOKEN, GITHUB_TOKEN (in order of precedence): an authentication token for github.com
API requests. Setting this avoids being prompted to authenticate and takes precedence over
previously stored credentials.
@ -41,7 +50,8 @@ var HelpTopics = map[string]map[string]string{
token for API requests to GitHub Enterprise. When setting this, also set GH_HOST.
GH_HOST: specify the GitHub hostname for commands that would otherwise assume the
"github.com" host when not in a context of an existing repository.
"github.com" host when not in a context of an existing repository. When setting this,
also set GH_ENTERPRISE_TOKEN.
GH_REPO: specify the GitHub repository in the "[HOST/]OWNER/REPO" format for commands
that otherwise operate on a local repository.
@ -91,12 +101,14 @@ var HelpTopics = map[string]map[string]string{
its own path such as in the cygwin terminal.
`),
},
"reference": {
"short": "A comprehensive reference of all gh commands",
{
name: "reference",
short: "A comprehensive reference of all gh commands",
},
"formatting": {
"short": "Formatting options for JSON data exported from gh",
"long": heredoc.Docf(`
{
name: "formatting",
short: "Formatting options for JSON data exported from gh",
long: heredoc.Docf(`
By default, the result of %[1]sgh%[1]s commands are output in line-based plain text format.
Some commands support passing the %[1]s--json%[1]s flag, which converts the output to JSON format.
Once in JSON, the output can be further formatted according to a required formatting string by
@ -132,7 +144,7 @@ var HelpTopics = map[string]map[string]string{
To learn more about Go templates, see: <https://golang.org/pkg/text/template/>.
`, "`"),
"example": heredoc.Doc(`
example: heredoc.Doc(`
# default output format
$ gh pr list
Showing 23 of 23 open pull requests in cli/cli
@ -242,9 +254,10 @@ var HelpTopics = map[string]map[string]string{
mislav COMMENTED This is going along great! Thanks for working on this
`),
},
"exit-codes": {
"short": "Exit codes used by gh",
"long": heredoc.Doc(`
{
name: "exit-codes",
short: "Exit codes used by gh",
long: heredoc.Doc(`
gh follows normal conventions regarding exit codes.
- If a command completes successfully, the exit code will be 0
@ -262,30 +275,31 @@ var HelpTopics = map[string]map[string]string{
},
}
func NewHelpTopic(ios *iostreams.IOStreams, topic string) *cobra.Command {
func NewCmdHelpTopic(ios *iostreams.IOStreams, ht helpTopic) *cobra.Command {
cmd := &cobra.Command{
Use: topic,
Short: HelpTopics[topic]["short"],
Long: HelpTopics[topic]["long"],
Example: HelpTopics[topic]["example"],
Use: ht.name,
Short: ht.short,
Long: ht.long,
Example: ht.example,
Hidden: true,
Annotations: map[string]string{
"markdown:generate": "true",
"markdown:basename": "gh_help_" + topic,
"markdown:basename": "gh_help_" + ht.name,
},
}
cmd.SetHelpFunc(func(c *cobra.Command, args []string) {
helpTopicHelpFunc(ios.Out, c, args)
})
cmd.SetUsageFunc(func(c *cobra.Command) error {
return helpTopicUsageFunc(ios.ErrOut, c)
})
cmd.SetHelpFunc(func(c *cobra.Command, _ []string) {
helpTopicHelpFunc(ios.Out, c)
})
return cmd
}
func helpTopicHelpFunc(w io.Writer, command *cobra.Command, args []string) {
func helpTopicHelpFunc(w io.Writer, command *cobra.Command) {
fmt.Fprint(w, command.Long)
if command.Example != "" {
fmt.Fprintf(w, "\n\nEXAMPLES\n")

View file

@ -4,76 +4,85 @@ import (
"testing"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewHelpTopic(t *testing.T) {
func TestCmdHelpTopic(t *testing.T) {
t.Parallel()
tests := []struct {
name string
topic string
args []string
flags []string
wantsErr bool
name string
topic helpTopic
args []string
flags []string
errorAssertion require.ErrorAssertionFunc
expectedAnnotations map[string]string
}{
{
name: "valid topic",
topic: "environment",
args: []string{},
flags: []string{},
wantsErr: false,
name: "when there are no args or flags, prints the long message to stdout",
topic: helpTopic{name: "test-topic", long: "test-topic-long"},
args: []string{},
flags: []string{},
errorAssertion: require.NoError,
},
{
name: "invalid topic",
topic: "invalid",
args: []string{},
flags: []string{},
wantsErr: false,
name: "when an arg is provided, it is ignored and help is printed",
topic: helpTopic{name: "test-topic"},
args: []string{"anything"},
flags: []string{},
errorAssertion: require.NoError,
},
{
name: "more than zero args",
topic: "environment",
args: []string{"invalid"},
flags: []string{},
wantsErr: false,
name: "when a flag is provided, returns an error",
topic: helpTopic{name: "test-topic"},
args: []string{},
flags: []string{"--anything"},
errorAssertion: require.Error,
},
{
name: "more than zero flags",
topic: "environment",
args: []string{},
flags: []string{"--invalid"},
wantsErr: true,
name: "when there is an example, include it in the stdout",
topic: helpTopic{name: "test-topic", example: "test-topic-example"},
args: []string{},
flags: []string{},
errorAssertion: require.NoError,
},
{
name: "help arg",
topic: "environment",
args: []string{"help"},
flags: []string{},
wantsErr: false,
},
{
name: "help flag",
topic: "environment",
args: []string{},
flags: []string{"--help"},
wantsErr: false,
name: "sets important markdown annotations on the command",
topic: helpTopic{name: "test-topic"},
args: []string{},
flags: []string{},
errorAssertion: require.NoError,
expectedAnnotations: map[string]string{
"markdown:generate": "true",
"markdown:basename": "gh_help_test-topic",
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
ios, _, _, stderr := iostreams.Test()
t.Parallel()
cmd := NewHelpTopic(ios, tt.topic)
ios, _, stdout, _ := iostreams.Test()
cmd := NewCmdHelpTopic(ios, tt.topic)
cmd.SetArgs(append(tt.args, tt.flags...))
cmd.SetOut(stderr)
cmd.SetErr(stderr)
_, err := cmd.ExecuteC()
if tt.wantsErr {
assert.Error(t, err)
return
tt.errorAssertion(t, err)
if tt.topic.long != "" {
require.Contains(t, stdout.String(), tt.topic.long)
}
if tt.topic.example != "" {
require.Contains(t, stdout.String(), tt.topic.example)
}
if len(tt.expectedAnnotations) > 0 {
require.Equal(t, cmd.Annotations, tt.expectedAnnotations)
}
assert.NoError(t, err)
})
}
}

View file

@ -127,18 +127,26 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
cmd.AddCommand(cacheCmd.NewCmdCache(&repoResolvingCmdFactory))
// Help topics
cmd.AddCommand(NewHelpTopic(f.IOStreams, "environment"))
cmd.AddCommand(NewHelpTopic(f.IOStreams, "formatting"))
cmd.AddCommand(NewHelpTopic(f.IOStreams, "mintty"))
cmd.AddCommand(NewHelpTopic(f.IOStreams, "exit-codes"))
referenceCmd := NewHelpTopic(f.IOStreams, "reference")
referenceCmd.SetHelpFunc(referenceHelpFn(f.IOStreams))
cmd.AddCommand(referenceCmd)
var referenceCmd *cobra.Command
for _, ht := range HelpTopics {
helpTopicCmd := NewCmdHelpTopic(f.IOStreams, ht)
cmd.AddCommand(helpTopicCmd)
// See bottom of the function for why we explicitly care about the reference cmd
if ht.name == "reference" {
referenceCmd = helpTopicCmd
}
}
cmdutil.DisableAuthCheck(cmd)
// this needs to appear last:
referenceCmd.Long = referenceLong(cmd)
// The reference command produces paged output that displays information on every other command.
// Therefore, we explicitly set the Long text and HelpFunc here after all other commands are registered.
// We experimented with producing the paged output dynamically when the HelpFunc is called but since
// doc generation makes use of the Long text, it is simpler to just be explicit here that this command
// is special.
referenceCmd.Long = stringifyReference(cmd)
referenceCmd.SetHelpFunc(longPager(f.IOStreams))
return cmd
}

20
script/label-assets Executable file
View file

@ -0,0 +1,20 @@
#!/bin/bash
set -e
if [ $# -eq 0 ]; then
echo "usage: script/label-assets dist/gh_*" >&2
exit 1
fi
for asset; do
label="$(basename "$asset")"
label="${label%.*}"
label="${label%.tar}"
label="GitHub CLI $(tr '_' ' ' <<<"${label#gh_}")"
case "$asset" in
*.msi ) label="${label} installer" ;;
*.deb ) label="${label} deb" ;;
*.rpm ) label="${label} RPM" ;;
esac
printf '"%s#%s"\n' "$asset" "$label"
done

115
script/release Executable file
View file

@ -0,0 +1,115 @@
#!/bin/bash
set -e
print_help() {
cat <<EOF
To tag a new release:
script/release [--staging] <tag-name> [--platform {linux|macos|windows}] [--branch <branch>]
To build staging binaries from the current branch:
script/release --current [--platform {linux|macos|windows}]
To build binaries locally with goreleaser:
script/release --local --platform {linux|macos|windows}
EOF
}
if [ $# -eq 0 ]; then
print_help >&2
exit 1
fi
tag_name=""
is_local=""
do_push=""
platform=""
branch="trunk"
deploy_env="production"
while [ $# -gt 0 ]; do
case "$1" in
-h | --help )
print_help
exit 0
;;
-b | --branch )
branch="$2"
shift 2
;;
-p | --platform )
platform="$2"
shift 2
;;
--local )
is_local=1
shift 1
;;
--staging )
deploy_env="staging"
shift 1
;;
--current )
deploy_env="staging"
tag_name="$(git describe --tags --abbrev=0)"
branch="$(git rev-parse --symbolic-full-name '@{upstream}' 2>/dev/null || git branch --show-current)"
branch="${branch#refs/remotes/*/}"
do_push=1
shift 1
;;
-* )
printf "unrecognized flag: %s\n" "$1" >&2
exit 1
;;
* )
tag_name="$1"
shift 1
;;
esac
done
announce() {
local tmpdir="${TMPDIR:-/tmp}"
echo "$*" | sed "s:${tmpdir%/}:\$TMPDIR:"
"$@"
}
trigger_deployment() {
announce gh workflow -R cli/cli run deployment.yml --ref "$branch" -f tag_name="$tag_name" -f environment="$deploy_env"
}
build_local() {
local goreleaser_config=".goreleaser.yml"
case "$platform" in
linux )
sed '/#build:windows/,/^$/d; /#build:macos/,/^$/d' .goreleaser.yml >.goreleaser.generated.yml
goreleaser_config=".goreleaser.generated.yml"
;;
macos )
sed '/#build:windows/,/^$/d; /#build:linux/,/^$/d' .goreleaser.yml >.goreleaser.generated.yml
goreleaser_config=".goreleaser.generated.yml"
;;
windows )
sed '/#build:linux/,/^$/d; /#build:macos/,/^$/d' .goreleaser.yml >.goreleaser.generated.yml
goreleaser_config=".goreleaser.generated.yml"
;;
esac
[ -z "$tag_name" ] || export GORELEASER_CURRENT_TAG="$tag_name"
announce goreleaser release -f "$goreleaser_config" --clean --skip-validate --skip-publish --release-notes="$(mktemp)"
}
if [ -n "$is_local" ]; then
build_local
else
if [ -n "$do_push" ]; then
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "refusing to continue due to uncomitted local changes" >&2
exit 1
fi
announce git push
fi
trigger_deployment
if [ "$deploy_env" = "production" ]; then
echo
echo "Go to Slack to manually approve this production deployment."
fi
fi

View file

@ -1,31 +0,0 @@
#!/bin/bash
# Usage: cat checksums.txt | script/scoop-gen <version> <json-file>
set -e
tagname="${1?}"
jsonfile="${2?}"
jq_args=(
--arg version "${tagname#v}"
--arg urlprefix "https://github.com/cli/cli/releases/download/$tagname/"
$(cat | awk '
/windows_386/ {
printf "--arg win32hash %s\n", $1
printf "--arg win32file %s\n", $2
}
/windows_amd64/ {
printf "--arg win64hash %s\n", $1
printf "--arg win64file %s\n", $2
}
')
)
jq '
.version = $version |
.architecture."32bit".url = $urlprefix + $win32file |
.architecture."32bit".hash = $win32hash |
.architecture."64bit".url = $urlprefix + $win64file |
.architecture."64bit".hash = $win64hash
' "${jq_args[@]}" --indent 4 "$jsonfile" > "$jsonfile"~
mv "$jsonfile"{~,}

65
script/sign Executable file
View file

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

View file

@ -1,25 +0,0 @@
#!/bin/bash
set -e
EXE="$1"
if [ -z "$CERT_FILE" ]; then
echo "skipping Windows code-signing; CERT_FILE not set" >&2
exit 0
fi
if [ ! -f "$CERT_FILE" ]; then
echo "error Windows code-signing; file '$CERT_FILE' not found" >&2
exit 1
fi
if [ -z "$CERT_PASSWORD" ]; then
echo "error Windows code-signing; no value for CERT_PASSWORD" >&2
exit 1
fi
osslsigncode sign -n "GitHub CLI" -t http://timestamp.digicert.com \
-pkcs12 "$CERT_FILE" -readpass <(printf "%s" "$CERT_PASSWORD") -h sha256 \
-in "$EXE" -out "$EXE"~
mv "$EXE"~ "$EXE"

8
script/sign.bat Normal file
View file

@ -0,0 +1,8 @@
@echo off
if "%CERT_FILE%" == "" (
echo skipping Windows code-signing; CERT_FILE not set
exit /b
)
.\script\signtool sign /d "GitHub CLI" /f "%CERT_FILE%" /p "%CERT_PASSWORD%" /fd sha256 /tr http://timestamp.digicert.com /v "%1"

View file

@ -1,12 +0,0 @@
param (
[string]$Certificate = $(throw "-Certificate is required."),
[string]$Executable = $(throw "-Executable is required.")
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$ProgramName = "GitHub CLI"
$scriptPath = split-path -parent $MyInvocation.MyCommand.Definition
& $scriptPath\signtool.exe sign /d $ProgramName /f $Certificate /p $env:CERT_PASSWORD /fd sha256 /tr http://timestamp.digicert.com /v $Executable