diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000000..bc047d1c5b1 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,24 @@ +{ + "image": "mcr.microsoft.com/devcontainers/go:1.23", + "features": { + "ghcr.io/devcontainers/features/sshd:1": {} + }, + "remoteUser": "vscode", + "customizations": { + "vscode": { + "extensions": [ + "golang.go" + ], + "settings": { + "go.toolsManagement.checkForUpdates": "local", + "go.useLanguageServer": true, + "go.gopath": "/go" + } + } + }, + "runArgs": [ + "--cap-add=SYS_PTRACE", + "--security-opt", + "seccomp=unconfined" + ] +} diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000000..40683d917a3 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,10 @@ +* @cli/code-reviewers + +pkg/cmd/codespace/ @cli/codespaces +internal/codespaces/ @cli/codespaces + +# Limit Package Security team ownership to the attestation command package and related integration tests +pkg/cmd/attestation/ @cli/package-security +test/integration/attestation-cmd @cli/package-security + +pkg/cmd/attestation/verification/embed/tuf-repo.github.com/ @cli/tuf-root-reviewers diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 3b258406387..40d0a83e3cb 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -4,26 +4,27 @@ Hi! Thanks for your interest in contributing to the GitHub CLI! We accept pull requests for bug fixes and features where we've discussed the approach in an issue and given the go-ahead for a community member to work on it. We'd also love to hear about ideas for new features as issues. -Please do: +### Please do: -* Check existing issues to verify that the [bug][bug issues] or [feature request][feature request issues] has not already been submitted. -* Open an issue if things aren't working as expected. -* Open an issue to propose a significant change. -* Open a pull request to fix a bug. -* Open a pull request to fix documentation about a command. -* Open a pull request for any issue labelled [`help wanted`][hw] or [`good first issue`][gfi]. +* Check issues to verify that a [bug][bug issues] or [feature request][feature request issues] issue does not already exist for the same problem or feature +* Open an issue if things aren't working as expected +* Open an issue to propose a significant change +* Open an issue to propose a design for an issue labelled [`needs-design` and `help wanted`][needs design and help wanted], following the [proposing a design guidelines](#proposing-a-design) instructions below +* Mention `@cli/code-reviewers` when an issue you want to work on does not have clear Acceptance Criteria +* Open a pull request for any issue labelled [`help wanted`][hw] and [`good first issue`][gfi] -Please avoid: +### Please _do not_: -* Opening pull requests for issues marked `needs-design`, `needs-investigation`, or `blocked`. -* Adding installation instructions specifically for your OS/package manager. -* Opening pull requests for any issue marked `core`. These issues require additional context from - the core CLI team at GitHub and any external pull requests will not be accepted. +* Open a pull request for issues without the `help wanted` label or explicit Acceptance Criteria +* Expand pull request scope to include changes that are not described in the issue's Acceptance Criteria +* Add installation instructions specifically for your OS/package manager +* Open pull requests for any issue marked `core`. These issues require additional context from + the core CLI team at GitHub and any external pull requests will not be accepted ## Building the project Prerequisites: -- Go 1.16+ +- Go 1.22+ Build with: * Unix-like systems: `make` @@ -51,7 +52,21 @@ We generate manual pages from source on every release. You do not need to submit ## Design guidelines -You may reference the [CLI Design System][] when suggesting features, and are welcome to use our [Google Docs Template][] to suggest designs. +### Proposing a design + +You may propose a design to solve an open bug or feature request issue that has both [the `needs-design` and `help-wanted` labels][needs design and help wanted]. + +To propose a design: + +- Open a new issue using the [design proposal issue template](./ISSUE_TEMPLATE/submit-a-design-proposal.md). +- Include a link to the issue that the design is for. +- Describe the design you are proposing to resolve the issue, leveraging the [CLI Design System][]. +- Mock up the design you are proposing using our [Google Docs Template][] or code blocks. + - Mock ups should cleary illustrate the command(s) being run and the expected output(s). + +### (core team only) Revewing a design + +A member of the core team will [triage](../docs/triage.md) the design proposal. Once a member of the core team has reviewed the design, they may add the [`help wanted`][hw] label to the issue, so a PR can be opened to provide the implementation. ## Resources @@ -61,6 +76,7 @@ You may reference the [CLI Design System][] when suggesting features, and are we [bug issues]: https://github.com/cli/cli/issues?q=is%3Aopen+is%3Aissue+label%3Abug +[needs design and help wanted]: https://github.com/cli/cli/issues?q=state%3Aclosed%20is%3Aissue%20label%3Aneeds-design%20label%3A%22help%20wanted%22 [feature request issues]: https://github.com/cli/cli/issues?q=is%3Aopen+is%3Aissue+label%3Aenhancement [hw]: https://github.com/cli/cli/labels/help%20wanted [gfi]: https://github.com/cli/cli/labels/good%20first%20issue diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 16e5348f13a..ae0d29096a4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -9,7 +9,11 @@ assignees: '' ### Describe the bug -A clear and concise description of what the bug is. Include version by typing `gh --version`. +A clear and concise description of what the bug is. + +### Affected version + +Please run `gh version` and paste the output below. ### Steps to reproduce the behavior @@ -24,3 +28,5 @@ A clear and concise description of what you expected to happen and what actually ### Logs Paste the activity from your command line. Redact if needed. + + diff --git a/.github/ISSUE_TEMPLATE/submit-a-design-proposal.md b/.github/ISSUE_TEMPLATE/submit-a-design-proposal.md new file mode 100644 index 00000000000..fab4b7a88ea --- /dev/null +++ b/.github/ISSUE_TEMPLATE/submit-a-design-proposal.md @@ -0,0 +1,58 @@ +--- +name: "🎨 Submit a design proposal" +about: Submit a design to resolve an open issue that has both `needs-design` and `help-wanted` labels +title: '' +labels: enhancement +assignees: '' + +--- + + + +### Link to issue for design submission + + + +### Proposed Design + + + +### Mockup + + \ No newline at end of file diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 27170f56408..50e489c545a 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -1,3 +1,17 @@ -If you discover a security issue in this repository, please submit it through the [GitHub Security Bug Bounty](https://hackerone.com/github). +GitHub takes the security of our software products and services seriously, including the open source code repositories managed through our GitHub organizations, such as [cli](https://github.com/cli). + +If you believe you have found a security vulnerability in GitHub CLI, you can report it to us in one of two ways: + +* Report it to this repository directly using [private vulnerability reporting][]. + * Include a description of your investigation of the GitHub CLI's codebase and why you believe an exploit is possible. + * POCs and links to code are greatly encouraged. + * Such reports are not eligible for a bounty reward. + +* Submit the report through [HackerOne][] to be eligible for a bounty reward. + +**Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.** Thanks for helping make GitHub safe for everyone. + + [private vulnerability reporting]: https://github.com/cli/cli/security/advisories + [HackerOne]: https://hackerone.com/github diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d46c0bcf330..8cd5ecbee68 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -10,19 +10,29 @@ on: schedule: - cron: "0 0 * * 0" +permissions: + actions: read # for github/codeql-action/init to get workflow details + contents: read # for actions/checkout to fetch code + security-events: write # for github/codeql-action/analyze to upload SARIF results + jobs: CodeQL-Build: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v3 with: languages: go queries: security-and-quality + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml new file mode 100644 index 00000000000..efc729711cd --- /dev/null +++ b/.github/workflows/deployment.yml @@ -0,0 +1,395 @@ +name: Deployment +run-name: ${{ inputs.tag_name }} / ${{ inputs.environment }} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }} + cancel-in-progress: true + +permissions: + attestations: write + contents: write + id-token: write + +on: + workflow_dispatch: + inputs: + tag_name: + required: true + type: string + environment: + default: production + type: environment + platforms: + default: "linux,macos,windows" + type: string + release: + description: "Whether to create a GitHub Release" + type: boolean + default: true + +jobs: + validate-tag-name: + runs-on: ubuntu-latest + steps: + - name: Validate tag name format + run: | + if [[ ! "${{ inputs.tag_name }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Invalid tag name format. Must be in the form v1.2.3" + exit 1 + fi + linux: + needs: validate-tag-name + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + if: contains(inputs.platforms, 'linux') + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + - name: Install GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + version: "~1.17.1" + install-only: true + - name: Build release binaries + env: + TAG_NAME: ${{ inputs.tag_name }} + run: script/release --local "$TAG_NAME" --platform linux + - name: Generate web manual pages + run: | + go run ./cmd/gen-docs --website --doc-path dist/manual + tar -czvf dist/manual.tar.gz -C dist -- manual + - uses: actions/upload-artifact@v4 + with: + name: linux + if-no-files-found: error + retention-days: 7 + path: | + dist/*.tar.gz + dist/*.rpm + dist/*.deb + + macos: + needs: validate-tag-name + runs-on: macos-latest + environment: ${{ inputs.environment }} + if: contains(inputs.platforms, 'macos') + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + - name: Configure macOS signing + if: inputs.environment == 'production' + env: + APPLE_DEVELOPER_ID: ${{ vars.APPLE_DEVELOPER_ID }} + APPLE_APPLICATION_CERT: ${{ secrets.APPLE_APPLICATION_CERT }} + APPLE_APPLICATION_CERT_PASSWORD: ${{ secrets.APPLE_APPLICATION_CERT_PASSWORD }} + run: | + keychain="$RUNNER_TEMP/buildagent.keychain" + keychain_password="password1" + + security create-keychain -p "$keychain_password" "$keychain" + security default-keychain -s "$keychain" + security unlock-keychain -p "$keychain_password" "$keychain" + + base64 -D <<<"$APPLE_APPLICATION_CERT" > "$RUNNER_TEMP/cert.p12" + security import "$RUNNER_TEMP/cert.p12" -k "$keychain" -P "$APPLE_APPLICATION_CERT_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S "apple-tool:,apple:,codesign:" -s -k "$keychain_password" "$keychain" + rm "$RUNNER_TEMP/cert.p12" + - name: Install GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + version: "~1.17.1" + install-only: true + - name: Build release binaries + env: + TAG_NAME: ${{ inputs.tag_name }} + APPLE_DEVELOPER_ID: ${{ vars.APPLE_DEVELOPER_ID }} + run: script/release --local "$TAG_NAME" --platform macos + - name: Notarize macOS archives + if: inputs.environment == 'production' + env: + APPLE_ID: ${{ vars.APPLE_ID }} + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + APPLE_DEVELOPER_ID: ${{ vars.APPLE_DEVELOPER_ID }} + run: | + shopt -s failglob + script/sign dist/gh_*_macOS_*.zip + - name: Build universal macOS pkg installer + if: inputs.environment != 'production' + env: + TAG_NAME: ${{ inputs.tag_name }} + run: script/pkgmacos "$TAG_NAME" + - name: Build & notarize universal macOS pkg installer + if: inputs.environment == 'production' + env: + TAG_NAME: ${{ inputs.tag_name }} + APPLE_DEVELOPER_INSTALLER_ID: ${{ vars.APPLE_DEVELOPER_INSTALLER_ID }} + run: | + shopt -s failglob + script/pkgmacos "$TAG_NAME" + - uses: actions/upload-artifact@v4 + with: + name: macos + if-no-files-found: error + retention-days: 7 + path: | + dist/*.tar.gz + dist/*.zip + dist/*.pkg + + windows: + needs: validate-tag-name + runs-on: windows-latest + environment: ${{ inputs.environment }} + if: contains(inputs.platforms, 'windows') + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + - name: Install GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + version: "~1.17.1" + install-only: true + - name: Install Azure Code Signing Client + shell: pwsh + env: + ACS_DIR: ${{ runner.temp }}\acs + ACS_ZIP: ${{ runner.temp }}\acs.zip + CORRELATION_ID: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + METADATA_PATH: ${{ runner.temp }}\acs\metadata.json + run: | + # Download Azure Code Signing client containing the DLL needed for signtool in script/sign + Invoke-WebRequest -Uri https://www.nuget.org/api/v2/package/Azure.CodeSigning.Client/1.0.43 -OutFile $Env:ACS_ZIP -Verbose + Expand-Archive $Env:ACS_ZIP -Destination $Env:ACS_DIR -Force -Verbose + + # Generate metadata file for signtool, used in signing box .exe and .msi + @{ + CertificateProfileName = "GitHubInc" + CodeSigningAccountName = "GitHubInc" + CorrelationId = $Env:CORRELATION_ID + Endpoint = "https://wus.codesigning.azure.net/" + } | ConvertTo-Json | Out-File -FilePath $Env:METADATA_PATH + + # Azure Code Signing leverages the environment variables for secrets that complement the metadata.json + # file generated above (AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID) + # For more information, see https://learn.microsoft.com/en-us/dotnet/api/azure.identity.defaultazurecredential?view=azure-dotnet + - name: Build release binaries + shell: bash + env: + AZURE_CLIENT_ID: ${{ secrets.SPN_GITHUB_CLI_SIGNING_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.SPN_GITHUB_CLI_SIGNING }} + AZURE_TENANT_ID: ${{ secrets.SPN_GITHUB_CLI_SIGNING_TENANT_ID }} + DLIB_PATH: ${{ runner.temp }}\acs\bin\x64\Azure.CodeSigning.Dlib.dll + METADATA_PATH: ${{ runner.temp }}\acs\metadata.json + TAG_NAME: ${{ inputs.tag_name }} + run: script/release --local "$TAG_NAME" --platform windows + - name: Set up MSBuild + id: setupmsbuild + uses: microsoft/setup-msbuild@v2.0.0 + - name: Build MSI + shell: bash + env: + MSBUILD_PATH: ${{ steps.setupmsbuild.outputs.msbuildPath }} + run: | + for ZIP_FILE in dist/gh_*_windows_*.zip; do + MSI_NAME="$(basename "$ZIP_FILE" ".zip")" + MSI_VERSION="$(cut -d_ -f2 <<<"$MSI_NAME" | cut -d- -f1)" + case "$MSI_NAME" in + *_386 ) + source_dir="$PWD/dist/windows_windows_386" + platform="x86" + ;; + *_amd64 ) + source_dir="$PWD/dist/windows_windows_amd64_v1" + platform="x64" + ;; + *_arm64 ) + source_dir="$PWD/dist/windows_windows_arm64" + platform="arm64" + ;; + * ) + printf "unsupported architecture: %s\n" "$MSI_NAME" >&2 + exit 1 + ;; + esac + "${MSBUILD_PATH}\MSBuild.exe" ./build/windows/gh.wixproj -p:SourceDir="$source_dir" -p:OutputPath="$PWD/dist" -p:OutputName="$MSI_NAME" -p:ProductVersion="${MSI_VERSION#v}" -p:Platform="$platform" + done + - name: Sign .msi release binaries + if: inputs.environment == 'production' + shell: pwsh + env: + AZURE_CLIENT_ID: ${{ secrets.SPN_GITHUB_CLI_SIGNING_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.SPN_GITHUB_CLI_SIGNING }} + AZURE_TENANT_ID: ${{ secrets.SPN_GITHUB_CLI_SIGNING_TENANT_ID }} + DLIB_PATH: ${{ runner.temp }}\acs\bin\x64\Azure.CodeSigning.Dlib.dll + METADATA_PATH: ${{ runner.temp }}\acs\metadata.json + run: | + Get-ChildItem -Path .\dist -Filter *.msi | ForEach-Object { + .\script\sign.ps1 $_.FullName + } + - uses: actions/upload-artifact@v4 + with: + name: windows + if-no-files-found: error + retention-days: 7 + path: | + dist/*.zip + dist/*.msi + + release: + runs-on: ubuntu-latest + needs: [linux, macos, windows] + environment: ${{ inputs.environment }} + if: inputs.release + steps: + - name: Checkout cli/cli + uses: actions/checkout@v4 + - name: Merge built artifacts + uses: actions/download-artifact@v4 + - name: Checkout documentation site + uses: actions/checkout@v4 + with: + repository: github/cli.github.com + path: site + fetch-depth: 0 + token: ${{ secrets.SITE_DEPLOY_PAT }} + - name: Update site man pages + env: + GIT_COMMITTER_NAME: cli automation + GIT_AUTHOR_NAME: cli automation + GIT_COMMITTER_EMAIL: noreply@github.com + GIT_AUTHOR_EMAIL: noreply@github.com + TAG_NAME: ${{ inputs.tag_name }} + run: | + git -C site rm 'manual/gh*.md' 2>/dev/null || true + tar -xzvf linux/manual.tar.gz -C site + git -C site add 'manual/gh*.md' + sed -i.bak -E "s/(assign version = )\".+\"/\1\"${TAG_NAME#v}\"/" site/index.html + rm -f site/index.html.bak + git -C site add index.html + git -C site diff --quiet --cached || git -C site commit -m "gh ${TAG_NAME#v}" + - name: Prepare release assets + env: + TAG_NAME: ${{ inputs.tag_name }} + run: | + shopt -s failglob + rm -rf dist + mkdir dist + mv -v {linux,macos,windows}/gh_* dist/ + - name: Install packaging dependencies + run: sudo apt-get install -y rpm reprepro + - name: Set up GPG + if: inputs.environment == 'production' + env: + GPG_PUBKEY: ${{ secrets.GPG_PUBKEY }} + GPG_KEY: ${{ secrets.GPG_KEY }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + GPG_KEYGRIP: ${{ secrets.GPG_KEYGRIP }} + run: | + base64 -d <<<"$GPG_PUBKEY" | gpg --import --no-tty --batch --yes + base64 -d <<<"$GPG_KEY" | gpg --import --no-tty --batch --yes + echo "allow-preset-passphrase" > ~/.gnupg/gpg-agent.conf + gpg-connect-agent RELOADAGENT /bye + /usr/lib/gnupg2/gpg-preset-passphrase --preset "$GPG_KEYGRIP" <<<"$GPG_PASSPHRASE" + - name: Sign RPMs + if: inputs.environment == 'production' + run: | + cp script/rpmmacros ~/.rpmmacros + rpmsign --addsign dist/*.rpm + - name: Attest release artifacts + if: inputs.environment == 'production' + uses: actions/attest-build-provenance@520d128f165991a6c774bcb264f323e3d70747f4 # v2.2.0 + with: + subject-path: "dist/gh_*" + - name: Run createrepo + env: + GPG_SIGN: ${{ inputs.environment == 'production' }} + run: | + mkdir -p site/packages/rpm + cp dist/*.rpm site/packages/rpm/ + ./script/createrepo.sh + cp -r dist/repodata site/packages/rpm/ + pushd site/packages/rpm + [ "$GPG_SIGN" = "false" ] || gpg --yes --detach-sign --armor repodata/repomd.xml + popd + - name: Run reprepro + env: + GPG_SIGN: ${{ inputs.environment == 'production' }} + # We are no longer adding to the distribution list. + # All apt distributions should use "stable" according to our install documentation. + # In the future we will remove legacy distributions listed here. + RELEASES: "cosmic eoan disco groovy focal stable oldstable testing sid unstable buster bullseye stretch jessie bionic trusty precise xenial hirsute impish kali-rolling" + run: | + mkdir -p upload + [ "$GPG_SIGN" = "true" ] || sed -i.bak '/^SignWith:/d' script/distributions + for release in $RELEASES; do + for file in dist/*.deb; do + reprepro --confdir="+b/script" includedeb "$release" "$file" + done + done + cp -a dists/ pool/ upload/ + mkdir -p site/packages + cp -a upload/* site/packages/ + - name: Create the release + env: + # In non-production environments, the assets will not have been signed + DO_PUBLISH: ${{ inputs.environment == 'production' }} + TAG_NAME: ${{ inputs.tag_name }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + shopt -s failglob + pushd dist + shasum -a 256 gh_* > checksums.txt + mv checksums.txt gh_${TAG_NAME#v}_checksums.txt + popd + release_args=( + "$TAG_NAME" + --title "GitHub CLI ${TAG_NAME#v}" + --target "$GITHUB_SHA" + --generate-notes + ) + if [[ $TAG_NAME == *-* ]]; then + release_args+=( --prerelease ) + fi + guard="echo" + [ "$DO_PUBLISH" = "false" ] || guard="" + script/label-assets dist/gh_* | xargs $guard gh release create "${release_args[@]}" -- + - name: Publish site + env: + DO_PUBLISH: ${{ inputs.environment == 'production' && !contains(inputs.tag_name, '-') }} + TAG_NAME: ${{ inputs.tag_name }} + GIT_COMMITTER_NAME: cli automation + GIT_AUTHOR_NAME: cli automation + GIT_COMMITTER_EMAIL: noreply@github.com + GIT_AUTHOR_EMAIL: noreply@github.com + working-directory: ./site + run: | + git add packages + git commit -m "Add rpm and deb packages for $TAG_NAME" + if [ "$DO_PUBLISH" = "true" ]; then + git push + else + git log --oneline @{upstream}.. + git diff --name-status @{upstream}.. + fi + - name: Bump homebrew-core formula + uses: mislav/bump-homebrew-formula-action@v3 + if: inputs.environment == 'production' && !contains(inputs.tag_name, '-') + with: + formula-name: gh + formula-path: Formula/g/gh.rb + tag-name: ${{ inputs.tag_name }} + push-to: williammartin/homebrew-core + env: + COMMITTER_TOKEN: ${{ secrets.HOMEBREW_PR_PAT }} diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index f12d8f0d011..9b22701a7d3 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -1,5 +1,9 @@ -name: Tests +name: Unit and Integration Tests on: [push, pull_request] + +permissions: + contents: read + jobs: build: strategy: @@ -9,28 +13,45 @@ jobs: runs-on: ${{ matrix.os }} steps: - - name: Set up Go 1.16 - uses: actions/setup-go@v2 - with: - go-version: 1.16 - - name: Check out code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - - name: Cache Go modules - uses: actions/cache@v2 + - name: Set up Go + uses: actions/setup-go@v5 with: - path: ~/go - key: ${{ runner.os }}-build-${{ hashFiles('go.mod') }} - restore-keys: | - ${{ runner.os }}-build- - ${{ runner.os }}- + go-version-file: "go.mod" - name: Download dependencies run: go mod download - - name: Run tests - run: go test -race ./... + - name: Run unit and integration tests + run: go test -race -tags=integration ./... - name: Build run: go build -v ./cmd/gh + + integration-tests: + env: + GH_TOKEN: ${{ github.token }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: "go.mod" + + - name: Build executable + run: make + + - name: Run attestation command set integration tests + shell: bash + run: | + ./test/integration/attestation-cmd/run-all-tests.sh "${{ matrix.os }}" diff --git a/.github/workflows/homebrew-bump.yml b/.github/workflows/homebrew-bump.yml new file mode 100644 index 00000000000..2bc395a1eb4 --- /dev/null +++ b/.github/workflows/homebrew-bump.yml @@ -0,0 +1,26 @@ +name: homebrew-bump-debug + +permissions: + contents: write + +on: + workflow_dispatch: + inputs: + tag_name: + required: true + type: string + environment: + default: production + type: environment +jobs: + bump: + runs-on: ubuntu-latest + steps: + - name: Bump homebrew-core formula + uses: mislav/bump-homebrew-formula-action@v3 + if: inputs.environment == 'production' && !contains(inputs.tag_name, '-') + with: + formula-name: gh + tag-name: ${{ inputs.tag_name }} + env: + COMMITTER_TOKEN: ${{ secrets.HOMEBREW_PR_PAT }} diff --git a/.github/workflows/issueauto.yml b/.github/workflows/issueauto.yml index a366d6ed87a..40c4b36e700 100644 --- a/.github/workflows/issueauto.yml +++ b/.github/workflows/issueauto.yml @@ -2,16 +2,21 @@ name: Issue Automation on: issues: types: [opened] + +permissions: + contents: none + issues: write + jobs: issue-auto: runs-on: ubuntu-latest steps: - name: label incoming issue env: - GH_REPO: ${{ github.repository }} - GH_TOKEN: ${{ secrets.AUTOMATION_TOKEN }} - ISSUENUM: ${{ github.event.issue.number }} - ISSUEAUTHOR: ${{ github.event.issue.user.login }} + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.AUTOMATION_TOKEN }} + ISSUENUM: ${{ github.event.issue.number }} + ISSUEAUTHOR: ${{ github.event.issue.user.login }} run: | if ! gh api orgs/cli/public_members/$ISSUEAUTHOR --silent 2>/dev/null then diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 78d94c78e5d..f1ae1e522a4 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -11,25 +11,28 @@ on: - go.mod - go.sum +permissions: + contents: read + jobs: lint: runs-on: ubuntu-latest steps: - - name: Set up Go 1.16 - uses: actions/setup-go@v2 - with: - go-version: 1.16 - - name: Check out code - uses: actions/checkout@v2 + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' - name: Verify dependencies run: | go mod verify go mod download - LINT_VERSION=1.39.0 + LINT_VERSION=1.63.4 curl -fsSL https://github.com/golangci/golangci-lint/releases/download/v${LINT_VERSION}/golangci-lint-${LINT_VERSION}-linux-amd64.tar.gz | \ tar xz --strip-components 1 --wildcards \*/golangci-lint mkdir -p bin && mv golangci-lint bin/ @@ -50,6 +53,6 @@ jobs: assert-nothing-changed go fmt ./... assert-nothing-changed go mod tidy - bin/golangci-lint run --out-format=github-actions --timeout=3m || STATUS=$? + bin/golangci-lint run --out-format=colored-line-number --timeout=3m || STATUS=$? exit $STATUS diff --git a/.github/workflows/prauto.yml b/.github/workflows/prauto.yml index 047fb52eaa4..b7ed90183de 100644 --- a/.github/workflows/prauto.yml +++ b/.github/workflows/prauto.yml @@ -2,6 +2,12 @@ name: PR Automation on: pull_request_target: types: [ready_for_review, opened, reopened] + +permissions: + contents: none + issues: write + pull-requests: write + jobs: pr-auto: runs-on: ubuntu-latest @@ -10,7 +16,6 @@ jobs: env: GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ secrets.AUTOMATION_TOKEN }} - PRID: ${{ github.event.pull_request.node_id }} PRBODY: ${{ github.event.pull_request.body }} PRNUM: ${{ github.event.pull_request.number }} PRHEAD: ${{ github.event.pull_request.head.label }} @@ -37,26 +42,12 @@ jobs: -q ".data.repository.project.columns.nodes[] | select(.name | startswith(\"$1\")) | .id" } - addToBoard () { - gh api graphql --silent -f query=' - mutation($colID:ID!, $prID:ID!) { addProjectCard(input: { projectColumnId: $colID, contentId: $prID }) { clientMutationId } } - ' -f colID="$(colID "Needs review")" -f prID="$PRID" - } - if [ "$PR_AUTHOR_TYPE" = "Bot" ] || gh api orgs/cli/public_members/$PRAUTHOR --silent 2>/dev/null then if [ "$PR_AUTHOR_TYPE" != "Bot" ] then gh pr edit $PRNUM --add-assignee $PRAUTHOR fi - if ! errtext="$(addToBoard 2>&1)" - then - cat <<<"$errtext" >&2 - if ! grep -iq 'project already has the associated issue' <<<"$errtext" - then - exit 1 - fi - fi exit 0 fi @@ -80,5 +71,4 @@ jobs: commentPR "Hi! Thanks for the pull request. Please ensure that this change is linked to an issue by mentioning an issue number in the description of the pull request. If this pull request would close the issue, please put the word 'Fixes' before the issue number somewhere in the pull request body. If this is a tiny change like fixing a typo, feel free to ignore this message." fi - addToBoard exit 0 diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml deleted file mode 100644 index 216fa9dbafb..00000000000 --- a/.github/workflows/releases.yml +++ /dev/null @@ -1,223 +0,0 @@ -name: goreleaser - -on: - push: - tags: - - "v*" - -jobs: - goreleaser: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Set up Go 1.16 - uses: actions/setup-go@v2 - with: - go-version: 1.16 - - name: Generate changelog - id: changelog - run: | - echo "::set-output name=tag-name::${GITHUB_REF#refs/tags/}" - 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: Run GoReleaser - uses: goreleaser/goreleaser-action@v2 - with: - version: v0.174.1 - args: release --release-notes=CHANGELOG.md - env: - GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - GORELEASER_CURRENT_TAG: ${{steps.changelog.outputs.tag-name}} - GITHUB_CERT_PASSWORD: ${{secrets.GITHUB_CERT_PASSWORD}} - DESKTOP_CERT_TOKEN: ${{secrets.DESKTOP_CERT_TOKEN}} - - name: Checkout documentation site - uses: actions/checkout@v2 - 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: | - gpg --import --no-tty --batch --yes < script/pubkey.asc - 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 867DAD5051270B843EF54F6186FA10E3A1D22DC5 - - 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: - 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@v2 - - name: Download gh.exe - id: download_exe - shell: bash - run: | - hub release download "${GITHUB_REF#refs/tags/}" -i '*windows_amd64*.zip' - printf "::set-output name=zip::%s\n" *.zip - unzip -o *.zip && rm -v *.zip - env: - GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - - name: Install go-msi - run: choco install -y "go-msi" - - name: Prepare PATH - shell: bash - run: | - echo "$WIX\\bin" >> $GITHUB_PATH - echo "C:\\Program Files\\go-msi" >> $GITHUB_PATH - - name: Build MSI - id: buildmsi - shell: bash - env: - ZIP_FILE: ${{ steps.download_exe.outputs.zip }} - run: | - mkdir -p build - msi="$(basename "$ZIP_FILE" ".zip").msi" - printf "::set-output name=msi::%s\n" "$msi" - go-msi make --msi "$PWD/$msi" --out "$PWD/build" --version "${GITHUB_REF#refs/tags/}" - - name: Obtain signing cert - id: obtain_cert - env: - DESKTOP_CERT_TOKEN: ${{ secrets.DESKTOP_CERT_TOKEN }} - run: .\script\setup-windows-certificate.ps1 - - name: Sign MSI - env: - CERT_FILE: ${{ steps.obtain_cert.outputs.cert-file }} - EXE_FILE: ${{ steps.buildmsi.outputs.msi }} - GITHUB_CERT_PASSWORD: ${{ secrets.GITHUB_CERT_PASSWORD }} - run: .\script\sign.ps1 -Certificate $env:CERT_FILE -Executable $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@v1 - if: "!contains(github.ref, '-')" # skip prereleases - with: - formula-name: gh - env: - COMMITTER_TOKEN: ${{ secrets.UPLOAD_GITHUB_TOKEN }} - - name: Checkout scoop bucket - uses: actions/checkout@v2 - 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 - - name: Bump Winget manifest - shell: pwsh - env: - WINGETCREATE_VERSION: v0.2.0.29-preview - GITHUB_TOKEN: ${{ secrets.UPLOAD_GITHUB_TOKEN }} - run: | - $tagname = $env:GITHUB_REF.Replace("refs/tags/", "") - $version = $tagname.Replace("v", "") - $url = "https://github.com/cli/cli/releases/download/${tagname}/gh_${version}_windows_amd64.msi" - iwr https://github.com/microsoft/winget-create/releases/download/${env:WINGETCREATE_VERSION}/wingetcreate.exe -OutFile wingetcreate.exe - - .\wingetcreate.exe update GitHub.cli --url $url --version $version - if ($version -notmatch "-") { - .\wingetcreate.exe submit .\manifests\g\GitHub\cli\${version}\ --token $env:GITHUB_TOKEN - } diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml new file mode 100644 index 00000000000..849beebada4 --- /dev/null +++ b/.github/workflows/triage.yml @@ -0,0 +1,71 @@ +name: Discussion Triage +run-name: ${{ github.event_name == 'issues' && github.event.issue.title || github.event.pull_request.title }} +on: + issues: + types: + - labeled + pull_request_target: + types: + - labeled +env: + TARGET_REPO: github/cli +jobs: + issue: + runs-on: ubuntu-latest + if: github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'discuss' + steps: + - name: Create issue based on source issue + env: + BODY: ${{ github.event.issue.body }} + CREATED: ${{ github.event.issue.created_at }} + GH_TOKEN: ${{ secrets.CLI_DISCUSSION_TRIAGE_TOKEN }} + LINK: ${{ github.repository }}#${{ github.event.issue.number }} + TITLE: ${{ github.event.issue.title }} + TRIGGERED_BY: ${{ github.triggering_actor }} + run: | + # Markdown quote source body by replacing newlines for newlines and markdown quoting + BODY="${BODY//$'\n'/$'\n'> }" + + # Create issue using dynamically constructed body within heredoc + cat << EOF | gh issue create --title "Triage issue \"$TITLE\"" --body-file - --repo "$TARGET_REPO" --label triage + **Title:** $TITLE + **Issue:** $LINK + **Created:** $CREATED + **Triggered by:** @$TRIGGERED_BY + + --- + + cc: @github/cli + + > $BODY + EOF + + pull_request: + runs-on: ubuntu-latest + if: github.event_name == 'pull_request_target' && github.event.action == 'labeled' && github.event.label.name == 'discuss' + steps: + - name: Create issue based on source pull request + env: + BODY: ${{ github.event.pull_request.body }} + CREATED: ${{ github.event.pull_request.created_at }} + GH_TOKEN: ${{ secrets.CLI_DISCUSSION_TRIAGE_TOKEN }} + LINK: ${{ github.repository }}#${{ github.event.pull_request.number }} + TITLE: ${{ github.event.pull_request.title }} + TRIGGERED_BY: ${{ github.triggering_actor }} + run: | + # Markdown quote source body by replacing newlines for newlines and markdown quoting + BODY="${BODY//$'\n'/$'\n'> }" + + # Create issue using dynamically constructed body within heredoc + cat << EOF | gh issue create --title "Triage PR \"$TITLE\"" --body-file - --repo "$TARGET_REPO" --label triage + **Title:** $TITLE + **Pull request:** $LINK + **Created:** $CREATED + **Triggered by:** @$TRIGGERED_BY + + --- + + cc: @github/cli + + > $BODY + EOF diff --git a/.gitignore b/.gitignore index 9057015344c..272b7703d5c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,19 @@ /bin +/share/bash-completion/completions +/share/fish/vendor_completions.d /share/man/man1 +/share/zsh/site-functions /gh-cli .envrc /dist /site .github/**/node_modules /CHANGELOG.md +/.goreleaser.generated.yml /script/build +/script/build.exe +/pkg_payload +/build/macOS/resources # VS Code .vscode @@ -20,4 +27,7 @@ # vim *.swp +# Emacs +*~ + vendor/ diff --git a/.goreleaser.yml b/.goreleaser.yml index 01c727d932d..a7b293d6e56 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -7,61 +7,79 @@ release: before: hooks: - - go mod tidy - - make manpages GH_VERSION={{.Version}} - - ./script/prepare-windows-cert.sh '{{ if index .Env "GITHUB_CERT_PASSWORD" }}{{ .Env.GITHUB_CERT_PASSWORD}}{{ end }}' '{{ if index .Env "DESKTOP_CERT_TOKEN" }}{{ .Env.DESKTOP_CERT_TOKEN}}{{ end }}' - + - >- # The linux and windows archives package the manpages below + {{ if eq .Runtime.Goos "windows" }}echo{{ end }} make manpages GH_VERSION={{.Version}} + - >- # On linux the completions are used in nfpms below, but on macos they are used outside in the deployment build. + {{ if eq .Runtime.Goos "windows" }}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"}} - - -X main.updaterEnabled=cli/cli - id: macos + - id: macos #build:macos goos: [darwin] - goarch: [amd64] + 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] + goarch: [386, amd64, arm64] hooks: post: - - ./script/sign-windows-executable.sh '{{ .Path }}' + - cmd: >- + {{ if eq .Runtime.Goos "windows" }}pwsh .\script\sign.ps1{{ 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: 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 + - 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 - bindir: /usr/bin + bindir: /usr dependencies: - git description: GitHub’s official command line tool. @@ -71,3 +89,9 @@ nfpms: contents: - src: "./share/man/man1/gh*.1" dst: "/usr/share/man/man1" + - src: "./share/bash-completion/completions/gh" + dst: "/usr/share/bash-completion/completions/gh" + - src: "./share/fish/vendor_completions.d/gh.fish" + dst: "/usr/share/fish/vendor_completions.d/gh.fish" + - src: "./share/zsh/site-functions/_gh" + dst: "/usr/share/zsh/site-functions/_gh" diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 99a52d6d6e0..00000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "search.exclude": { - "vendor/**": true - } -} \ No newline at end of file diff --git a/CODEOWNERS b/CODEOWNERS deleted file mode 100644 index 4b902931306..00000000000 --- a/CODEOWNERS +++ /dev/null @@ -1,5 +0,0 @@ -* @cli/code-reviewers - -pkg/cmd/codespace/ @cli/codespaces -pkg/liveshare/ @cli/codespaces -internal/codespaces/ @cli/codespaces diff --git a/Makefile b/Makefile index 46d40a7a928..32a06df2f1f 100644 --- a/Makefile +++ b/Makefile @@ -6,32 +6,48 @@ 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: bin/gh$(EXE) + mkdir -p ./share/bash-completion/completions ./share/fish/vendor_completions.d ./share/zsh/site-functions + 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` +# just convenience tasks around `go test` .PHONY: test test: go test ./... +# For more information, see https://github.com/cli/cli/blob/trunk/acceptance/README.md +.PHONY: acceptance +acceptance: + go test -tags acceptance ./acceptance + ## Site-related tasks are exclusively intended for use by the GitHub CLI team and for our release automation. site: @@ -60,15 +76,33 @@ endif DESTDIR := prefix := /usr/local bindir := ${prefix}/bin -mandir := ${prefix}/share/man +datadir := ${prefix}/share +mandir := ${datadir}/man .PHONY: install -install: bin/gh manpages +install: bin/gh manpages completions install -d ${DESTDIR}${bindir} install -m755 bin/gh ${DESTDIR}${bindir}/ install -d ${DESTDIR}${mandir}/man1 install -m644 ./share/man/man1/* ${DESTDIR}${mandir}/man1/ + install -d ${DESTDIR}${datadir}/bash-completion/completions + install -m644 ./share/bash-completion/completions/gh ${DESTDIR}${datadir}/bash-completion/completions/gh + install -d ${DESTDIR}${datadir}/fish/vendor_completions.d + install -m644 ./share/fish/vendor_completions.d/gh.fish ${DESTDIR}${datadir}/fish/vendor_completions.d/gh.fish + install -d ${DESTDIR}${datadir}/zsh/site-functions + install -m644 ./share/zsh/site-functions/_gh ${DESTDIR}${datadir}/zsh/site-functions/_gh .PHONY: uninstall uninstall: rm -f ${DESTDIR}${bindir}/gh ${DESTDIR}${mandir}/man1/gh.1 ${DESTDIR}${mandir}/man1/gh-*.1 + rm -f ${DESTDIR}${datadir}/bash-completion/completions/gh + rm -f ${DESTDIR}${datadir}/fish/vendor_completions.d/gh.fish + rm -f ${DESTDIR}${datadir}/zsh/site-functions/_gh + +.PHONY: macospkg +macospkg: manpages completions +ifndef VERSION + $(error VERSION is not set. Use `make macospkg VERSION=vX.Y.Z`) +endif + ./script/release --local "$(VERSION)" --platform macos + ./script/pkgmacos $(VERSION) diff --git a/README.md b/README.md index e503c88c9fc..fbd99a5a807 100644 --- a/README.md +++ b/README.md @@ -4,22 +4,27 @@ ![screenshot of gh pr status](https://user-images.githubusercontent.com/98482/84171218-327e7a80-aa40-11ea-8cd1-5177fc2d0e72.png) -GitHub CLI is available for repositories hosted on GitHub.com and GitHub Enterprise Server 2.20+, and to install on macOS, Windows, and Linux. +GitHub CLI is supported for users on GitHub.com, GitHub Enterprise Cloud, and GitHub Enterprise Server 2.20+ with support for macOS, Windows, and Linux. ## Documentation -[See the manual][manual] for setup and usage instructions. +For [installation options see below](#installation), for usage instructions [see the manual][manual]. ## Contributing If anything feels off, or if you feel that some functionality is missing, please check out the [contributing page][contributing]. There you will find instructions for sharing your feedback, building the tool locally, and submitting pull requests to the project. +If you are a hubber and are interested in shipping new commands for the CLI, check out our [doc on internal contributions][intake-doc]. + ## Installation ### macOS -`gh` is available via [Homebrew][], [MacPorts][], [Conda][], [Spack][], and as a downloadable binary from the [releases page][]. +`gh` is available via [Homebrew][], [MacPorts][], [Conda][], [Spack][], [Webi][], and as a downloadable binary including Mac OS installer `.pkg` from the [releases page][]. + +> [!NOTE] +> As of May 29th, Mac OS installer `.pkg` are unsigned with efforts prioritized in [`cli/cli#9139`](https://github.com/cli/cli/issues/9139) to support signing them. #### Homebrew @@ -47,15 +52,35 @@ Additional Conda installation options available on the [gh-feedstock page](https | ------------------ | ---------------------------------------- | | `spack install gh` | `spack uninstall gh && spack install gh` | +#### Webi + +| Install: | Upgrade: | +| ----------------------------------- | ---------------- | +| `curl -sS https://webi.sh/gh \| sh` | `webi gh@stable` | + +For more information about the Webi installer see [its homepage](https://webinstall.dev/). + +#### Flox + +| Install: | Upgrade: | +| ----------------- | ----------------------- | +| `flox install gh` | `flox upgrade toplevel` | + +For more information about Flox, see [its homepage](https://flox.dev) + ### Linux & BSD -`gh` is available via [Homebrew](#homebrew), [Conda](#conda), [Spack](#spack), and as downloadable binaries from the [releases page][]. +`gh` is available via: +- [our Debian and RPM repositories](./docs/install_linux.md); +- community-maintained repositories in various Linux distros; +- OS-agnostic package managers such as [Homebrew](#homebrew), [Conda](#conda), [Spack](#spack), [Webi](#webi); and +- our [releases page][] as precompiled binaries. -For instructions on specific distributions and package managers, see [Linux & BSD installation](./docs/install_linux.md). +For more information, see [Linux & BSD installation](./docs/install_linux.md). ### Windows -`gh` is available via [WinGet][], [scoop][], [Chocolatey][], [Conda](#conda), and as downloadable MSI. +`gh` is available via [WinGet][], [scoop][], [Chocolatey][], [Conda](#conda), [Webi](#webi), and as downloadable MSI. #### WinGet @@ -63,6 +88,9 @@ For instructions on specific distributions and package managers, see [Linux & BS | ------------------- | --------------------| | `winget install --id GitHub.cli` | `winget upgrade --id GitHub.cli` | +> [!NOTE] +> The Windows installer modifies your PATH. When using Windows Terminal, you will need to **open a new window** for the changes to take effect. (Simply opening a new tab will _not_ be sufficient.) + #### scoop | Install: | Upgrade: | @@ -79,6 +107,16 @@ For instructions on specific distributions and package managers, see [Linux & BS MSI installers are available for download on the [releases page][]. +### Codespaces + +To add GitHub CLI to your codespace, add the following to your [devcontainer file](https://docs.github.com/en/codespaces/setting-up-your-project-for-codespaces/adding-features-to-a-devcontainer-file): + +```json +"features": { + "ghcr.io/devcontainers/features/github-cli:1": {} +} +``` + ### GitHub Actions GitHub CLI comes pre-installed in all [GitHub-Hosted Runners](https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners). @@ -87,6 +125,38 @@ GitHub CLI comes pre-installed in all [GitHub-Hosted Runners](https://docs.githu Download packaged binaries from the [releases page][]. +#### Verification of binaries + +Since version 2.50.0 `gh` has been producing [Build Provenance Attestation](https://github.blog/changelog/2024-06-25-artifact-attestations-is-generally-available/) enabling a cryptographically verifiable paper-trail back to the origin GitHub repository, git revision and build instructions used. The build provenance attestations are signed and relies on Public Good [Sigstore](https://www.sigstore.dev/) for PKI. + +There are two common ways to verify a downloaded release, depending if `gh` is already installed or not. If `gh` is installed, it's trivial to verify a new release: + +- **Option 1: Using `gh` if already installed:** + + ```shell + $ gh at verify -R cli/cli gh_2.62.0_macOS_arm64.zip + Loaded digest sha256:fdb77f31b8a6dd23c3fd858758d692a45f7fc76383e37d475bdcae038df92afc for file://gh_2.62.0_macOS_arm64.zip + Loaded 1 attestation from GitHub API + ✓ Verification succeeded! + + sha256:fdb77f31b8a6dd23c3fd858758d692a45f7fc76383e37d475bdcae038df92afc was attested by: + REPO PREDICATE_TYPE WORKFLOW + cli/cli https://slsa.dev/provenance/v1 .github/workflows/deployment.yml@refs/heads/trunk + ``` + +- **Option 2: Using Sigstore [`cosign`](https://github.com/sigstore/cosign):** + + To perform this, download the [attestation](https://github.com/cli/cli/attestations) for the downloaded release and use cosign to verify the authenticity of the downloaded release: + + ```shell + $ cosign verify-blob-attestation --bundle cli-cli-attestation-3120304.sigstore.json \ + --new-bundle-format \ + --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \ + --certificate-identity-regexp="^https://github.com/cli/cli/.github/workflows/deployment.yml@refs/heads/trunk$" \ + gh_2.62.0_macOS_arm64.zip + Verified OK + ``` + ### Build from source See here on how to [build GitHub CLI from source][build from source]. @@ -106,8 +176,10 @@ tool. Check out our [more detailed explanation][gh-vs-hub] to learn more. [Chocolatey]: https://chocolatey.org [Conda]: https://docs.conda.io/en/latest/ [Spack]: https://spack.io +[Webi]: https://webinstall.dev [releases page]: https://github.com/cli/cli/releases/latest [hub]: https://github.com/github/hub [contributing]: ./.github/CONTRIBUTING.md [gh-vs-hub]: ./docs/gh-vs-hub.md [build from source]: ./docs/source.md +[intake-doc]: ./docs/working-with-us.md diff --git a/acceptance/README.md b/acceptance/README.md new file mode 100644 index 00000000000..8e8d7838ef6 --- /dev/null +++ b/acceptance/README.md @@ -0,0 +1,195 @@ +## Acceptance Tests + +The acceptance tests are blackbox* tests that are expected to interact with resources on a real GitHub instance. They are built on top of the [`go-internal/testscript`](https://pkg.go.dev/github.com/rogpeppe/go-internal/testscript) package, which provides a framework for building tests for command line tools. + +*Note: they aren't strictly blackbox because `exec gh` commands delegate to a binary set up by `testscript` that calls into `ghcmd.Main`. However, since our real `func main` is an extremely thin adapter over `ghcmd.Main`, this is reasonable. This tradeoff avoids us building the binary ourselves for the tests, and allows us to get code coverage metrics. + +### Running the Acceptance Tests + +The acceptance tests have a build constraint of `//go:build acceptance`, this means that `go test ./...` will continue to work without any modifications. The `acceptance` tag must therefore be provided when running `go test`. + +The following environment variables are required: + +#### `GH_ACCEPTANCE_HOST` + +The GitHub host to target e.g. `github.com` + +#### `GH_ACCEPTANCE_ORG` + +The organization in which the acceptance tests can manage resources in. Consider using `gh-acceptance-testing` on `github.com`. + +#### `GH_ACCEPTANCE_TOKEN` + +The token to use for authenticatin with the `GH_ACCEPTANCE_HOST`. This must already have the necessary scopes for each test, and must have permissions to act in the `GH_ACCEPTANCE_ORG`. See [Effective Test Authoring](#effective-test-authoring) for how tests must handle tokens without sufficient scopes. + +It's recommended to create and use a Legacy PAT for this; Fine-Grained PATs do not offer all the necessary privileges required. You can use an OAuth token provided via `gh auth login --web` and can provide it to the acceptance tests via `GH_ACCEPTANCE_TOKEN=$(gh auth token --hostname )` but this can be a bit confusing and annoying if you `gh auth login` again without `-s` and lose the required scopes. + +--- + +A full example invocation can be found below: + +``` +GH_ACCEPTANCE_HOST= GH_ACCEPTANCE_ORG= GH_ACCEPTANCE_TOKEN= go test -tags=acceptance ./acceptance +``` + +While writing a new test, it can be useful to target that specific script by providing the `GH_ACCEPTANCE_SCRIPT` env var in combination with the `-run` flag, for example: + +``` +GH_ACCEPTANCE_SCRIPT=pr-view.txtar GH_ACCEPTANCE_HOST= GH_ACCEPTANCE_ORG= GH_ACCEPTANCE_TOKEN= go test -tags=acceptance -run ^TestPullRequests$ ./acceptance +``` + +#### Code Coverage + +To get code coverage, `go test` can be invoked with `coverpkg` and `coverprofile` like so: + +``` +GH_ACCEPTANCE_HOST= GH_ACCEPTANCE_ORG= GH_ACCEPTANCE_TOKEN= go test -tags=acceptance -coverprofile=coverage.out -coverpkg=./... ./acceptance +``` + +### Writing Tests + +This section is to be expanded over time as we write more tests and learn more. + +#### Environment Variables + +The following custom environment variables are made available to the scripts: + * `GH_HOST`: Set to value of the `GH_ACCEPTANCE_ORG` env var provided to `go test` + * `ORG`: Set to the value of the `GH_ACCEPTANCE_ORG` env var provided to `go test` + * `GH_TOKEN`: Set to the value of the `GH_ACCEPTANCE_TOKEN` env var provided to `go test` + * `RANDOM_STRING`: Set to a length 10 random string of letters to help isolate globally visible resources + * `SCRIPT_NAME`: Set to the name of the `testscript` currently running, without extension and replacing hyphens with underscores e.g. `pr_view` + * `HOME`: Set to the initial working directory. Required for `git` operations + * `GH_CONFIG_DIR`: Set to the initial working directory. Required for `gh` operations + +#### Custom Commands + +The following custom commands are defined within [`acceptance_test.go`](./acceptance_test.go) to help with writing tests: + +- `defer`: register a command to run after the testscript completes + + ```txtar + # Defer repo cleanup + defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + ``` + +- `env2upper`: set environment variable to the uppercase version of another environment variable + + ```txtar + # Prepare organization secret, GitHub Actions uppercases secret names + env2upper ORG_SECRET_NAME=$RANDOM_STRING + ``` + +- `replace`: replace placeholders in file with interpolated content provided + + ```txtar + env2upper SECRET_NAME=$SCRIPT_NAME_$RANDOM_STRING + + # Modify workflow file to use generated organization secret name + mv ../workflow.yml .github/workflows/workflow.yml + replace .github/workflows/workflow.yml SECRET_NAME=$SECRET_NAME + + -- workflow.yml -- + on: + workflow_dispatch: + env: + ORG_SECRET: ${{ secrets.$SECRET_NAME }} + ``` + +- `stdout2env`: set environment variable containing standard output from previous command + + ```txtar + # Create the PR + exec gh pr create --title 'Feature Title' --body 'Feature Body' --assignee '@me' --label 'bug' + stdout2env PR_URL + ``` + +### Acceptance Test VS Code Support + +Due to the `//go:build acceptance` build constraint, some functionality is limited because `gopls` isn't being informed about the tag. To resolve this, set the following in your `settings.json`: + +```json + "gopls": { + "buildFlags": [ + "-tags=acceptance" + ] + }, +``` + +You can install the [`txtar`](https://marketplace.visualstudio.com/items?itemName=brody715.txtar) or [`vscode-testscript`](https://marketplace.visualstudio.com/items?itemName=twpayne.vscode-testscript) extensions to get syntax highlighting. + +### Debugging Tests + +When tests fail they fail like this: + +``` +➜ go test -tags=acceptance ./acceptance +--- FAIL: TestPullRequests (0.00s) + --- FAIL: TestPullRequests/pr-merge (11.07s) + testscript.go:584: WORK=/private/var/folders/45/sdnm1hp10nj1s9q57dp3bc5h0000gn/T/go-test-script2778137936/script-pr-merge + # Use gh as a credential helper (0.693s) + # Create a repository with a file so it has a default branch (1.155s) + # Defer repo cleanup (0.000s) + # Clone the repo (1.551s) + # Prepare a branch to PR with a single file (1.168s) + # Create the PR (1.903s) + # Check that the file doesn't exist on the main branch (0.059s) + # Merge the PR (2.426s) + # Check that the state of the PR is now merged (0.571s) + # Pull and check the file exists on the main branch (1.074s) + # And check we had a merge commit (0.462s) + > exec git show HEAD + [stdout] + commit 85d32c1a83ace270f6754c61f3f7e14956be0a47 + Author: William Martin + Date: Fri Oct 11 15:23:56 2024 +0200 + + Add file.txt + + diff --git a/file.txt b/file.txt + new file mode 100644 + index 0000000..7449899 + --- /dev/null + +++ b/file.txt + @@ -0,0 +1 @@ + +Unimportant contents + > stdout 'Merge pull request #1' + FAIL: testdata/pr/pr-merge.txtar:42: no match for `Merge pull request #1` found in stdout +``` + +This is generally enough information to understand why a test has failed. However, we can get more information by providing the `-v` flag to `go test`, which turns on verbose mode and shows each command and any associated `stdio`. + +> [!WARNING] +> Verbose mode dumps the `testscript` environment variables, so make sure there is nothing sensitive in there. +> We have taken steps to [redact tokens](https://github.com/cli/cli/pull/9804) in log output but there's no +> guarantee it's comprehensive. + +By default `testscript` removes the directory in which it was running the script, and if you've been a conscientious engineer, you should be cleaning up resources using the `defer` statement. However, this can be an impediment to debugging. As such you can set `GH_ACCEPTANCE_PRESERVE_WORK_DIR=true` and `GH_ACCEPTANCE_SKIP_DEFER=true` to skip these cleanup steps. + +### Effective Test Authoring + +This section is to be expanded over time as we write more tests and learn more. + +#### Test Isolation + +The `testscript` library creates a somewhat isolated environment for each script. Each script gets a directory with limited environment variables by default. As far as reasonable, we should look to write scripts that depend on nothing more than themselves, the GitHub resources they manage, and limited additional environmental injection from our own `testscript` setup. + +Here are some guidelines around test isolation: + * Favour duplication in test setup over abstracting a new `testscript` command + * Favour a `testscript` owning an entire resource lifecycle over shared resource until we see a performance or rate limiting issue + * Use the `RANDOM_STRING` env var for globally visible resources to avoid conflicts + +### Debris + +Since these scripts are creating resources on a GitHub instance, we should try our best to cleanup after them. Use the `defer` keyword to ensure a command runs at the end of a test even in the case of failure. + +#### Scope Validation + +TODO: I believe tests should early exit if the correct scopes aren't in place to execute the entire lifecycle. It's extremely annoying if a `defer` fails to clean up resources because there's no `delete_repo` scope for example. However, I'm not sure yet whether this scope checking should be in the Go tests or in the scripts themselves. It seems very cool to understand required scopes for a script just by looking at the script itself. + +### Further Reading + +https://bitfieldconsulting.com/posts/test-scripts + +https://atlasgo.io/blog/2024/09/09/how-go-tests-go-test + +https://encore.dev/blog/testscript-hidden-testing-gem diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go new file mode 100644 index 00000000000..b22929ed560 --- /dev/null +++ b/acceptance/acceptance_test.go @@ -0,0 +1,421 @@ +//go:build acceptance + +package acceptance_test + +import ( + "fmt" + "os" + "path" + "strconv" + "strings" + "testing" + "time" + + "math/rand" + + "github.com/cli/cli/v2/internal/ghcmd" + "github.com/cli/go-internal/testscript" +) + +func ghMain() int { + return int(ghcmd.Main()) +} + +func TestMain(m *testing.M) { + os.Exit(testscript.RunMain(m, map[string]func() int{ + "gh": ghMain, + })) +} + +func TestAPI(t *testing.T) { + var tsEnv testScriptEnv + if err := tsEnv.fromEnv(); err != nil { + t.Fatal(err) + } + + testscript.Run(t, testScriptParamsFor(tsEnv, "api")) +} + +func TestAuth(t *testing.T) { + var tsEnv testScriptEnv + if err := tsEnv.fromEnv(); err != nil { + t.Fatal(err) + } + + testscript.Run(t, testScriptParamsFor(tsEnv, "auth")) +} + +func TestGPGKeys(t *testing.T) { + var tsEnv testScriptEnv + if err := tsEnv.fromEnv(); err != nil { + t.Fatal(err) + } + + testscript.Run(t, testScriptParamsFor(tsEnv, "gpg-key")) +} + +func TestExtensions(t *testing.T) { + var tsEnv testScriptEnv + if err := tsEnv.fromEnv(); err != nil { + t.Fatal(err) + } + + testscript.Run(t, testScriptParamsFor(tsEnv, "extension")) +} + +func TestIssues(t *testing.T) { + var tsEnv testScriptEnv + if err := tsEnv.fromEnv(); err != nil { + t.Fatal(err) + } + + testscript.Run(t, testScriptParamsFor(tsEnv, "pr")) +} + +func TestLabels(t *testing.T) { + var tsEnv testScriptEnv + if err := tsEnv.fromEnv(); err != nil { + t.Fatal(err) + } + + testscript.Run(t, testScriptParamsFor(tsEnv, "label")) +} + +func TestOrg(t *testing.T) { + var tsEnv testScriptEnv + if err := tsEnv.fromEnv(); err != nil { + t.Fatal(err) + } + + testscript.Run(t, testScriptParamsFor(tsEnv, "org")) +} + +func TestProject(t *testing.T) { + var tsEnv testScriptEnv + if err := tsEnv.fromEnv(); err != nil { + t.Fatal(err) + } + + testscript.Run(t, testScriptParamsFor(tsEnv, "project")) +} + +func TestPullRequests(t *testing.T) { + var tsEnv testScriptEnv + if err := tsEnv.fromEnv(); err != nil { + t.Fatal(err) + } + + testscript.Run(t, testScriptParamsFor(tsEnv, "pr")) +} + +func TestReleases(t *testing.T) { + var tsEnv testScriptEnv + if err := tsEnv.fromEnv(); err != nil { + t.Fatal(err) + } + + testscript.Run(t, testScriptParamsFor(tsEnv, "release")) +} + +func TestRepo(t *testing.T) { + var tsEnv testScriptEnv + if err := tsEnv.fromEnv(); err != nil { + t.Fatal(err) + } + + testscript.Run(t, testScriptParamsFor(tsEnv, "repo")) +} + +func TestRulesets(t *testing.T) { + var tsEnv testScriptEnv + if err := tsEnv.fromEnv(); err != nil { + t.Fatal(err) + } + + testscript.Run(t, testScriptParamsFor(tsEnv, "ruleset")) +} + +func TestSearches(t *testing.T) { + var tsEnv testScriptEnv + if err := tsEnv.fromEnv(); err != nil { + t.Fatal(err) + } + + testscript.Run(t, testScriptParamsFor(tsEnv, "search")) +} + +func TestSecrets(t *testing.T) { + var tsEnv testScriptEnv + if err := tsEnv.fromEnv(); err != nil { + t.Fatal(err) + } + + testscript.Run(t, testScriptParamsFor(tsEnv, "secret")) +} + +func TestSSHKeys(t *testing.T) { + var tsEnv testScriptEnv + if err := tsEnv.fromEnv(); err != nil { + t.Fatal(err) + } + + testscript.Run(t, testScriptParamsFor(tsEnv, "ssh-key")) +} + +func TestVariables(t *testing.T) { + var tsEnv testScriptEnv + if err := tsEnv.fromEnv(); err != nil { + t.Fatal(err) + } + + testscript.Run(t, testScriptParamsFor(tsEnv, "variable")) +} + +func TestWorkflows(t *testing.T) { + var tsEnv testScriptEnv + if err := tsEnv.fromEnv(); err != nil { + t.Fatal(err) + } + + testscript.Run(t, testScriptParamsFor(tsEnv, "workflow")) +} + +func testScriptParamsFor(tsEnv testScriptEnv, command string) testscript.Params { + var files []string + if tsEnv.script != "" { + files = []string{path.Join("testdata", command, tsEnv.script)} + } + + var dir string + if len(files) == 0 { + dir = path.Join("testdata", command) + } + + return testscript.Params{ + Dir: dir, + Files: files, + Setup: sharedSetup(tsEnv), + Cmds: sharedCmds(tsEnv), + RequireExplicitExec: true, + RequireUniqueNames: true, + TestWork: tsEnv.preserveWorkDir, + } +} + +var keyT struct{} + +func sharedSetup(tsEnv testScriptEnv) func(ts *testscript.Env) error { + return func(ts *testscript.Env) error { + scriptName, ok := extractScriptName(ts.Vars) + if !ok { + ts.T().Fatal("script name not found") + } + + // When using script name to uniquely identify where test data comes from, + // some places like GitHub Actions secret names don't accept hyphens. + // Replace them with underscores until such a time this becomes a problem. + ts.Setenv("SCRIPT_NAME", strings.ReplaceAll(scriptName, "-", "_")) + ts.Setenv("HOME", ts.Cd) + ts.Setenv("GH_CONFIG_DIR", ts.Cd) + + ts.Setenv("GH_HOST", tsEnv.host) + ts.Setenv("ORG", tsEnv.org) + ts.Setenv("GH_TOKEN", tsEnv.token) + + ts.Setenv("RANDOM_STRING", randomString(10)) + + ts.Values[keyT] = ts.T() + return nil + } +} + +// sharedCmds defines a collection of custom testscript commands for our use. +func sharedCmds(tsEnv testScriptEnv) map[string]func(ts *testscript.TestScript, neg bool, args []string) { + return map[string]func(ts *testscript.TestScript, neg bool, args []string){ + "defer": func(ts *testscript.TestScript, neg bool, args []string) { + if neg { + ts.Fatalf("unsupported: ! defer") + } + + if tsEnv.skipDefer { + return + } + + tt, ok := ts.Value(keyT).(testscript.T) + if !ok { + ts.Fatalf("%v is not a testscript.T", ts.Value(keyT)) + } + + ts.Defer(func() { + // If you're wondering why we're not using ts.Check here, it's because it raises a panic, and testscript + // only catches the panics directly from commands, not from the deferred functions. So what we do + // instead is grab the `t` in the setup function and store it as a value. It's important that we use + // `t` from the setup function because it represents the subtest created for each individual script, + // rather than each top-level test. + // See: https://github.com/rogpeppe/go-internal/issues/276 + if err := ts.Exec(args[0], args[1:]...); err != nil { + tt.FailNow() + } + }) + }, + "env2upper": func(ts *testscript.TestScript, neg bool, args []string) { + if neg { + ts.Fatalf("unsupported: ! env2upper") + } + if len(args) == 0 { + ts.Fatalf("usage: env2upper name=value ...") + } + for _, env := range args { + i := strings.Index(env, "=") + + if i < 0 { + ts.Fatalf("env2upper: argument does not match name=value") + } + + ts.Setenv(env[:i], strings.ToUpper(env[i+1:])) + } + }, + "replace": func(ts *testscript.TestScript, neg bool, args []string) { + if neg { + ts.Fatalf("unsupported: ! replace") + } + if len(args) < 2 { + ts.Fatalf("usage: replace file env...") + } + + src := ts.MkAbs(args[0]) + ts.Logf("replace src: %s", src) + + // Preserve the existing file mode while replacing the contents similar to native cp behavior + info, err := os.Stat(src) + ts.Check(err) + mode := info.Mode() & 0o777 + data, err := os.ReadFile(src) + ts.Check(err) + + for _, arg := range args[1:] { + i := strings.Index(arg, "=") + if i < 0 { + ts.Fatalf("replace: %s argument does not match name=value", arg) + } + + name := fmt.Sprintf("$%s", arg[:i]) + value := arg[i+1:] + ts.Logf("replace %s: %s", name, value) + + // `replace` was originally built similar to `cp` and `cmpenv`, expanding environment variables within a file. + // However files with content that looks like environments variable such as GitHub Actions workflows + // were being modified unexpectedly. Thus `replace` has been designed to using string replacement + // looking for `$KEY` specifically. + data = []byte(strings.ReplaceAll(string(data), name, value)) + } + + ts.Check(os.WriteFile(src, data, mode)) + }, + "stdout2env": func(ts *testscript.TestScript, neg bool, args []string) { + if neg { + ts.Fatalf("unsupported: ! stdout2env") + } + if len(args) != 1 { + ts.Fatalf("usage: stdout2env name") + } + + ts.Setenv(args[0], strings.TrimRight(ts.ReadFile("stdout"), "\n")) + }, + "sleep": func(ts *testscript.TestScript, neg bool, args []string) { + if neg { + ts.Fatalf("unsupported: ! sleep") + } + if len(args) != 1 { + ts.Fatalf("usage: sleep seconds") + } + + // sleep for the given number of seconds + seconds, err := strconv.Atoi(args[0]) + if err != nil { + ts.Fatalf("invalid number of seconds: %v", err) + } + + d := time.Duration(seconds) * time.Second + time.Sleep(d) + }, + } +} + +var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + +func randomString(n int) string { + b := make([]rune, n) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + return string(b) +} + +func extractScriptName(vars []string) (string, bool) { + for _, kv := range vars { + if strings.HasPrefix(kv, "WORK=") { + v := strings.Split(kv, "=")[1] + return strings.CutPrefix(path.Base(v), "script-") + } + } + return "", false +} + +type missingEnvError struct { + missingEnvs []string +} + +func (e missingEnvError) Error() string { + return fmt.Sprintf("environment variable(s) %s must be set and non-empty", strings.Join(e.missingEnvs, ", ")) +} + +type testScriptEnv struct { + host string + org string + token string + + script string + + skipDefer bool + preserveWorkDir bool +} + +func (e *testScriptEnv) fromEnv() error { + envMap := map[string]string{} + + requiredEnvVars := []string{ + "GH_ACCEPTANCE_HOST", + "GH_ACCEPTANCE_ORG", + "GH_ACCEPTANCE_TOKEN", + } + + var missingEnvs []string + for _, key := range requiredEnvVars { + val, ok := os.LookupEnv(key) + if val == "" || !ok { + missingEnvs = append(missingEnvs, key) + continue + } + + envMap[key] = val + } + + if len(missingEnvs) > 0 { + return missingEnvError{missingEnvs: missingEnvs} + } + + if envMap["GH_ACCEPTANCE_ORG"] == "github" || envMap["GH_ACCEPTANCE_ORG"] == "cli" { + return fmt.Errorf("GH_ACCEPTANCE_ORG cannot be 'github' or 'cli'") + } + + e.host = envMap["GH_ACCEPTANCE_HOST"] + e.org = envMap["GH_ACCEPTANCE_ORG"] + e.token = envMap["GH_ACCEPTANCE_TOKEN"] + + e.script = os.Getenv("GH_ACCEPTANCE_SCRIPT") + e.preserveWorkDir = os.Getenv("GH_ACCEPTANCE_PRESERVE_WORK_DIR") == "true" + e.skipDefer = os.Getenv("GH_ACCEPTANCE_SKIP_DEFER") == "true" + + return nil +} diff --git a/acceptance/testdata/api/basic-graphql.txtar b/acceptance/testdata/api/basic-graphql.txtar new file mode 100644 index 00000000000..15c16c49c7f --- /dev/null +++ b/acceptance/testdata/api/basic-graphql.txtar @@ -0,0 +1,3 @@ +# Basic graphql request +exec gh api graphql -f query='query { viewer { login } }' +stdout '"login":' \ No newline at end of file diff --git a/acceptance/testdata/api/basic-rest.txtar b/acceptance/testdata/api/basic-rest.txtar new file mode 100644 index 00000000000..58d3b7570d2 --- /dev/null +++ b/acceptance/testdata/api/basic-rest.txtar @@ -0,0 +1,3 @@ +# Basic REST request +exec gh api /user +stdout '"login":' \ No newline at end of file diff --git a/acceptance/testdata/auth/auth-login-logout.txtar b/acceptance/testdata/auth/auth-login-logout.txtar new file mode 100644 index 00000000000..fb25f9eb54c --- /dev/null +++ b/acceptance/testdata/auth/auth-login-logout.txtar @@ -0,0 +1,25 @@ +# We aren't logged in at the moment, but GH_TOKEN will override the +# need to login. We are going to clear GH_TOKEN first to ensure no +# overrides are happening + +# Copy $GH_TOKEN to a new env var +env LOGIN_TOKEN=$GH_TOKEN + +# Remove GH_TOKEN env var so we don't fall back to it +env GH_TOKEN='' + +# Login to the host by feeding the token to stdin +exec echo $LOGIN_TOKEN +stdin stdout +exec gh auth login --hostname=$GH_HOST --with-token --insecure-storage + +# Check that we are logged in +exec gh auth status --hostname $GH_HOST +stdout $GH_HOST + +# Logout of the host +exec gh auth logout --hostname $GH_HOST +stderr 'Logged out of' + +# Check that we are logged out +! exec gh auth status --hostname $GH_HOST diff --git a/acceptance/testdata/auth/auth-setup-git.txtar b/acceptance/testdata/auth/auth-setup-git.txtar new file mode 100644 index 00000000000..e3be28cd51e --- /dev/null +++ b/acceptance/testdata/auth/auth-setup-git.txtar @@ -0,0 +1,10 @@ +# Check that the credential helper is unset for the host. This command is +# expected to fail before gh auth setup-git is run. +! exec git config --get credential.https://${GH_HOST}.helper + +# Run the setup-git command +exec gh auth setup-git + +# Check that the credential helper is set to gh +exec git config --get credential.https://${GH_HOST}.helper +stdout '^.*gh auth git-credential$' diff --git a/acceptance/testdata/auth/auth-status.txtar b/acceptance/testdata/auth/auth-status.txtar new file mode 100644 index 00000000000..2afee1eb61b --- /dev/null +++ b/acceptance/testdata/auth/auth-status.txtar @@ -0,0 +1,3 @@ +# Check the authentication status +exec gh auth status --hostname $GH_HOST +stdout '✓ Logged in to ' \ No newline at end of file diff --git a/acceptance/testdata/auth/auth-token.txtar b/acceptance/testdata/auth/auth-token.txtar new file mode 100644 index 00000000000..614d11817b4 --- /dev/null +++ b/acceptance/testdata/auth/auth-token.txtar @@ -0,0 +1,3 @@ +# Check authentication token +exec gh auth token --hostname $GH_HOST +stdout $GH_TOKEN \ No newline at end of file diff --git a/acceptance/testdata/extension/extension.txtar b/acceptance/testdata/extension/extension.txtar new file mode 100644 index 00000000000..a4d194757de --- /dev/null +++ b/acceptance/testdata/extension/extension.txtar @@ -0,0 +1,69 @@ +# Skip if Bash is not available given script extension +[!exec:bash] skip + +# Setup environment variables used for testscript +env EXT_NAME=${SCRIPT_NAME}-${RANDOM_STRING} +env EXT_SCRIPT=gh-${EXT_NAME} +env REPO=gh-${EXT_NAME} + +# Use gh as a credential helper +exec gh auth setup-git + +# Create local repository for extension +exec gh extension create $EXT_NAME +cd $REPO + +# Setup v1 executable baseline for extension +mv ../v1.sh $EXT_SCRIPT +chmod 777 $EXT_SCRIPT +exec git add $EXT_SCRIPT +exec git commit -m 'Setup extension as v1' + +# Upload local extension repository +exec gh repo create $ORG/$REPO --private --source . --push + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$REPO + +# Verify extension shows up in search, sleep for indexing +exec gh repo edit --add-topic gh-extension +sleep 10 +exec gh extension search --owner $ORG $EXT_NAME +stdout ${ORG}/${REPO} + +# Verify repository can be installed as extension +exec gh extension install $ORG/$REPO +exec gh extension list +stdout ${ORG}/${REPO} + +# Verify v1 extension behavior before upgrade +exec gh extension exec $EXT_NAME +stdout 'gh ext create v1' + +# Setup v2 executable upgrade for extension +mv ../v2.sh $EXT_SCRIPT +chmod 777 $EXT_SCRIPT +exec git add $EXT_SCRIPT +exec git commit -m 'Upgrade extension to v2' +exec git push -u origin + +# Verify v2 extension upgrade +exec gh extension upgrade $EXT_NAME +exec gh extension exec $EXT_NAME +stdout 'gh ext upgrade v2' + +# Verify extension can be removed +exec gh extension remove $EXT_NAME +! stdout ${ORG}/${REPO} + +-- v1.sh -- +#!/usr/bin/env bash +set -e + +echo "gh ext create v1" + +-- v2.sh -- +#!/usr/bin/env bash +set -e + +echo "gh ext upgrade v2" diff --git a/acceptance/testdata/gpg-key/gpg-key.txtar b/acceptance/testdata/gpg-key/gpg-key.txtar new file mode 100644 index 00000000000..8f0d7154567 --- /dev/null +++ b/acceptance/testdata/gpg-key/gpg-key.txtar @@ -0,0 +1,36 @@ +skip 'it modifies the user''s personal GitHub account GPG keys' + +# This test requires the admin:gpg_key scope to add and delete GPG keys to and +# from the user's personal GitHub account. +# This test uses a GPG key that generated for this test only. The private key +# has been deleted + +# Add the gpg key to GH account +exec gh gpg-key add gpg-key.pub + +# Verify the gpg key was added to GH account +exec gh gpg-key list +stdout '24C30F9C9115E747' + +# Delete the gpg key from GH account +exec gh gpg-key delete --yes '24C30F9C9115E747' + +# Check the key is deleted +exec gh gpg-key list +! stdout '24C30F9C9115E747' + +-- gpg-key.pub -- +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEZxpWhhYJKwYBBAHaRw8BAQdAmYiobR2ai/lVWOBtlAPRG1ZEMG5Effavpt5w +n+wQ//W0R0dIIENMSSBhY2NlcHRhbmNlIHRlc3QgKGZvciBHSCBDTEkgYWNjZXB0 +YW5jZSB0ZXN0aW5nKSA8Y2xpQGdpdGh1Yi5jb20+iJkEExYKAEEWIQTEAQLLUl1x +MDSmbL0kww+ckRXnRwUCZxpWhgIbAwUJAAFRgAULCQgHAgIiAgYVCgkICwIEFgID +AQIeBwIXgAAKCRAkww+ckRXnRxkuAP9GiFi/etWxRjnkomdTaOU8Ccd6oHspuEzB +PFxOJdYslQD+MXgY5UhM/q2iEVj0tiVsfRzDqB+g2weaF5EpqIwWcQ+4OARnGlaG +EgorBgEEAZdVAQUBAQdA3D1vnVTc9URDQw/oAd1mG/zRX7vF4QrjFqFIt7uMf2gD +AQgHiH4EGBYKACYWIQTEAQLLUl1xMDSmbL0kww+ckRXnRwUCZxpWhgIbDAUJAAFR +gAAKCRAkww+ckRXnRxVuAQCngnR11jh2mob0FN0rPWce2juoJsh5gPB2d7LS4r5P +VwEA6F2FeetcP51EyKyQGTp3GpmZgk0uCGJa1G5uqT+9mgc= +=RLWi +-----END PGP PUBLIC KEY BLOCK----- \ No newline at end of file diff --git a/acceptance/testdata/issue/issue-comment.txtar b/acceptance/testdata/issue/issue-comment.txtar new file mode 100644 index 00000000000..f47bf619c3d --- /dev/null +++ b/acceptance/testdata/issue/issue-comment.txtar @@ -0,0 +1,20 @@ +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Create an issue in the repo +cd $SCRIPT_NAME-$RANDOM_STRING +exec gh issue create --title 'Feature Request' --body 'Feature Body' +stdout2env ISSUE_URL + +# Comment on the issue +exec gh issue comment $ISSUE_URL --body 'Looks like a great feature!' + +# View the issue +exec gh issue view $ISSUE_URL --comments +stdout 'Looks like a great feature!' diff --git a/acceptance/testdata/issue/issue-create-basic.txtar b/acceptance/testdata/issue/issue-create-basic.txtar new file mode 100644 index 00000000000..ddba28eec23 --- /dev/null +++ b/acceptance/testdata/issue/issue-create-basic.txtar @@ -0,0 +1,17 @@ +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Create an issue in the repo +cd $SCRIPT_NAME-$RANDOM_STRING +exec gh issue create --title 'Feature Request' --body 'Feature Body' +stdout2env ISSUE_URL + +# Check the issue was created +exec gh issue view $ISSUE_URL +stdout 'title:\tFeature Request$' diff --git a/acceptance/testdata/issue/issue-create-with-metadata.txtar b/acceptance/testdata/issue/issue-create-with-metadata.txtar new file mode 100644 index 00000000000..8187f96cf0f --- /dev/null +++ b/acceptance/testdata/issue/issue-create-with-metadata.txtar @@ -0,0 +1,19 @@ +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Create an issue in the repo +cd $SCRIPT_NAME-$RANDOM_STRING +exec gh issue create --title 'Feature Request' --body 'Feature Body' --assignee '@me' --label 'bug' +stdout2env ISSUE_URL + +# Check the issue was create +exec gh issue view $ISSUE_URL +stdout 'title:\tFeature Request$' +stdout 'assignees:\t.+$' +stdout 'labels:\tbug$' diff --git a/acceptance/testdata/issue/issue-list.txtar b/acceptance/testdata/issue/issue-list.txtar new file mode 100644 index 00000000000..5a810f5e1a7 --- /dev/null +++ b/acceptance/testdata/issue/issue-list.txtar @@ -0,0 +1,16 @@ +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Create an issue in the repo +cd $SCRIPT_NAME-$RANDOM_STRING +exec gh issue create --title 'Feature Request' --body 'Feature Body' + +# Check the issue is included in the list output +exec gh issue list +stdout 'OPEN\tFeature Request' diff --git a/acceptance/testdata/issue/issue-view.txtar b/acceptance/testdata/issue/issue-view.txtar new file mode 100644 index 00000000000..ddba28eec23 --- /dev/null +++ b/acceptance/testdata/issue/issue-view.txtar @@ -0,0 +1,17 @@ +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Create an issue in the repo +cd $SCRIPT_NAME-$RANDOM_STRING +exec gh issue create --title 'Feature Request' --body 'Feature Body' +stdout2env ISSUE_URL + +# Check the issue was created +exec gh issue view $ISSUE_URL +stdout 'title:\tFeature Request$' diff --git a/acceptance/testdata/label/label.txtar b/acceptance/testdata/label/label.txtar new file mode 100644 index 00000000000..dd72133da58 --- /dev/null +++ b/acceptance/testdata/label/label.txtar @@ -0,0 +1,25 @@ +# Setup useful env vars +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} + +# Create a repository +exec gh repo create ${ORG}/${REPO} --private + +# Defer repo cleanup +defer gh repo delete --yes ${ORG}/${REPO} + +# Set the GH_REPO env var to reduce redunant flags +env GH_REPO=${ORG}/${REPO} + +# Create a custom label +exec gh label create 'acceptance-test' --description 'First Description' + +# List the labels and check our custom label is there +exec gh label list +stdout 'acceptance-test\tFirst Description' + +# Edit the label +exec gh label edit 'acceptance-test' --description 'Edited Description' + +# List the labels and check our custom label has been updated +exec gh label list +stdout 'acceptance-test\tEdited Description' diff --git a/acceptance/testdata/org/org-list.txtar b/acceptance/testdata/org/org-list.txtar new file mode 100644 index 00000000000..ca114babeb1 --- /dev/null +++ b/acceptance/testdata/org/org-list.txtar @@ -0,0 +1,6 @@ +# This test could fail if the user is a member of more than 30 organizations because +# the `gh org list` command only returns the first 30 organizations the user is a member of + +# List organizations the user is a member of +exec gh org list +stdout ${GH_ACCEPTANCE_ORG} \ No newline at end of file diff --git a/acceptance/testdata/pr/pr-checkout-by-number.txtar b/acceptance/testdata/pr/pr-checkout-by-number.txtar new file mode 100644 index 00000000000..374926f1d27 --- /dev/null +++ b/acceptance/testdata/pr/pr-checkout-by-number.txtar @@ -0,0 +1,33 @@ +# Set up env vars +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} + +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create ${ORG}/${REPO} --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes ${ORG}/${REPO} + +# Clone the repo +exec gh repo clone ${ORG}/${REPO} + +# Prepare a branch to PR +cd ${REPO} +exec git checkout -b feature-branch +exec git commit --allow-empty -m 'Empty Commit' +exec git push -u origin feature-branch + +# Create the PR +exec gh pr create --title 'Feature Title' --body 'Feature Body' +stdout2env PR_URL + +# Remove the local branch +exec git checkout main +exec git branch -D feature-branch +stdout 'Deleted branch feature-branch' + +# Checkout the PR +exec gh pr checkout 1 +stderr 'Switched to a new branch ''feature-branch''' diff --git a/acceptance/testdata/pr/pr-checkout-with-url-from-fork.txtar b/acceptance/testdata/pr/pr-checkout-with-url-from-fork.txtar new file mode 100644 index 00000000000..9a0494f4bb6 --- /dev/null +++ b/acceptance/testdata/pr/pr-checkout-with-url-from-fork.txtar @@ -0,0 +1,37 @@ +# Set up env vars +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} + +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create ${ORG}/${REPO} --add-readme --private + +# Defer upstream cleanup +defer gh repo delete --yes ${ORG}/${REPO} + +# Create a fork +exec gh repo fork ${ORG}/${REPO} --org ${ORG} --fork-name ${REPO}-fork + +# Defer fork cleanup +defer gh repo delete --yes ${ORG}/${REPO}-fork + +# Clone both repos +exec gh repo clone ${ORG}/${REPO} +exec gh repo clone ${ORG}/${REPO}-fork + +# Prepare a branch to PR in the fork itself +cd ${REPO}-fork +exec git checkout -b feature-branch +exec git commit --allow-empty -m 'Empty Commit' +exec git push -u origin feature-branch + +# Create the PR inside the fork +exec gh repo set-default ${ORG}/${REPO}-fork +exec gh pr create --title 'Feature Title' --body 'Feature Body' +stdout2env PR_URL + +# Checkout the PR by full URL in the upstream repo +cd ${WORK}/${REPO} +exec gh pr checkout ${PR_URL} +stderr 'Switched to branch ''feature-branch''' diff --git a/acceptance/testdata/pr/pr-checkout.txtar b/acceptance/testdata/pr/pr-checkout.txtar new file mode 100644 index 00000000000..4cfe96c1a08 --- /dev/null +++ b/acceptance/testdata/pr/pr-checkout.txtar @@ -0,0 +1,30 @@ +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Prepare a branch to PR +cd $SCRIPT_NAME-$RANDOM_STRING +exec git checkout -b feature-branch +exec git commit --allow-empty -m 'Empty Commit' +exec git push -u origin feature-branch + +# Create the PR +exec gh pr create --title 'Feature Title' --body 'Feature Body' +stdout2env PR_URL + +# Remove the local branch +exec git checkout main +exec git branch -D feature-branch +stdout 'Deleted branch feature-branch' + +# Checkout the PR +exec gh pr checkout $PR_URL +stderr 'Switched to a new branch ''feature-branch''' diff --git a/acceptance/testdata/pr/pr-comment.txtar b/acceptance/testdata/pr/pr-comment.txtar new file mode 100644 index 00000000000..2c4d488b597 --- /dev/null +++ b/acceptance/testdata/pr/pr-comment.txtar @@ -0,0 +1,28 @@ +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Prepare a branch to PR +cd $SCRIPT_NAME-$RANDOM_STRING +exec git checkout -b feature-branch +exec git commit --allow-empty -m 'Empty Commit' +exec git push -u origin feature-branch + +# Create the PR +exec gh pr create --title 'Feature Title' --body 'Feature Body' +stdout2env PR_URL + +# Comment on the PR +exec gh pr comment $PR_URL --body 'Looks like a great feature!' + +# View the PR +exec gh pr view $PR_URL --comments +stdout 'Looks like a great feature!' diff --git a/acceptance/testdata/pr/pr-create-basic.txtar b/acceptance/testdata/pr/pr-create-basic.txtar new file mode 100644 index 00000000000..98bb2faa9dc --- /dev/null +++ b/acceptance/testdata/pr/pr-create-basic.txtar @@ -0,0 +1,24 @@ +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Prepare a branch to PR +cd $SCRIPT_NAME-$RANDOM_STRING +exec git checkout -b feature-branch +exec git commit --allow-empty -m 'Empty Commit' +exec git push -u origin feature-branch + +# Create the PR +exec gh pr create --title 'Feature Title' --body 'Feature Body' + +# Check the PR is indeed created +exec gh pr view +stdout 'Feature Title' diff --git a/acceptance/testdata/pr/pr-create-from-issue-develop-base.txtar b/acceptance/testdata/pr/pr-create-from-issue-develop-base.txtar new file mode 100644 index 00000000000..f0619940e06 --- /dev/null +++ b/acceptance/testdata/pr/pr-create-from-issue-develop-base.txtar @@ -0,0 +1,37 @@ +# Set up env vars +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} + +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create ${ORG}/${REPO} --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes ${ORG}/${REPO} + +# Clone the repo +exec gh repo clone ${ORG}/${REPO} + +# Create a branch to act as the merge base branch +cd ${REPO} +exec git checkout -b long-lived-feature-branch +exec git push -u origin long-lived-feature-branch + +# Create an issue to develop against +exec gh issue create --title 'Feature Request' --body 'Request Body' +stdout2env ISSUE_URL + +# Create a new branch using issue develop with the long lived branch as the base +exec gh issue develop --name 'feature-branch' --base 'long-lived-feature-branch' --checkout ${ISSUE_URL} + +# Prepare a PR on the develop branch +exec git commit --allow-empty -m 'Empty Commit' +exec git push -u origin feature-branch + +# Create the PR +exec gh pr create --title 'Feature Title' --body 'Feature Body' + +# Check the PR is created against the base branch we specified +exec gh pr view --json 'baseRefName' --jq '.baseRefName' +stdout 'long-lived-feature-branch' diff --git a/acceptance/testdata/pr/pr-create-from-manual-merge-base.txtar b/acceptance/testdata/pr/pr-create-from-manual-merge-base.txtar new file mode 100644 index 00000000000..97ae168f539 --- /dev/null +++ b/acceptance/testdata/pr/pr-create-from-manual-merge-base.txtar @@ -0,0 +1,34 @@ +# Set up env vars +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} + +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create ${ORG}/${REPO} --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes ${ORG}/${REPO} + +# Clone the repo +exec gh repo clone ${ORG}/${REPO} + +# Create a branch to act as the merge base branch +cd ${REPO} +exec git checkout -b long-lived-feature-branch +exec git push -u origin long-lived-feature-branch + +# Prepare a branch from the merge base to PR +exec git checkout -b feature-branch +exec git commit --allow-empty -m 'Empty Commit' +exec git push -u origin feature-branch + +# Set the merge-base branch config +exec git config 'branch.feature-branch.gh-merge-base' 'long-lived-feature-branch' + +# Create the PR +exec gh pr create --title 'Feature Title' --body 'Feature Body' + +# Check the PR is created against the merge base branch +exec gh pr view --json 'baseRefName' --jq '.baseRefName' +stdout 'long-lived-feature-branch' diff --git a/acceptance/testdata/pr/pr-create-with-metadata.txtar b/acceptance/testdata/pr/pr-create-with-metadata.txtar new file mode 100644 index 00000000000..3e06b533be8 --- /dev/null +++ b/acceptance/testdata/pr/pr-create-with-metadata.txtar @@ -0,0 +1,26 @@ +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Prepare a branch to PR +cd $SCRIPT_NAME-$RANDOM_STRING +exec git checkout -b feature-branch +exec git commit --allow-empty -m 'Empty Commit' +exec git push -u origin feature-branch + +# Create the PR +exec gh pr create --title 'Feature Title' --body 'Feature Body' --assignee '@me' --label 'bug' +stdout2env PR_URL + +# Check the PR is indeed created +exec gh pr view $PR_URL +stdout 'assignees:\t.+$' +stdout 'labels:\tbug$' diff --git a/acceptance/testdata/pr/pr-create-without-upstream-config.txtar b/acceptance/testdata/pr/pr-create-without-upstream-config.txtar new file mode 100644 index 00000000000..00f3535a775 --- /dev/null +++ b/acceptance/testdata/pr/pr-create-without-upstream-config.txtar @@ -0,0 +1,27 @@ +# This test is the same as pr-create-basic, except that the git push doesn't include the -u argument +# This causes a git config read to fail during gh pr create, but it should not be fatal + +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Prepare a branch to PR +cd $SCRIPT_NAME-$RANDOM_STRING +exec git checkout -b feature-branch +exec git commit --allow-empty -m 'Empty Commit' +exec git push origin feature-branch + +# Create the PR +exec gh pr create --title 'Feature Title' --body 'Feature Body' + +# Check the PR is indeed created +exec gh pr view +stdout 'Feature Title' diff --git a/acceptance/testdata/pr/pr-list.txtar b/acceptance/testdata/pr/pr-list.txtar new file mode 100644 index 00000000000..6fcd8e6b717 --- /dev/null +++ b/acceptance/testdata/pr/pr-list.txtar @@ -0,0 +1,24 @@ +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Prepare a branch to PR +cd $SCRIPT_NAME-$RANDOM_STRING +exec git checkout -b feature-branch +exec git commit --allow-empty -m 'Empty Commit' +exec git push -u origin feature-branch + +# Create the PR +exec gh pr create --title 'Feature Title' --body 'Feature Body' + +# List PRs and see the new PR is in the list +exec gh pr list +stdout 'Feature Title\tfeature-branch\tOPEN' diff --git a/acceptance/testdata/pr/pr-merge-merge-strategy.txtar b/acceptance/testdata/pr/pr-merge-merge-strategy.txtar new file mode 100644 index 00000000000..1d8355506c3 --- /dev/null +++ b/acceptance/testdata/pr/pr-merge-merge-strategy.txtar @@ -0,0 +1,45 @@ +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Prepare a branch to PR with a single file +cd $SCRIPT_NAME-$RANDOM_STRING +exec git checkout -b feature-branch +mv ../file.txt file.txt +exec git add . +exec git commit -m 'Add file.txt' +exec git push -u origin feature-branch + +# Create the PR +exec gh pr create --title 'Feature Title' --body 'Feature Body' +stdout2env PR_URL + +# Check that the file doesn't exist on the main branch +exec git checkout main +! exists file.txt + +# Merge the PR +exec gh pr merge $PR_URL --merge + +# Check that the state of the PR is now merged +exec gh pr view $PR_URL +stdout 'state:\tMERGED$' + +# Pull and check the file exists on the main branch +exec git pull -r +exists file.txt + +# And check we had a merge commit +exec git show HEAD +stdout 'Merge pull request #1' + +-- file.txt -- +Unimportant contents diff --git a/acceptance/testdata/pr/pr-merge-rebase-strategy.txtar b/acceptance/testdata/pr/pr-merge-rebase-strategy.txtar new file mode 100644 index 00000000000..f26338c4ade --- /dev/null +++ b/acceptance/testdata/pr/pr-merge-rebase-strategy.txtar @@ -0,0 +1,45 @@ +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Prepare a branch to PR with a single file +cd $SCRIPT_NAME-$RANDOM_STRING +exec git checkout -b feature-branch +mv ../file.txt file.txt +exec git add . +exec git commit -m 'Add file.txt' +exec git push -u origin feature-branch + +# Create the PR +exec gh pr create --title 'Feature Title' --body 'Feature Body' +stdout2env PR_URL + +# Check that the file doesn't exist on the main branch +exec git checkout main +! exists file.txt + +# Merge the PR +exec gh pr merge $PR_URL --rebase + +# Check that the state of the PR is now merged +exec gh pr view $PR_URL +stdout 'state:\tMERGED$' + +# Pull and check the file exists on the main branch +exec git pull -r +exists file.txt + +# And check our commit was rebased +exec git show HEAD +stdout 'Add file.txt' + +-- file.txt -- +Unimportant contents diff --git a/acceptance/testdata/pr/pr-view-outside-repo.txtar b/acceptance/testdata/pr/pr-view-outside-repo.txtar new file mode 100644 index 00000000000..edfb37ed4c1 --- /dev/null +++ b/acceptance/testdata/pr/pr-view-outside-repo.txtar @@ -0,0 +1,28 @@ +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Prepare a branch to PR +cd $SCRIPT_NAME-$RANDOM_STRING +exec git checkout -b feature-branch +exec git commit --allow-empty -m 'Empty Commit' +exec git push -u origin feature-branch + +# Create the PR +exec gh pr create --title 'Feature Title' --body 'Feature Body' +stdout2env PR_URL + +# Change directories +cd .. + +# View the PR +exec gh pr view $PR_URL +stdout 'Feature Title' diff --git a/acceptance/testdata/pr/pr-view-same-org-fork.txtar b/acceptance/testdata/pr/pr-view-same-org-fork.txtar new file mode 100644 index 00000000000..ca58918a911 --- /dev/null +++ b/acceptance/testdata/pr/pr-view-same-org-fork.txtar @@ -0,0 +1,39 @@ +# Setup environment variables used for testscript +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} +env FORK=${REPO}-fork + +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository to act as upstream with a file so it has a default branch +exec gh repo create ${ORG}/${REPO} --add-readme --private + +# Defer repo cleanup of upstream +defer gh repo delete --yes ${ORG}/${REPO} +exec gh repo view ${ORG}/${REPO} --json id --jq '.id' +stdout2env REPO_ID + +# Create a fork in the same org +exec gh repo fork ${ORG}/${REPO} --org ${ORG} --fork-name ${FORK} + +# Defer repo cleanup of fork +defer gh repo delete --yes ${ORG}/${FORK} +sleep 1 +exec gh repo view ${ORG}/${FORK} --json id --jq '.id' +stdout2env FORK_ID + +# Clone the fork +exec gh repo clone ${ORG}/${FORK} +cd ${FORK} + +# Prepare a branch +exec git checkout -b feature-branch +exec git commit --allow-empty -m 'Empty Commit' +exec git push -u origin feature-branch + +# Create the PR spanning upstream and fork repositories, gh pr create does not support headRepositoryId needed for private forks +exec gh api graphql -F repositoryId="${REPO_ID}" -F headRepositoryId="${FORK_ID}" -F query='mutation CreatePullRequest($headRepositoryId: ID!, $repositoryId: ID!) { createPullRequest(input:{ baseRefName: "main", body: "Feature Body", draft: false, headRefName: "feature-branch", headRepositoryId: $headRepositoryId, repositoryId: $repositoryId, title:"Feature Title" }){ pullRequest{ id url } } }' + +# View the PR +exec gh pr view +stdout 'Feature Title' diff --git a/acceptance/testdata/pr/pr-view-status-respects-branch-pushremote.txtar b/acceptance/testdata/pr/pr-view-status-respects-branch-pushremote.txtar new file mode 100644 index 00000000000..ef80cd8babf --- /dev/null +++ b/acceptance/testdata/pr/pr-view-status-respects-branch-pushremote.txtar @@ -0,0 +1,45 @@ +# Setup environment variables used for testscript +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} +env FORK=${REPO}-fork + +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository to act as upstream with a file so it has a default branch +exec gh repo create ${ORG}/${REPO} --add-readme --private + +# Defer repo cleanup of upstream +defer gh repo delete --yes ${ORG}/${REPO} +exec gh repo view ${ORG}/${REPO} --json id --jq '.id' +stdout2env REPO_ID + +# Create a user fork of repository as opposed to private organization fork +exec gh repo fork ${ORG}/${REPO} --org ${ORG} --fork-name ${FORK} + +# Defer repo cleanup of fork +defer gh repo delete --yes ${ORG}/${FORK} +sleep 5 +exec gh repo view ${ORG}/${FORK} --json id --jq '.id' +stdout2env FORK_ID + +# Clone the repo +exec gh repo clone ${ORG}/${FORK} +cd ${FORK} + +# Prepare a branch where changes are pulled from the upstream default branch but pushed to fork +exec git checkout -b feature-branch upstream/main +exec git config branch.feature-branch.pushRemote origin +exec git commit --allow-empty -m 'Empty Commit' +exec git push + +# Create the PR spanning upstream and fork repositories, gh pr create does not support headRepositoryId needed for private forks +exec gh api graphql -F repositoryId="${REPO_ID}" -F headRepositoryId="${FORK_ID}" -F query='mutation CreatePullRequest($headRepositoryId: ID!, $repositoryId: ID!) { createPullRequest(input:{ baseRefName: "main", body: "Feature Body", draft: false, headRefName: "feature-branch", headRepositoryId: $headRepositoryId, repositoryId: $repositoryId, title:"Feature Title" }){ pullRequest{ id url } } }' + +# View the PR +exec gh pr view +stdout 'Feature Title' + +# Check the PR status +env PR_STATUS_BRANCH=#1 Feature Title [${ORG}:feature-branch] +exec gh pr status +stdout $PR_STATUS_BRANCH diff --git a/acceptance/testdata/pr/pr-view-status-respects-push-destination.txtar b/acceptance/testdata/pr/pr-view-status-respects-push-destination.txtar new file mode 100644 index 00000000000..ff9db4037c0 --- /dev/null +++ b/acceptance/testdata/pr/pr-view-status-respects-push-destination.txtar @@ -0,0 +1,38 @@ +# Setup environment variables used for testscript +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} + +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create ${ORG}/${REPO} --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes ${ORG}/${REPO} + +# Clone the repo +exec gh repo clone ${ORG}/${REPO} +cd ${REPO} + +# Configure default push behavior so local and remote branches will be the same +exec git config push.default current + +# Prepare a branch where changes are pulled from the default branch instead of remote branch of same name +exec git checkout -b feature-branch +exec git branch --set-upstream-to origin/main +exec git rev-parse --abbrev-ref feature-branch@{upstream} +stdout origin/main + +# Create the PR +exec git commit --allow-empty -m 'Empty Commit' +exec git push +exec gh pr create -B main -H feature-branch --title 'Feature Title' --body 'Feature Body' + +# View the PR +exec gh pr view +stdout 'Feature Title' + +# Check the PR status +env PR_STATUS_BRANCH=#1 Feature Title [feature-branch] +exec gh pr status +stdout $PR_STATUS_BRANCH diff --git a/acceptance/testdata/pr/pr-view-status-respects-remote-pushdefault.txtar b/acceptance/testdata/pr/pr-view-status-respects-remote-pushdefault.txtar new file mode 100644 index 00000000000..8bfac28376a --- /dev/null +++ b/acceptance/testdata/pr/pr-view-status-respects-remote-pushdefault.txtar @@ -0,0 +1,45 @@ +# Setup environment variables used for testscript +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} +env FORK=${REPO}-fork + +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository to act as upstream with a file so it has a default branch +exec gh repo create ${ORG}/${REPO} --add-readme --private + +# Defer repo cleanup of upstream +defer gh repo delete --yes ${ORG}/${REPO} +exec gh repo view ${ORG}/${REPO} --json id --jq '.id' +stdout2env REPO_ID + +# Create a user fork of repository as opposed to private organization fork +exec gh repo fork ${ORG}/${REPO} --org ${ORG} --fork-name ${FORK} + +# Defer repo cleanup of fork +defer gh repo delete --yes ${ORG}/${FORK} +sleep 5 +exec gh repo view ${ORG}/${FORK} --json id --jq '.id' +stdout2env FORK_ID + +# Clone the repo +exec gh repo clone ${ORG}/${FORK} +cd ${FORK} + +# Prepare a branch where changes are pulled from the upstream default branch but pushed to fork +exec git checkout -b feature-branch upstream/main +exec git config remote.pushDefault origin +exec git commit --allow-empty -m 'Empty Commit' +exec git push + +# Create the PR spanning upstream and fork repositories, gh pr create does not support headRepositoryId needed for private forks +exec gh api graphql -F repositoryId="${REPO_ID}" -F headRepositoryId="${FORK_ID}" -F query='mutation CreatePullRequest($headRepositoryId: ID!, $repositoryId: ID!) { createPullRequest(input:{ baseRefName: "main", body: "Feature Body", draft: false, headRefName: "feature-branch", headRepositoryId: $headRepositoryId, repositoryId: $repositoryId, title:"Feature Title" }){ pullRequest{ id url } } }' + +# View the PR +exec gh pr view +stdout 'Feature Title' + +# Check the PR status +env PR_STATUS_BRANCH=#1 Feature Title [${ORG}:feature-branch] +exec gh pr status +stdout $PR_STATUS_BRANCH diff --git a/acceptance/testdata/pr/pr-view-status-respects-simple-pushdefault.txtar b/acceptance/testdata/pr/pr-view-status-respects-simple-pushdefault.txtar new file mode 100644 index 00000000000..114f401ecb6 --- /dev/null +++ b/acceptance/testdata/pr/pr-view-status-respects-simple-pushdefault.txtar @@ -0,0 +1,35 @@ +# Setup environment variables used for testscript +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} + +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create ${ORG}/${REPO} --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes ${ORG}/${REPO} + +# Clone the repo +exec gh repo clone ${ORG}/${REPO} +cd ${REPO} + +# Configure default push behavior so local and remote branches have to be the same +exec git config push.default simple + +# Prepare a branch where changes are pulled from the default branch instead of remote branch of same name +exec git checkout -b feature-branch origin/main + +# Create the PR +exec git commit --allow-empty -m 'Empty Commit' +exec git push origin feature-branch +exec gh pr create -H feature-branch --title 'Feature Title' --body 'Feature Body' + +# View the PR +exec gh pr view +stdout 'Feature Title' + +# Check the PR status +env PR_STATUS_BRANCH=#1 Feature Title [feature-branch] +exec gh pr status +stdout $PR_STATUS_BRANCH diff --git a/acceptance/testdata/pr/pr-view.txtar b/acceptance/testdata/pr/pr-view.txtar new file mode 100644 index 00000000000..6166d15ad80 --- /dev/null +++ b/acceptance/testdata/pr/pr-view.txtar @@ -0,0 +1,25 @@ +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Prepare a branch to PR +cd $SCRIPT_NAME-$RANDOM_STRING +exec git checkout -b feature-branch +exec git commit --allow-empty -m 'Empty Commit' +exec git push -u origin feature-branch + +# Create the PR +exec gh pr create --title 'Feature Title' --body 'Feature Body' +stdout2env PR_URL + +# View the PR +exec gh pr view $PR_URL +stdout 'Feature Title' diff --git a/acceptance/testdata/project/project-create-delete.txtar b/acceptance/testdata/project/project-create-delete.txtar new file mode 100644 index 00000000000..1152536695c --- /dev/null +++ b/acceptance/testdata/project/project-create-delete.txtar @@ -0,0 +1,13 @@ +# Create a project and get the project number +env PROJECT_TITLE=$SCRIPT_NAME-$RANDOM_STRING +exec gh project create --owner=$ORG --title=$PROJECT_TITLE --format='json' --jq='.number' +stdout2env PROJECT_NUMBER + +# Confirm the project has been created +exec gh project view --owner=$ORG $PROJECT_NUMBER + +# Delete the project +exec gh project delete --owner=$ORG $PROJECT_NUMBER + +# Confirm the project has been deleted +! exec gh project view --owner=$ORG $PROJECT_NUMBER \ No newline at end of file diff --git a/acceptance/testdata/release/release-create.txtar b/acceptance/testdata/release/release-create.txtar new file mode 100644 index 00000000000..3bdafe76926 --- /dev/null +++ b/acceptance/testdata/release/release-create.txtar @@ -0,0 +1,12 @@ +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Create a release in the repo +cd $SCRIPT_NAME-$RANDOM_STRING +exec gh release create v1.2.3 --notes 'awesome release' --latest diff --git a/acceptance/testdata/release/release-list.txtar b/acceptance/testdata/release/release-list.txtar new file mode 100644 index 00000000000..844b25daa21 --- /dev/null +++ b/acceptance/testdata/release/release-list.txtar @@ -0,0 +1,16 @@ +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Create a release in the repo +cd $SCRIPT_NAME-$RANDOM_STRING +exec gh release create v1.2.3 --notes 'awesome release' --latest + +# List the releases +exec gh release list +stdout 'v1.2.3' \ No newline at end of file diff --git a/acceptance/testdata/release/release-upload-download.txtar b/acceptance/testdata/release/release-upload-download.txtar new file mode 100644 index 00000000000..e19bc06d727 --- /dev/null +++ b/acceptance/testdata/release/release-upload-download.txtar @@ -0,0 +1,26 @@ +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Create a release in the repo +cd $SCRIPT_NAME-$RANDOM_STRING +exec gh release create v1.2.3 --notes 'awesome release' --latest + +# Upload an asset to the release +exec gh release upload v1.2.3 ../asset.txt + +# Download the asset from the release +exec gh release download v1.2.3 +exists asset.txt + +# Download the asset in archive form +exec gh release download v1.2.3 --archive=zip +exists $SCRIPT_NAME-$RANDOM_STRING-1.2.3.zip + +-- asset.txt -- +Hello, world! diff --git a/acceptance/testdata/release/release-view.txtar b/acceptance/testdata/release/release-view.txtar new file mode 100644 index 00000000000..a7138812abb --- /dev/null +++ b/acceptance/testdata/release/release-view.txtar @@ -0,0 +1,16 @@ +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Create a release in the repo +cd $SCRIPT_NAME-$RANDOM_STRING +exec gh release create v1.2.3 --notes 'awesome release' --latest + +# View the release +exec gh release view v1.2.3 +stdout 'v1.2.3' \ No newline at end of file diff --git a/acceptance/testdata/repo/repo-archive-unarchive.txtar b/acceptance/testdata/repo/repo-archive-unarchive.txtar new file mode 100644 index 00000000000..33ff519f3a1 --- /dev/null +++ b/acceptance/testdata/repo/repo-archive-unarchive.txtar @@ -0,0 +1,23 @@ +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Check that the repo exists and isn't archived +exec gh repo view $ORG/$SCRIPT_NAME-$RANDOM_STRING --json=isArchived --jq='.isArchived' +stdout 'false' + +# Archive the repo +exec gh repo archive $ORG/$SCRIPT_NAME-$RANDOM_STRING --yes + +# Check that the repo is archived +exec gh repo view $ORG/$SCRIPT_NAME-$RANDOM_STRING --json=isArchived --jq='.isArchived' +stdout 'true' + +# Unarchive the repo +exec gh repo unarchive $ORG/$SCRIPT_NAME-$RANDOM_STRING --yes + +# Check that the repo is unarchived +exec gh repo view $ORG/$SCRIPT_NAME-$RANDOM_STRING --json=isArchived --jq='.isArchived' +stdout 'false' diff --git a/acceptance/testdata/repo/repo-clone.txtar b/acceptance/testdata/repo/repo-clone.txtar new file mode 100644 index 00000000000..b90a0894b40 --- /dev/null +++ b/acceptance/testdata/repo/repo-clone.txtar @@ -0,0 +1,11 @@ +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Ensure the repo was cloned +exists $SCRIPT_NAME-$RANDOM_STRING/README.md diff --git a/acceptance/testdata/repo/repo-create-bare.txtar b/acceptance/testdata/repo/repo-create-bare.txtar new file mode 100644 index 00000000000..b835c420b4d --- /dev/null +++ b/acceptance/testdata/repo/repo-create-bare.txtar @@ -0,0 +1,35 @@ +# It's unclear what we want to do with these acceptance tests beyond our GHEC discovery, so skip new ones by default +skip + +# Set up env var +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} + +# Use gh as a credential helper +exec gh auth setup-git + +# Initialise a local repository with two branches +# We expect a bare repo to have all refs pushed with --mirror +mkdir ${REPO} +cd ${REPO} +exec git init +exec git checkout -b feature-1 +exec git commit --allow-empty -m 'Empty Commit 1' + +exec git checkout -b feature-2 +exec git commit --allow-empty -m 'Empty Commit 2' + +# Clone a bare repo from that local repo +cd .. +exec git clone --bare ${REPO} ${REPO}-bare +cd ${REPO}-bare + +# Create a GitHub repository from that bare repo +exec gh repo create ${ORG}/${REPO} --private --source . --push --remote bare + +# Defer repo cleanup +defer gh repo delete --yes ${ORG}/${REPO} + +# Check the remote repo has both branches +exec gh api /repos/${ORG}/${REPO}/branches +stdout 'feature-1' +stdout 'feature-2' diff --git a/acceptance/testdata/repo/repo-create-view.txtar b/acceptance/testdata/repo/repo-create-view.txtar new file mode 100644 index 00000000000..9774def3502 --- /dev/null +++ b/acceptance/testdata/repo/repo-create-view.txtar @@ -0,0 +1,9 @@ +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Check that the repo exists +exec gh repo view $ORG/$SCRIPT_NAME-$RANDOM_STRING --json=name --jq='.name' +stdout $SCRIPT_NAME-$RANDOM_STRING diff --git a/acceptance/testdata/repo/repo-delete.txtar b/acceptance/testdata/repo/repo-delete.txtar new file mode 100644 index 00000000000..b82388068e7 --- /dev/null +++ b/acceptance/testdata/repo/repo-delete.txtar @@ -0,0 +1,13 @@ +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Check that the repo exists +exec gh repo view $ORG/$SCRIPT_NAME-$RANDOM_STRING --json name --jq '.name' +stdout $SCRIPT_NAME-$RANDOM_STRING + +# Delete the repo +exec gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Ensure that the repo was deleted +! exec gh repo view $ORG/$SCRIPT_NAME-$RANDOM_STRING +stderr 'Could not resolve to a Repository with the name' diff --git a/acceptance/testdata/repo/repo-deploy-key.txtar b/acceptance/testdata/repo/repo-deploy-key.txtar new file mode 100644 index 00000000000..d93d07ee5d2 --- /dev/null +++ b/acceptance/testdata/repo/repo-deploy-key.txtar @@ -0,0 +1,31 @@ +# Create and clone a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private --clone + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# cd to the repo and list the deploy keys. There should be no keys +cd $SCRIPT_NAME-$RANDOM_STRING +exec gh repo deploy-key list --json=title +! stdout title + +# Add a deploy key +exec gh repo deploy-key add ../deployKey.pub + +# Ensure the deploy key was added +exec gh repo deploy-key list --json=title --jq='.[].title' +stdout myTitle + +# Get the deploy key id +exec gh repo deploy-key list --json=title,id --jq='.[].title="myTitle" | .[].id' +stdout2env DEPLOY_KEY_ID + +# Delete the deploy key +exec gh repo deploy-key delete $DEPLOY_KEY_ID + +# Ensure the deploy key was deleted +exec gh repo deploy-key list --json=id --jq='.[].id' +! stdout $DEPLOY_KEY_ID + +-- deployKey.pub -- +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAZmdeRNskfpvYL5YHB/YJaW8hTEXpnvPMkx5Ri+YwUr myTitle diff --git a/acceptance/testdata/repo/repo-edit.txtar b/acceptance/testdata/repo/repo-edit.txtar new file mode 100644 index 00000000000..00d3cdd2c5b --- /dev/null +++ b/acceptance/testdata/repo/repo-edit.txtar @@ -0,0 +1,16 @@ +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Check that the repo description is empty +exec gh repo view $ORG/$SCRIPT_NAME-$RANDOM_STRING --json description --jq '.description' +! stdout '.' + +# Edit the repo description +exec gh repo edit $ORG/$SCRIPT_NAME-$RANDOM_STRING --description 'newDescription' + +# Check that the repo description is updated +exec gh repo view $ORG/$SCRIPT_NAME-$RANDOM_STRING --json description --jq '.description' +stdout 'newDescription' diff --git a/acceptance/testdata/repo/repo-fork-sync.txtar b/acceptance/testdata/repo/repo-fork-sync.txtar new file mode 100644 index 00000000000..6ed7b94e1a7 --- /dev/null +++ b/acceptance/testdata/repo/repo-fork-sync.txtar @@ -0,0 +1,42 @@ +# Use gh as a credential helper +exec gh auth setup-git + +# Create and clone a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private --clone + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Fork and clone the repo +exec gh repo fork $ORG/$SCRIPT_NAME-$RANDOM_STRING --org $ORG --fork-name $SCRIPT_NAME-$RANDOM_STRING-fork --clone + +# Defer fork cleanup +defer gh repo delete $ORG/$SCRIPT_NAME-$RANDOM_STRING-fork --yes + +# Sleep so that the BE has time to sync +sleep 5 + +# Check that the repo was forked +exec gh repo view $ORG/$SCRIPT_NAME-$RANDOM_STRING-fork --json='isFork' --jq='.isFork' +stdout 'true' + +# Modify original repo +cd $SCRIPT_NAME-$RANDOM_STRING +mv ../asset.txt asset.txt +exec git add . +exec git commit -m 'Add asset.txt' +exec git push + +# Checkout the forked repo and ensure asset.txt is not present +cd ../$SCRIPT_NAME-$RANDOM_STRING-fork +exec git checkout main +! exists asset.txt + +# Sync the forked repo with the original repo +exec gh repo sync + +# Check that asset.txt now exists in the fork +exists asset.txt + +-- asset.txt -- +Hello, world! diff --git a/acceptance/testdata/repo/repo-list-rename.txtar b/acceptance/testdata/repo/repo-list-rename.txtar new file mode 100644 index 00000000000..7f3ff1281df --- /dev/null +++ b/acceptance/testdata/repo/repo-list-rename.txtar @@ -0,0 +1,16 @@ +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# List the repos and check for the new repo +exec gh repo list $ORG --json=name --jq='.[].name' +stdout $SCRIPT_NAME-$RANDOM_STRING + +# Rename the repo +exec gh repo rename $SCRIPT_NAME-$RANDOM_STRING-renamed --repo=$ORG/$SCRIPT_NAME-$RANDOM_STRING --yes + +# Defer repo deletion +defer gh repo delete $ORG/$SCRIPT_NAME-$RANDOM_STRING-renamed --yes + +# List the repos and check for the renamed repo +exec gh repo list $ORG --json=name --jq='.[].name' +stdout $SCRIPT_NAME-$RANDOM_STRING-renamed diff --git a/acceptance/testdata/repo/repo-rename-transfer-ownership.txtar b/acceptance/testdata/repo/repo-rename-transfer-ownership.txtar new file mode 100644 index 00000000000..6b1a3e3fbcd --- /dev/null +++ b/acceptance/testdata/repo/repo-rename-transfer-ownership.txtar @@ -0,0 +1,9 @@ +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Attempt to rename the repo with a slash in the name +! exec gh repo rename $ORG/new-name --repo=$ORG/$SCRIPT_NAME-$RANDOM_STRING --yes +stderr 'New repository name cannot contain '/' character - to transfer a repository to a new owner, you must follow additional steps on GitHub.com. For more information on transferring repository ownership, see .' + +# Defer repo deletion +defer gh repo delete $ORG/$SCRIPT_NAME-$RANDOM_STRING --yes \ No newline at end of file diff --git a/acceptance/testdata/repo/repo-set-default.txtar b/acceptance/testdata/repo/repo-set-default.txtar new file mode 100644 index 00000000000..4f7fa327303 --- /dev/null +++ b/acceptance/testdata/repo/repo-set-default.txtar @@ -0,0 +1,17 @@ +# Create and clone a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private --clone + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Ensure that no default is set +cd $SCRIPT_NAME-$RANDOM_STRING +exec gh repo set-default --view +stderr 'no default repository has been set; use `gh repo set-default` to select one' + +# Set the default +exec gh repo set-default $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Check that the default is set +exec gh repo set-default --view +stdout $ORG/$SCRIPT_NAME-$RANDOM_STRING diff --git a/acceptance/testdata/ruleset/ruleset.txtar b/acceptance/testdata/ruleset/ruleset.txtar new file mode 100644 index 00000000000..99be3683c80 --- /dev/null +++ b/acceptance/testdata/ruleset/ruleset.txtar @@ -0,0 +1,62 @@ +# Setup environment variables used for testscript +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} + +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$REPO --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$REPO + +# Clone the repo +exec gh repo clone $ORG/$REPO +cd $REPO + +# Verify repository ruleset does not exist +env LIST_MATCH=testscript\s+$ORG/$REPO (repo) +exec gh ruleset list +! stdout $LIST_MATCH + +# Verify no repository ruleset applies to default branch +exec gh ruleset check +stdout '0 rules apply' + +# Create a repository ruleset +exec gh api /repos/{owner}/{repo}/rulesets -X POST --input ../create-repo-ruleset.json + +# Verify repository ruleset does exist +exec gh ruleset list +stdout $LIST_MATCH + +# Verify repository ruleset associated with branch +exec gh ruleset check +stdout '- pull_request:.+dismiss_stale_reviews_on_push: false.+require_code_owner_review: true.+require_last_push_approval: false.+required_approving_review_count: 1.+required_review_thread_resolution: false' + +-- create-repo-ruleset.json -- +{ + "name": "testscript", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": [ + "~DEFAULT_BRANCH" + ], + "exclude": [] + } + }, + "rules": [ + { + "type": "pull_request", + "parameters": { + "dismiss_stale_reviews_on_push": false, + "require_code_owner_review": true, + "require_last_push_approval": false, + "required_approving_review_count": 1, + "required_review_thread_resolution": false + } + } + ] +} diff --git a/acceptance/testdata/search/search-issues.txtar b/acceptance/testdata/search/search-issues.txtar new file mode 100644 index 00000000000..82184f3f1a5 --- /dev/null +++ b/acceptance/testdata/search/search-issues.txtar @@ -0,0 +1,20 @@ +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Create an issue in the repo +cd $SCRIPT_NAME-$RANDOM_STRING + +exec gh issue create --title 'Feature Request' --body $RANDOM_STRING + +# It takes some time for the issue to be created and indexed +sleep 5 + +# Search for the issue +exec gh search issues $RANDOM_STRING -R $ORG/$SCRIPT_NAME-$RANDOM_STRING +stdout $RANDOM_STRING \ No newline at end of file diff --git a/acceptance/testdata/secret/secret-org.txtar b/acceptance/testdata/secret/secret-org.txtar new file mode 100644 index 00000000000..7d383009c97 --- /dev/null +++ b/acceptance/testdata/secret/secret-org.txtar @@ -0,0 +1,85 @@ +# Setup environment variables used for testscript +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} +env2upper SECRET_NAME=${SCRIPT_NAME}_${RANDOM_STRING} + +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$REPO --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$REPO + +# Clone the repo +exec gh repo clone $ORG/$REPO +cd $REPO + +# Confirm organization secret does not exist, will fail admin:org scope missing +exec gh secret list --org $ORG +! stdout $SECRET_NAME + +# Create an organization secret +exec gh secret set $SECRET_NAME --org $ORG --body 'just an organization secret' --repos $REPO + +# Defer organization secret cleanup +defer gh secret delete $SECRET_NAME --org $ORG + +# Verify new organization secret exists +exec gh secret list --org $ORG +stdout $SECRET_NAME + +# Commit workflow file creating dispatchable workflow able to verify secret matches +mkdir .github/workflows +mv ../workflow.yml .github/workflows/workflow.yml +replace .github/workflows/workflow.yml SECRET_NAME=$SECRET_NAME +exec git add .github/workflows/workflow.yml +exec git commit -m 'Create workflow file' +exec git push -u origin main + +# Sleep because it takes a second for the workflow to register +sleep 1 + +# Check the workflow is indeed created +exec gh workflow list +stdout 'Test Workflow Name' + +# Run the workflow +exec gh workflow run 'Test Workflow Name' + +# It takes some time for a workflow run to register +sleep 10 + +# Get the run ID we want to watch & delete +exec gh run list --json databaseId --jq '.[0].databaseId' +stdout2env RUN_ID + +# Wait for workflow to complete +exec gh run watch $RUN_ID --exit-status + +# Verify secret matched what was set earlier +exec gh run view $RUN_ID --log +stdout 'GitHub Actions secret value matches$' + +-- workflow.yml -- +# This workflow is intended to assert the value of the GitHub Actions secret was set appropriately +name: Test Workflow Name +on: + # Allow workflow to be dispatched by gh workflow run + workflow_dispatch: + +jobs: + # This workflow contains a single job called "assert" that should only pass if the GitHub Actions secret value matches + assert: + runs-on: ubuntu-latest + steps: + - name: Assert secret value matches + env: + ORG_SECRET: ${{ secrets.$SECRET_NAME }} + run: | + if [[ "$ORG_SECRET" == "just an organization secret" ]]; then + echo "GitHub Actions secret value matches" + else + echo "GitHub Actions secret value does not match" + exit 1 + fi diff --git a/acceptance/testdata/secret/secret-repo-env.txtar b/acceptance/testdata/secret/secret-repo-env.txtar new file mode 100644 index 00000000000..a9a2c735354 --- /dev/null +++ b/acceptance/testdata/secret/secret-repo-env.txtar @@ -0,0 +1,80 @@ +# Setup environment variables used for testscript +env REPO=$SCRIPT_NAME-$RANDOM_STRING + +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$REPO --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$REPO + +# Clone the repo +exec gh repo clone $ORG/$REPO +cd $REPO + +# Create a repository environment, will fail if organization does not have environment support +exec gh api /repos/$ORG/$REPO/environments/testscripts -X PUT + +# Create a repository environment secret +exec gh secret set TESTSCRIPTS_ENV --env testscripts --body 'just a repository environment secret' + +# Verify new repository secret exists +exec gh secret list --env testscripts +stdout 'TESTSCRIPTS_ENV' + +# Commit workflow file creating dispatchable workflow able to verify secret matches +mkdir .github/workflows +mv ../workflow.yml .github/workflows/workflow.yml +exec git add .github/workflows/workflow.yml +exec git commit -m 'Create workflow file' +exec git push -u origin main + +# Sleep because it takes a second for the workflow to register +sleep 1 + +# Check the workflow is indeed created +exec gh workflow list +stdout 'Test Workflow Name' + +# Run the workflow +exec gh workflow run 'Test Workflow Name' + +# It takes some time for a workflow run to register +sleep 10 + +# Get the run ID we want to watch & delete +exec gh run list --json databaseId --jq '.[0].databaseId' +stdout2env RUN_ID + +# Wait for workflow to complete +exec gh run watch $RUN_ID --exit-status + +# Verify secret matched what was set earlier +exec gh run view $RUN_ID --log +stdout 'GitHub Actions secret value matches$' + +-- workflow.yml -- +# This workflow is intended to assert the value of the GitHub Actions secret was set appropriately +name: Test Workflow Name +on: + # Allow workflow to be dispatched by gh workflow run + workflow_dispatch: + +jobs: + # This workflow contains a single job called "assert" that should only pass if the GitHub Actions secret value matches + assert: + runs-on: ubuntu-latest + environment: testscripts + steps: + - name: Assert secret value matches + env: + TESTSCRIPTS_ENV: ${{ secrets.TESTSCRIPTS_ENV }} + run: | + if [[ "$TESTSCRIPTS_ENV" == "just a repository environment secret" ]]; then + echo "GitHub Actions secret value matches" + else + echo "GitHub Actions secret value does not match" + exit 1 + fi diff --git a/acceptance/testdata/secret/secret-repo.txtar b/acceptance/testdata/secret/secret-repo.txtar new file mode 100644 index 00000000000..ed336626f86 --- /dev/null +++ b/acceptance/testdata/secret/secret-repo.txtar @@ -0,0 +1,76 @@ +# Setup environment variables used for testscript +env REPO=$SCRIPT_NAME-$RANDOM_STRING + +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$REPO --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$REPO + +# Clone the repo +exec gh repo clone $ORG/$REPO +cd $REPO + +# Create a repository secret +exec gh secret set TESTSCRIPTS --body 'just a repository secret' + +# Verify new repository secret exists +exec gh secret list +stdout 'TESTSCRIPTS' + +# Commit workflow file creating dispatchable workflow able to verify secret matches +mkdir .github/workflows +mv ../workflow.yml .github/workflows/workflow.yml +exec git add .github/workflows/workflow.yml +exec git commit -m 'Create workflow file' +exec git push -u origin main + +# Sleep because it takes a second for the workflow to register +sleep 1 + +# Check the workflow is indeed created +exec gh workflow list +stdout 'Test Workflow Name' + +# Run the workflow +exec gh workflow run 'Test Workflow Name' + +# It takes some time for a workflow run to register +sleep 10 + +# Get the run ID we want to watch & delete +exec gh run list --json databaseId --jq '.[0].databaseId' +stdout2env RUN_ID + +# Wait for workflow to complete +exec gh run watch $RUN_ID --exit-status + +# Verify secret matched what was set earlier +exec gh run view $RUN_ID --log +stdout 'GitHub Actions secret value matches$' + +-- workflow.yml -- +# This workflow is intended to assert the value of the GitHub Actions secret was set appropriately +name: Test Workflow Name +on: + # Allow workflow to be dispatched by gh workflow run + workflow_dispatch: + +jobs: + # This workflow contains a single job called "assert" that should only pass if the GitHub Actions secret value matches + assert: + runs-on: ubuntu-latest + steps: + - name: Assert secret value matches + env: + TESTSCRIPTS: ${{ secrets.TESTSCRIPTS }} + run: | + if [[ "$TESTSCRIPTS" == "just a repository secret" ]]; then + echo "GitHub Actions secret value matches" + else + echo "GitHub Actions secret value does not match" + exit 1 + fi diff --git a/acceptance/testdata/secret/secret-require-remote-disambiguation.txtar b/acceptance/testdata/secret/secret-require-remote-disambiguation.txtar new file mode 100644 index 00000000000..f4d1bbb4a53 --- /dev/null +++ b/acceptance/testdata/secret/secret-require-remote-disambiguation.txtar @@ -0,0 +1,36 @@ +# Set up env vars +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} + +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create ${ORG}/${REPO} --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes ${ORG}/${REPO} + +# Create a fork +exec gh repo fork ${ORG}/${REPO} --org ${ORG} --fork-name ${REPO}-fork + +# Defer fork cleanup +defer gh repo delete --yes ${ORG}/${REPO}-fork + +# Sleep to allow the fork to be created before cloning +sleep 2 + +# Clone and move into the fork repo +exec gh repo clone ${ORG}/${REPO}-fork +cd ${REPO}-fork + +# Secret list requires disambiguation +! exec gh secret list +stderr 'multiple remotes detected. please specify which repo to use by providing the -R, --repo argument' + +# Secret set requires disambiguation +! exec gh secret set 'TEST_SECRET_NAME' --body 'TEST_SECRET_VALUE' +stderr 'multiple remotes detected. please specify which repo to use by providing the -R, --repo argument' + +# Secret delete requires disambiguation +! exec gh secret delete 'TEST_SECRET_NAME' +stderr 'multiple remotes detected. please specify which repo to use by providing the -R, --repo argument' diff --git a/acceptance/testdata/ssh-key/ssh-key.txtar b/acceptance/testdata/ssh-key/ssh-key.txtar new file mode 100644 index 00000000000..4ba8643bb33 --- /dev/null +++ b/acceptance/testdata/ssh-key/ssh-key.txtar @@ -0,0 +1,24 @@ +skip 'it modifies the user''s personal GitHub account SSH keys' + +# scopes admin:ssh_signing_key,admin:public_key + +# Add an SSH key to the account +exec gh ssh-key add sshKey.pub --title 'acceptance-test-key' + +# List the SSH keys +exec gh ssh-key list +stdout 'acceptance-test-key' + +# Get the ID of the key we created +exec gh api /user/keys --jq '.[] | select(.title == "acceptance-test-key") | .id' +stdout2env SSH_KEY_ID + +# Delete the SSH key +exec gh ssh-key delete --yes ${SSH_KEY_ID} + +# Check the key is deleted +exec gh ssh-key list +! stdout 'acceptance-test-key' + +-- sshKey.pub -- +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAZmdeRNskfpvYL5YHB/YJaW8hTEXpnvPMkx5Ri+YwUr acceptance diff --git a/acceptance/testdata/variable/variable-org.txtar b/acceptance/testdata/variable/variable-org.txtar new file mode 100644 index 00000000000..c09381b295b --- /dev/null +++ b/acceptance/testdata/variable/variable-org.txtar @@ -0,0 +1,20 @@ +# Setup environment variables used for testscript +env2upper VAR_NAME=${SCRIPT_NAME}_${RANDOM_STRING} + +# Confirm organization variable does not exist, will fail admin:org scope missing +exec gh variable list --org $ORG +! stdout $VAR_NAME + +# Create an organization variable +exec gh variable set $VAR_NAME --org $ORG --body 'just an org variable' + +# Defer organization variable cleanup +defer gh variable delete $VAR_NAME --org $ORG + +# Verify new organization variable exists +exec gh variable list --org $ORG +stdout $VAR_NAME + +# Verify organization variable can be retrieved +exec gh variable get $VAR_NAME --org $ORG +stdout 'just an org variable' diff --git a/acceptance/testdata/variable/variable-repo-env.txtar b/acceptance/testdata/variable/variable-repo-env.txtar new file mode 100644 index 00000000000..99dbb6b9f60 --- /dev/null +++ b/acceptance/testdata/variable/variable-repo-env.txtar @@ -0,0 +1,32 @@ +# Setup environment variables used for testscript +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} +env ENV_NAME=testscripts +env VAR_NAME=TESTSCRIPTS_ENV + +# Create a repository where the variable will be registered +exec gh repo create $ORG/$REPO --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$REPO + +# Clone the repo +exec gh repo clone $ORG/$REPO +cd $REPO + +# Create a repository environment, will fail if organization does not have environment support +exec gh api /repos/$ORG/$REPO/environments/$ENV_NAME -X PUT --jq '.name' + +# Verify repository environment variable does not exist +exec gh variable list --env $ENV_NAME +! stdout $VAR_NAME + +# Create a repository environment variable +exec gh variable set $VAR_NAME --env $ENV_NAME --body 'just a repo env variable' + +# Verify new repository environment variable exists +exec gh variable list --env $ENV_NAME +stdout $VAR_NAME + +# Verify repository environment variable can be retrieved +exec gh variable get $VAR_NAME --env $ENV_NAME +stdout 'just a repo env variable' diff --git a/acceptance/testdata/variable/variable-repo.txtar b/acceptance/testdata/variable/variable-repo.txtar new file mode 100644 index 00000000000..9ff64db0ffe --- /dev/null +++ b/acceptance/testdata/variable/variable-repo.txtar @@ -0,0 +1,28 @@ +# Setup environment variables used for testscript +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} +env VAR_NAME=TESTSCRIPTS + +# Create a repository where the variable will be registered +exec gh repo create $ORG/$REPO --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$REPO + +# Clone the repo +exec gh repo clone $ORG/$REPO +cd $REPO + +# Verify repository variable does not exist +exec gh variable list +! stdout $VAR_NAME + +# Create a repository variable +exec gh variable set $VAR_NAME --body 'just a repo variable' + +# Verify new repository variable exists +exec gh variable list +stdout $VAR_NAME + +# Verify repository variable can be retrieved +exec gh variable get $VAR_NAME +stdout 'just a repo variable' diff --git a/acceptance/testdata/workflow/cache-list-delete.txtar b/acceptance/testdata/workflow/cache-list-delete.txtar new file mode 100644 index 00000000000..6a99f4bc268 --- /dev/null +++ b/acceptance/testdata/workflow/cache-list-delete.txtar @@ -0,0 +1,69 @@ +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# commit the workflow file +cd $SCRIPT_NAME-$RANDOM_STRING +mkdir .github/workflows +mv ../workflow.yml .github/workflows/workflow.yml +exec git add .github/workflows/workflow.yml +exec git commit -m 'Create workflow file' +exec git push -u origin main + +# Sleep because it takes a second for the workflow to register +sleep 1 + +# Check the workflow is indeed created +exec gh workflow list +stdout 'Test Workflow Name' + +# Run the workflow +exec gh workflow run 'Test Workflow Name' + +# It takes some time for a workflow run to register +sleep 10 + +# Get the run ID we want to watch +exec gh run list --json databaseId --jq '.[0].databaseId' +stdout2env RUN_ID + +# Wait for workflow to complete +exec gh run watch $RUN_ID --exit-status + +# List the cache +exec gh cache list +stdout 'Linux-values' + +# Delete the cache +exec gh cache delete 'Linux-values' + +-- workflow.yml -- +name: Test Workflow Name + +on: workflow_dispatch + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Cache values + id: cache-values + uses: actions/cache@v4 + with: + path: values.txt + key: ${{ runner.os }}-values + + - name: Generate values file + if: steps.cache-values.outputs.cache-hit != 'true' + run: echo "values" > values.txt diff --git a/acceptance/testdata/workflow/cache-list-empty.txtar b/acceptance/testdata/workflow/cache-list-empty.txtar new file mode 100644 index 00000000000..0e6d32cb70a --- /dev/null +++ b/acceptance/testdata/workflow/cache-list-empty.txtar @@ -0,0 +1,36 @@ +# It's unclear what we want to do with these acceptance tests beyond our GHEC discovery, so skip new ones by default +skip + +# Set up env vars +env REPO=${ORG}/${SCRIPT_NAME}-${RANDOM_STRING} + +# Create a repository with a file so it has a default branch +exec gh repo create ${REPO} --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes ${REPO} + +# Set the repo to be targeted by all following commands +env GH_REPO=${REPO} + +# Listing the cache non-interactively shows nothing +exec gh cache list +! stdout '.' + +# Listing the cache non-interactively with --json shows an empty array +exec gh cache list --json id +stdout '\[\]' + +# Now set an env var so the commands run interactively and without colour for stdout matching +# Unfortunately testscript provides no way to turn them off again, and since this +# script is for discovery, we're not adding a new command. +env GH_FORCE_TTY=true +env CLICOLOR=0 + +# Listing the cache interactively shows an informative message on stderr +exec gh cache list +stderr 'No caches found in' + +# Listing the cache interactively with --json shows an empty array +exec gh cache list --json id +stdout '\[\]' diff --git a/acceptance/testdata/workflow/run-cancel.txtar b/acceptance/testdata/workflow/run-cancel.txtar new file mode 100644 index 00000000000..08d8d519a48 --- /dev/null +++ b/acceptance/testdata/workflow/run-cancel.txtar @@ -0,0 +1,73 @@ +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# commit the workflow file +cd $SCRIPT_NAME-$RANDOM_STRING +mkdir .github/workflows +mv ../workflow.yml .github/workflows/workflow.yml +exec git add .github/workflows/workflow.yml +exec git commit -m 'Create workflow file' +exec git push -u origin main + +# Sleep because it takes a second for the workflow to register +sleep 1 + +# Check the workflow is indeed created +exec gh workflow list +stdout 'Test Workflow Name' + +# Run the workflow +exec gh workflow run 'Test Workflow Name' + +# It takes some time for a workflow run to register +sleep 10 + +# Get the run ID we want to cancel +exec gh run list --json databaseId --jq '.[0].databaseId' +stdout2env RUN_ID + +# cancel the workflow run +exec gh run cancel $RUN_ID +stdout '✓ Request to cancel workflow [0-9]+ submitted.' + +# Wait for workflow to complete +exec gh run watch $RUN_ID + +# Check the workflow run is cancelled +exec gh run list --json conclusion --jq '.[0].conclusion' +stdout 'cancelled' + +-- workflow.yml -- +# This is a basic workflow to help you get started with Actions + +name: Test Workflow Name + +# Controls when the workflow will run +on: + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v4 + + # Runs a single command using the runners shell + - name: Run a one-line script + run: sleep 30 diff --git a/acceptance/testdata/workflow/run-delete.txtar b/acceptance/testdata/workflow/run-delete.txtar new file mode 100644 index 00000000000..b78135330e7 --- /dev/null +++ b/acceptance/testdata/workflow/run-delete.txtar @@ -0,0 +1,74 @@ +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# commit the workflow file +cd $SCRIPT_NAME-$RANDOM_STRING +mkdir .github/workflows +mv ../workflow.yml .github/workflows/workflow.yml +exec git add .github/workflows/workflow.yml +exec git commit -m 'Create workflow file' +exec git push -u origin main + +# Sleep because it takes a second for the workflow to register +sleep 1 + +# Check the workflow is indeed created +exec gh workflow list +stdout 'Test Workflow Name' + +# Run the workflow +exec gh workflow run 'Test Workflow Name' + +# It takes some time for a workflow run to register +sleep 10 + +# Get the run ID we want to watch & delete +exec gh run list --json databaseId --jq '.[0].databaseId' +stdout2env RUN_ID + +# Wait for workflow to complete +exec gh run watch $RUN_ID --exit-status + +# Delete the workflow run +exec gh run delete $RUN_ID +stdout '✓ Request to delete workflow run submitted.' + +# It takes some time for a workflow run to be deleted +sleep 5 + +# Check the workflow run is cancelled, which is implied by an empty list +exec gh run list +stdout '' + +-- workflow.yml -- +# This is a basic workflow to help you get started with Actions + +name: Test Workflow Name + +# Controls when the workflow will run +on: + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + + # Runs a single command using the runners shell + - name: Run a one-line script + run: echo Hello, world! diff --git a/acceptance/testdata/workflow/run-download-traversal.txtar b/acceptance/testdata/workflow/run-download-traversal.txtar new file mode 100644 index 00000000000..a8a64475216 --- /dev/null +++ b/acceptance/testdata/workflow/run-download-traversal.txtar @@ -0,0 +1,71 @@ +# Set up env +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} + +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create ${ORG}/${REPO} --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes ${ORG}/${REPO} + +# Clone the repo +exec gh repo clone ${ORG}/${REPO} + +# commit the workflow file +cd ${REPO} +mkdir .github/workflows +mv ../workflow.yml .github/workflows/workflow.yml +exec git add .github/workflows/workflow.yml +exec git commit -m 'Create workflow file' +exec git push -u origin main + +# Sleep because it takes a second for the workflow to register +sleep 1 + +# Check the workflow is indeed created +exec gh workflow list +stdout 'Test Workflow Name' + +# Run the workflow +exec gh workflow run 'Test Workflow Name' + +# It takes some time for a workflow run to register +sleep 10 + +# Get the run ID we want to watch +exec gh run list --json databaseId --jq '.[0].databaseId' +stdout2env RUN_ID + +# Wait for workflow to complete +exec gh run watch ${RUN_ID} --exit-status + +# Download the artifact and see there is an error +! exec gh run download ${RUN_ID} +stderr 'would result in path traversal' + +-- workflow.yml -- +# This is a basic workflow to help you get started with Actions + +name: Test Workflow Name + +# Controls when the workflow will run +on: + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + - run: echo hello > world.txt + - uses: actions/upload-artifact@v4 + with: + name: .. + path: world.txt diff --git a/acceptance/testdata/workflow/run-download.txtar b/acceptance/testdata/workflow/run-download.txtar new file mode 100644 index 00000000000..8089cf2cd2e --- /dev/null +++ b/acceptance/testdata/workflow/run-download.txtar @@ -0,0 +1,90 @@ +# Set up env +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} + +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create ${ORG}/${REPO} --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes ${ORG}/${REPO} + +# Clone the repo +exec gh repo clone ${ORG}/${REPO} + +# commit the workflow file +cd ${REPO} +mkdir .github/workflows +mv ../workflow.yml .github/workflows/workflow.yml +exec git add .github/workflows/workflow.yml +exec git commit -m 'Create workflow file' +exec git push -u origin main + +# Sleep because it takes a second for the workflow to register +sleep 1 + +# Check the workflow is indeed created +exec gh workflow list +stdout 'Test Workflow Name' + +# Run the workflow +exec gh workflow run 'Test Workflow Name' + +# It takes some time for a workflow run to register +sleep 10 + +# Get the run ID we want to watch +exec gh run list --json databaseId --jq '.[0].databaseId' +stdout2env RUN_ID + +# Wait for workflow to complete +exec gh run watch ${RUN_ID} --exit-status + +# Download the artifact to current dir +exec gh run download ${RUN_ID} + +# Check that we downloaded the artifact and extracted into a dir with the name of the artifact +exists ./my-artifact/child/world.txt + +# Remove the artifact +rm ./my-artifact + +# Download the artifact via name to current dir +exec gh run download -n 'my-artifact' ${RUN_ID} + +# Check that we downloaded the artifact and extracted into the current dir +exists ./child/world.txt + +# Download the artifact via name to a specific dir +exec gh run download -n 'my-artifact' ${RUN_ID} --dir '..' + +# Check that we downloaded the artifact and extracted into the specified dir +exists ../child/world.txt + +-- workflow.yml -- +# This is a basic workflow to help you get started with Actions + +name: Test Workflow Name + +# Controls when the workflow will run +on: + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + - run: | + mkdir -p ./parent/child + echo hello > ./parent/child/world.txt + - uses: actions/upload-artifact@v4 + with: + name: my-artifact + path: ./parent diff --git a/acceptance/testdata/workflow/run-rerun.txtar b/acceptance/testdata/workflow/run-rerun.txtar new file mode 100644 index 00000000000..446aabbc4ca --- /dev/null +++ b/acceptance/testdata/workflow/run-rerun.txtar @@ -0,0 +1,72 @@ +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# commit the workflow file +cd $SCRIPT_NAME-$RANDOM_STRING +mkdir .github/workflows +mv ../workflow.yml .github/workflows/workflow.yml +exec git add .github/workflows/workflow.yml +exec git commit -m 'Create workflow file' +exec git push -u origin main + +# Sleep because it takes a second for the workflow to register +sleep 1 + +# Check the workflow is indeed created +exec gh workflow list +stdout 'Test Workflow Name' + +# Run the workflow +exec gh workflow run 'Test Workflow Name' + +# It takes some time for a workflow run to register +sleep 10 + +# Get the run ID we want to rerun +exec gh run list --json databaseId --jq '.[0].databaseId' +stdout2env RUN_ID + +# Wait for workflow to complete +exec gh run watch $RUN_ID --exit-status + +# Rerun the workflow run +exec gh run rerun $RUN_ID + +# It takes some time for a workflow run to register +sleep 10 + +# Wait for workflow to complete +exec gh run watch $RUN_ID --exit-status + +-- workflow.yml -- +# This is a basic workflow to help you get started with Actions + +name: Test Workflow Name + +# Controls when the workflow will run +on: + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + + # Runs a single command using the runners shell + - name: Run a one-line script + run: echo Hello, world! diff --git a/acceptance/testdata/workflow/run-view.txtar b/acceptance/testdata/workflow/run-view.txtar new file mode 100644 index 00000000000..25f12c3a520 --- /dev/null +++ b/acceptance/testdata/workflow/run-view.txtar @@ -0,0 +1,66 @@ +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# commit the workflow file +cd $SCRIPT_NAME-$RANDOM_STRING +mkdir .github/workflows +mv ../workflow.yml .github/workflows/workflow.yml +exec git add .github/workflows/workflow.yml +exec git commit -m 'Create workflow file' +exec git push -u origin main + +# Sleep because it takes a second for the workflow to register +sleep 1 + +# Check the workflow is indeed created +exec gh workflow list +stdout 'Test Workflow Name' + +# Run the workflow +exec gh workflow run 'Test Workflow Name' + +# It takes some time for a workflow run to register +sleep 10 + +# Get the run ID we want to view +exec gh run list --json databaseId --jq '.[0].databaseId' +stdout2env RUN_ID + +# Wait for workflow to complete +exec gh run watch $RUN_ID --exit-status + +# View the workflow run +exec gh run view $RUN_ID + +-- workflow.yml -- +# This is a basic workflow to help you get started with Actions + +name: Test Workflow Name + +# Controls when the workflow will run +on: + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + + # Runs a single command using the runners shell + - name: Run a one-line script + run: echo Hello, world! diff --git a/acceptance/testdata/workflow/workflow-enable-disable.txtar b/acceptance/testdata/workflow/workflow-enable-disable.txtar new file mode 100644 index 00000000000..f0b58116fc4 --- /dev/null +++ b/acceptance/testdata/workflow/workflow-enable-disable.txtar @@ -0,0 +1,66 @@ +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# commit the workflow file +cd $SCRIPT_NAME-$RANDOM_STRING +mkdir .github/workflows +mv ../workflow.yml .github/workflows/workflow.yml +exec git add .github/workflows/workflow.yml +exec git commit -m 'Create workflow file' +exec git push -u origin main + +# Sleep because it takes a second for the workflow to register +sleep 1 + +# Check the workflow is indeed created +exec gh workflow list +stdout 'Test Workflow Name' + +# disable the workflow +exec gh workflow disable 'Test Workflow Name' + +# Check that the listing shows it is disabled +exec gh workflow list --all +stdout 'Test\s+Workflow\s+Name\s+disabled_manually\s+\d+' + +# enable the workflow +exec gh workflow enable 'Test Workflow Name' + +# Check the workflow is indeed enabled +exec gh workflow list +stdout 'Test\s+Workflow\s+Name\s+active\s+\d+' + +-- workflow.yml -- +# This is a basic workflow to help you get started with Actions + +name: Test Workflow Name + +# Controls when the workflow will run +on: + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v4 + + # Runs a single command using the runners shell + - name: Run a one-line script + run: echo Hello, world! diff --git a/acceptance/testdata/workflow/workflow-list.txtar b/acceptance/testdata/workflow/workflow-list.txtar new file mode 100644 index 00000000000..ad0d87c88bd --- /dev/null +++ b/acceptance/testdata/workflow/workflow-list.txtar @@ -0,0 +1,52 @@ +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# commit the workflow file +cd $SCRIPT_NAME-$RANDOM_STRING +mkdir .github/workflows +mv ../workflow.yml .github/workflows/workflow.yml +exec git add .github/workflows/workflow.yml +exec git commit -m 'Create workflow file' +exec git push -u origin main + +# Sleep because it takes a second for the workflow to register +sleep 1 + +# Check the workflow is indeed created +exec gh workflow list +stdout 'Test Workflow Name' + +-- workflow.yml -- +# This is a basic workflow to help you get started with Actions + +name: Test Workflow Name + +# Controls when the workflow will run +on: + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v4 + + # Runs a single command using the runners shell + - name: Run a one-line script + run: echo Hello, world! diff --git a/acceptance/testdata/workflow/workflow-run.txtar b/acceptance/testdata/workflow/workflow-run.txtar new file mode 100644 index 00000000000..010189c0141 --- /dev/null +++ b/acceptance/testdata/workflow/workflow-run.txtar @@ -0,0 +1,62 @@ +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# commit the workflow file +cd $SCRIPT_NAME-$RANDOM_STRING +mkdir .github/workflows +mv ../workflow.yml .github/workflows/workflow.yml +exec git add .github/workflows/workflow.yml +exec git commit -m 'Create workflow file' +exec git push -u origin main + +# Sleep because it takes a second for the workflow to register +sleep 1 + +# Check the workflow is indeed created +exec gh workflow list +stdout 'Test Workflow Name' + +# Run the workflow +exec gh workflow run 'Test Workflow Name' + +# It takes some time for a workflow run to register +sleep 10 + +# Check the workflow run exists +exec gh run list +stdout 'Test Workflow Name' + +-- workflow.yml -- +# This is a basic workflow to help you get started with Actions + +name: Test Workflow Name + +# Controls when the workflow will run +on: + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v4 + + # Runs a single command using the runners shell + - name: Run a one-line script + run: echo Hello, world! diff --git a/acceptance/testdata/workflow/workflow-view.txtar b/acceptance/testdata/workflow/workflow-view.txtar new file mode 100644 index 00000000000..d3bc3d25224 --- /dev/null +++ b/acceptance/testdata/workflow/workflow-view.txtar @@ -0,0 +1,52 @@ +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# commit the workflow file +cd $SCRIPT_NAME-$RANDOM_STRING +mkdir .github/workflows +mv ../workflow.yml .github/workflows/workflow.yml +exec git add .github/workflows/workflow.yml +exec git commit -m 'Create workflow file' +exec git push -u origin main + +# Sleep because it takes a second for the workflow to register +sleep 1 + +# Check the workflow is indeed created +exec gh workflow view 'Test Workflow Name' +stdout 'Test Workflow Name - workflow.yml' + +-- workflow.yml -- +# This is a basic workflow to help you get started with Actions + +name: Test Workflow Name + +# Controls when the workflow will run +on: + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v4 + + # Runs a single command using the runners shell + - name: Run a one-line script + run: echo Hello, world! diff --git a/api/cache.go b/api/cache.go deleted file mode 100644 index fc9d1c5ba2a..00000000000 --- a/api/cache.go +++ /dev/null @@ -1,179 +0,0 @@ -package api - -import ( - "bufio" - "bytes" - "crypto/sha256" - "errors" - "fmt" - "io" - "io/ioutil" - "net/http" - "os" - "path/filepath" - "strings" - "sync" - "time" -) - -func NewCachedClient(httpClient *http.Client, cacheTTL time.Duration) *http.Client { - cacheDir := filepath.Join(os.TempDir(), "gh-cli-cache") - return &http.Client{ - Transport: CacheResponse(cacheTTL, cacheDir)(httpClient.Transport), - } -} - -func isCacheableRequest(req *http.Request) bool { - if strings.EqualFold(req.Method, "GET") || strings.EqualFold(req.Method, "HEAD") { - return true - } - - if strings.EqualFold(req.Method, "POST") && (req.URL.Path == "/graphql" || req.URL.Path == "/api/graphql") { - return true - } - - return false -} - -func isCacheableResponse(res *http.Response) bool { - return res.StatusCode < 500 && res.StatusCode != 403 -} - -// CacheResponse produces a RoundTripper that caches HTTP responses to disk for a specified amount of time -func CacheResponse(ttl time.Duration, dir string) ClientOption { - fs := fileStorage{ - dir: dir, - ttl: ttl, - mu: &sync.RWMutex{}, - } - - return func(tr http.RoundTripper) http.RoundTripper { - return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) { - if !isCacheableRequest(req) { - return tr.RoundTrip(req) - } - - key, keyErr := cacheKey(req) - if keyErr == nil { - if res, err := fs.read(key); err == nil { - res.Request = req - return res, nil - } - } - - res, err := tr.RoundTrip(req) - if err == nil && keyErr == nil && isCacheableResponse(res) { - _ = fs.store(key, res) - } - return res, err - }} - } -} - -func copyStream(r io.ReadCloser) (io.ReadCloser, io.ReadCloser) { - b := &bytes.Buffer{} - nr := io.TeeReader(r, b) - return ioutil.NopCloser(b), &readCloser{ - Reader: nr, - Closer: r, - } -} - -type readCloser struct { - io.Reader - io.Closer -} - -func cacheKey(req *http.Request) (string, error) { - h := sha256.New() - fmt.Fprintf(h, "%s:", req.Method) - fmt.Fprintf(h, "%s:", req.URL.String()) - fmt.Fprintf(h, "%s:", req.Header.Get("Accept")) - fmt.Fprintf(h, "%s:", req.Header.Get("Authorization")) - - if req.Body != nil { - var bodyCopy io.ReadCloser - req.Body, bodyCopy = copyStream(req.Body) - defer bodyCopy.Close() - if _, err := io.Copy(h, bodyCopy); err != nil { - return "", err - } - } - - digest := h.Sum(nil) - return fmt.Sprintf("%x", digest), nil -} - -type fileStorage struct { - dir string - ttl time.Duration - mu *sync.RWMutex -} - -func (fs *fileStorage) filePath(key string) string { - if len(key) >= 6 { - return filepath.Join(fs.dir, key[0:2], key[2:4], key[4:]) - } - return filepath.Join(fs.dir, key) -} - -func (fs *fileStorage) read(key string) (*http.Response, error) { - cacheFile := fs.filePath(key) - - fs.mu.RLock() - defer fs.mu.RUnlock() - - f, err := os.Open(cacheFile) - if err != nil { - return nil, err - } - defer f.Close() - - stat, err := f.Stat() - if err != nil { - return nil, err - } - - age := time.Since(stat.ModTime()) - if age > fs.ttl { - return nil, errors.New("cache expired") - } - - body := &bytes.Buffer{} - _, err = io.Copy(body, f) - if err != nil { - return nil, err - } - - res, err := http.ReadResponse(bufio.NewReader(body), nil) - return res, err -} - -func (fs *fileStorage) store(key string, res *http.Response) error { - cacheFile := fs.filePath(key) - - fs.mu.Lock() - defer fs.mu.Unlock() - - err := os.MkdirAll(filepath.Dir(cacheFile), 0755) - if err != nil { - return err - } - - f, err := os.OpenFile(cacheFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) - if err != nil { - return err - } - defer f.Close() - - var origBody io.ReadCloser - if res.Body != nil { - origBody, res.Body = copyStream(res.Body) - defer res.Body.Close() - } - err = res.Write(f) - if origBody != nil { - res.Body = origBody - } - return err -} diff --git a/api/cache_test.go b/api/cache_test.go deleted file mode 100644 index f4a6a756ee4..00000000000 --- a/api/cache_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package api - -import ( - "bytes" - "fmt" - "io" - "io/ioutil" - "net/http" - "path/filepath" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func Test_CacheResponse(t *testing.T) { - counter := 0 - fakeHTTP := funcTripper{ - roundTrip: func(req *http.Request) (*http.Response, error) { - counter += 1 - body := fmt.Sprintf("%d: %s %s", counter, req.Method, req.URL.String()) - status := 200 - if req.URL.Path == "/error" { - status = 500 - } - return &http.Response{ - StatusCode: status, - Body: ioutil.NopCloser(bytes.NewBufferString(body)), - }, nil - }, - } - - cacheDir := filepath.Join(t.TempDir(), "gh-cli-cache") - httpClient := NewHTTPClient(ReplaceTripper(fakeHTTP), CacheResponse(time.Minute, cacheDir)) - - do := func(method, url string, body io.Reader) (string, error) { - req, err := http.NewRequest(method, url, body) - if err != nil { - return "", err - } - res, err := httpClient.Do(req) - if err != nil { - return "", err - } - defer res.Body.Close() - resBody, err := ioutil.ReadAll(res.Body) - if err != nil { - err = fmt.Errorf("ReadAll: %w", err) - } - return string(resBody), err - } - - var res string - var err error - - res, err = do("GET", "http://example.com/path", nil) - require.NoError(t, err) - assert.Equal(t, "1: GET http://example.com/path", res) - res, err = do("GET", "http://example.com/path", nil) - require.NoError(t, err) - assert.Equal(t, "1: GET http://example.com/path", res) - - res, err = do("GET", "http://example.com/path2", nil) - require.NoError(t, err) - assert.Equal(t, "2: GET http://example.com/path2", res) - - res, err = do("POST", "http://example.com/path2", nil) - require.NoError(t, err) - assert.Equal(t, "3: POST http://example.com/path2", res) - - res, err = do("POST", "http://example.com/graphql", bytes.NewBufferString(`hello`)) - require.NoError(t, err) - assert.Equal(t, "4: POST http://example.com/graphql", res) - res, err = do("POST", "http://example.com/graphql", bytes.NewBufferString(`hello`)) - require.NoError(t, err) - assert.Equal(t, "4: POST http://example.com/graphql", res) - - res, err = do("POST", "http://example.com/graphql", bytes.NewBufferString(`hello2`)) - require.NoError(t, err) - assert.Equal(t, "5: POST http://example.com/graphql", res) - - res, err = do("GET", "http://example.com/error", nil) - require.NoError(t, err) - assert.Equal(t, "6: GET http://example.com/error", res) - res, err = do("GET", "http://example.com/error", nil) - require.NoError(t, err) - assert.Equal(t, "7: GET http://example.com/error", res) -} diff --git a/api/client.go b/api/client.go index 3fcf0d930e9..b30f5f164b4 100644 --- a/api/client.go +++ b/api/client.go @@ -1,112 +1,35 @@ package api import ( - "bytes" + "context" "encoding/json" + "errors" "fmt" "io" - "io/ioutil" "net/http" - "net/url" "regexp" "strings" - "github.com/cli/cli/v2/internal/ghinstance" - graphql "github.com/cli/shurcooL-graphql" - "github.com/henvic/httpretty" + ghAPI "github.com/cli/go-gh/v2/pkg/api" + ghauth "github.com/cli/go-gh/v2/pkg/auth" ) -// ClientOption represents an argument to NewClient -type ClientOption = func(http.RoundTripper) http.RoundTripper - -// NewHTTPClient initializes an http.Client -func NewHTTPClient(opts ...ClientOption) *http.Client { - tr := http.DefaultTransport - for _, opt := range opts { - tr = opt(tr) - } - return &http.Client{Transport: tr} -} +const ( + accept = "Accept" + authorization = "Authorization" + cacheTTL = "X-GH-CACHE-TTL" + graphqlFeatures = "GraphQL-Features" + features = "merge_queue" + userAgent = "User-Agent" +) -// NewClient initializes a Client -func NewClient(opts ...ClientOption) *Client { - client := &Client{http: NewHTTPClient(opts...)} - return client -} +var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`) -// NewClientFromHTTP takes in an http.Client instance func NewClientFromHTTP(httpClient *http.Client) *Client { client := &Client{http: httpClient} return client } -// AddHeader turns a RoundTripper into one that adds a request header -func AddHeader(name, value string) ClientOption { - return func(tr http.RoundTripper) http.RoundTripper { - return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) { - if req.Header.Get(name) == "" { - req.Header.Add(name, value) - } - return tr.RoundTrip(req) - }} - } -} - -// AddHeaderFunc is an AddHeader that gets the string value from a function -func AddHeaderFunc(name string, getValue func(*http.Request) (string, error)) ClientOption { - return func(tr http.RoundTripper) http.RoundTripper { - return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) { - if req.Header.Get(name) != "" { - return tr.RoundTrip(req) - } - value, err := getValue(req) - if err != nil { - return nil, err - } - if value != "" { - req.Header.Add(name, value) - } - return tr.RoundTrip(req) - }} - } -} - -// VerboseLog enables request/response logging within a RoundTripper -func VerboseLog(out io.Writer, logTraffic bool, colorize bool) ClientOption { - logger := &httpretty.Logger{ - Time: true, - TLS: false, - Colors: colorize, - RequestHeader: logTraffic, - RequestBody: logTraffic, - ResponseHeader: logTraffic, - ResponseBody: logTraffic, - Formatters: []httpretty.Formatter{&httpretty.JSONFormatter{}}, - MaxResponseBody: 10000, - } - logger.SetOutput(out) - logger.SetBodyFilter(func(h http.Header) (skip bool, err error) { - return !inspectableMIMEType(h.Get("Content-Type")), nil - }) - return logger.RoundTripper -} - -// ReplaceTripper substitutes the underlying RoundTripper with a custom one -func ReplaceTripper(tr http.RoundTripper) ClientOption { - return func(http.RoundTripper) http.RoundTripper { - return tr - } -} - -type funcTripper struct { - roundTrip func(*http.Request) (*http.Response, error) -} - -func (tr funcTripper) RoundTrip(req *http.Request) (*http.Response, error) { - return tr.roundTrip(req) -} - -// Client facilitates making HTTP requests to the GitHub API type Client struct { http *http.Client } @@ -115,311 +38,236 @@ func (c *Client) HTTP() *http.Client { return c.http } -type graphQLResponse struct { - Data interface{} - Errors []GraphQLError -} - -// GraphQLError is a single error returned in a GraphQL response type GraphQLError struct { - Type string - Message string - Path []interface{} // mixed strings and numbers -} - -func (ge GraphQLError) PathString() string { - var res strings.Builder - for i, v := range ge.Path { - if i > 0 { - res.WriteRune('.') - } - fmt.Fprintf(&res, "%v", v) - } - return res.String() + *ghAPI.GraphQLError } -// GraphQLErrorResponse contains errors returned in a GraphQL response -type GraphQLErrorResponse struct { - Errors []GraphQLError -} - -func (gr GraphQLErrorResponse) Error() string { - errorMessages := make([]string, 0, len(gr.Errors)) - for _, e := range gr.Errors { - msg := e.Message - if p := e.PathString(); p != "" { - msg = fmt.Sprintf("%s (%s)", msg, p) - } - errorMessages = append(errorMessages, msg) - } - return fmt.Sprintf("GraphQL: %s", strings.Join(errorMessages, ", ")) -} - -// Match checks if this error is only about a specific type on a specific path. If the path argument ends -// with a ".", it will match all its subpaths as well. -func (gr GraphQLErrorResponse) Match(expectType, expectPath string) bool { - for _, e := range gr.Errors { - if e.Type != expectType || !matchPath(e.PathString(), expectPath) { - return false - } - } - return true -} - -func matchPath(p, expect string) bool { - if strings.HasSuffix(expect, ".") { - return strings.HasPrefix(p, expect) || p == strings.TrimSuffix(expect, ".") - } - return p == expect -} - -// HTTPError is an error returned by a failed API call type HTTPError struct { - StatusCode int - RequestURL *url.URL - Message string - Errors []HTTPErrorItem - + *ghAPI.HTTPError scopesSuggestion string } -type HTTPErrorItem struct { - Message string - Resource string - Field string - Code string -} - -func (err HTTPError) Error() string { - if msgs := strings.SplitN(err.Message, "\n", 2); len(msgs) > 1 { - return fmt.Sprintf("HTTP %d: %s (%s)\n%s", err.StatusCode, msgs[0], err.RequestURL, msgs[1]) - } else if err.Message != "" { - return fmt.Sprintf("HTTP %d: %s (%s)", err.StatusCode, err.Message, err.RequestURL) - } - return fmt.Sprintf("HTTP %d (%s)", err.StatusCode, err.RequestURL) -} - func (err HTTPError) ScopesSuggestion() string { return err.scopesSuggestion } -// ScopesSuggestion is an error messaging utility that prints the suggestion to request additional OAuth -// scopes in case a server response indicates that there are missing scopes. -func ScopesSuggestion(resp *http.Response) string { - if resp.StatusCode < 400 || resp.StatusCode > 499 || resp.StatusCode == 422 { - return "" - } - - endpointNeedsScopes := resp.Header.Get("X-Accepted-Oauth-Scopes") - tokenHasScopes := resp.Header.Get("X-Oauth-Scopes") - if tokenHasScopes == "" { - return "" - } - - gotScopes := map[string]struct{}{} - for _, s := range strings.Split(tokenHasScopes, ",") { - s = strings.TrimSpace(s) - gotScopes[s] = struct{}{} - if strings.HasPrefix(s, "admin:") { - gotScopes["read:"+strings.TrimPrefix(s, "admin:")] = struct{}{} - gotScopes["write:"+strings.TrimPrefix(s, "admin:")] = struct{}{} - } else if strings.HasPrefix(s, "write:") { - gotScopes["read:"+strings.TrimPrefix(s, "write:")] = struct{}{} - } - } - - for _, s := range strings.Split(endpointNeedsScopes, ",") { - s = strings.TrimSpace(s) - if _, gotScope := gotScopes[s]; s == "" || gotScope { - continue - } - return fmt.Sprintf( - "This API operation needs the %[1]q scope. To request it, run: gh auth refresh -h %[2]s -s %[1]s", - s, - ghinstance.NormalizeHostname(resp.Request.URL.Hostname()), - ) - } - - return "" -} - -// EndpointNeedsScopes adds additional OAuth scopes to an HTTP response as if they were returned from the -// server endpoint. This improves HTTP 4xx error messaging for endpoints that don't explicitly list the -// OAuth scopes they need. -func EndpointNeedsScopes(resp *http.Response, s string) *http.Response { - if resp.StatusCode >= 400 && resp.StatusCode < 500 { - oldScopes := resp.Header.Get("X-Accepted-Oauth-Scopes") - resp.Header.Set("X-Accepted-Oauth-Scopes", fmt.Sprintf("%s, %s", oldScopes, s)) - } - return resp -} - -// GraphQL performs a GraphQL request and parses the response. If there are errors in the response, -// *GraphQLErrorResponse will be returned, but the data will also be parsed into the receiver. +// GraphQL performs a GraphQL request using the query string and parses the response into data receiver. If there are errors in the response, +// GraphQLError will be returned, but the receiver will also be partially populated. func (c Client) GraphQL(hostname string, query string, variables map[string]interface{}, data interface{}) error { - reqBody, err := json.Marshal(map[string]interface{}{"query": query, "variables": variables}) + opts := clientOptions(hostname, c.http.Transport) + opts.Headers[graphqlFeatures] = features + gqlClient, err := ghAPI.NewGraphQLClient(opts) if err != nil { return err } + return handleResponse(gqlClient.Do(query, variables, data)) +} - req, err := http.NewRequest("POST", ghinstance.GraphQLEndpoint(hostname), bytes.NewBuffer(reqBody)) +// Mutate performs a GraphQL mutation based on a struct and parses the response with the same struct as the receiver. If there are errors in the response, +// GraphQLError will be returned, but the receiver will also be partially populated. +func (c Client) Mutate(hostname, name string, mutation interface{}, variables map[string]interface{}) error { + opts := clientOptions(hostname, c.http.Transport) + opts.Headers[graphqlFeatures] = features + gqlClient, err := ghAPI.NewGraphQLClient(opts) if err != nil { return err } + return handleResponse(gqlClient.Mutate(name, mutation, variables)) +} - req.Header.Set("Content-Type", "application/json; charset=utf-8") - - resp, err := c.http.Do(req) +// Query performs a GraphQL query based on a struct and parses the response with the same struct as the receiver. If there are errors in the response, +// GraphQLError will be returned, but the receiver will also be partially populated. +func (c Client) Query(hostname, name string, query interface{}, variables map[string]interface{}) error { + opts := clientOptions(hostname, c.http.Transport) + opts.Headers[graphqlFeatures] = features + gqlClient, err := ghAPI.NewGraphQLClient(opts) if err != nil { return err } - defer resp.Body.Close() - - return handleResponse(resp, data) + return handleResponse(gqlClient.Query(name, query, variables)) } -func graphQLClient(h *http.Client, hostname string) *graphql.Client { - return graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), h) +// QueryWithContext performs a GraphQL query based on a struct and parses the response with the same struct as the receiver. If there are errors in the response, +// GraphQLError will be returned, but the receiver will also be partially populated. +func (c Client) QueryWithContext(ctx context.Context, hostname, name string, query interface{}, variables map[string]interface{}) error { + opts := clientOptions(hostname, c.http.Transport) + opts.Headers[graphqlFeatures] = features + gqlClient, err := ghAPI.NewGraphQLClient(opts) + if err != nil { + return err + } + return handleResponse(gqlClient.QueryWithContext(ctx, name, query, variables)) } // REST performs a REST request and parses the response. func (c Client) REST(hostname string, method string, p string, body io.Reader, data interface{}) error { - req, err := http.NewRequest(method, restURL(hostname, p), body) + opts := clientOptions(hostname, c.http.Transport) + restClient, err := ghAPI.NewRESTClient(opts) if err != nil { return err } + return handleResponse(restClient.Do(method, p, body, data)) +} - req.Header.Set("Content-Type", "application/json; charset=utf-8") +func (c Client) RESTWithNext(hostname string, method string, p string, body io.Reader, data interface{}) (string, error) { + opts := clientOptions(hostname, c.http.Transport) + restClient, err := ghAPI.NewRESTClient(opts) + if err != nil { + return "", err + } - resp, err := c.http.Do(req) + resp, err := restClient.Request(method, p, body) if err != nil { - return err + return "", err } defer resp.Body.Close() success := resp.StatusCode >= 200 && resp.StatusCode < 300 if !success { - return HandleHTTPError(resp) + return "", HandleHTTPError(resp) } if resp.StatusCode == http.StatusNoContent { - return nil + return "", nil } - b, err := ioutil.ReadAll(resp.Body) + b, err := io.ReadAll(resp.Body) if err != nil { - return err + return "", err } + err = json.Unmarshal(b, &data) if err != nil { - return err + return "", err } - return nil -} - -func restURL(hostname string, pathOrURL string) string { - if strings.HasPrefix(pathOrURL, "https://") || strings.HasPrefix(pathOrURL, "http://") { - return pathOrURL + var next string + for _, m := range linkRE.FindAllStringSubmatch(resp.Header.Get("Link"), -1) { + if len(m) > 2 && m[2] == "next" { + next = m[1] + } } - return ghinstance.RESTPrefix(hostname) + pathOrURL + + return next, nil } -func handleResponse(resp *http.Response, data interface{}) error { - success := resp.StatusCode >= 200 && resp.StatusCode < 300 +// HandleHTTPError parses a http.Response into a HTTPError. +func HandleHTTPError(resp *http.Response) error { + return handleResponse(ghAPI.HandleHTTPError(resp)) +} - if !success { - return HandleHTTPError(resp) +// handleResponse takes a ghAPI.HTTPError or ghAPI.GraphQLError and converts it into an +// HTTPError or GraphQLError respectively. +func handleResponse(err error) error { + if err == nil { + return nil } - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - return err + var restErr *ghAPI.HTTPError + if errors.As(err, &restErr) { + return HTTPError{ + HTTPError: restErr, + scopesSuggestion: generateScopesSuggestion(restErr.StatusCode, + restErr.Headers.Get("X-Accepted-Oauth-Scopes"), + restErr.Headers.Get("X-Oauth-Scopes"), + restErr.RequestURL.Hostname()), + } } - gr := &graphQLResponse{Data: data} - err = json.Unmarshal(body, &gr) - if err != nil { - return err + var gqlErr *ghAPI.GraphQLError + if errors.As(err, &gqlErr) { + return GraphQLError{ + GraphQLError: gqlErr, + } } - if len(gr.Errors) > 0 { - return &GraphQLErrorResponse{Errors: gr.Errors} - } - return nil + return err } -func HandleHTTPError(resp *http.Response) error { - httpError := HTTPError{ - StatusCode: resp.StatusCode, - RequestURL: resp.Request.URL, - scopesSuggestion: ScopesSuggestion(resp), - } +// ScopesSuggestion is an error messaging utility that prints the suggestion to request additional OAuth +// scopes in case a server response indicates that there are missing scopes. +func ScopesSuggestion(resp *http.Response) string { + return generateScopesSuggestion(resp.StatusCode, + resp.Header.Get("X-Accepted-Oauth-Scopes"), + resp.Header.Get("X-Oauth-Scopes"), + resp.Request.URL.Hostname()) +} - if !jsonTypeRE.MatchString(resp.Header.Get("Content-Type")) { - httpError.Message = resp.Status - return httpError +// EndpointNeedsScopes adds additional OAuth scopes to an HTTP response as if they were returned from the +// server endpoint. This improves HTTP 4xx error messaging for endpoints that don't explicitly list the +// OAuth scopes they need. +func EndpointNeedsScopes(resp *http.Response, s string) *http.Response { + if resp.StatusCode >= 400 && resp.StatusCode < 500 { + oldScopes := resp.Header.Get("X-Accepted-Oauth-Scopes") + resp.Header.Set("X-Accepted-Oauth-Scopes", fmt.Sprintf("%s, %s", oldScopes, s)) } + return resp +} - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - httpError.Message = err.Error() - return httpError +func generateScopesSuggestion(statusCode int, endpointNeedsScopes, tokenHasScopes, hostname string) string { + if statusCode < 400 || statusCode > 499 || statusCode == 422 { + return "" } - var parsedBody struct { - Message string `json:"message"` - Errors []json.RawMessage - } - if err := json.Unmarshal(body, &parsedBody); err != nil { - return httpError + if tokenHasScopes == "" { + return "" } - var messages []string - if parsedBody.Message != "" { - messages = append(messages, parsedBody.Message) - } - for _, raw := range parsedBody.Errors { - switch raw[0] { - case '"': - var errString string - _ = json.Unmarshal(raw, &errString) - messages = append(messages, errString) - httpError.Errors = append(httpError.Errors, HTTPErrorItem{Message: errString}) - case '{': - var errInfo HTTPErrorItem - _ = json.Unmarshal(raw, &errInfo) - msg := errInfo.Message - if errInfo.Code != "" && errInfo.Code != "custom" { - msg = fmt.Sprintf("%s.%s %s", errInfo.Resource, errInfo.Field, errorCodeToMessage(errInfo.Code)) - } - if msg != "" { - messages = append(messages, msg) - } - httpError.Errors = append(httpError.Errors, errInfo) + gotScopes := map[string]struct{}{} + for _, s := range strings.Split(tokenHasScopes, ",") { + s = strings.TrimSpace(s) + gotScopes[s] = struct{}{} + + // Certain scopes may be grouped under a single "top-level" scope. The following branch + // statements include these grouped/implied scopes when the top-level scope is encountered. + // See https://docs.github.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps. + if s == "repo" { + gotScopes["repo:status"] = struct{}{} + gotScopes["repo_deployment"] = struct{}{} + gotScopes["public_repo"] = struct{}{} + gotScopes["repo:invite"] = struct{}{} + gotScopes["security_events"] = struct{}{} + } else if s == "user" { + gotScopes["read:user"] = struct{}{} + gotScopes["user:email"] = struct{}{} + gotScopes["user:follow"] = struct{}{} + } else if s == "codespace" { + gotScopes["codespace:secrets"] = struct{}{} + } else if strings.HasPrefix(s, "admin:") { + gotScopes["read:"+strings.TrimPrefix(s, "admin:")] = struct{}{} + gotScopes["write:"+strings.TrimPrefix(s, "admin:")] = struct{}{} + } else if strings.HasPrefix(s, "write:") { + gotScopes["read:"+strings.TrimPrefix(s, "write:")] = struct{}{} } } - httpError.Message = strings.Join(messages, "\n") - - return httpError -} -func errorCodeToMessage(code string) string { - // https://docs.github.com/en/rest/overview/resources-in-the-rest-api#client-errors - switch code { - case "missing", "missing_field": - return "is missing" - case "invalid", "unprocessable": - return "is invalid" - case "already_exists": - return "already exists" - default: - return code + for _, s := range strings.Split(endpointNeedsScopes, ",") { + s = strings.TrimSpace(s) + if _, gotScope := gotScopes[s]; s == "" || gotScope { + continue + } + return fmt.Sprintf( + "This API operation needs the %[1]q scope. To request it, run: gh auth refresh -h %[2]s -s %[1]s", + s, + ghauth.NormalizeHostname(hostname), + ) } -} -var jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`) + return "" +} -func inspectableMIMEType(t string) bool { - return strings.HasPrefix(t, "text/") || jsonTypeRE.MatchString(t) +func clientOptions(hostname string, transport http.RoundTripper) ghAPI.ClientOptions { + // AuthToken, and Headers are being handled by transport, + // so let go-gh know that it does not need to resolve them. + opts := ghAPI.ClientOptions{ + AuthToken: "none", + Headers: map[string]string{ + authorization: "", + }, + Host: hostname, + SkipDefaultHeaders: true, + Transport: transport, + LogIgnoreEnv: true, + } + return opts } diff --git a/api/client_test.go b/api/client_test.go index ccf911f9453..1701a17a967 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -3,20 +3,25 @@ package api import ( "bytes" "errors" - "io/ioutil" + "io" "net/http" + "net/http/httptest" "testing" "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" "github.com/stretchr/testify/assert" ) +func newTestClient(reg *httpmock.Registry) *Client { + client := &http.Client{} + httpmock.ReplaceTripper(client, reg) + return NewClientFromHTTP(client) +} + func TestGraphQL(t *testing.T) { http := &httpmock.Registry{} - client := NewClient( - ReplaceTripper(http), - AddHeader("Authorization", "token OTOKEN"), - ) + client := newTestClient(http) vars := map[string]interface{}{"name": "Mona"} response := struct { @@ -35,18 +40,17 @@ func TestGraphQL(t *testing.T) { assert.Equal(t, "hubot", response.Viewer.Login) req := http.Requests[0] - reqBody, _ := ioutil.ReadAll(req.Body) + reqBody, _ := io.ReadAll(req.Body) assert.Equal(t, `{"query":"QUERY","variables":{"name":"Mona"}}`, string(reqBody)) - assert.Equal(t, "token OTOKEN", req.Header.Get("Authorization")) } func TestGraphQLError(t *testing.T) { - http := &httpmock.Registry{} - client := NewClient(ReplaceTripper(http)) + reg := &httpmock.Registry{} + client := newTestClient(reg) response := struct{}{} - http.Register( + reg.Register( httpmock.GraphQL(""), httpmock.StringResponse(` { "errors": [ @@ -73,10 +77,7 @@ func TestGraphQLError(t *testing.T) { func TestRESTGetDelete(t *testing.T) { http := &httpmock.Registry{} - - client := NewClient( - ReplaceTripper(http), - ) + client := newTestClient(http) http.Register( httpmock.REST("DELETE", "applications/CLIENTID/grant"), @@ -90,7 +91,7 @@ func TestRESTGetDelete(t *testing.T) { func TestRESTWithFullURL(t *testing.T) { http := &httpmock.Registry{} - client := NewClient(ReplaceTripper(http)) + client := newTestClient(http) http.Register( httpmock.REST("GET", "api/v3/user/repos"), @@ -110,13 +111,13 @@ func TestRESTWithFullURL(t *testing.T) { func TestRESTError(t *testing.T) { fakehttp := &httpmock.Registry{} - client := NewClient(ReplaceTripper(fakehttp)) + client := newTestClient(fakehttp) fakehttp.Register(httpmock.MatchAny, func(req *http.Request) (*http.Response, error) { return &http.Response{ Request: req, StatusCode: 422, - Body: ioutil.NopCloser(bytes.NewBufferString(`{"message": "OH NO"}`)), + Body: io.NopCloser(bytes.NewBufferString(`{"message": "OH NO"}`)), Header: map[string][]string{ "Content-Type": {"application/json; charset=utf-8"}, }, @@ -134,7 +135,6 @@ func TestRESTError(t *testing.T) { } if httpErr.Error() != "HTTP 422: OH NO (https://api.github.com/repos/branch)" { t.Errorf("got %q", httpErr.Error()) - } } @@ -146,7 +146,7 @@ func TestHandleHTTPError_GraphQL502(t *testing.T) { resp := &http.Response{ Request: req, StatusCode: 502, - Body: ioutil.NopCloser(bytes.NewBufferString(`{ "data": null, "errors": [{ "message": "Something went wrong" }] }`)), + Body: io.NopCloser(bytes.NewBufferString(`{ "data": null, "errors": [{ "message": "Something went wrong" }] }`)), Header: map[string][]string{"Content-Type": {"application/json"}}, } err = HandleHTTPError(resp) @@ -164,7 +164,7 @@ func TestHTTPError_ScopesSuggestion(t *testing.T) { return &http.Response{ Request: req, StatusCode: s, - Body: ioutil.NopCloser(bytes.NewBufferString(`{}`)), + Body: io.NopCloser(bytes.NewBufferString(`{}`)), Header: map[string][]string{ "Content-Type": {"application/json"}, "X-Oauth-Scopes": {haveScopes}, @@ -223,3 +223,35 @@ func TestHTTPError_ScopesSuggestion(t *testing.T) { }) } } + +func TestHTTPHeaders(t *testing.T) { + var gotReq *http.Request + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotReq = r + w.WriteHeader(http.StatusNoContent) + })) + defer ts.Close() + + ios, _, _, stderr := iostreams.Test() + httpClient, err := NewHTTPClient(HTTPClientOptions{ + AppVersion: "v1.2.3", + Config: tinyConfig{ts.URL[7:] + ":oauth_token": "MYTOKEN"}, + Log: ios.ErrOut, + }) + assert.NoError(t, err) + client := NewClientFromHTTP(httpClient) + + err = client.REST(ts.URL, "GET", ts.URL+"/user/repos", nil, nil) + assert.NoError(t, err) + + wantHeader := map[string]string{ + "Accept": "application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview", + "Authorization": "token MYTOKEN", + "Content-Type": "application/json; charset=utf-8", + "User-Agent": "GitHub CLI v1.2.3", + } + for name, value := range wantHeader { + assert.Equal(t, value, gotReq.Header.Get(name), name) + } + assert.Equal(t, "", stderr.String()) +} diff --git a/api/export_pr.go b/api/export_pr.go index 18bce025b16..bb33108118a 100644 --- a/api/export_pr.go +++ b/api/export_pr.go @@ -19,6 +19,15 @@ func (issue *Issue) ExportData(fields []string) map[string]interface{} { data[f] = issue.Labels.Nodes case "projectCards": data[f] = issue.ProjectCards.Nodes + case "projectItems": + items := make([]map[string]interface{}, 0, len(issue.ProjectItems.Nodes)) + for _, n := range issue.ProjectItems.Nodes { + items = append(items, map[string]interface{}{ + "status": n.Status, + "title": n.Project.Title, + }) + } + data[f] = items default: sf := fieldByName(v, f) data[f] = sf.Interface() @@ -38,7 +47,30 @@ func (pr *PullRequest) ExportData(fields []string) map[string]interface{} { data[f] = pr.HeadRepository case "statusCheckRollup": if n := pr.StatusCheckRollup.Nodes; len(n) > 0 { - data[f] = n[0].Commit.StatusCheckRollup.Contexts.Nodes + checks := make([]interface{}, 0, len(n[0].Commit.StatusCheckRollup.Contexts.Nodes)) + for _, c := range n[0].Commit.StatusCheckRollup.Contexts.Nodes { + if c.TypeName == "CheckRun" { + checks = append(checks, map[string]interface{}{ + "__typename": c.TypeName, + "name": c.Name, + "workflowName": c.CheckSuite.WorkflowRun.Workflow.Name, + "status": c.Status, + "conclusion": c.Conclusion, + "startedAt": c.StartedAt, + "completedAt": c.CompletedAt, + "detailsUrl": c.DetailsURL, + }) + } else { + checks = append(checks, map[string]interface{}{ + "__typename": c.TypeName, + "context": c.Context, + "state": c.State, + "targetUrl": c.TargetURL, + "startedAt": c.CreatedAt, + }) + } + } + data[f] = checks } else { data[f] = nil } @@ -73,8 +105,19 @@ func (pr *PullRequest) ExportData(fields []string) map[string]interface{} { data[f] = pr.Labels.Nodes case "projectCards": data[f] = pr.ProjectCards.Nodes + case "projectItems": + items := make([]map[string]interface{}, 0, len(pr.ProjectItems.Nodes)) + for _, n := range pr.ProjectItems.Nodes { + items = append(items, map[string]interface{}{ + "status": n.Status, + "title": n.Project.Title, + }) + } + data[f] = items case "reviews": data[f] = pr.Reviews.Nodes + case "latestReviews": + data[f] = pr.LatestReviews.Nodes case "files": data[f] = pr.Files.Nodes case "reviewRequests": diff --git a/api/export_pr_test.go b/api/export_pr_test.go index dde730884bd..b7f4dcddbed 100644 --- a/api/export_pr_test.go +++ b/api/export_pr_test.go @@ -75,6 +75,38 @@ func TestIssue_ExportData(t *testing.T) { } `), }, + { + name: "project items", + fields: []string{"projectItems"}, + inputJSON: heredoc.Doc(` + { "projectItems": { "nodes": [ + { + "id": "PVTI_id", + "project": { + "id": "PVT_id", + "title": "Some Project" + }, + "status": { + "name": "Todo", + "optionId": "abc123" + } + } + ] } } + `), + outputJSON: heredoc.Doc(` + { + "projectItems": [ + { + "status": { + "optionId": "abc123", + "name": "Todo" + }, + "title": "Some Project" + } + ] + } + `), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -140,11 +172,19 @@ func TestPullRequest_ExportData(t *testing.T) { { "__typename": "CheckRun", "name": "mycheck", + "checkSuite": {"workflowRun": {"workflow": {"name": "myworkflow"}}}, "status": "COMPLETED", "conclusion": "SUCCESS", "startedAt": "2020-08-31T15:44:24+02:00", "completedAt": "2020-08-31T15:45:24+02:00", "detailsUrl": "http://example.com/details" + }, + { + "__typename": "StatusContext", + "context": "mycontext", + "state": "SUCCESS", + "createdAt": "2020-08-31T15:44:24+02:00", + "targetUrl": "http://example.com/details" } ] } } } } ] } } @@ -155,11 +195,51 @@ func TestPullRequest_ExportData(t *testing.T) { { "__typename": "CheckRun", "name": "mycheck", + "workflowName": "myworkflow", "status": "COMPLETED", "conclusion": "SUCCESS", "startedAt": "2020-08-31T15:44:24+02:00", "completedAt": "2020-08-31T15:45:24+02:00", "detailsUrl": "http://example.com/details" + }, + { + "__typename": "StatusContext", + "context": "mycontext", + "state": "SUCCESS", + "startedAt": "2020-08-31T15:44:24+02:00", + "targetUrl": "http://example.com/details" + } + ] + } + `), + }, + { + name: "project items", + fields: []string{"projectItems"}, + inputJSON: heredoc.Doc(` + { "projectItems": { "nodes": [ + { + "id": "PVTPR_id", + "project": { + "id": "PVT_id", + "title": "Some Project" + }, + "status": { + "name": "Todo", + "optionId": "abc123" + } + } + ] } } + `), + outputJSON: heredoc.Doc(` + { + "projectItems": [ + { + "status": { + "optionId": "abc123", + "name": "Todo" + }, + "title": "Some Project" } ] } @@ -178,7 +258,14 @@ func TestPullRequest_ExportData(t *testing.T) { enc := json.NewEncoder(&buf) enc.SetIndent("", "\t") require.NoError(t, enc.Encode(exported)) - assert.Equal(t, tt.outputJSON, buf.String()) + + var gotData interface{} + dec = json.NewDecoder(&buf) + require.NoError(t, dec.Decode(&gotData)) + var expectData interface{} + require.NoError(t, json.Unmarshal([]byte(tt.outputJSON), &expectData)) + + assert.Equal(t, expectData, gotData) }) } } diff --git a/api/http_client.go b/api/http_client.go new file mode 100644 index 00000000000..146b96df6b9 --- /dev/null +++ b/api/http_client.go @@ -0,0 +1,140 @@ +package api + +import ( + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/cli/cli/v2/utils" + ghAPI "github.com/cli/go-gh/v2/pkg/api" + ghauth "github.com/cli/go-gh/v2/pkg/auth" +) + +type tokenGetter interface { + ActiveToken(string) (string, string) +} + +type HTTPClientOptions struct { + AppVersion string + CacheTTL time.Duration + Config tokenGetter + EnableCache bool + Log io.Writer + LogColorize bool + LogVerboseHTTP bool +} + +func NewHTTPClient(opts HTTPClientOptions) (*http.Client, error) { + // Provide invalid host, and token values so gh.HTTPClient will not automatically resolve them. + // The real host and token are inserted at request time. + clientOpts := ghAPI.ClientOptions{ + Host: "none", + AuthToken: "none", + LogIgnoreEnv: true, + } + + debugEnabled, debugValue := utils.IsDebugEnabled() + if strings.Contains(debugValue, "api") { + opts.LogVerboseHTTP = true + } + + if opts.LogVerboseHTTP || debugEnabled { + clientOpts.Log = opts.Log + clientOpts.LogColorize = opts.LogColorize + clientOpts.LogVerboseHTTP = opts.LogVerboseHTTP + } + + headers := map[string]string{ + userAgent: fmt.Sprintf("GitHub CLI %s", opts.AppVersion), + } + clientOpts.Headers = headers + + if opts.EnableCache { + clientOpts.EnableCache = opts.EnableCache + clientOpts.CacheTTL = opts.CacheTTL + } + + client, err := ghAPI.NewHTTPClient(clientOpts) + if err != nil { + return nil, err + } + + if opts.Config != nil { + client.Transport = AddAuthTokenHeader(client.Transport, opts.Config) + } + + return client, nil +} + +func NewCachedHTTPClient(httpClient *http.Client, ttl time.Duration) *http.Client { + newClient := *httpClient + newClient.Transport = AddCacheTTLHeader(httpClient.Transport, ttl) + return &newClient +} + +// AddCacheTTLHeader adds an header to the request telling the cache that the request +// should be cached for a specified amount of time. +func AddCacheTTLHeader(rt http.RoundTripper, ttl time.Duration) http.RoundTripper { + return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) { + // If the header is already set in the request, don't overwrite it. + if req.Header.Get(cacheTTL) == "" { + req.Header.Set(cacheTTL, ttl.String()) + } + return rt.RoundTrip(req) + }} +} + +// AddAuthTokenHeader adds an authentication token header for the host specified by the request. +func AddAuthTokenHeader(rt http.RoundTripper, cfg tokenGetter) http.RoundTripper { + return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) { + // If the header is already set in the request, don't overwrite it. + if req.Header.Get(authorization) == "" { + var redirectHostnameChange bool + if req.Response != nil && req.Response.Request != nil { + redirectHostnameChange = getHost(req) != getHost(req.Response.Request) + } + // Only set header if an initial request or redirect request to the same host as the initial request. + // If the host has changed during a redirect do not add the authentication token header. + if !redirectHostnameChange { + hostname := ghauth.NormalizeHostname(getHost(req)) + if token, _ := cfg.ActiveToken(hostname); token != "" { + req.Header.Set(authorization, fmt.Sprintf("token %s", token)) + } + } + } + return rt.RoundTrip(req) + }} +} + +// ExtractHeader extracts a named header from any response received by this client and, +// if non-blank, saves it to dest. +func ExtractHeader(name string, dest *string) func(http.RoundTripper) http.RoundTripper { + return func(tr http.RoundTripper) http.RoundTripper { + return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) { + res, err := tr.RoundTrip(req) + if err == nil { + if value := res.Header.Get(name); value != "" { + *dest = value + } + } + return res, err + }} + } +} + +type funcTripper struct { + roundTrip func(*http.Request) (*http.Response, error) +} + +func (tr funcTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return tr.roundTrip(req) +} + +func getHost(r *http.Request) string { + if r.Host != "" { + return r.Host + } + return r.URL.Host +} diff --git a/api/http_client_test.go b/api/http_client_test.go new file mode 100644 index 00000000000..ce20a2684a4 --- /dev/null +++ b/api/http_client_test.go @@ -0,0 +1,285 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewHTTPClient(t *testing.T) { + type args struct { + config tokenGetter + appVersion string + logVerboseHTTP bool + } + tests := []struct { + name string + args args + host string + wantHeader map[string]string + wantStderr string + }{ + { + name: "github.com", + args: args{ + config: tinyConfig{"github.com:oauth_token": "MYTOKEN"}, + appVersion: "v1.2.3", + logVerboseHTTP: false, + }, + host: "github.com", + wantHeader: map[string]string{ + "authorization": "token MYTOKEN", + "user-agent": "GitHub CLI v1.2.3", + "accept": "application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview", + }, + wantStderr: "", + }, + { + name: "GHES", + args: args{ + config: tinyConfig{"example.com:oauth_token": "GHETOKEN"}, + appVersion: "v1.2.3", + }, + host: "example.com", + wantHeader: map[string]string{ + "authorization": "token GHETOKEN", + "user-agent": "GitHub CLI v1.2.3", + "accept": "application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview", + }, + wantStderr: "", + }, + { + name: "github.com no authentication token", + args: args{ + config: tinyConfig{"example.com:oauth_token": "MYTOKEN"}, + appVersion: "v1.2.3", + logVerboseHTTP: false, + }, + host: "github.com", + wantHeader: map[string]string{ + "authorization": "", + "user-agent": "GitHub CLI v1.2.3", + "accept": "application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview", + }, + wantStderr: "", + }, + { + name: "GHES no authentication token", + args: args{ + config: tinyConfig{"github.com:oauth_token": "MYTOKEN"}, + appVersion: "v1.2.3", + logVerboseHTTP: false, + }, + host: "example.com", + wantHeader: map[string]string{ + "authorization": "", + "user-agent": "GitHub CLI v1.2.3", + "accept": "application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview", + }, + wantStderr: "", + }, + { + name: "github.com in verbose mode", + args: args{ + config: tinyConfig{"github.com:oauth_token": "MYTOKEN"}, + appVersion: "v1.2.3", + logVerboseHTTP: true, + }, + host: "github.com", + wantHeader: map[string]string{ + "authorization": "token MYTOKEN", + "user-agent": "GitHub CLI v1.2.3", + "accept": "application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview", + }, + wantStderr: heredoc.Doc(` + * Request at