Merge branch 'trunk' into build/customize-prefix
This commit is contained in:
commit
85ae53e702
1251 changed files with 7084 additions and 175576 deletions
6
.github/CONTRIBUTING.md
vendored
6
.github/CONTRIBUTING.md
vendored
|
|
@ -2,19 +2,19 @@
|
|||
|
||||
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.
|
||||
We accept pull requests for issues labelled `help wanted`. We encourage issues and discussion posts for all other contributions.
|
||||
|
||||
### Please do:
|
||||
|
||||
* 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 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
|
||||
* Open an issue to propose a new community supported `gh` package with details about support and redistribution
|
||||
* 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 _do not_:
|
||||
### Please _do NOT_:
|
||||
|
||||
* 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
|
||||
|
|
|
|||
28
.github/ISSUE_TEMPLATE/feedback.md
vendored
28
.github/ISSUE_TEMPLATE/feedback.md
vendored
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
name: "\U0001F4E3 Feedback"
|
||||
about: Give us general feedback about the GitHub CLI
|
||||
title: ''
|
||||
labels: feedback
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
# CLI Feedback
|
||||
|
||||
You can use this template to give us structured feedback or just wipe it and leave us a note. Thank you!
|
||||
|
||||
## What have you loved?
|
||||
|
||||
_eg "the nice colors"_
|
||||
|
||||
## What was confusing or gave you pause?
|
||||
|
||||
_eg "it did something unexpected"_
|
||||
|
||||
## Are there features you'd like to see added?
|
||||
|
||||
_eg "gh cli needs mini-games"_
|
||||
|
||||
## Anything else?
|
||||
|
||||
_eg "have a nice day"_
|
||||
17
.github/licenses.tmpl
vendored
17
.github/licenses.tmpl
vendored
|
|
@ -1,13 +1,8 @@
|
|||
# GitHub CLI dependencies
|
||||
GitHub CLI third-party dependencies
|
||||
====================================
|
||||
|
||||
The following open source dependencies are used to build the [cli/cli][] GitHub CLI.
|
||||
The following open source dependencies are used to build the GitHub CLI.
|
||||
|
||||
## Go Packages
|
||||
|
||||
Some packages may only be included on certain architectures or operating systems.
|
||||
|
||||
{{ range . }}
|
||||
- [{{.Name}}](https://pkg.go.dev/{{.Name}}) ([{{.LicenseName}}]({{.LicenseURL}}))
|
||||
{{- end }}
|
||||
|
||||
[cli/cli]: https://github.com/cli/cli
|
||||
{{ range . -}}
|
||||
{{.Name}} ({{.Version}}) - {{.LicenseName}} - {{.LicenseURL}}
|
||||
{{ end }}
|
||||
|
|
|
|||
3
.github/secret_scanning.yml
vendored
3
.github/secret_scanning.yml
vendored
|
|
@ -1,3 +0,0 @@
|
|||
paths-ignore:
|
||||
- 'third-party/**'
|
||||
- 'third-party-licenses.*.md'
|
||||
1
.github/workflows/bump-go.yml
vendored
1
.github/workflows/bump-go.yml
vendored
|
|
@ -2,6 +2,7 @@ name: Bump Go
|
|||
on:
|
||||
schedule:
|
||||
- cron: "0 3 * * *" # 3 AM UTC
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
|
|
|||
56
.github/workflows/deployment.yml
vendored
56
.github/workflows/deployment.yml
vendored
|
|
@ -50,10 +50,18 @@ jobs:
|
|||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- name: Install GoReleaser
|
||||
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a
|
||||
uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0
|
||||
with:
|
||||
version: "~1.17.1"
|
||||
# The version is pinned not only for security purposes, but also to avoid breaking
|
||||
# our scripts, which rely on the specific file names generated by GoReleaser.
|
||||
version: v2.13.1
|
||||
install-only: true
|
||||
# We temporarily create a tag on HEAD to make the right version embedded
|
||||
# in the built binaries, BUT we don't push it to the remote.
|
||||
- name: Create temporary tag
|
||||
env:
|
||||
TAG_NAME: ${{ inputs.tag_name }}
|
||||
run: git tag "$TAG_NAME"
|
||||
- name: Build release binaries
|
||||
env:
|
||||
TAG_NAME: ${{ inputs.tag_name }}
|
||||
|
|
@ -62,7 +70,7 @@ jobs:
|
|||
run: |
|
||||
go run ./cmd/gen-docs --website --doc-path dist/manual
|
||||
tar -czvf dist/manual.tar.gz -C dist -- manual
|
||||
- uses: actions/upload-artifact@v5
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: linux
|
||||
if-no-files-found: error
|
||||
|
|
@ -103,10 +111,18 @@ jobs:
|
|||
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@e435ccd777264be153ace6237001ef4d979d3a7a
|
||||
uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0
|
||||
with:
|
||||
version: "~1.17.1"
|
||||
# The version is pinned not only for security purposes, but also to avoid breaking
|
||||
# our scripts, which rely on the specific file names generated by GoReleaser.
|
||||
version: v2.13.1
|
||||
install-only: true
|
||||
# We temporarily create a tag on HEAD to make the right version embedded
|
||||
# in the built binaries, BUT we don't push it to the remote.
|
||||
- name: Create temporary tag
|
||||
env:
|
||||
TAG_NAME: ${{ inputs.tag_name }}
|
||||
run: git tag "$TAG_NAME"
|
||||
- name: Build release binaries
|
||||
env:
|
||||
TAG_NAME: ${{ inputs.tag_name }}
|
||||
|
|
@ -134,7 +150,7 @@ jobs:
|
|||
run: |
|
||||
shopt -s failglob
|
||||
script/pkgmacos "$TAG_NAME"
|
||||
- uses: actions/upload-artifact@v5
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: macos
|
||||
if-no-files-found: error
|
||||
|
|
@ -157,9 +173,11 @@ jobs:
|
|||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- name: Install GoReleaser
|
||||
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a
|
||||
uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0
|
||||
with:
|
||||
version: "~1.17.1"
|
||||
# The version is pinned not only for security purposes, but also to avoid breaking
|
||||
# our scripts, which rely on the specific file names generated by GoReleaser.
|
||||
version: v2.13.1
|
||||
install-only: true
|
||||
- name: Install Azure Code Signing Client
|
||||
shell: pwsh
|
||||
|
|
@ -170,7 +188,7 @@ jobs:
|
|||
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
|
||||
Invoke-WebRequest -Uri https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client/1.0.95 -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
|
||||
|
|
@ -178,9 +196,16 @@ jobs:
|
|||
CertificateProfileName = "GitHubInc"
|
||||
CodeSigningAccountName = "GitHubInc"
|
||||
CorrelationId = $Env:CORRELATION_ID
|
||||
Endpoint = "https://wus.codesigning.azure.net/"
|
||||
Endpoint = "https://wus3.codesigning.azure.net/"
|
||||
} | ConvertTo-Json | Out-File -FilePath $Env:METADATA_PATH
|
||||
|
||||
# We temporarily create a tag on HEAD to make the right version embedded
|
||||
# in the built binaries, BUT we don't push it to the remote.
|
||||
- name: Create temporary tag
|
||||
shell: bash
|
||||
env:
|
||||
TAG_NAME: ${{ inputs.tag_name }}
|
||||
run: git tag "$TAG_NAME"
|
||||
# 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
|
||||
|
|
@ -207,7 +232,7 @@ jobs:
|
|||
MSI_VERSION="$(cut -d_ -f2 <<<"$MSI_NAME" | cut -d- -f1)"
|
||||
case "$MSI_NAME" in
|
||||
*_386 )
|
||||
source_dir="$PWD/dist/windows_windows_386"
|
||||
source_dir="$PWD/dist/windows_windows_386_sse2"
|
||||
platform="x86"
|
||||
;;
|
||||
*_amd64 )
|
||||
|
|
@ -215,7 +240,7 @@ jobs:
|
|||
platform="x64"
|
||||
;;
|
||||
*_arm64 )
|
||||
source_dir="$PWD/dist/windows_windows_arm64"
|
||||
source_dir="$PWD/dist/windows_windows_arm64_v8.0"
|
||||
platform="arm64"
|
||||
;;
|
||||
* )
|
||||
|
|
@ -238,7 +263,7 @@ jobs:
|
|||
Get-ChildItem -Path .\dist -Filter *.msi | ForEach-Object {
|
||||
.\script\sign.ps1 $_.FullName
|
||||
}
|
||||
- uses: actions/upload-artifact@v5
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: windows
|
||||
if-no-files-found: error
|
||||
|
|
@ -256,7 +281,7 @@ jobs:
|
|||
- name: Checkout cli/cli
|
||||
uses: actions/checkout@v6
|
||||
- name: Merge built artifacts
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v8
|
||||
- name: Checkout documentation site
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
|
|
@ -309,9 +334,10 @@ jobs:
|
|||
rpmsign --addsign dist/*.rpm
|
||||
- name: Attest release artifacts
|
||||
if: inputs.environment == 'production'
|
||||
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
with:
|
||||
subject-path: "dist/gh_*"
|
||||
create-storage-record: false # (default: true)
|
||||
- name: Run createrepo
|
||||
env:
|
||||
GPG_SIGN: ${{ inputs.environment == 'production' }}
|
||||
|
|
|
|||
36
.github/workflows/feature-request-comment.yml
vendored
36
.github/workflows/feature-request-comment.yml
vendored
|
|
@ -1,36 +0,0 @@
|
|||
name: Add feature-request comment
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- labeled
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
add-comment-to-feature-request-issues:
|
||||
if: github.event.label.name == 'enhancement'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
NUMBER: ${{ github.event.issue.number }}
|
||||
BODY: >
|
||||
Thank you for your issue! We have categorized it as a feature request,
|
||||
and it has been added to our backlog. In doing so, **we are not
|
||||
committing to implementing this feature at this time**, but, we will
|
||||
consider it for future releases based on community feedback and our own
|
||||
product roadmap.
|
||||
|
||||
|
||||
Unless you see the
|
||||
https://github.com/cli/cli/labels/help%20wanted label, we are
|
||||
not currently looking for external contributions for this feature.
|
||||
|
||||
|
||||
**If you come across this issue and would like to see it implemented,
|
||||
please add a thumbs up!** This will help us prioritize the feature.
|
||||
Please only comment if you have additional information or viewpoints to
|
||||
contribute.
|
||||
steps:
|
||||
- run: gh issue comment "$NUMBER" --body "$BODY"
|
||||
25
.github/workflows/issueauto.yml
vendored
25
.github/workflows/issueauto.yml
vendored
|
|
@ -1,25 +0,0 @@
|
|||
name: Issue Automation
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: none
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
issue-auto:
|
||||
runs-on: ubuntu-latest
|
||||
environment: cli-automation
|
||||
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 }}
|
||||
run: |
|
||||
if ! gh api orgs/cli/public_members/$ISSUEAUTHOR --silent 2>/dev/null
|
||||
then
|
||||
gh issue edit $ISSUENUM --add-label "needs-triage"
|
||||
fi
|
||||
12
.github/workflows/lint.yml
vendored
12
.github/workflows/lint.yml
vendored
|
|
@ -8,14 +8,14 @@ on:
|
|||
- go.mod
|
||||
- go.sum
|
||||
- ".github/licenses.tmpl"
|
||||
- "script/licenses*"
|
||||
- "script/licenses"
|
||||
pull_request:
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- ".github/licenses.tmpl"
|
||||
- "script/licenses*"
|
||||
- "script/licenses"
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
|
|
@ -50,16 +50,16 @@ jobs:
|
|||
with:
|
||||
version: v2.6.0
|
||||
|
||||
# Verify that license generation succeeds for all release platforms (GOOS/GOARCH).
|
||||
# This catches issues like new dependencies with unrecognized licenses before release time.
|
||||
#
|
||||
# actions/setup-go does not setup the installed toolchain to be preferred over the system install,
|
||||
# which causes go-licenses to raise "Package ... does not have module info" errors.
|
||||
# For more information, https://github.com/google/go-licenses/issues/244#issuecomment-1885098633
|
||||
#
|
||||
# go-licenses has been pinned for automation use.
|
||||
- name: Check licenses
|
||||
- name: Verify license generation
|
||||
run: |
|
||||
export GOROOT=$(go env GOROOT)
|
||||
export PATH=${GOROOT}/bin:$PATH
|
||||
go install github.com/google/go-licenses@5348b744d0983d85713295ea08a20cca1654a45e # v2.0.1
|
||||
make licenses-check
|
||||
|
||||
# Discover vulnerabilities within Go standard libraries used to build GitHub CLI using govulncheck.
|
||||
|
|
|
|||
46
.github/workflows/pr-help-wanted.yml
vendored
46
.github/workflows/pr-help-wanted.yml
vendored
|
|
@ -1,46 +0,0 @@
|
|||
name: PR Help Wanted Check
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: "Pull Request number to check"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: none
|
||||
issues: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
check-help-wanted:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set PR variables for workflow_dispatch event
|
||||
id: pr-vars-dispatch
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.inputs.pr_number }}
|
||||
run: |
|
||||
# We only need to construct the PR URL from the dispatch event input.
|
||||
echo "pr_url=https://github.com/cli/cli/pull/${PR_NUMBER}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check for issues without help-wanted label
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# These variables are optionally used in the check-help-wanted.sh
|
||||
# script for additional checks; but they are not strictly necessary
|
||||
# for the script to run. This is why we are okay with them being
|
||||
# empty when the event is workflow_dispatch.
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }}
|
||||
PR_AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }}
|
||||
PR_URL: ${{ github.event.pull_request.html_url || steps.pr-vars-dispatch.outputs.pr_url }}
|
||||
run: |
|
||||
# Run the script to check for issues without help-wanted label
|
||||
bash .github/workflows/scripts/check-help-wanted.sh "${PR_URL}"
|
||||
75
.github/workflows/prauto.yml
vendored
75
.github/workflows/prauto.yml
vendored
|
|
@ -1,75 +0,0 @@
|
|||
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
|
||||
environment: cli-automation
|
||||
steps:
|
||||
- name: lint pr
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ secrets.AUTOMATION_TOKEN }}
|
||||
PRBODY: ${{ github.event.pull_request.body }}
|
||||
PRNUM: ${{ github.event.pull_request.number }}
|
||||
PRHEAD: ${{ github.event.pull_request.head.label }}
|
||||
PRAUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }}
|
||||
if: "!github.event.pull_request.draft"
|
||||
run: |
|
||||
commentPR () {
|
||||
gh pr comment $PRNUM -b "${1}"
|
||||
}
|
||||
|
||||
closePR () {
|
||||
gh pr close $PRNUM
|
||||
}
|
||||
|
||||
colID () {
|
||||
gh api graphql -f query='query($owner:String!, $repo:String!) {
|
||||
repository(owner:$owner, name:$repo) {
|
||||
project(number:1) {
|
||||
columns(first:10) { nodes {id,name} }
|
||||
}
|
||||
}
|
||||
}' -f owner="${GH_REPO%/*}" -f repo="${GH_REPO#*/}" \
|
||||
-q ".data.repository.project.columns.nodes[] | select(.name | startswith(\"$1\")) | .id"
|
||||
}
|
||||
|
||||
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
|
||||
exit 0
|
||||
fi
|
||||
|
||||
gh pr edit $PRNUM --add-label "external"
|
||||
|
||||
if [ "$PRHEAD" = "cli:trunk" ]
|
||||
then
|
||||
closePR
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ $(wc -c <<<"$PRBODY") -lt 10 ]
|
||||
then
|
||||
commentPR "Thanks for the pull request! We're a small team and it's helpful to have context around community submissions in order to review them appropriately. Our automation has closed this pull request since it does not have an adequate description. Please edit the body of this pull request to describe what this does, then reopen it."
|
||||
closePR
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! grep -Eq '(#|issues/)[0-9]+' <<<"$PRBODY"
|
||||
then
|
||||
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
|
||||
|
||||
exit 0
|
||||
105
.github/workflows/scripts/check-help-wanted.sh
vendored
105
.github/workflows/scripts/check-help-wanted.sh
vendored
|
|
@ -1,105 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
PR_URL="$1"
|
||||
|
||||
if [ -z "$PR_URL" ]; then
|
||||
echo "Usage: $0 <PR_URL>"
|
||||
echo ""
|
||||
echo "Check if the PR references any non-help-wanted issues and, if so, comment"
|
||||
echo "on it explaining why the team might close/dismiss it."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Skip if PR is from a bot or org member
|
||||
if [ "$PR_AUTHOR_TYPE" = "Bot" ] || [ "$PR_AUTHOR_ASSOCIATION" = "MEMBER" ] || [ "$PR_AUTHOR_ASSOCIATION" = "OWNER" ]; then
|
||||
echo "Skipping check for PR $PR_URL as it is from a bot ($PR_AUTHOR_TYPE) or an org member ($PR_AUTHOR_ASSOCIATION: MEMBER/OWNER)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Skip if PR is a draft
|
||||
if [ "$(gh pr view "${PR_URL}" --json isDraft --jq '.isDraft')" != "false" ]; then
|
||||
echo "Skipping check for PR $PR_URL as it is a draft"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract PR number from URL for logging
|
||||
PR_NUM="$(basename "$PR_URL")"
|
||||
|
||||
# Extract cli/cli closing issues references from PR
|
||||
CLOSING_ISSUES="$(gh pr view "$PR_URL" --json closingIssuesReferences --jq '.closingIssuesReferences[] | select(.repository.name == "cli" and .repository.owner.login == "cli") | .number')"
|
||||
|
||||
if [ -z "$CLOSING_ISSUES" ]; then
|
||||
echo "No closing issues found for PR #$PR_NUM"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check each closing issue for 'help-wanted' label
|
||||
ISSUES_WITHOUT_HELP_WANTED=()
|
||||
|
||||
for issue_num in $CLOSING_ISSUES; do
|
||||
echo "Checking issue #$issue_num for 'help wanted' label..."
|
||||
|
||||
# Get issue labels
|
||||
LABELS=$(gh issue view "$issue_num" --json labels --jq '.labels[].name')
|
||||
|
||||
# Skip if the issue has the gh-attestion or gh-codespace label
|
||||
# This is because the codeowners for these commands may not be public
|
||||
# cli org members, and so unless we authenticate with a PAT, we can't
|
||||
# know who is an external contributor or not.
|
||||
# So we skip these issues to avoid falsely writing a comment
|
||||
# on each PR opened by these codeowners.
|
||||
if echo "$LABELS" | grep -q -e "gh-attestation" -e "gh-codespace"; then
|
||||
echo "Issue #$issue_num is skipped due to labels"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check if 'help wanted' label exists
|
||||
if ! echo "$LABELS" | grep -qE '^help wanted$'; then
|
||||
ISSUES_WITHOUT_HELP_WANTED+=("$issue_num")
|
||||
echo "Issue #$issue_num does not have 'help wanted' label"
|
||||
else
|
||||
echo "Issue #$issue_num has 'help wanted' label"
|
||||
fi
|
||||
done
|
||||
|
||||
# If we found issues without 'help wanted' label, post a comment
|
||||
if [ ${#ISSUES_WITHOUT_HELP_WANTED[@]} -gt 0 ]; then
|
||||
echo "Found ${#ISSUES_WITHOUT_HELP_WANTED[@]} issues without 'help wanted' label"
|
||||
|
||||
# Build issue list for comment
|
||||
ISSUE_LIST=""
|
||||
for issue_num in "${ISSUES_WITHOUT_HELP_WANTED[@]}"; do
|
||||
ISSUE_LIST="$ISSUE_LIST- #$issue_num"$'\n'
|
||||
done
|
||||
|
||||
# Create comment message
|
||||
gh pr comment "$PR_URL" --body-file - <<EOF
|
||||
Thank you for your pull request! 🎉
|
||||
|
||||
This PR appears to fix the following issues that are not labeled with https://github.com/cli/cli/labels/help%20wanted:
|
||||
|
||||
$ISSUE_LIST
|
||||
As outlined in our [Contributing Guidelines](https://github.com/cli/cli/blob/trunk/.github/CONTRIBUTING.md), we expect that PRs are only created for issues that have been labeled \`help wanted\`.
|
||||
|
||||
While we appreciate your initiative, please note that:
|
||||
|
||||
- **PRs for non-\`help wanted\` issues may not be reviewed immediately** as they might not align with our current priorities
|
||||
- **The issue might already be assigned** to a team member or planned for a specific release
|
||||
- **We may need to close this PR**. For example, if it conflicts with ongoing work or architectural decisions
|
||||
|
||||
**What happens next:**
|
||||
- Our team will review this PR and the associated issues
|
||||
- We may add the \`help wanted\` label to the issues, if appropriate, and review this pull request
|
||||
- In some cases, we may need to close the PR. For example, if it doesn't fit our current roadmap
|
||||
|
||||
Thank you for your understanding and contribution to the project! 🙏
|
||||
|
||||
*This comment was automatically generated by cliAutomation.*
|
||||
EOF
|
||||
|
||||
echo "Posted comment on PR #$PR_NUM"
|
||||
else
|
||||
echo "All closing issues have 'help wanted' label - no action needed"
|
||||
fi
|
||||
|
|
@ -918,7 +918,7 @@ testData:
|
|||
|
||||
We have an automation to nudge on issues waiting for user info (like after one week), and close the issue if there's no further activity (like after one more week).
|
||||
|
||||
- Automatically add the stale label to issues labelled needs-user-input after 30 days of inactivity. When the stale label is added, also post a comment to the issue explaining what this means: the issue will close after 30 days of inactivity; contributors can comment on the issue to remove the stale label and keep it open. Maintainers can also add the keep label to make the stale automation ignore that issue.
|
||||
- Automatically add the stale label to issues labelled more-info-needed after 30 days of inactivity. When the stale label is added, also post a comment to the issue explaining what this means: the issue will close after 30 days of inactivity; contributors can comment on the issue to remove the stale label and keep it open. Maintainers can also add the keep label to make the stale automation ignore that issue.
|
||||
- Automatically close issues labelled stale after they have been stale for 30 days. When the issue is closed, add a comment explaining why this happened. Encourage them to leave a comment if the close was done in error.
|
||||
- The above automation should only act on new issues after the date of the automation's implementation.
|
||||
</BODY>
|
||||
|
|
|
|||
36
.github/workflows/stale-issues.yml
vendored
36
.github/workflows/stale-issues.yml
vendored
|
|
@ -1,36 +0,0 @@
|
|||
name: Marks/closes stale issues
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 3 * * *" # 3 AM UTC
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
mark-stale-issues:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
start-date: "2025-07-10T00:00:00Z" # Skip for issues created before this date
|
||||
days-before-issue-stale: 30
|
||||
only-issue-labels:
|
||||
"needs-triage,needs-user-input" # Only issues with all of these labels can be marked as stale
|
||||
exempt-issue-labels: "keep" # Issues marked with this label should not be marked as stale
|
||||
stale-issue-label: "stale" # Mark stale issues with this label
|
||||
stale-issue-message: |
|
||||
This issue has been automatically marked as stale because it has not had any activity in the last 30 days,
|
||||
and it will be closed in 30 days if no further activity occurs.
|
||||
|
||||
If you think this is a mistake, please comment on this issue to keep it open.
|
||||
|
||||
days-before-issue-close: 30
|
||||
close-issue-reason: "not_planned"
|
||||
close-issue-message: |
|
||||
This issue has been automatically closed due to inactivity.
|
||||
|
||||
If you think this is a mistake, please comment on this issue.
|
||||
|
||||
# Exclude PRs from closing or being marked as stale
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
23
.github/workflows/triage-discussion-label.yml
vendored
Normal file
23
.github/workflows/triage-discussion-label.yml
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
name: Process Discuss Label
|
||||
run-name: ${{ github.event_name == 'issues' && github.event.issue.title || github.event.pull_request.title }}
|
||||
permissions: {}
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- labeled
|
||||
# pull_request_target (not pull_request) to access secrets for fork PRs.
|
||||
# Safe: no PR code is checked out or executed.
|
||||
pull_request_target:
|
||||
types:
|
||||
- labeled
|
||||
|
||||
jobs:
|
||||
discuss:
|
||||
if: github.event.action == 'labeled' && github.event.label.name == 'discuss'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-discuss.yml@main
|
||||
with:
|
||||
target_repo: 'github/cli'
|
||||
cc_team: '@github/cli'
|
||||
environment: cli-discuss-automation
|
||||
secrets:
|
||||
discussion_token: ${{ secrets.CLI_DISCUSSION_TRIAGE_TOKEN }}
|
||||
61
.github/workflows/triage-issues.yml
vendored
Normal file
61
.github/workflows/triage-issues.yml
vendored
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
name: Issue Triaging
|
||||
on:
|
||||
issues:
|
||||
types: [opened, reopened, labeled, unlabeled, closed]
|
||||
|
||||
jobs:
|
||||
label-incoming:
|
||||
if: github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'unlabeled'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-label-incoming.yml@main
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
close-invalid:
|
||||
if: github.event.action == 'labeled'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-close-invalid.yml@main
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
close-suspected-spam:
|
||||
if: github.event.action == 'labeled'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-close-suspected-spam.yml@main
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
close-single-word:
|
||||
if: github.event.action == 'opened'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-close-single-word-issues.yml@main
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
close-off-topic:
|
||||
if: github.event.action == 'labeled'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-close-off-topic.yml@main
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
enhancement-comment:
|
||||
if: github.event.action == 'labeled'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-enhancement-comment.yml@main
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
unable-to-reproduce:
|
||||
if: github.event.action == 'labeled'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-unable-to-reproduce-comment.yml@main
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
remove-needs-triage:
|
||||
if: github.event.action == 'labeled'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-remove-needs-triage.yml@main
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
on-issue-close:
|
||||
if: github.event.action == 'closed'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-on-issue-close.yml@main
|
||||
permissions:
|
||||
issues: write
|
||||
59
.github/workflows/triage-pull-requests.yml
vendored
Normal file
59
.github/workflows/triage-pull-requests.yml
vendored
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
name: PR Triaging
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, edited, labeled, ready_for_review]
|
||||
schedule:
|
||||
- cron: '0 4 * * *' # Daily at 4 AM UTC — close unmet-requirements PRs
|
||||
|
||||
jobs:
|
||||
label-external:
|
||||
if: >-
|
||||
github.event_name == 'pull_request_target' &&
|
||||
(github.event.action == 'opened' || github.event.action == 'reopened')
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-label-external-pr.yml@main
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
repository-projects: read
|
||||
|
||||
close-from-default-branch:
|
||||
if: >-
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.action == 'opened'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-close-from-default-branch.yml@main
|
||||
with:
|
||||
default_branch: trunk
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
check-requirements:
|
||||
if: >-
|
||||
github.event_name == 'pull_request_target' &&
|
||||
(github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'edited')
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-pr-requirements.yml@main
|
||||
permissions:
|
||||
issues: read
|
||||
pull-requests: write
|
||||
|
||||
close-unmet-requirements:
|
||||
if: github.event_name == 'schedule'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-pr-requirements.yml@main
|
||||
permissions:
|
||||
issues: read
|
||||
pull-requests: write
|
||||
|
||||
close-no-help-wanted:
|
||||
if: >-
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.action == 'labeled'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-close-no-help-wanted.yml@main
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
ready-for-review:
|
||||
if: >-
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.action == 'labeled'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-ready-for-review.yml@main
|
||||
permissions:
|
||||
pull-requests: write
|
||||
26
.github/workflows/triage-scheduled-tasks.yml
vendored
Normal file
26
.github/workflows/triage-scheduled-tasks.yml
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
name: Triage Scheduled Tasks
|
||||
on:
|
||||
workflow_dispatch:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
schedule:
|
||||
- cron: '5 * * * *' # Hourly — no-response close
|
||||
- cron: '0 3 * * *' # Daily at 3 AM UTC — stale issues
|
||||
|
||||
jobs:
|
||||
no-response:
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-no-response-close.yml@main
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
stale:
|
||||
if: github.event.schedule == '0 3 * * *'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-stale-issues.yml@main
|
||||
with:
|
||||
days_before_stale: 30
|
||||
days_before_close: -1
|
||||
start_date: '2025-07-10T00:00:00Z'
|
||||
stale_issue_label: 'stale'
|
||||
exempt_issue_labels: 'keep'
|
||||
permissions:
|
||||
issues: write
|
||||
74
.github/workflows/triage.yml
vendored
74
.github/workflows/triage.yml
vendored
|
|
@ -1,74 +0,0 @@
|
|||
name: Discussion Triage
|
||||
run-name: ${{ github.event_name == 'issues' && github.event.issue.title || github.event.pull_request.title }}
|
||||
permissions: {}
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- labeled
|
||||
pull_request_target:
|
||||
types:
|
||||
- labeled
|
||||
env:
|
||||
TARGET_REPO: github/cli
|
||||
jobs:
|
||||
issue:
|
||||
environment: cli-discuss-automation
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'discuss'
|
||||
steps:
|
||||
- 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
|
||||
environment: cli-discuss-automation
|
||||
if: github.event_name == 'pull_request_target' && github.event.action == 'labeled' && github.event.label.name == 'discuss'
|
||||
steps:
|
||||
- name: Create issue based on source pull request
|
||||
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
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -18,6 +18,10 @@
|
|||
# Windows resource files
|
||||
/cmd/gh/*.syso
|
||||
|
||||
# Third-party licenses
|
||||
/internal/licenses/embed/*/*
|
||||
!/internal/licenses/embed/*/PLACEHOLDER
|
||||
|
||||
# VS Code
|
||||
.vscode
|
||||
|
||||
|
|
|
|||
|
|
@ -3,14 +3,27 @@ version: "2"
|
|||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- bodyclose
|
||||
- copyloopvar
|
||||
- durationcheck
|
||||
- gocritic
|
||||
- govet
|
||||
- ineffassign
|
||||
- nilerr
|
||||
- nolintlint
|
||||
- asasalint # checks for pass []any as any in variadic func(...any)
|
||||
- asciicheck # checks that your code does not contain non-ASCII identifiers
|
||||
- bidichk # checks for dangerous unicode character sequences
|
||||
- bodyclose # checks whether HTTP response body is closed successfully
|
||||
- copyloopvar # detects places where loop variables are copied (Go 1.22+)
|
||||
- durationcheck # checks for two durations multiplied together
|
||||
- exptostd # detects functions from golang.org/x/exp/ that can be replaced by std functions
|
||||
- fatcontext # detects nested contexts in loops
|
||||
- gocheckcompilerdirectives # validates go compiler directive comments (//go:)
|
||||
- gochecksumtype # checks exhaustiveness on Go "sum types"
|
||||
- gocritic # provides diagnostics that check for bugs, performance and style issues
|
||||
- gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod
|
||||
- goprintffuncname # checks that printf-like functions are named with f at the end
|
||||
- govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string
|
||||
- ineffassign # detects when assignments to existing variables are not used
|
||||
- nilerr # finds the code that returns nil even if it checks that the error is not nil
|
||||
- nolintlint # reports ill-formed or insufficient nolint directives
|
||||
- nosprintfhostport # checks for misuse of Sprintf to construct a host with port in a URL
|
||||
- reassign # checks that package variables are not reassigned
|
||||
- unused # checks for unused constants, variables, functions and types
|
||||
|
||||
# To enable later due to too many issues, and confirm we need them:
|
||||
# - gosec
|
||||
# - staticcheck
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
version: 2
|
||||
|
||||
project_name: gh
|
||||
|
||||
release:
|
||||
|
|
@ -18,6 +20,9 @@ builds:
|
|||
goos: [darwin]
|
||||
goarch: [amd64, arm64]
|
||||
hooks:
|
||||
pre:
|
||||
- cmd: bash ./script/licenses {{ .Os }} {{ .Arch }}
|
||||
output: true
|
||||
post:
|
||||
- cmd: ./script/sign '{{ .Path }}'
|
||||
output: true
|
||||
|
|
@ -28,9 +33,13 @@ builds:
|
|||
|
||||
- id: linux #build:linux
|
||||
goos: [linux]
|
||||
goarch: [386, arm, amd64, arm64]
|
||||
goarch: ["386", arm, amd64, arm64]
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
hooks:
|
||||
pre:
|
||||
- cmd: bash ./script/licenses {{ .Os }} {{ .Arch }}
|
||||
output: true
|
||||
binary: bin/gh
|
||||
main: ./cmd/gh
|
||||
ldflags:
|
||||
|
|
@ -38,8 +47,11 @@ builds:
|
|||
|
||||
- id: windows #build:windows
|
||||
goos: [windows]
|
||||
goarch: [386, amd64, arm64]
|
||||
goarch: ["386", amd64, arm64]
|
||||
hooks:
|
||||
pre:
|
||||
- cmd: bash ./script/licenses {{ .Os }} {{ .Arch }}
|
||||
output: true
|
||||
post:
|
||||
- cmd: pwsh .\script\sign.ps1 '{{ .Path }}'
|
||||
output: true
|
||||
|
|
@ -50,34 +62,32 @@ builds:
|
|||
|
||||
archives:
|
||||
- id: linux-archive
|
||||
builds: [linux]
|
||||
ids: [linux]
|
||||
name_template: "gh_{{ .Version }}_linux_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
|
||||
wrap_in_directory: true
|
||||
format: tar.gz
|
||||
rlcp: true
|
||||
formats: [tar.gz]
|
||||
files:
|
||||
- LICENSE
|
||||
- ./share/man/man1/gh*.1
|
||||
- id: macos-archive
|
||||
builds: [macos]
|
||||
ids: [macos]
|
||||
name_template: "gh_{{ .Version }}_macOS_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
|
||||
wrap_in_directory: true
|
||||
format: zip
|
||||
rlcp: true
|
||||
formats: [zip]
|
||||
files:
|
||||
- LICENSE
|
||||
- ./share/man/man1/gh*.1
|
||||
- id: windows-archive
|
||||
builds: [windows]
|
||||
ids: [windows]
|
||||
name_template: "gh_{{ .Version }}_windows_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
|
||||
wrap_in_directory: false
|
||||
format: zip
|
||||
rlcp: true
|
||||
formats: [zip]
|
||||
files:
|
||||
- LICENSE
|
||||
|
||||
nfpms: #build:linux
|
||||
- license: MIT
|
||||
- ids: [linux]
|
||||
license: MIT
|
||||
maintainer: GitHub
|
||||
homepage: https://github.com/cli/cli
|
||||
bindir: /usr
|
||||
|
|
|
|||
4
Makefile
4
Makefile
|
|
@ -109,8 +109,8 @@ endif
|
|||
|
||||
.PHONY: licenses
|
||||
licenses:
|
||||
./script/licenses
|
||||
./script/licenses $$(go env GOOS) $$(go env GOARCH)
|
||||
|
||||
.PHONY: licenses-check
|
||||
licenses-check:
|
||||
./script/licenses-check
|
||||
./script/licenses --check
|
||||
|
|
|
|||
|
|
@ -10,12 +10,15 @@ import (
|
|||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/cli/cli/v2/pkg/set"
|
||||
ghAPI "github.com/cli/go-gh/v2/pkg/api"
|
||||
ghauth "github.com/cli/go-gh/v2/pkg/auth"
|
||||
)
|
||||
|
||||
const (
|
||||
accept = "Accept"
|
||||
apiVersion = "X-GitHub-Api-Version"
|
||||
apiVersionValue = "2022-11-28"
|
||||
authorization = "Authorization"
|
||||
cacheTTL = "X-GH-CACHE-TTL"
|
||||
graphqlFeatures = "GraphQL-Features"
|
||||
|
|
@ -178,6 +181,10 @@ func handleResponse(err error) error {
|
|||
|
||||
var gqlErr *ghAPI.GraphQLError
|
||||
if errors.As(err, &gqlErr) {
|
||||
scopeErr := GenerateScopeErrorForGQL(gqlErr)
|
||||
if scopeErr != nil {
|
||||
return scopeErr
|
||||
}
|
||||
return GraphQLError{
|
||||
GraphQLError: gqlErr,
|
||||
}
|
||||
|
|
@ -186,6 +193,40 @@ func handleResponse(err error) error {
|
|||
return err
|
||||
}
|
||||
|
||||
func GenerateScopeErrorForGQL(gqlErr *ghAPI.GraphQLError) error {
|
||||
missing := set.NewStringSet()
|
||||
for _, e := range gqlErr.Errors {
|
||||
if e.Type != "INSUFFICIENT_SCOPES" {
|
||||
continue
|
||||
}
|
||||
missing.AddValues(requiredScopesFromServerMessage(e.Message))
|
||||
}
|
||||
if missing.Len() > 0 {
|
||||
s := missing.ToSlice()
|
||||
// TODO: this duplicates parts of generateScopesSuggestion
|
||||
return fmt.Errorf(
|
||||
"error: your authentication token is missing required scopes %v\n"+
|
||||
"To request it, run: gh auth refresh -s %s",
|
||||
s,
|
||||
strings.Join(s, ","))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var scopesRE = regexp.MustCompile(`one of the following scopes: \[(.+?)]`)
|
||||
|
||||
func requiredScopesFromServerMessage(msg string) []string {
|
||||
m := scopesRE.FindStringSubmatch(msg)
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
var scopes []string
|
||||
for _, mm := range strings.Split(m[1], ",") {
|
||||
scopes = append(scopes, strings.Trim(mm, "' "))
|
||||
}
|
||||
return scopes
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
|
@ -264,6 +305,7 @@ func clientOptions(hostname string, transport http.RoundTripper) ghAPI.ClientOpt
|
|||
AuthToken: "none",
|
||||
Headers: map[string]string{
|
||||
authorization: "",
|
||||
apiVersion: apiVersionValue,
|
||||
},
|
||||
Host: hostname,
|
||||
SkipDefaultHeaders: true,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
|
||||
"github.com/cli/cli/v2/pkg/httpmock"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/cli/go-gh/v2/pkg/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
|
@ -245,13 +246,90 @@ func TestHTTPHeaders(t *testing.T) {
|
|||
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",
|
||||
"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",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
}
|
||||
for name, value := range wantHeader {
|
||||
assert.Equal(t, value, gotReq.Header.Get(name), name)
|
||||
}
|
||||
assert.Equal(t, "", stderr.String())
|
||||
}
|
||||
|
||||
func TestGenerateScopeErrorForGQL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
gqlError *api.GraphQLError
|
||||
wantErr bool
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "missing scope",
|
||||
gqlError: &api.GraphQLError{
|
||||
Errors: []api.GraphQLErrorItem{
|
||||
{
|
||||
Type: "INSUFFICIENT_SCOPES",
|
||||
Message: "The 'addProjectV2ItemById' field requires one of the following scopes: ['project']",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
expected: "error: your authentication token is missing required scopes [project]\n" +
|
||||
"To request it, run: gh auth refresh -s project",
|
||||
},
|
||||
|
||||
{
|
||||
name: "ignore non-scope errors",
|
||||
gqlError: &api.GraphQLError{
|
||||
Errors: []api.GraphQLErrorItem{
|
||||
{
|
||||
Type: "NOT_FOUND",
|
||||
Message: "Could not resolve to a Repository",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := GenerateScopeErrorForGQL(tt.gqlError)
|
||||
if tt.wantErr {
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, tt.expected, err.Error())
|
||||
} else {
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiredScopesFromServerMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
msg string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
msg: "requires one of the following scopes: ['project']",
|
||||
expected: []string{"project"},
|
||||
},
|
||||
{
|
||||
msg: "requires one of the following scopes: ['repo', 'read:org']",
|
||||
expected: []string{"repo", "read:org"},
|
||||
},
|
||||
{
|
||||
msg: "no match here",
|
||||
expected: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.msg, func(t *testing.T) {
|
||||
output := requiredScopesFromServerMessage(tt.msg)
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,32 @@ func TestIssue_ExportData(t *testing.T) {
|
|||
}
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "assignees",
|
||||
fields: []string{"assignees"},
|
||||
inputJSON: heredoc.Doc(`
|
||||
{ "assignees": { "nodes": [
|
||||
{
|
||||
"id": "MDQ6VXNlcjE=",
|
||||
"login": "monalisa",
|
||||
"name": "Mona Lisa",
|
||||
"databaseId": 1234
|
||||
}
|
||||
] } }
|
||||
`),
|
||||
outputJSON: heredoc.Doc(`
|
||||
{
|
||||
"assignees": [
|
||||
{
|
||||
"id": "MDQ6VXNlcjE=",
|
||||
"login": "monalisa",
|
||||
"name": "Mona Lisa",
|
||||
"databaseId": 1234
|
||||
}
|
||||
]
|
||||
}
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "linked pull requests",
|
||||
fields: []string{"closedByPullRequestsReferences"},
|
||||
|
|
@ -316,6 +342,32 @@ func TestPullRequest_ExportData(t *testing.T) {
|
|||
}
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "assignees",
|
||||
fields: []string{"assignees"},
|
||||
inputJSON: heredoc.Doc(`
|
||||
{ "assignees": { "nodes": [
|
||||
{
|
||||
"id": "MDQ6VXNlcjE=",
|
||||
"login": "monalisa",
|
||||
"name": "Mona Lisa",
|
||||
"databaseId": 1234
|
||||
}
|
||||
] } }
|
||||
`),
|
||||
outputJSON: heredoc.Doc(`
|
||||
{
|
||||
"assignees": [
|
||||
{
|
||||
"id": "MDQ6VXNlcjE=",
|
||||
"login": "monalisa",
|
||||
"name": "Mona Lisa",
|
||||
"databaseId": 1234
|
||||
}
|
||||
]
|
||||
}
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "linked issues",
|
||||
fields: []string{"closingIssuesReferences"},
|
||||
|
|
|
|||
|
|
@ -49,7 +49,8 @@ func NewHTTPClient(opts HTTPClientOptions) (*http.Client, error) {
|
|||
}
|
||||
|
||||
headers := map[string]string{
|
||||
userAgent: fmt.Sprintf("GitHub CLI %s", opts.AppVersion),
|
||||
userAgent: fmt.Sprintf("GitHub CLI %s", opts.AppVersion),
|
||||
apiVersion: apiVersionValue,
|
||||
}
|
||||
clientOpts.Headers = headers
|
||||
|
||||
|
|
|
|||
|
|
@ -39,9 +39,10 @@ func TestNewHTTPClient(t *testing.T) {
|
|||
},
|
||||
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"},
|
||||
"authorization": {"token MYTOKEN"},
|
||||
"user-agent": {"GitHub CLI v1.2.3"},
|
||||
"x-github-api-version": {"2022-11-28"},
|
||||
"accept": {"application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview"},
|
||||
},
|
||||
wantStderr: "",
|
||||
},
|
||||
|
|
@ -53,9 +54,10 @@ func TestNewHTTPClient(t *testing.T) {
|
|||
},
|
||||
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"},
|
||||
"authorization": {"token GHETOKEN"},
|
||||
"user-agent": {"GitHub CLI v1.2.3"},
|
||||
"x-github-api-version": {"2022-11-28"},
|
||||
"accept": {"application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview"},
|
||||
},
|
||||
wantStderr: "",
|
||||
},
|
||||
|
|
@ -68,9 +70,10 @@ func TestNewHTTPClient(t *testing.T) {
|
|||
},
|
||||
host: "github.com",
|
||||
wantHeader: map[string][]string{
|
||||
"authorization": nil, // should not be set
|
||||
"user-agent": {"GitHub CLI v1.2.3"},
|
||||
"accept": {"application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview"},
|
||||
"authorization": nil, // should not be set
|
||||
"user-agent": {"GitHub CLI v1.2.3"},
|
||||
"x-github-api-version": {"2022-11-28"},
|
||||
"accept": {"application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview"},
|
||||
},
|
||||
wantStderr: "",
|
||||
},
|
||||
|
|
@ -83,9 +86,10 @@ func TestNewHTTPClient(t *testing.T) {
|
|||
},
|
||||
host: "example.com",
|
||||
wantHeader: map[string][]string{
|
||||
"authorization": nil, // should not be set
|
||||
"user-agent": {"GitHub CLI v1.2.3"},
|
||||
"accept": {"application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview"},
|
||||
"authorization": nil, // should not be set
|
||||
"user-agent": {"GitHub CLI v1.2.3"},
|
||||
"x-github-api-version": {"2022-11-28"},
|
||||
"accept": {"application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview"},
|
||||
},
|
||||
wantStderr: "",
|
||||
},
|
||||
|
|
@ -98,9 +102,10 @@ func TestNewHTTPClient(t *testing.T) {
|
|||
},
|
||||
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"},
|
||||
"authorization": {"token MYTOKEN"},
|
||||
"user-agent": {"GitHub CLI v1.2.3"},
|
||||
"x-github-api-version": {"2022-11-28"},
|
||||
"accept": {"application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview"},
|
||||
},
|
||||
wantStderr: heredoc.Doc(`
|
||||
* Request at <time>
|
||||
|
|
@ -112,6 +117,7 @@ func TestNewHTTPClient(t *testing.T) {
|
|||
> Content-Type: application/json; charset=utf-8
|
||||
> Time-Zone: <timezone>
|
||||
> User-Agent: GitHub CLI v1.2.3
|
||||
> X-Github-Api-Version: 2022-11-28
|
||||
|
||||
< HTTP/1.1 204 No Content
|
||||
< Date: <time>
|
||||
|
|
@ -128,10 +134,11 @@ func TestNewHTTPClient(t *testing.T) {
|
|||
},
|
||||
host: "github.com",
|
||||
wantHeader: map[string][]string{
|
||||
"accept": nil,
|
||||
"authorization": nil,
|
||||
"content-type": nil,
|
||||
"user-agent": {"GitHub CLI v1.2.3"},
|
||||
"accept": nil,
|
||||
"authorization": nil,
|
||||
"content-type": nil,
|
||||
"user-agent": {"GitHub CLI v1.2.3"},
|
||||
"x-github-api-version": {"2022-11-28"},
|
||||
},
|
||||
wantStderr: heredoc.Doc(`
|
||||
* Request at <time>
|
||||
|
|
@ -140,6 +147,7 @@ func TestNewHTTPClient(t *testing.T) {
|
|||
> Host: github.com
|
||||
> Time-Zone: <timezone>
|
||||
> User-Agent: GitHub CLI v1.2.3
|
||||
> X-Github-Api-Version: 2022-11-28
|
||||
|
||||
< HTTP/1.1 204 No Content
|
||||
< Date: <time>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
|
|
@ -295,9 +296,10 @@ type PullRequestCommitCommit struct {
|
|||
}
|
||||
|
||||
type PullRequestFile struct {
|
||||
Path string `json:"path"`
|
||||
Additions int `json:"additions"`
|
||||
Deletions int `json:"deletions"`
|
||||
Path string `json:"path"`
|
||||
Additions int `json:"additions"`
|
||||
Deletions int `json:"deletions"`
|
||||
ChangeType string `json:"changeType"`
|
||||
}
|
||||
|
||||
type ReviewRequests struct {
|
||||
|
|
@ -316,6 +318,9 @@ type RequestedReviewer struct {
|
|||
} `json:"organization"`
|
||||
}
|
||||
|
||||
const teamTypeName = "Team"
|
||||
const botTypeName = "Bot"
|
||||
|
||||
func (r RequestedReviewer) LoginOrSlug() string {
|
||||
if r.TypeName == teamTypeName {
|
||||
return fmt.Sprintf("%s/%s", r.Organization.Login, r.Slug)
|
||||
|
|
@ -323,7 +328,21 @@ func (r RequestedReviewer) LoginOrSlug() string {
|
|||
return r.Login
|
||||
}
|
||||
|
||||
const teamTypeName = "Team"
|
||||
// DisplayName returns a user-friendly name for the reviewer.
|
||||
// For Copilot bot, returns "Copilot (AI)". For teams, returns "org/slug".
|
||||
// For users, returns "login (Name)" if name is available, otherwise just login.
|
||||
func (r RequestedReviewer) DisplayName() string {
|
||||
if r.TypeName == teamTypeName {
|
||||
return fmt.Sprintf("%s/%s", r.Organization.Login, r.Slug)
|
||||
}
|
||||
if r.TypeName == botTypeName && r.Login == CopilotReviewerLogin {
|
||||
return "Copilot (AI)"
|
||||
}
|
||||
if r.Name != "" {
|
||||
return fmt.Sprintf("%s (%s)", r.Login, r.Name)
|
||||
}
|
||||
return r.Login
|
||||
}
|
||||
|
||||
func (r ReviewRequests) Logins() []string {
|
||||
logins := make([]string, len(r.Nodes))
|
||||
|
|
@ -333,6 +352,15 @@ func (r ReviewRequests) Logins() []string {
|
|||
return logins
|
||||
}
|
||||
|
||||
// DisplayNames returns user-friendly display names for all requested reviewers.
|
||||
func (r ReviewRequests) DisplayNames() []string {
|
||||
names := make([]string, len(r.Nodes))
|
||||
for i, r := range r.Nodes {
|
||||
names[i] = r.RequestedReviewer.DisplayName()
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (pr PullRequest) HeadLabel() string {
|
||||
if pr.IsCrossRepository {
|
||||
return fmt.Sprintf("%s:%s", pr.HeadRepositoryOwner.Login, pr.HeadRefName)
|
||||
|
|
@ -631,7 +659,32 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter
|
|||
return pr, nil
|
||||
}
|
||||
|
||||
// extractTeamSlugs extracts just the slug portion from team identifiers.
|
||||
// Team identifiers can be in "org/slug" format; this returns just the slug.
|
||||
func extractTeamSlugs(teams []string) []string {
|
||||
slugs := make([]string, 0, len(teams))
|
||||
for _, t := range teams {
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
s := strings.SplitN(t, "/", 2)
|
||||
slugs = append(slugs, s[len(s)-1])
|
||||
}
|
||||
return slugs
|
||||
}
|
||||
|
||||
// toGitHubV4Strings converts a string slice to a githubv4.String slice,
|
||||
// optionally appending a suffix to each element.
|
||||
func toGitHubV4Strings(strs []string, suffix string) []githubv4.String {
|
||||
result := make([]githubv4.String, len(strs))
|
||||
for i, s := range strs {
|
||||
result[i] = githubv4.String(s + suffix)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// AddPullRequestReviews adds the given user and team reviewers to a pull request using the REST API.
|
||||
// Team identifiers can be in "org/slug" format.
|
||||
func AddPullRequestReviews(client *Client, repo ghrepo.Interface, prNumber int, users, teams []string) error {
|
||||
if len(users) == 0 && len(teams) == 0 {
|
||||
return nil
|
||||
|
|
@ -641,9 +694,6 @@ func AddPullRequestReviews(client *Client, repo ghrepo.Interface, prNumber int,
|
|||
if users == nil {
|
||||
users = []string{}
|
||||
}
|
||||
if teams == nil {
|
||||
teams = []string{}
|
||||
}
|
||||
|
||||
path := fmt.Sprintf(
|
||||
"repos/%s/%s/pulls/%d/requested_reviewers",
|
||||
|
|
@ -656,7 +706,7 @@ func AddPullRequestReviews(client *Client, repo ghrepo.Interface, prNumber int,
|
|||
TeamReviewers []string `json:"team_reviewers"`
|
||||
}{
|
||||
Reviewers: users,
|
||||
TeamReviewers: teams,
|
||||
TeamReviewers: extractTeamSlugs(teams),
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
if err := json.NewEncoder(buf).Encode(body); err != nil {
|
||||
|
|
@ -667,6 +717,7 @@ func AddPullRequestReviews(client *Client, repo ghrepo.Interface, prNumber int,
|
|||
}
|
||||
|
||||
// RemovePullRequestReviews removes requested reviewers from a pull request using the REST API.
|
||||
// Team identifiers can be in "org/slug" format.
|
||||
func RemovePullRequestReviews(client *Client, repo ghrepo.Interface, prNumber int, users, teams []string) error {
|
||||
if len(users) == 0 && len(teams) == 0 {
|
||||
return nil
|
||||
|
|
@ -676,9 +727,6 @@ func RemovePullRequestReviews(client *Client, repo ghrepo.Interface, prNumber in
|
|||
if users == nil {
|
||||
users = []string{}
|
||||
}
|
||||
if teams == nil {
|
||||
teams = []string{}
|
||||
}
|
||||
|
||||
path := fmt.Sprintf(
|
||||
"repos/%s/%s/pulls/%d/requested_reviewers",
|
||||
|
|
@ -691,7 +739,7 @@ func RemovePullRequestReviews(client *Client, repo ghrepo.Interface, prNumber in
|
|||
TeamReviewers []string `json:"team_reviewers"`
|
||||
}{
|
||||
Reviewers: users,
|
||||
TeamReviewers: teams,
|
||||
TeamReviewers: extractTeamSlugs(teams),
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
if err := json.NewEncoder(buf).Encode(body); err != nil {
|
||||
|
|
@ -701,6 +749,363 @@ func RemovePullRequestReviews(client *Client, repo ghrepo.Interface, prNumber in
|
|||
return client.REST(repo.RepoHost(), "DELETE", path, buf, nil)
|
||||
}
|
||||
|
||||
// RequestReviewsByLogin sets requested reviewers on a pull request using the GraphQL mutation.
|
||||
// This mutation replaces existing reviewers with the provided set unless union is true.
|
||||
// Only available on github.com, not GHES.
|
||||
// Bot logins should include the [bot] suffix (e.g., "copilot-pull-request-reviewer[bot]").
|
||||
// Team slugs should be in the format "org/team-slug".
|
||||
// When union is false (replace mode), passing empty slices will remove all reviewers.
|
||||
func RequestReviewsByLogin(client *Client, repo ghrepo.Interface, prID string, userLogins, botLogins, teamSlugs []string, union bool) error {
|
||||
// In union mode (additive), nothing to do if all lists are empty.
|
||||
// In replace mode, we may still need to call the mutation to clear reviewers.
|
||||
if union && len(userLogins) == 0 && len(botLogins) == 0 && len(teamSlugs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var mutation struct {
|
||||
RequestReviewsByLogin struct {
|
||||
ClientMutationId string `graphql:"clientMutationId"`
|
||||
} `graphql:"requestReviewsByLogin(input: $input)"`
|
||||
}
|
||||
|
||||
type RequestReviewsByLoginInput struct {
|
||||
PullRequestID githubv4.ID `json:"pullRequestId"`
|
||||
UserLogins *[]githubv4.String `json:"userLogins,omitempty"`
|
||||
BotLogins *[]githubv4.String `json:"botLogins,omitempty"`
|
||||
TeamSlugs *[]githubv4.String `json:"teamSlugs,omitempty"`
|
||||
Union githubv4.Boolean `json:"union"`
|
||||
}
|
||||
|
||||
input := RequestReviewsByLoginInput{
|
||||
PullRequestID: githubv4.ID(prID),
|
||||
Union: githubv4.Boolean(union),
|
||||
}
|
||||
|
||||
userLoginValues := toGitHubV4Strings(userLogins, "")
|
||||
input.UserLogins = &userLoginValues
|
||||
|
||||
// Bot logins require the [bot] suffix for the mutation
|
||||
botLoginValues := toGitHubV4Strings(botLogins, "[bot]")
|
||||
input.BotLogins = &botLoginValues
|
||||
|
||||
teamSlugValues := toGitHubV4Strings(teamSlugs, "")
|
||||
input.TeamSlugs = &teamSlugValues
|
||||
|
||||
variables := map[string]interface{}{
|
||||
"input": input,
|
||||
}
|
||||
|
||||
return client.Mutate(repo.RepoHost(), "RequestReviewsByLogin", &mutation, variables)
|
||||
}
|
||||
|
||||
// SuggestedAssignableActors fetches up to 10 suggested actors for a specific assignable
|
||||
// (Issue or PullRequest) node ID. `assignableID` is the GraphQL node ID for the Issue/PR.
|
||||
// Returns the actors, the total count of available assignees in the repo, and an error.
|
||||
func SuggestedAssignableActors(client *Client, repo ghrepo.Interface, assignableID string, query string) ([]AssignableActor, int, error) {
|
||||
type responseData struct {
|
||||
Repository struct {
|
||||
AssignableUsers struct {
|
||||
TotalCount int
|
||||
}
|
||||
} `graphql:"repository(owner: $owner, name: $name)"`
|
||||
Node struct {
|
||||
Issue struct {
|
||||
SuggestedActors struct {
|
||||
Nodes []struct {
|
||||
TypeName string `graphql:"__typename"`
|
||||
User struct {
|
||||
ID string
|
||||
Login string
|
||||
Name string
|
||||
} `graphql:"... on User"`
|
||||
Bot struct {
|
||||
ID string
|
||||
Login string
|
||||
} `graphql:"... on Bot"`
|
||||
}
|
||||
} `graphql:"suggestedActors(first: 10, query: $query)"`
|
||||
} `graphql:"... on Issue"`
|
||||
PullRequest struct {
|
||||
SuggestedActors struct {
|
||||
Nodes []struct {
|
||||
TypeName string `graphql:"__typename"`
|
||||
User struct {
|
||||
ID string
|
||||
Login string
|
||||
Name string
|
||||
} `graphql:"... on User"`
|
||||
Bot struct {
|
||||
ID string
|
||||
Login string
|
||||
} `graphql:"... on Bot"`
|
||||
}
|
||||
} `graphql:"suggestedActors(first: 10, query: $query)"`
|
||||
} `graphql:"... on PullRequest"`
|
||||
} `graphql:"node(id: $id)"`
|
||||
}
|
||||
|
||||
variables := map[string]interface{}{
|
||||
"id": githubv4.ID(assignableID),
|
||||
"query": githubv4.String(query),
|
||||
"owner": githubv4.String(repo.RepoOwner()),
|
||||
"name": githubv4.String(repo.RepoName()),
|
||||
}
|
||||
|
||||
var result responseData
|
||||
if err := client.Query(repo.RepoHost(), "SuggestedAssignableActors", &result, variables); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
availableAssigneesCount := result.Repository.AssignableUsers.TotalCount
|
||||
|
||||
var nodes []struct {
|
||||
TypeName string `graphql:"__typename"`
|
||||
User struct {
|
||||
ID string
|
||||
Login string
|
||||
Name string
|
||||
} `graphql:"... on User"`
|
||||
Bot struct {
|
||||
ID string
|
||||
Login string
|
||||
} `graphql:"... on Bot"`
|
||||
}
|
||||
|
||||
if result.Node.PullRequest.SuggestedActors.Nodes != nil {
|
||||
nodes = result.Node.PullRequest.SuggestedActors.Nodes
|
||||
} else if result.Node.Issue.SuggestedActors.Nodes != nil {
|
||||
nodes = result.Node.Issue.SuggestedActors.Nodes
|
||||
}
|
||||
|
||||
actors := make([]AssignableActor, 0, len(nodes))
|
||||
|
||||
for _, n := range nodes {
|
||||
if n.TypeName == "User" && n.User.Login != "" {
|
||||
actors = append(actors, AssignableUser{id: n.User.ID, login: n.User.Login, name: n.User.Name})
|
||||
} else if n.TypeName == "Bot" && n.Bot.Login != "" {
|
||||
actors = append(actors, AssignableBot{id: n.Bot.ID, login: n.Bot.Login})
|
||||
}
|
||||
}
|
||||
|
||||
return actors, availableAssigneesCount, nil
|
||||
}
|
||||
|
||||
// ReviewerCandidate represents a potential reviewer for a pull request.
|
||||
// This can be a User, Bot, or Team.
|
||||
type ReviewerCandidate interface {
|
||||
DisplayName() string
|
||||
Login() string
|
||||
|
||||
sealedReviewerCandidate()
|
||||
}
|
||||
|
||||
// ReviewerUser is a user who can review a pull request.
|
||||
type ReviewerUser struct {
|
||||
AssignableUser
|
||||
}
|
||||
|
||||
func NewReviewerUser(login, name string) ReviewerUser {
|
||||
return ReviewerUser{
|
||||
AssignableUser: NewAssignableUser("", login, name),
|
||||
}
|
||||
}
|
||||
|
||||
func (r ReviewerUser) sealedReviewerCandidate() {}
|
||||
|
||||
// ReviewerBot is a bot who can review a pull request.
|
||||
type ReviewerBot struct {
|
||||
AssignableBot
|
||||
}
|
||||
|
||||
func NewReviewerBot(login string) ReviewerBot {
|
||||
return ReviewerBot{
|
||||
AssignableBot: NewAssignableBot("", login),
|
||||
}
|
||||
}
|
||||
|
||||
func (b ReviewerBot) DisplayName() string {
|
||||
if b.login == CopilotReviewerLogin {
|
||||
return fmt.Sprintf("%s (AI)", CopilotActorName)
|
||||
}
|
||||
return b.Login()
|
||||
}
|
||||
|
||||
func (r ReviewerBot) sealedReviewerCandidate() {}
|
||||
|
||||
// ReviewerTeam is a team that can review a pull request.
|
||||
type ReviewerTeam struct {
|
||||
org string
|
||||
teamSlug string
|
||||
}
|
||||
|
||||
// NewReviewerTeam creates a new ReviewerTeam.
|
||||
func NewReviewerTeam(orgName, teamSlug string) ReviewerTeam {
|
||||
return ReviewerTeam{org: orgName, teamSlug: teamSlug}
|
||||
}
|
||||
|
||||
func (r ReviewerTeam) DisplayName() string {
|
||||
return fmt.Sprintf("%s/%s", r.org, r.teamSlug)
|
||||
}
|
||||
|
||||
func (r ReviewerTeam) Login() string {
|
||||
return fmt.Sprintf("%s/%s", r.org, r.teamSlug)
|
||||
}
|
||||
|
||||
func (r ReviewerTeam) Slug() string {
|
||||
return r.teamSlug
|
||||
}
|
||||
|
||||
func (r ReviewerTeam) sealedReviewerCandidate() {}
|
||||
|
||||
// SuggestedReviewerActors fetches suggested reviewers for a pull request.
|
||||
// It combines results from three sources using a cascading quota system:
|
||||
// - suggestedReviewerActors - suggested based on PR activity (base quota: 5)
|
||||
// - repository collaborators - all collaborators (base quota: 5 + unfilled from suggestions)
|
||||
// - organization teams - all teams for org repos (base quota: 5 + unfilled from collaborators)
|
||||
//
|
||||
// This ensures we show up to 15 total candidates, with each source filling any
|
||||
// unfilled quota from the previous source. Results are deduplicated.
|
||||
// Returns the candidates, a MoreResults count, and an error.
|
||||
func SuggestedReviewerActors(client *Client, repo ghrepo.Interface, prID string, query string) ([]ReviewerCandidate, int, error) {
|
||||
// Fetch 10 from each source to allow cascading quota to fill from available results.
|
||||
// Use a single query that includes organization.teams - if the owner is not an org,
|
||||
// we'll get a "Could not resolve to an Organization" error which we handle gracefully.
|
||||
// We also fetch unfiltered total counts via aliases for the "X more" display.
|
||||
type responseData struct {
|
||||
Node struct {
|
||||
PullRequest struct {
|
||||
SuggestedActors struct {
|
||||
Nodes []struct {
|
||||
IsAuthor bool
|
||||
IsCommenter bool
|
||||
Reviewer struct {
|
||||
TypeName string `graphql:"__typename"`
|
||||
User struct {
|
||||
Login string
|
||||
Name string
|
||||
} `graphql:"... on User"`
|
||||
Bot struct {
|
||||
Login string
|
||||
} `graphql:"... on Bot"`
|
||||
}
|
||||
}
|
||||
} `graphql:"suggestedReviewerActors(first: 10, query: $query)"`
|
||||
} `graphql:"... on PullRequest"`
|
||||
} `graphql:"node(id: $id)"`
|
||||
Repository struct {
|
||||
Collaborators struct {
|
||||
Nodes []struct {
|
||||
Login string
|
||||
Name string
|
||||
}
|
||||
} `graphql:"collaborators(first: 10, query: $query)"`
|
||||
CollaboratorsTotalCount struct {
|
||||
TotalCount int
|
||||
} `graphql:"collaboratorsTotalCount: collaborators(first: 0)"`
|
||||
} `graphql:"repository(owner: $owner, name: $name)"`
|
||||
Organization struct {
|
||||
Teams struct {
|
||||
Nodes []struct {
|
||||
Slug string
|
||||
}
|
||||
} `graphql:"teams(first: 10, query: $query)"`
|
||||
TeamsTotalCount struct {
|
||||
TotalCount int
|
||||
} `graphql:"teamsTotalCount: teams(first: 0)"`
|
||||
} `graphql:"organization(login: $owner)"`
|
||||
}
|
||||
|
||||
variables := map[string]interface{}{
|
||||
"id": githubv4.ID(prID),
|
||||
"query": githubv4.String(query),
|
||||
"owner": githubv4.String(repo.RepoOwner()),
|
||||
"name": githubv4.String(repo.RepoName()),
|
||||
}
|
||||
|
||||
var result responseData
|
||||
err := client.Query(repo.RepoHost(), "SuggestedReviewerActors", &result, variables)
|
||||
// Handle the case where the owner is not an organization - the query still returns
|
||||
// partial data (repository, node), so we can continue processing.
|
||||
if err != nil && !strings.Contains(err.Error(), errorResolvingOrganization) {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Build candidates using cascading quota logic:
|
||||
// Each source has a base quota of 5, plus any unfilled quota from previous sources.
|
||||
// This ensures we show up to 15 total candidates, filling gaps when earlier sources have fewer.
|
||||
seen := make(map[string]bool)
|
||||
var candidates []ReviewerCandidate
|
||||
const baseQuota = 5
|
||||
|
||||
// Suggested reviewers (excluding author)
|
||||
suggestionsAdded := 0
|
||||
for _, n := range result.Node.PullRequest.SuggestedActors.Nodes {
|
||||
if suggestionsAdded >= baseQuota {
|
||||
break
|
||||
}
|
||||
if n.IsAuthor {
|
||||
continue
|
||||
}
|
||||
var candidate ReviewerCandidate
|
||||
var login string
|
||||
if n.Reviewer.TypeName == "User" && n.Reviewer.User.Login != "" {
|
||||
login = n.Reviewer.User.Login
|
||||
candidate = NewReviewerUser(login, n.Reviewer.User.Name)
|
||||
} else if n.Reviewer.TypeName == "Bot" && n.Reviewer.Bot.Login != "" {
|
||||
login = n.Reviewer.Bot.Login
|
||||
candidate = NewReviewerBot(login)
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
if !seen[login] {
|
||||
seen[login] = true
|
||||
candidates = append(candidates, candidate)
|
||||
suggestionsAdded++
|
||||
}
|
||||
}
|
||||
|
||||
// Collaborators: quota = base + unfilled from suggestions
|
||||
collaboratorsQuota := baseQuota + (baseQuota - suggestionsAdded)
|
||||
collaboratorsAdded := 0
|
||||
for _, c := range result.Repository.Collaborators.Nodes {
|
||||
if collaboratorsAdded >= collaboratorsQuota {
|
||||
break
|
||||
}
|
||||
if c.Login == "" {
|
||||
continue
|
||||
}
|
||||
if !seen[c.Login] {
|
||||
seen[c.Login] = true
|
||||
candidates = append(candidates, NewReviewerUser(c.Login, c.Name))
|
||||
collaboratorsAdded++
|
||||
}
|
||||
}
|
||||
|
||||
// Teams: quota = base + unfilled from collaborators
|
||||
teamsQuota := baseQuota + (collaboratorsQuota - collaboratorsAdded)
|
||||
teamsAdded := 0
|
||||
ownerName := repo.RepoOwner()
|
||||
for _, t := range result.Organization.Teams.Nodes {
|
||||
if teamsAdded >= teamsQuota {
|
||||
break
|
||||
}
|
||||
if t.Slug == "" {
|
||||
continue
|
||||
}
|
||||
teamLogin := fmt.Sprintf("%s/%s", ownerName, t.Slug)
|
||||
if !seen[teamLogin] {
|
||||
seen[teamLogin] = true
|
||||
candidates = append(candidates, NewReviewerTeam(ownerName, t.Slug))
|
||||
teamsAdded++
|
||||
}
|
||||
}
|
||||
|
||||
// MoreResults uses unfiltered total counts (teams will be 0 for personal repos)
|
||||
moreResults := result.Repository.CollaboratorsTotalCount.TotalCount + result.Organization.TeamsTotalCount.TotalCount
|
||||
|
||||
return candidates, moreResults, nil
|
||||
}
|
||||
|
||||
func UpdatePullRequestBranch(client *Client, repo ghrepo.Interface, params githubv4.UpdatePullRequestBranchInput) error {
|
||||
var mutation struct {
|
||||
UpdatePullRequestBranch struct {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package api
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
|
|
@ -136,3 +138,227 @@ func Test_Logins(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mockReviewerResponse generates a GraphQL response for SuggestedReviewerActors tests.
|
||||
// It creates suggestions (s1, s2...), collaborators (c1, c2...), and teams (team1, team2...).
|
||||
// totalCollabs and totalTeams set the unfiltered TotalCount fields (for "more results" calculation).
|
||||
func mockReviewerResponse(suggestions, collabs, teams, totalCollabs, totalTeams int) string {
|
||||
var suggestionNodes, collabNodes, teamNodes []string
|
||||
|
||||
for i := 1; i <= suggestions; i++ {
|
||||
suggestionNodes = append(suggestionNodes,
|
||||
fmt.Sprintf(`{"isAuthor": false, "reviewer": {"__typename": "User", "login": "s%d", "name": "S%d"}}`, i, i))
|
||||
}
|
||||
for i := 1; i <= collabs; i++ {
|
||||
collabNodes = append(collabNodes,
|
||||
fmt.Sprintf(`{"login": "c%d", "name": "C%d"}`, i, i))
|
||||
}
|
||||
for i := 1; i <= teams; i++ {
|
||||
teamNodes = append(teamNodes,
|
||||
fmt.Sprintf(`{"slug": "team%d"}`, i))
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`{
|
||||
"data": {
|
||||
"node": {"suggestedReviewerActors": {"nodes": [%s]}},
|
||||
"repository": {
|
||||
"collaborators": {"nodes": [%s]},
|
||||
"collaboratorsTotalCount": {"totalCount": %d}
|
||||
},
|
||||
"organization": {
|
||||
"teams": {"nodes": [%s]},
|
||||
"teamsTotalCount": {"totalCount": %d}
|
||||
}
|
||||
}
|
||||
}`, strings.Join(suggestionNodes, ","), strings.Join(collabNodes, ","), totalCollabs,
|
||||
strings.Join(teamNodes, ","), totalTeams)
|
||||
}
|
||||
|
||||
func TestSuggestedReviewerActors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
httpStubs func(*httpmock.Registry)
|
||||
expectedCount int
|
||||
expectedLogins []string
|
||||
expectedMore int
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "all sources plentiful - 5 each from cascading quota",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query SuggestedReviewerActors\b`),
|
||||
httpmock.StringResponse(mockReviewerResponse(6, 6, 6, 20, 10)))
|
||||
},
|
||||
expectedCount: 15,
|
||||
expectedLogins: []string{"s1", "s2", "s3", "s4", "s5", "c1", "c2", "c3", "c4", "c5", "OWNER/team1", "OWNER/team2", "OWNER/team3", "OWNER/team4", "OWNER/team5"},
|
||||
expectedMore: 30,
|
||||
},
|
||||
{
|
||||
name: "few suggestions - collaborators fill gap",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query SuggestedReviewerActors\b`),
|
||||
httpmock.StringResponse(mockReviewerResponse(2, 10, 6, 50, 10)))
|
||||
},
|
||||
expectedCount: 15,
|
||||
expectedLogins: []string{"s1", "s2", "c1", "c2", "c3", "c4", "c5", "c6", "c7", "c8", "OWNER/team1", "OWNER/team2", "OWNER/team3", "OWNER/team4", "OWNER/team5"},
|
||||
expectedMore: 60,
|
||||
},
|
||||
{
|
||||
name: "few suggestions and collaborators - teams fill gap",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query SuggestedReviewerActors\b`),
|
||||
httpmock.StringResponse(mockReviewerResponse(2, 3, 10, 3, 10)))
|
||||
},
|
||||
expectedCount: 15,
|
||||
expectedLogins: []string{"s1", "s2", "c1", "c2", "c3", "OWNER/team1", "OWNER/team2", "OWNER/team3", "OWNER/team4", "OWNER/team5", "OWNER/team6", "OWNER/team7", "OWNER/team8", "OWNER/team9", "OWNER/team10"},
|
||||
expectedMore: 13,
|
||||
},
|
||||
{
|
||||
name: "no suggestions or collaborators - teams only",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query SuggestedReviewerActors\b`),
|
||||
httpmock.StringResponse(mockReviewerResponse(0, 0, 10, 0, 10)))
|
||||
},
|
||||
expectedCount: 10, // max 15, but only 10 teams available
|
||||
expectedLogins: []string{"OWNER/team1", "OWNER/team2", "OWNER/team3", "OWNER/team4", "OWNER/team5", "OWNER/team6", "OWNER/team7", "OWNER/team8", "OWNER/team9", "OWNER/team10"},
|
||||
expectedMore: 10,
|
||||
},
|
||||
{
|
||||
name: "author excluded from suggestions",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
// Custom response with author flag
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query SuggestedReviewerActors\b`),
|
||||
httpmock.StringResponse(`{
|
||||
"data": {
|
||||
"node": {"suggestedReviewerActors": {"nodes": [
|
||||
{"isAuthor": true, "reviewer": {"__typename": "User", "login": "author", "name": "Author"}},
|
||||
{"isAuthor": false, "reviewer": {"__typename": "User", "login": "s1", "name": "S1"}},
|
||||
{"isAuthor": false, "reviewer": {"__typename": "User", "login": "s2", "name": "S2"}}
|
||||
]}},
|
||||
"repository": {
|
||||
"collaborators": {"nodes": [{"login": "c1", "name": "C1"}]},
|
||||
"collaboratorsTotalCount": {"totalCount": 5}
|
||||
},
|
||||
"organization": {
|
||||
"teams": {"nodes": [{"slug": "team1"}]},
|
||||
"teamsTotalCount": {"totalCount": 3}
|
||||
}
|
||||
}
|
||||
}`))
|
||||
},
|
||||
expectedCount: 4,
|
||||
expectedLogins: []string{"s1", "s2", "c1", "OWNER/team1"},
|
||||
expectedMore: 8,
|
||||
},
|
||||
{
|
||||
name: "deduplication across sources",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
// Custom response with duplicate user
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query SuggestedReviewerActors\b`),
|
||||
httpmock.StringResponse(`{
|
||||
"data": {
|
||||
"node": {"suggestedReviewerActors": {"nodes": [
|
||||
{"isAuthor": false, "reviewer": {"__typename": "User", "login": "shareduser", "name": "Shared"}}
|
||||
]}},
|
||||
"repository": {
|
||||
"collaborators": {"nodes": [
|
||||
{"login": "shareduser", "name": "Shared"},
|
||||
{"login": "c1", "name": "C1"}
|
||||
]},
|
||||
"collaboratorsTotalCount": {"totalCount": 10}
|
||||
},
|
||||
"organization": {
|
||||
"teams": {"nodes": [{"slug": "team1"}]},
|
||||
"teamsTotalCount": {"totalCount": 5}
|
||||
}
|
||||
}
|
||||
}`))
|
||||
},
|
||||
expectedCount: 3,
|
||||
expectedLogins: []string{"shareduser", "c1", "OWNER/team1"},
|
||||
expectedMore: 15,
|
||||
},
|
||||
{
|
||||
name: "personal repo - no organization teams",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query SuggestedReviewerActors\b`),
|
||||
httpmock.StringResponse(`{
|
||||
"data": {
|
||||
"node": {"suggestedReviewerActors": {"nodes": [
|
||||
{"isAuthor": false, "reviewer": {"__typename": "User", "login": "s1", "name": "S1"}}
|
||||
]}},
|
||||
"repository": {
|
||||
"collaborators": {"nodes": [{"login": "c1", "name": "C1"}]},
|
||||
"collaboratorsTotalCount": {"totalCount": 3}
|
||||
},
|
||||
"organization": null
|
||||
},
|
||||
"errors": [{"message": "Could not resolve to an Organization with the login of 'OWNER'."}]
|
||||
}`))
|
||||
},
|
||||
expectedCount: 2,
|
||||
expectedLogins: []string{"s1", "c1"},
|
||||
expectedMore: 3,
|
||||
},
|
||||
{
|
||||
name: "bot reviewer included",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query SuggestedReviewerActors\b`),
|
||||
httpmock.StringResponse(`{
|
||||
"data": {
|
||||
"node": {"suggestedReviewerActors": {"nodes": [
|
||||
{"isAuthor": false, "reviewer": {"__typename": "Bot", "login": "copilot-pull-request-reviewer"}},
|
||||
{"isAuthor": false, "reviewer": {"__typename": "User", "login": "s1", "name": "S1"}}
|
||||
]}},
|
||||
"repository": {
|
||||
"collaborators": {"nodes": []},
|
||||
"collaboratorsTotalCount": {"totalCount": 5}
|
||||
},
|
||||
"organization": {
|
||||
"teams": {"nodes": []},
|
||||
"teamsTotalCount": {"totalCount": 0}
|
||||
}
|
||||
}
|
||||
}`))
|
||||
},
|
||||
expectedCount: 2,
|
||||
expectedLogins: []string{"copilot-pull-request-reviewer", "s1"},
|
||||
expectedMore: 5,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
if tt.httpStubs != nil {
|
||||
tt.httpStubs(reg)
|
||||
}
|
||||
|
||||
client := newTestClient(reg)
|
||||
repo, _ := ghrepo.FromFullName("OWNER/REPO")
|
||||
|
||||
candidates, moreResults, err := SuggestedReviewerActors(client, repo, "PR_123", "")
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.expectedCount, len(candidates), "candidate count mismatch")
|
||||
assert.Equal(t, tt.expectedMore, moreResults, "moreResults mismatch")
|
||||
|
||||
logins := make([]string, len(candidates))
|
||||
for i, c := range candidates {
|
||||
logins[i] = c.Login()
|
||||
}
|
||||
assert.Equal(t, tt.expectedLogins, logins)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,9 @@ func ProjectsV2ItemsForIssue(client *Client, repo ghrepo.Interface, issue *Issue
|
|||
return err
|
||||
}
|
||||
for _, projectItemNode := range query.Repository.Issue.ProjectItems.Nodes {
|
||||
if projectItemNode == nil {
|
||||
continue
|
||||
}
|
||||
items.Nodes = append(items.Nodes, &ProjectV2Item{
|
||||
ID: projectItemNode.ID,
|
||||
Project: ProjectV2ItemProject{
|
||||
|
|
@ -175,6 +178,9 @@ func ProjectsV2ItemsForPullRequest(client *Client, repo ghrepo.Interface, pr *Pu
|
|||
}
|
||||
|
||||
for _, projectItemNode := range query.Repository.PullRequest.ProjectItems.Nodes {
|
||||
if projectItemNode == nil {
|
||||
continue
|
||||
}
|
||||
items.Nodes = append(items.Nodes, &ProjectV2Item{
|
||||
ID: projectItemNode.ID,
|
||||
Project: ProjectV2ItemProject{
|
||||
|
|
@ -314,7 +320,7 @@ func CurrentUserProjectsV2(client *Client, hostname string) ([]ProjectV2, error)
|
|||
return projectsV2, nil
|
||||
}
|
||||
|
||||
// When querying ProjectsV2 fields we generally dont want to show the user
|
||||
// When querying ProjectsV2 fields we generally don't want to show the user
|
||||
// scope errors and field does not exist errors. ProjectsV2IgnorableError
|
||||
// checks against known error strings to see if an error can be safely ignored.
|
||||
// Due to the fact that the GraphQLClient can return multiple types of errors
|
||||
|
|
|
|||
|
|
@ -129,6 +129,17 @@ func TestProjectsV2ItemsForIssue(t *testing.T) {
|
|||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "skips null project items for issue",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query IssueProjectItems\b`),
|
||||
httpmock.GraphQLQuery(`{"data":{"repository":{"issue":{"projectItems":{"totalCount":1,"nodes":[null]}}}}}`,
|
||||
func(query string, inputs map[string]interface{}) {}),
|
||||
)
|
||||
},
|
||||
expectItems: ProjectItems{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -186,6 +197,17 @@ func TestProjectsV2ItemsForPullRequest(t *testing.T) {
|
|||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "skips null project items for pull request",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query PullRequestProjectItems\b`),
|
||||
httpmock.GraphQLQuery(`{"data":{"repository":{"pullRequest":{"projectItems":{"totalCount":1,"nodes":[null]}}}}}`,
|
||||
func(query string, inputs map[string]interface{}) {}),
|
||||
)
|
||||
},
|
||||
expectItems: ProjectItems{},
|
||||
},
|
||||
{
|
||||
name: "retrieves project items that have status columns",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
|
|
|
|||
|
|
@ -1080,10 +1080,11 @@ func RepoProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error)
|
|||
return projects, nil
|
||||
}
|
||||
|
||||
// Expected login for Copilot when retrieved as an Actor
|
||||
// This is returned from assignable actors and issue/pr assigned actors.
|
||||
// We use this to check if the actor is Copilot.
|
||||
const CopilotActorLogin = "copilot-swe-agent"
|
||||
// Expected login for Copilot when retrieved as an assignee
|
||||
const CopilotAssigneeLogin = "copilot-swe-agent"
|
||||
|
||||
// Expected login for Copilot when retrieved as a Pull Request Reviewer.
|
||||
const CopilotReviewerLogin = "copilot-pull-request-reviewer"
|
||||
const CopilotActorName = "Copilot"
|
||||
|
||||
type AssignableActor interface {
|
||||
|
|
@ -1144,7 +1145,7 @@ func NewAssignableBot(id, login string) AssignableBot {
|
|||
}
|
||||
|
||||
func (b AssignableBot) DisplayName() string {
|
||||
if b.login == CopilotActorLogin {
|
||||
if b.login == CopilotAssigneeLogin {
|
||||
return fmt.Sprintf("%s (AI)", CopilotActorName)
|
||||
}
|
||||
return b.Login()
|
||||
|
|
|
|||
|
|
@ -94,12 +94,16 @@ var issueClosedByPullRequestsReferences = shortenQuery(`
|
|||
}
|
||||
`)
|
||||
|
||||
// prReviewRequests includes ...on Bot to support Copilot as a reviewer on github.com.
|
||||
// On GHES, Bot is not part of the RequestedReviewer union, but the fragment is
|
||||
// silently ignored (verified on GHES 3.19).
|
||||
var prReviewRequests = shortenQuery(`
|
||||
reviewRequests(first: 100) {
|
||||
nodes {
|
||||
requestedReviewer {
|
||||
__typename,
|
||||
...on User{login},
|
||||
...on User{login,name},
|
||||
...on Bot{login},
|
||||
...on Team{
|
||||
organization{login}
|
||||
name,
|
||||
|
|
@ -144,7 +148,8 @@ var prFiles = shortenQuery(`
|
|||
nodes {
|
||||
additions,
|
||||
deletions,
|
||||
path
|
||||
path,
|
||||
changeType
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
|
@ -384,7 +389,7 @@ func IssueGraphQL(fields []string) string {
|
|||
case "headRepository":
|
||||
q = append(q, `headRepository{id,name}`)
|
||||
case "assignees":
|
||||
q = append(q, `assignees(first:100){nodes{id,login,name},totalCount}`)
|
||||
q = append(q, `assignees(first:100){nodes{id,login,name,databaseId},totalCount}`)
|
||||
case "assignedActors":
|
||||
q = append(q, assignedActors)
|
||||
case "labels":
|
||||
|
|
|
|||
|
|
@ -21,12 +21,12 @@ func TestPullRequestGraphQL(t *testing.T) {
|
|||
{
|
||||
name: "fields with nested structures",
|
||||
fields: []string{"author", "assignees"},
|
||||
want: "author{login,...on User{id,name}},assignees(first:100){nodes{id,login,name},totalCount}",
|
||||
want: "author{login,...on User{id,name}},assignees(first:100){nodes{id,login,name,databaseId},totalCount}",
|
||||
},
|
||||
{
|
||||
name: "compressed query",
|
||||
fields: []string{"files"},
|
||||
want: "files(first: 100) {nodes {additions,deletions,path}}",
|
||||
want: "files(first: 100) {nodes {additions,deletions,path,changeType}}",
|
||||
},
|
||||
{
|
||||
name: "invalid fields",
|
||||
|
|
@ -67,12 +67,12 @@ func TestIssueGraphQL(t *testing.T) {
|
|||
{
|
||||
name: "fields with nested structures",
|
||||
fields: []string{"author", "assignees"},
|
||||
want: "author{login,...on User{id,name}},assignees(first:100){nodes{id,login,name},totalCount}",
|
||||
want: "author{login,...on User{id,name}},assignees(first:100){nodes{id,login,name,databaseId},totalCount}",
|
||||
},
|
||||
{
|
||||
name: "compressed query",
|
||||
fields: []string{"files"},
|
||||
want: "files(first: 100) {nodes {additions,deletions,path}}",
|
||||
want: "files(first: 100) {nodes {additions,deletions,path,changeType}}",
|
||||
},
|
||||
{
|
||||
name: "projectItems",
|
||||
|
|
|
|||
|
|
@ -4,43 +4,31 @@ GitHub CLI complies with the software licenses of its dependencies. This documen
|
|||
|
||||
## Overview
|
||||
|
||||
When a dependency is added or updated, the license information needs to be updated. We use the [`google/go-licenses`](https://github.com/google/go-licenses) tool to:
|
||||
Third-party license information is embedded into the `gh` binary at build time using [`google/go-licenses`](https://github.com/google/go-licenses). Each release binary contains the correct license listing for its target platform (GOOS/GOARCH), since the set of dependencies can vary by platform.
|
||||
|
||||
1. Generate markdown documentation listing all Go dependencies and their licenses
|
||||
2. Copy license files for dependencies that require redistribution
|
||||
## Viewing License Information
|
||||
|
||||
## License Files
|
||||
Users can view the third-party license information for their installed binary:
|
||||
|
||||
The following files contain license information:
|
||||
|
||||
- `third-party-licenses.darwin.md` - License information for macOS dependencies
|
||||
- `third-party-licenses.linux.md` - License information for Linux dependencies
|
||||
- `third-party-licenses.windows.md` - License information for Windows dependencies
|
||||
- `third-party/` - Directory containing source code and license files that require redistribution
|
||||
|
||||
## Updating License Information
|
||||
|
||||
When dependencies change, you need to update the license information:
|
||||
|
||||
1. Update license information for all platforms:
|
||||
|
||||
```shell
|
||||
make licenses
|
||||
```
|
||||
|
||||
2. Commit the changes:
|
||||
|
||||
```shell
|
||||
git add third-party-licenses.*.md third-party/
|
||||
git commit -m "Update third-party license information"
|
||||
```
|
||||
|
||||
## Checking License Compliance
|
||||
|
||||
The CI workflow checks if license information is up to date. To check locally:
|
||||
|
||||
```sh
|
||||
make licenses-check
|
||||
```shell
|
||||
gh licenses
|
||||
```
|
||||
|
||||
If the check fails, follow the instructions to update the license information.
|
||||
This opens a pager displaying all Go dependencies and their licenses, with links to the source code of each dependency.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. The `script/licenses` script accepts a GOOS and GOARCH and generates a license report using `go-licenses report`
|
||||
2. The report is written to `internal/licenses/embed/third-party-licenses.md`
|
||||
3. This file is embedded into the binary via `go:embed` in `internal/licenses/licenses.go`
|
||||
4. Goreleaser pre-build hooks call `script/licenses` with the correct platform before each build
|
||||
|
||||
## Local Development
|
||||
|
||||
During local development (`go build`), the embedded file contains a placeholder message. To generate real license information for your current platform:
|
||||
|
||||
```shell
|
||||
make licenses
|
||||
```
|
||||
|
||||
This runs `go-licenses report` for your host GOOS/GOARCH and writes the output to the embed path.
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
3. Build and install
|
||||
|
||||
#### Unix-like systems
|
||||
|
||||
```sh
|
||||
# installs to '/usr/local' by default; sudo may be required, or sudo -E for configured go environments
|
||||
$ make install
|
||||
|
|
@ -27,15 +28,18 @@
|
|||
```
|
||||
|
||||
#### Windows
|
||||
|
||||
```pwsh
|
||||
# build the `bin\gh.exe` binary
|
||||
> go run script\build.go
|
||||
```
|
||||
|
||||
There is no install step available on Windows.
|
||||
|
||||
4. Run `gh version` to check if it worked.
|
||||
|
||||
#### Windows
|
||||
|
||||
Run `bin\gh version` to check if it worked.
|
||||
|
||||
## Cross-compiling binaries for different platforms
|
||||
|
|
@ -44,10 +48,12 @@ You can use any platform with Go installed to build a binary that is intended fo
|
|||
or CPU architecture. This is achieved by setting environment variables such as GOOS and GOARCH.
|
||||
|
||||
For example, to compile the `gh` binary for the 32-bit Raspberry Pi OS:
|
||||
|
||||
```sh
|
||||
# on a Unix-like system:
|
||||
$ GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 make clean bin/gh
|
||||
```
|
||||
|
||||
```pwsh
|
||||
# on Windows, pass environment variables as arguments to the build script:
|
||||
> go run script\build.go clean bin\gh GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0
|
||||
|
|
|
|||
|
|
@ -14,16 +14,16 @@ For bugs, the FR should engage with the issue and community with the goal to rem
|
|||
|
||||
To be considered triaged, `bug` issues require the following:
|
||||
|
||||
- A severity label `p1`, `p2`, and `p3`
|
||||
- A severity label `priority-1`, `priority-2`, and `priority-3`
|
||||
- Clearly defined Acceptance Criteria, added to the Issue as a standalone comment (see [example](https://github.com/cli/cli/issues/9469#issuecomment-2292315743))
|
||||
|
||||
#### Bug severities
|
||||
|
||||
| Severity | Description |
|
||||
| - | - |
|
||||
| `p1` | Affects a large population and inhibits work |
|
||||
| `p2` | Affects more than a few users but doesn't prevent core functions |
|
||||
| `p3` | Affects a small number of users or is largely cosmetic |
|
||||
| `priority-1` | Affects a large population and inhibits work |
|
||||
| `priority-2` | Affects more than a few users but doesn't prevent core functions |
|
||||
| `priority-3` | Affects a small number of users or is largely cosmetic |
|
||||
|
||||
### Enhancements and Docs
|
||||
|
||||
|
|
@ -36,10 +36,10 @@ When a new issue is opened, the FR **should**:
|
|||
- Ensure there is enough information to understand the enhancement's scope and value
|
||||
- Ask the user for more information about value and use-case, if necessary
|
||||
- Leave the `needs-triage` label on the issue
|
||||
- Add the `needs-user-input` and `needs-investigation` labels as needed
|
||||
- Add the `more-info-needed` and `needs-investigation` labels as needed
|
||||
|
||||
When the FR has enough information to be triaged, they should:
|
||||
- Remove the `needs-user-input` and `needs-investigation` labels
|
||||
- Remove the `more-info-needed` and `needs-investigation` labels
|
||||
- Remove their assignment from the issue
|
||||
|
||||
The FR should **avoid**:
|
||||
|
|
@ -57,7 +57,7 @@ The FR can consider adding any of the following labels below.
|
|||
| - | - |
|
||||
| `discuss` | Some issues require discussion with the internal team. Adding this label will automatically open up an internal discussion with the team to facilitate this discussion. |
|
||||
| `core` | Defines what we would like to do internally. We tend to lean towards `help wanted` by default, and adding `core` should be reserved for trickier issues or implementations we have strong opinions/preferences about. |
|
||||
| `needs-user-input` | After asking any contributors for more information, add this label so it is clear that the issue has been responded to and we are waiting on the user. |
|
||||
| `more-info-needed` | After asking any contributors for more information, add this label so it is clear that the issue has been responded to and we are waiting on the user. |
|
||||
| `needs-investigation` | Used when the issue requires further investigation before it can be reviewed and triaged. This is often used for issues that are not clearly bugs or enhancements, or when the FR needs to gather more information before proceeding. |
|
||||
| `invalid` | Added to spam and abusive issues. |
|
||||
|
||||
|
|
|
|||
166
go.mod
166
go.mod
|
|
@ -1,8 +1,6 @@
|
|||
module github.com/cli/cli/v2
|
||||
|
||||
go 1.25.0
|
||||
|
||||
toolchain go1.25.5
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
github.com/AlecAivazis/survey/v2 v2.3.7
|
||||
|
|
@ -17,14 +15,14 @@ require (
|
|||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||
github.com/cli/go-gh/v2 v2.13.0
|
||||
github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24
|
||||
github.com/cli/oauth v1.2.0
|
||||
github.com/cli/oauth v1.2.2
|
||||
github.com/cli/safeexec v1.0.1
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea
|
||||
github.com/distribution/reference v0.6.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.11
|
||||
github.com/gdamore/tcell/v2 v2.13.2
|
||||
github.com/gabriel-vasile/mimetype v1.4.13
|
||||
github.com/gdamore/tcell/v2 v2.13.8
|
||||
github.com/golang/snappy v1.0.0
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/google/go-containerregistry v0.20.7
|
||||
|
|
@ -44,41 +42,27 @@ require (
|
|||
github.com/rivo/tview v0.42.0
|
||||
github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7
|
||||
github.com/sigstore/protobuf-specs v0.5.0
|
||||
github.com/sigstore/sigstore-go v1.1.3
|
||||
github.com/sigstore/sigstore-go v1.1.4
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/theupdateframework/go-tuf/v2 v2.3.0
|
||||
github.com/theupdateframework/go-tuf/v2 v2.4.1
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1
|
||||
github.com/yuin/goldmark v1.7.13
|
||||
github.com/yuin/goldmark v1.7.16
|
||||
github.com/zalando/go-keyring v0.2.6
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/sync v0.19.0
|
||||
golang.org/x/term v0.38.0
|
||||
golang.org/x/text v0.32.0
|
||||
google.golang.org/grpc v1.77.0
|
||||
google.golang.org/protobuf v1.36.10
|
||||
golang.org/x/term v0.40.0
|
||||
golang.org/x/text v0.34.0
|
||||
google.golang.org/grpc v1.79.1
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/h2non/gock.v1 v1.1.2
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
al.essio.dev/pkg/shellescape v1.6.0 // indirect
|
||||
cel.dev/expr v0.24.0 // indirect
|
||||
cloud.google.com/go v0.121.6 // indirect
|
||||
cloud.google.com/go/auth v0.16.5 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
cloud.google.com/go/iam v1.5.2 // indirect
|
||||
cloud.google.com/go/longrunning v0.6.7 // indirect
|
||||
cloud.google.com/go/monitoring v1.24.2 // indirect
|
||||
cloud.google.com/go/spanner v1.84.1 // indirect
|
||||
cloud.google.com/go/storage v1.56.1 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.5.3 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect
|
||||
github.com/Masterminds/goutils v1.1.1 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
|
||||
|
|
@ -90,16 +74,15 @@ require (
|
|||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.6 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.3.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.9.3 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.10.2 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250630141444-821143405392 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20250630141444-821143405392 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/cli/browser v1.3.0 // indirect
|
||||
github.com/cli/shurcooL-graphql v0.0.4 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f // indirect
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.18.1 // indirect
|
||||
github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect
|
||||
github.com/danieljoos/wincred v1.2.2 // indirect
|
||||
|
|
@ -110,50 +93,39 @@ require (
|
|||
github.com/docker/distribution v2.8.3+incompatible // indirect
|
||||
github.com/docker/docker-credential-helpers v0.9.3 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.35.0 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gdamore/encoding v1.0.1 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.3 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/analysis v0.23.0 // indirect
|
||||
github.com/go-openapi/errors v0.22.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.1 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
||||
github.com/go-openapi/loads v0.22.0 // indirect
|
||||
github.com/go-openapi/runtime v0.28.0 // indirect
|
||||
github.com/go-openapi/spec v0.21.0 // indirect
|
||||
github.com/go-openapi/strfmt v0.23.0 // indirect
|
||||
github.com/go-openapi/swag v0.24.1 // indirect
|
||||
github.com/go-openapi/swag/cmdutils v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/conv v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/fileutils v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/loading v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/mangling v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/netutils v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.24.0 // indirect
|
||||
github.com/go-openapi/validate v0.24.0 // indirect
|
||||
github.com/go-openapi/analysis v0.24.1 // indirect
|
||||
github.com/go-openapi/errors v0.22.6 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.4 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.4 // indirect
|
||||
github.com/go-openapi/loads v0.23.2 // indirect
|
||||
github.com/go-openapi/runtime v0.29.2 // indirect
|
||||
github.com/go-openapi/spec v0.22.3 // indirect
|
||||
github.com/go-openapi/strfmt v0.25.0 // indirect
|
||||
github.com/go-openapi/swag v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/cmdutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/fileutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/mangling v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/netutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
|
||||
github.com/go-openapi/validate v0.25.1 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/google/certificate-transparency-go v1.3.2 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
|
||||
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/henvic/httpretty v0.1.4 // indirect
|
||||
github.com/huandu/xstrings v1.5.0 // indirect
|
||||
github.com/in-toto/in-toto-golang v0.9.0 // indirect
|
||||
|
|
@ -161,18 +133,14 @@ require (
|
|||
github.com/itchyny/gojq v0.12.17 // indirect
|
||||
github.com/itchyny/timefmt-go v0.1.6 // indirect
|
||||
github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/klauspost/compress v1.18.1 // indirect
|
||||
github.com/letsencrypt/boulder v0.20250630.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.17 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
|
|
@ -181,65 +149,39 @@ require (
|
|||
github.com/oklog/ulid v1.3.1 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rodaine/table v1.3.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.9.0 // indirect
|
||||
github.com/sassoftware/relic v7.2.1+incompatible // indirect
|
||||
github.com/secure-systems-lab/go-securesystemslib v0.9.1 // indirect
|
||||
github.com/secure-systems-lab/go-securesystemslib v0.10.0 // indirect
|
||||
github.com/shibumi/go-pathspec v1.3.0 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect
|
||||
github.com/sigstore/rekor v1.4.2 // indirect
|
||||
github.com/sigstore/rekor-tiles v0.1.11 // indirect
|
||||
github.com/sigstore/sigstore v1.9.6-0.20250729224751-181c5d3339b3 // indirect
|
||||
github.com/sigstore/timestamp-authority v1.2.9 // indirect
|
||||
github.com/sigstore/rekor v1.5.0 // indirect
|
||||
github.com/sigstore/rekor-tiles/v2 v2.0.1 // indirect
|
||||
github.com/sigstore/sigstore v1.10.4 // indirect
|
||||
github.com/sigstore/timestamp-authority/v2 v2.0.3 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.14.0 // indirect
|
||||
github.com/spf13/cast v1.9.2 // indirect
|
||||
github.com/spf13/viper v1.20.1 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/theupdateframework/go-tuf v0.7.0 // indirect
|
||||
github.com/thlib/go-timezone-local v0.0.6 // indirect
|
||||
github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect
|
||||
github.com/transparency-dev/formats v0.0.0-20250421220931-bb8ad4d07c26 // indirect
|
||||
github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c // indirect
|
||||
github.com/transparency-dev/merkle v0.0.2 // indirect
|
||||
github.com/transparency-dev/tessera v1.0.0-rc3 // indirect
|
||||
github.com/vbatts/tar-split v0.12.2 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||
go.mongodb.org/mongo-driver v1.17.4 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
go.mongodb.org/mongo-driver v1.17.6 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
go.opentelemetry.io/otel v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/mod v0.30.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/oauth2 v0.33.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
google.golang.org/api v0.248.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect
|
||||
k8s.io/klog/v2 v2.130.1 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ func (md *DisabledDetectorMock) ProjectsV1() gh.ProjectsV1Support {
|
|||
return gh.ProjectsV1Unsupported
|
||||
}
|
||||
|
||||
func (md *DisabledDetectorMock) ProjectFeatures() (ProjectFeatures, error) {
|
||||
return ProjectFeatures{}, nil
|
||||
}
|
||||
|
||||
func (md *DisabledDetectorMock) SearchFeatures() (SearchFeatures, error) {
|
||||
return advancedIssueSearchNotSupported, nil
|
||||
}
|
||||
|
|
@ -28,6 +32,10 @@ func (md *DisabledDetectorMock) ReleaseFeatures() (ReleaseFeatures, error) {
|
|||
return ReleaseFeatures{}, nil
|
||||
}
|
||||
|
||||
func (md *DisabledDetectorMock) ActionsFeatures() (ActionsFeatures, error) {
|
||||
return ActionsFeatures{}, nil
|
||||
}
|
||||
|
||||
type EnabledDetectorMock struct{}
|
||||
|
||||
func (md *EnabledDetectorMock) IssueFeatures() (IssueFeatures, error) {
|
||||
|
|
@ -46,6 +54,10 @@ func (md *EnabledDetectorMock) ProjectsV1() gh.ProjectsV1Support {
|
|||
return gh.ProjectsV1Supported
|
||||
}
|
||||
|
||||
func (md *EnabledDetectorMock) ProjectFeatures() (ProjectFeatures, error) {
|
||||
return allProjectFeatures, nil
|
||||
}
|
||||
|
||||
func (md *EnabledDetectorMock) SearchFeatures() (SearchFeatures, error) {
|
||||
return advancedIssueSearchNotSupported, nil
|
||||
}
|
||||
|
|
@ -56,6 +68,12 @@ func (md *EnabledDetectorMock) ReleaseFeatures() (ReleaseFeatures, error) {
|
|||
}, nil
|
||||
}
|
||||
|
||||
func (md *EnabledDetectorMock) ActionsFeatures() (ActionsFeatures, error) {
|
||||
return ActionsFeatures{
|
||||
DispatchRunDetails: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type AdvancedIssueSearchDetectorMock struct {
|
||||
EnabledDetectorMock
|
||||
searchFeatures SearchFeatures
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@ type Detector interface {
|
|||
PullRequestFeatures() (PullRequestFeatures, error)
|
||||
RepositoryFeatures() (RepositoryFeatures, error)
|
||||
ProjectsV1() gh.ProjectsV1Support
|
||||
ProjectFeatures() (ProjectFeatures, error)
|
||||
SearchFeatures() (SearchFeatures, error)
|
||||
ReleaseFeatures() (ReleaseFeatures, error)
|
||||
ActionsFeatures() (ActionsFeatures, error)
|
||||
}
|
||||
|
||||
type IssueFeatures struct {
|
||||
|
|
@ -57,6 +59,16 @@ var allRepositoryFeatures = RepositoryFeatures{
|
|||
AutoMerge: true,
|
||||
}
|
||||
|
||||
type ProjectFeatures struct {
|
||||
// ProjectItemQuery indicates support for the `query` argument on
|
||||
// ProjectV2.items (supported on github.com and GHES 3.20+).
|
||||
ProjectItemQuery bool
|
||||
}
|
||||
|
||||
var allProjectFeatures = ProjectFeatures{
|
||||
ProjectItemQuery: true,
|
||||
}
|
||||
|
||||
type SearchFeatures struct {
|
||||
// AdvancedIssueSearch indicates whether the host supports advanced issue
|
||||
// search via API calls.
|
||||
|
|
@ -98,6 +110,16 @@ type ReleaseFeatures struct {
|
|||
ImmutableReleases bool
|
||||
}
|
||||
|
||||
type ActionsFeatures struct {
|
||||
// DispatchRunDetails indicates whether the API supports the `return_run_details`
|
||||
// field in workflow dispatches that, when set to true, will return the details
|
||||
// of the created workflow run in the response (with status code 200).
|
||||
//
|
||||
// On older API versions (e.g. GHES 3.20 or earlier), this new field is not
|
||||
// supported and setting it will cause an error.
|
||||
DispatchRunDetails bool
|
||||
}
|
||||
|
||||
type detector struct {
|
||||
host string
|
||||
httpClient *http.Client
|
||||
|
|
@ -268,6 +290,45 @@ func (d *detector) ProjectsV1() gh.ProjectsV1Support {
|
|||
return gh.ProjectsV1Unsupported
|
||||
}
|
||||
|
||||
func (d *detector) ProjectFeatures() (ProjectFeatures, error) {
|
||||
if !ghauth.IsEnterprise(d.host) {
|
||||
return allProjectFeatures, nil
|
||||
}
|
||||
|
||||
var features ProjectFeatures
|
||||
|
||||
var featureDetection struct {
|
||||
ProjectV2 struct {
|
||||
Fields []struct {
|
||||
Name string
|
||||
Args []struct {
|
||||
Name string
|
||||
}
|
||||
} `graphql:"fields(includeDeprecated: true)"`
|
||||
} `graphql:"ProjectV2: __type(name: \"ProjectV2\")"`
|
||||
}
|
||||
|
||||
gql := api.NewClientFromHTTP(d.httpClient)
|
||||
err := gql.Query(d.host, "ProjectV2_fields", &featureDetection, nil)
|
||||
if err != nil {
|
||||
return features, err
|
||||
}
|
||||
|
||||
for _, field := range featureDetection.ProjectV2.Fields {
|
||||
if field.Name == "items" {
|
||||
for _, arg := range field.Args {
|
||||
if arg.Name == "query" {
|
||||
features.ProjectItemQuery = true
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return features, nil
|
||||
}
|
||||
|
||||
const (
|
||||
// enterpriseAdvancedIssueSearchSupport is the minimum version of GHES that
|
||||
// supports advanced issue search and gh should use it.
|
||||
|
|
@ -393,6 +454,54 @@ func (d *detector) ReleaseFeatures() (ReleaseFeatures, error) {
|
|||
return ReleaseFeatures{}, nil
|
||||
}
|
||||
|
||||
const (
|
||||
enterpriseWorkflowDispatchRunDetailsSupport = "3.21.0"
|
||||
)
|
||||
|
||||
func (d *detector) ActionsFeatures() (ActionsFeatures, error) {
|
||||
// TODO workflowDispatchRunDetailsCleanup
|
||||
// Once GHES 3.20 support ends, we don't need feature detection for workflow dispatch (i.e. run details support).
|
||||
//
|
||||
// On github.com, workflow dispatch API now supports a new field named `return_run_details` that enabling it will
|
||||
// result in a 200 OK response with the details of the created workflow run. If not set (or set to false), the API
|
||||
// will keep the old behavior of returning a 204 No Content response.
|
||||
//
|
||||
// On GHES (current latest at 3.20), this new field is not available, and setting it will cause a 400 response.
|
||||
//
|
||||
// Once GHES 3.20 support ends, we can remove the feature detection and start using the new field in API calls.
|
||||
//
|
||||
// IMPORTANT: In the future REST API versions (i.e. breaking changes), the workflow dispatch endpoint is going to
|
||||
// always return the details of the created workflow run in the response, and the `return_run_details` field is
|
||||
// going to be ignored/removed. So, once we are migrating to the new API version we should double check the status
|
||||
// of the API.
|
||||
|
||||
if !ghauth.IsEnterprise(d.host) {
|
||||
return ActionsFeatures{
|
||||
DispatchRunDetails: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
minSupportedVersion, err := version.NewVersion(enterpriseWorkflowDispatchRunDetailsSupport)
|
||||
if err != nil {
|
||||
return ActionsFeatures{}, err
|
||||
}
|
||||
|
||||
hostVersion, err := resolveEnterpriseVersion(d.httpClient, d.host)
|
||||
if err != nil {
|
||||
return ActionsFeatures{}, err
|
||||
}
|
||||
|
||||
if hostVersion.GreaterThanOrEqual(minSupportedVersion) {
|
||||
return ActionsFeatures{
|
||||
DispatchRunDetails: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return ActionsFeatures{
|
||||
DispatchRunDetails: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveEnterpriseVersion(httpClient *http.Client, host string) (*version.Version, error) {
|
||||
var metaResponse struct {
|
||||
InstalledVersion string `json:"installed_version"`
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ func TestIssueFeatures(t *testing.T) {
|
|||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
defer reg.Verify(t)
|
||||
httpClient := &http.Client{}
|
||||
httpmock.ReplaceTripper(httpClient, reg)
|
||||
for query, resp := range tt.queryResponse {
|
||||
|
|
@ -586,6 +587,92 @@ func TestAdvancedIssueSearchSupport(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestProjectFeatures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hostname string
|
||||
queryResponse map[string]string
|
||||
wantFeatures ProjectFeatures
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "github.com",
|
||||
hostname: "github.com",
|
||||
wantFeatures: ProjectFeatures{
|
||||
ProjectItemQuery: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ghec data residency (ghe.com)",
|
||||
hostname: "stampname.ghe.com",
|
||||
wantFeatures: ProjectFeatures{
|
||||
ProjectItemQuery: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GHE empty response",
|
||||
hostname: "git.my.org",
|
||||
queryResponse: map[string]string{
|
||||
`query ProjectV2_fields\b`: `{"data": {}}`,
|
||||
},
|
||||
wantFeatures: ProjectFeatures{},
|
||||
},
|
||||
{
|
||||
name: "GHE items field without query arg",
|
||||
hostname: "git.my.org",
|
||||
queryResponse: map[string]string{
|
||||
`query ProjectV2_fields\b`: heredoc.Doc(`
|
||||
{ "data": { "ProjectV2": { "fields": [
|
||||
{"name": "items", "args": [
|
||||
{"name": "after"},
|
||||
{"name": "first"}
|
||||
]}
|
||||
] } } }
|
||||
`),
|
||||
},
|
||||
wantFeatures: ProjectFeatures{},
|
||||
},
|
||||
{
|
||||
name: "GHE items field with query arg",
|
||||
hostname: "git.my.org",
|
||||
queryResponse: map[string]string{
|
||||
`query ProjectV2_fields\b`: heredoc.Doc(`
|
||||
{ "data": { "ProjectV2": { "fields": [
|
||||
{"name": "items", "args": [
|
||||
{"name": "after"},
|
||||
{"name": "first"},
|
||||
{"name": "query"}
|
||||
]}
|
||||
] } } }
|
||||
`),
|
||||
},
|
||||
wantFeatures: ProjectFeatures{
|
||||
ProjectItemQuery: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
defer reg.Verify(t)
|
||||
httpClient := &http.Client{}
|
||||
httpmock.ReplaceTripper(httpClient, reg)
|
||||
for query, resp := range tt.queryResponse {
|
||||
reg.Register(httpmock.GraphQL(query), httpmock.StringResponse(resp))
|
||||
}
|
||||
detector := detector{host: tt.hostname, httpClient: httpClient}
|
||||
gotFeatures, err := detector.ProjectFeatures()
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.wantFeatures, gotFeatures)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseFeatures(t *testing.T) {
|
||||
withImmutableReleaseSupport := `{"data":{"Release":{"fields":[{"name":"author"},{"name":"name"},{"name":"immutable"}]}}}`
|
||||
withoutImmutableReleaseSupport := `{"data":{"Release":{"fields":[{"name":"author"},{"name":"name"}]}}}`
|
||||
|
|
@ -696,3 +783,71 @@ func TestReleaseFeatures(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionsFeatures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hostname string
|
||||
httpStubs func(*httpmock.Registry)
|
||||
wantFeatures ActionsFeatures
|
||||
}{
|
||||
{
|
||||
name: "github.com, workflow dispatch run details supported",
|
||||
hostname: "github.com",
|
||||
wantFeatures: ActionsFeatures{
|
||||
DispatchRunDetails: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ghec data residency (ghe.com), workflow dispatch run details supported",
|
||||
hostname: "stampname.ghe.com",
|
||||
wantFeatures: ActionsFeatures{
|
||||
DispatchRunDetails: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GHE 3.20, workflow dispatch run details not supported",
|
||||
hostname: "git.my.org",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "api/v3/meta"),
|
||||
httpmock.StringResponse(`{"installed_version":"3.20.999"}`),
|
||||
)
|
||||
},
|
||||
wantFeatures: ActionsFeatures{
|
||||
DispatchRunDetails: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GHE 3.21, workflow dispatch run details supported",
|
||||
hostname: "git.my.org",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "api/v3/meta"),
|
||||
httpmock.StringResponse(`{"installed_version":"3.21.0"}`),
|
||||
)
|
||||
},
|
||||
wantFeatures: ActionsFeatures{
|
||||
DispatchRunDetails: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
reg := &httpmock.Registry{}
|
||||
if tt.httpStubs != nil {
|
||||
tt.httpStubs(reg)
|
||||
}
|
||||
httpClient := &http.Client{}
|
||||
httpmock.ReplaceTripper(httpClient, reg)
|
||||
|
||||
detector := NewDetector(httpClient, tt.hostname)
|
||||
|
||||
features, err := detector.ActionsFeatures()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantFeatures, features)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
0
internal/licenses/embed/darwin-amd64/PLACEHOLDER
Normal file
0
internal/licenses/embed/darwin-amd64/PLACEHOLDER
Normal file
0
internal/licenses/embed/darwin-arm64/PLACEHOLDER
Normal file
0
internal/licenses/embed/darwin-arm64/PLACEHOLDER
Normal file
0
internal/licenses/embed/linux-386/PLACEHOLDER
Normal file
0
internal/licenses/embed/linux-386/PLACEHOLDER
Normal file
0
internal/licenses/embed/linux-amd64/PLACEHOLDER
Normal file
0
internal/licenses/embed/linux-amd64/PLACEHOLDER
Normal file
0
internal/licenses/embed/linux-arm/PLACEHOLDER
Normal file
0
internal/licenses/embed/linux-arm/PLACEHOLDER
Normal file
0
internal/licenses/embed/linux-arm64/PLACEHOLDER
Normal file
0
internal/licenses/embed/linux-arm64/PLACEHOLDER
Normal file
0
internal/licenses/embed/windows-386/PLACEHOLDER
Normal file
0
internal/licenses/embed/windows-386/PLACEHOLDER
Normal file
0
internal/licenses/embed/windows-amd64/PLACEHOLDER
Normal file
0
internal/licenses/embed/windows-amd64/PLACEHOLDER
Normal file
0
internal/licenses/embed/windows-arm64/PLACEHOLDER
Normal file
0
internal/licenses/embed/windows-arm64/PLACEHOLDER
Normal file
8
internal/licenses/embed_darwin_amd64.go
Normal file
8
internal/licenses/embed_darwin_amd64.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package licenses
|
||||
|
||||
import "embed"
|
||||
|
||||
const rootDir = "embed/darwin-amd64"
|
||||
|
||||
//go:embed all:embed/darwin-amd64
|
||||
var embedFS embed.FS
|
||||
8
internal/licenses/embed_darwin_arm64.go
Normal file
8
internal/licenses/embed_darwin_arm64.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package licenses
|
||||
|
||||
import "embed"
|
||||
|
||||
const rootDir = "embed/darwin-arm64"
|
||||
|
||||
//go:embed all:embed/darwin-arm64
|
||||
var embedFS embed.FS
|
||||
15
internal/licenses/embed_default.go
Normal file
15
internal/licenses/embed_default.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// This file is necessary to allow building on platforms that we do not have
|
||||
// official release builds for. Without this, `go build` or `go install` calls
|
||||
// would fail due to undefined symbols that are expected to be included in the
|
||||
// build.
|
||||
|
||||
//go:build !(darwin && (amd64 || arm64)) && !(linux && (386 || amd64 || arm || arm64)) && !(windows && (386 || amd64 || arm64))
|
||||
|
||||
package licenses
|
||||
|
||||
import "embed"
|
||||
|
||||
const rootDir = ""
|
||||
|
||||
// embedFS is left empty to indicate there's no embedded content.
|
||||
var embedFS embed.FS
|
||||
8
internal/licenses/embed_linux_386.go
Normal file
8
internal/licenses/embed_linux_386.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package licenses
|
||||
|
||||
import "embed"
|
||||
|
||||
const rootDir = "embed/linux-386"
|
||||
|
||||
//go:embed all:embed/linux-386
|
||||
var embedFS embed.FS
|
||||
8
internal/licenses/embed_linux_amd64.go
Normal file
8
internal/licenses/embed_linux_amd64.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package licenses
|
||||
|
||||
import "embed"
|
||||
|
||||
const rootDir = "embed/linux-amd64"
|
||||
|
||||
//go:embed all:embed/linux-amd64
|
||||
var embedFS embed.FS
|
||||
8
internal/licenses/embed_linux_arm.go
Normal file
8
internal/licenses/embed_linux_arm.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package licenses
|
||||
|
||||
import "embed"
|
||||
|
||||
const rootDir = "embed/linux-arm"
|
||||
|
||||
//go:embed all:embed/linux-arm
|
||||
var embedFS embed.FS
|
||||
8
internal/licenses/embed_linux_arm64.go
Normal file
8
internal/licenses/embed_linux_arm64.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package licenses
|
||||
|
||||
import "embed"
|
||||
|
||||
const rootDir = "embed/linux-arm64"
|
||||
|
||||
//go:embed all:embed/linux-arm64
|
||||
var embedFS embed.FS
|
||||
8
internal/licenses/embed_windows_386.go
Normal file
8
internal/licenses/embed_windows_386.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package licenses
|
||||
|
||||
import "embed"
|
||||
|
||||
const rootDir = "embed/windows-386"
|
||||
|
||||
//go:embed all:embed/windows-386
|
||||
var embedFS embed.FS
|
||||
8
internal/licenses/embed_windows_amd64.go
Normal file
8
internal/licenses/embed_windows_amd64.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package licenses
|
||||
|
||||
import "embed"
|
||||
|
||||
const rootDir = "embed/windows-amd64"
|
||||
|
||||
//go:embed all:embed/windows-amd64
|
||||
var embedFS embed.FS
|
||||
8
internal/licenses/embed_windows_arm64.go
Normal file
8
internal/licenses/embed_windows_arm64.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package licenses
|
||||
|
||||
import "embed"
|
||||
|
||||
const rootDir = "embed/windows-arm64"
|
||||
|
||||
//go:embed all:embed/windows-arm64
|
||||
var embedFS embed.FS
|
||||
85
internal/licenses/licenses.go
Normal file
85
internal/licenses/licenses.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package licenses
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Content returns the full license report, including the main report and all
|
||||
// third-party licenses.
|
||||
func Content() string {
|
||||
return content(embedFS, rootDir)
|
||||
}
|
||||
|
||||
func content(embedFS fs.ReadFileFS, rootDir string) string {
|
||||
var b strings.Builder
|
||||
|
||||
reportPath := path.Join(rootDir, "report.txt")
|
||||
thirdPartyPath := path.Join(rootDir, "third-party")
|
||||
|
||||
report, err := fs.ReadFile(embedFS, reportPath)
|
||||
if err != nil {
|
||||
return "License information is only available in official release builds.\n"
|
||||
}
|
||||
|
||||
b.Write(report)
|
||||
b.WriteString("\n")
|
||||
|
||||
// Walk the third-party directory and output each license/notice file
|
||||
// grouped by module path.
|
||||
type moduleFiles struct {
|
||||
path string
|
||||
files []string
|
||||
}
|
||||
|
||||
thirdPartyFS, err := fs.Sub(embedFS, thirdPartyPath)
|
||||
if err != nil {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
modules := map[string]*moduleFiles{}
|
||||
fs.WalkDir(thirdPartyFS, ".", func(filePath string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read embedded file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
dir := path.Dir(filePath)
|
||||
if _, ok := modules[dir]; !ok {
|
||||
modules[dir] = &moduleFiles{path: dir}
|
||||
}
|
||||
modules[dir].files = append(modules[dir].files, filePath)
|
||||
return nil
|
||||
})
|
||||
|
||||
// Sort modules by path for deterministic output
|
||||
sorted := make([]string, 0, len(modules))
|
||||
for k := range modules {
|
||||
sorted = append(sorted, k)
|
||||
}
|
||||
sort.Strings(sorted)
|
||||
|
||||
for _, modPath := range sorted {
|
||||
mod := modules[modPath]
|
||||
b.WriteString("================================================================================\n")
|
||||
fmt.Fprintf(&b, "%s\n", mod.path)
|
||||
b.WriteString("================================================================================\n\n")
|
||||
|
||||
for _, filePath := range mod.files {
|
||||
data, err := fs.ReadFile(thirdPartyFS, filePath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
b.Write(data)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
160
internal/licenses/licenses_test.go
Normal file
160
internal/licenses/licenses_test.go
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
package licenses
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestContent(t *testing.T) {
|
||||
// This test is to ensure that we don't accidentally commit actual license
|
||||
// files in the repo. The embedded content is only included in release builds,
|
||||
// so in a normal test build we should get a default message.
|
||||
require.Equal(t, "License information is only available in official release builds.\n", Content())
|
||||
}
|
||||
|
||||
func TestContent_tableTests(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fsys fstest.MapFS
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "report only",
|
||||
fsys: fstest.MapFS{
|
||||
"embed/os-arch/PLACEHOLDER": &fstest.MapFile{}, // Checked-in placeholder, so it's always there.
|
||||
"embed/os-arch/report.txt": &fstest.MapFile{Data: []byte("dep1 (v1.0.0) - MIT - https://example.com\n")},
|
||||
},
|
||||
expected: heredoc.Doc(`
|
||||
dep1 (v1.0.0) - MIT - https://example.com
|
||||
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "empty third-party dir",
|
||||
fsys: fstest.MapFS{
|
||||
"embed/os-arch/PLACEHOLDER": &fstest.MapFile{}, // Checked-in placeholder, so it's always there.
|
||||
"embed/os-arch/report.txt": &fstest.MapFile{Data: []byte("dep1 (v1.0.0) - MIT - https://example.com\n")},
|
||||
"embed/os-arch/third-party": &fstest.MapFile{Data: []byte{}, Mode: fs.ModeDir},
|
||||
},
|
||||
expected: heredoc.Doc(`
|
||||
dep1 (v1.0.0) - MIT - https://example.com
|
||||
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "unknown file at root ignored",
|
||||
fsys: fstest.MapFS{
|
||||
"embed/os-arch/PLACEHOLDER": &fstest.MapFile{}, // Checked-in placeholder, so it's always there.
|
||||
"embed/os-arch/report.txt": &fstest.MapFile{Data: []byte("dep1 (v1.0.0) - MIT - https://example.com\n")},
|
||||
"embed/os-arch/unknown": &fstest.MapFile{
|
||||
Data: []byte("MIT License\n\nCopyright (c) 2024"),
|
||||
},
|
||||
},
|
||||
expected: heredoc.Doc(`
|
||||
dep1 (v1.0.0) - MIT - https://example.com
|
||||
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "unknown directory at root ignored",
|
||||
fsys: fstest.MapFS{
|
||||
"embed/os-arch/PLACEHOLDER": &fstest.MapFile{}, // Checked-in placeholder, so it's always there.
|
||||
"embed/os-arch/report.txt": &fstest.MapFile{Data: []byte("dep1 (v1.0.0) - MIT - https://example.com\n")},
|
||||
"embed/os-arch/unknown/example.com/mod/LICENSE": &fstest.MapFile{
|
||||
Data: []byte("MIT License\n\nCopyright (c) 2024"),
|
||||
},
|
||||
},
|
||||
expected: heredoc.Doc(`
|
||||
dep1 (v1.0.0) - MIT - https://example.com
|
||||
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "single module",
|
||||
fsys: fstest.MapFS{
|
||||
"embed/os-arch/PLACEHOLDER": &fstest.MapFile{}, // Checked-in placeholder, so it's always there.
|
||||
"embed/os-arch/report.txt": &fstest.MapFile{Data: []byte("example.com/mod (v1.0.0) - MIT - https://example.com\n")},
|
||||
"embed/os-arch/third-party/example.com/mod/LICENSE": &fstest.MapFile{
|
||||
Data: []byte("MIT License\n\nCopyright (c) 2024"),
|
||||
},
|
||||
},
|
||||
expected: heredoc.Doc(`
|
||||
example.com/mod (v1.0.0) - MIT - https://example.com
|
||||
|
||||
================================================================================
|
||||
example.com/mod
|
||||
================================================================================
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024
|
||||
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "multiple modules sorted alphabetically",
|
||||
fsys: fstest.MapFS{
|
||||
"embed/os-arch/PLACEHOLDER": &fstest.MapFile{}, // Checked-in placeholder, so it's always there.
|
||||
"embed/os-arch/report.txt": &fstest.MapFile{Data: []byte("example.com/mod (v1.0.0) - MIT - https://example.com\n")},
|
||||
"embed/os-arch/third-party/github.com/zzz/pkg/LICENSE": &fstest.MapFile{
|
||||
Data: []byte("ZZZ License"),
|
||||
},
|
||||
"embed/os-arch/third-party/github.com/aaa/pkg/LICENSE": &fstest.MapFile{
|
||||
Data: []byte("AAA License"),
|
||||
},
|
||||
},
|
||||
expected: heredoc.Doc(`
|
||||
example.com/mod (v1.0.0) - MIT - https://example.com
|
||||
|
||||
================================================================================
|
||||
github.com/aaa/pkg
|
||||
================================================================================
|
||||
|
||||
AAA License
|
||||
|
||||
================================================================================
|
||||
github.com/zzz/pkg
|
||||
================================================================================
|
||||
|
||||
ZZZ License
|
||||
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "license and notice files",
|
||||
fsys: fstest.MapFS{
|
||||
"embed/os-arch/PLACEHOLDER": &fstest.MapFile{}, // Checked-in placeholder, so it's always there.
|
||||
"embed/os-arch/report.txt": &fstest.MapFile{Data: []byte("example.com/mod (v1.0.0) - MIT - https://example.com\n")},
|
||||
"embed/os-arch/third-party/example.com/mod/LICENSE": &fstest.MapFile{
|
||||
Data: []byte("Apache License 2.0"),
|
||||
},
|
||||
"embed/os-arch/third-party/example.com/mod/NOTICE": &fstest.MapFile{
|
||||
Data: []byte("Copyright 2024 Example Corp"),
|
||||
},
|
||||
},
|
||||
expected: heredoc.Doc(`
|
||||
example.com/mod (v1.0.0) - MIT - https://example.com
|
||||
|
||||
================================================================================
|
||||
example.com/mod
|
||||
================================================================================
|
||||
|
||||
Apache License 2.0
|
||||
|
||||
Copyright 2024 Example Corp
|
||||
|
||||
`),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := content(tt.fsys, "embed/os-arch")
|
||||
require.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -224,6 +224,217 @@ func TestAccessiblePrompter(t *testing.T) {
|
|||
assert.Equal(t, []int{1}, multiSelectValues)
|
||||
})
|
||||
|
||||
t.Run("MultiSelectWithSearch - basic flow", func(t *testing.T) {
|
||||
console := newTestVirtualTerminal(t)
|
||||
p := newTestAccessiblePrompter(t, console)
|
||||
persistentOptions := []string{"persistent-option-1"}
|
||||
searchFunc := func(input string) prompter.MultiSelectSearchResult {
|
||||
var searchResultKeys []string
|
||||
var searchResultLabels []string
|
||||
|
||||
// Initial search with no input
|
||||
if input == "" {
|
||||
moreResults := 2
|
||||
searchResultKeys = []string{"initial-result-1", "initial-result-2"}
|
||||
searchResultLabels = []string{"Initial Result Label 1", "Initial Result Label 2"}
|
||||
return prompter.MultiSelectSearchResult{
|
||||
Keys: searchResultKeys,
|
||||
Labels: searchResultLabels,
|
||||
MoreResults: moreResults,
|
||||
Err: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// Subsequent search with input
|
||||
moreResults := 0
|
||||
searchResultKeys = []string{"search-result-1", "search-result-2"}
|
||||
searchResultLabels = []string{"Search Result Label 1", "Search Result Label 2"}
|
||||
return prompter.MultiSelectSearchResult{
|
||||
Keys: searchResultKeys,
|
||||
Labels: searchResultLabels,
|
||||
MoreResults: moreResults,
|
||||
Err: nil,
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
// Wait for prompt to appear
|
||||
_, err := console.ExpectString("Select an option \r\n")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Select the search option, which will always be the first option
|
||||
_, err = console.SendLine("1")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Submit search
|
||||
_, err = console.SendLine("0")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for the search prompt to appear
|
||||
_, err = console.ExpectString("Search for an option")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Enter some search text to trigger the search
|
||||
_, err = console.SendLine("search text")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for the multiselect prompt to re-appear after search
|
||||
_, err = console.ExpectString("Select an option \r\n")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Select the first search result
|
||||
_, err = console.SendLine("2")
|
||||
require.NoError(t, err)
|
||||
|
||||
// This confirms selections
|
||||
_, err = console.SendLine("0")
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
multiSelectValues, err := p.MultiSelectWithSearch("Select an option", "Search for an option", []string{}, persistentOptions, searchFunc)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"search-result-1"}, multiSelectValues)
|
||||
})
|
||||
|
||||
t.Run("MultiSelectWithSearch - defaults are pre-selected", func(t *testing.T) {
|
||||
console := newTestVirtualTerminal(t)
|
||||
p := newTestAccessiblePrompter(t, console)
|
||||
initialSearchResultKeys := []string{"initial-result-1"}
|
||||
initialSearchResultLabels := []string{"Initial Result Label 1"}
|
||||
defaultOptions := initialSearchResultKeys
|
||||
searchFunc := func(input string) prompter.MultiSelectSearchResult {
|
||||
// Initial search with no input
|
||||
if input == "" {
|
||||
moreResults := 2
|
||||
return prompter.MultiSelectSearchResult{
|
||||
Keys: initialSearchResultKeys,
|
||||
Labels: initialSearchResultLabels,
|
||||
MoreResults: moreResults,
|
||||
Err: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// No search selected, so this should fail the test.
|
||||
t.FailNow()
|
||||
return prompter.MultiSelectSearchResult{
|
||||
Keys: nil,
|
||||
Labels: nil,
|
||||
MoreResults: 0,
|
||||
Err: nil,
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
// Wait for prompt to appear
|
||||
_, err := console.ExpectString("Select an option (default: Initial Result Label 1) \r\n")
|
||||
require.NoError(t, err)
|
||||
|
||||
// This confirms default selections
|
||||
_, err = console.SendLine("0")
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
multiSelectValues, err := p.MultiSelectWithSearch("Select an option", "Search for an option", defaultOptions, initialSearchResultKeys, searchFunc)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, defaultOptions, multiSelectValues)
|
||||
})
|
||||
|
||||
t.Run("MultiSelectWithSearch - selected options persist between searches", func(t *testing.T) {
|
||||
console := newTestVirtualTerminal(t)
|
||||
p := newTestAccessiblePrompter(t, console)
|
||||
initialSearchResultKeys := []string{"initial-result-1"}
|
||||
initialSearchResultLabels := []string{"Initial Result Label 1"}
|
||||
moreResultKeys := []string{"more-result-1"}
|
||||
moreResultLabels := []string{"More Result Label 1"}
|
||||
|
||||
searchFunc := func(input string) prompter.MultiSelectSearchResult {
|
||||
// Initial search with no input
|
||||
if input == "" {
|
||||
moreResults := 2
|
||||
return prompter.MultiSelectSearchResult{
|
||||
Keys: initialSearchResultKeys,
|
||||
Labels: initialSearchResultLabels,
|
||||
MoreResults: moreResults,
|
||||
Err: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// Subsequent search with input "more"
|
||||
if input == "more" {
|
||||
return prompter.MultiSelectSearchResult{
|
||||
Keys: moreResultKeys,
|
||||
Labels: moreResultLabels,
|
||||
MoreResults: 0,
|
||||
Err: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// No other searches expected
|
||||
t.FailNow()
|
||||
return prompter.MultiSelectSearchResult{
|
||||
Keys: nil,
|
||||
Labels: nil,
|
||||
MoreResults: 0,
|
||||
Err: nil,
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
// Wait for prompt to appear
|
||||
_, err := console.ExpectString("Select an option \r\n")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Select one of our initial search results
|
||||
_, err = console.SendLine("2")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Select to search
|
||||
_, err = console.SendLine("1")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Submit the search selection
|
||||
_, err = console.SendLine("0")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for the search prompt to appear
|
||||
_, err = console.ExpectString("Search for an option")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Enter some search text to trigger the search
|
||||
_, err = console.SendLine("more")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for the multiselect prompt to re-appear after search
|
||||
_, err = console.ExpectString("Select up to")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Select the new option from the new search results
|
||||
_, err = console.SendLine("3")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Submit selections
|
||||
_, err = console.SendLine("0")
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
multiSelectValues, err := p.MultiSelectWithSearch("Select an option", "Search for an option", []string{}, []string{}, searchFunc)
|
||||
require.NoError(t, err)
|
||||
expectedValues := append(initialSearchResultKeys, moreResultKeys...)
|
||||
assert.Equal(t, expectedValues, multiSelectValues)
|
||||
})
|
||||
|
||||
t.Run("MultiSelectWithSearch - search error propagates", func(t *testing.T) {
|
||||
console := newTestVirtualTerminal(t)
|
||||
p := newTestAccessiblePrompter(t, console)
|
||||
|
||||
searchFunc := func(input string) prompter.MultiSelectSearchResult {
|
||||
return prompter.MultiSelectSearchResult{
|
||||
Err: fmt.Errorf("search error"),
|
||||
}
|
||||
}
|
||||
|
||||
_, err := p.MultiSelectWithSearch("Select", "Search", []string{}, []string{}, searchFunc)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "search error")
|
||||
})
|
||||
|
||||
t.Run("Input", func(t *testing.T) {
|
||||
console := newTestVirtualTerminal(t)
|
||||
p := newTestAccessiblePrompter(t, console)
|
||||
|
|
@ -642,6 +853,9 @@ func newTestVirtualTerminal(t *testing.T) *expect.Console {
|
|||
failOnExpectError(t),
|
||||
failOnSendError(t),
|
||||
expect.WithDefaultTimeout(time.Second),
|
||||
// Use this logger to debug expect based tests by printing the
|
||||
// characters being read to stdout.
|
||||
// expect.WithLogger(log.New(os.Stdout, "", 0)),
|
||||
}
|
||||
|
||||
console, err := expect.NewConsole(consoleOpts...)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,15 @@ type Prompter interface {
|
|||
Select(prompt string, defaultValue string, options []string) (int, error)
|
||||
// MultiSelect prompts the user to select one or more options from a list of options.
|
||||
MultiSelect(prompt string, defaults []string, options []string) ([]int, error)
|
||||
// MultiSelectWithSearch is MultiSelect with an added search option to the list,
|
||||
// prompting the user for text input to filter the options via the searchFunc.
|
||||
// Items selected in the search are persisted in the list after subsequent searches.
|
||||
// Items passed in persistentOptions are always shown in the list, even when not selected.
|
||||
// Unlike MultiSelect, MultiselectWithSearch returns the selected option strings,
|
||||
// not their indices, since the list of options is dynamic.
|
||||
// The searchFunc has the signature: func(query string) MultiSelectSearchResult.
|
||||
// In the returned MultiSelectSearchResult, Keys are the values eventually returned by MultiSelectWithSearch and Labels are what is shown to the user in the prompt.
|
||||
MultiSelectWithSearch(prompt, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error)
|
||||
// Input prompts the user to enter a string value.
|
||||
Input(prompt string, defaultValue string) (string, error)
|
||||
// Password prompts the user to enter a password.
|
||||
|
|
@ -320,6 +329,10 @@ func (p *accessiblePrompter) MarkdownEditor(prompt, defaultValue string, blankAl
|
|||
return text, nil
|
||||
}
|
||||
|
||||
func (p *accessiblePrompter) MultiSelectWithSearch(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error) {
|
||||
return multiSelectWithSearch(p, prompt, searchPrompt, defaultValues, persistentValues, searchFunc)
|
||||
}
|
||||
|
||||
type surveyPrompter struct {
|
||||
prompter *ghPrompter.Prompter
|
||||
stdin ghPrompter.FileReader
|
||||
|
|
@ -336,6 +349,160 @@ func (p *surveyPrompter) MultiSelect(prompt string, defaultValues, options []str
|
|||
return p.prompter.MultiSelect(prompt, defaultValues, options)
|
||||
}
|
||||
|
||||
func (p *surveyPrompter) MultiSelectWithSearch(prompt string, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error) {
|
||||
return multiSelectWithSearch(p, prompt, searchPrompt, defaultValues, persistentValues, searchFunc)
|
||||
}
|
||||
|
||||
type MultiSelectSearchResult struct {
|
||||
Keys []string
|
||||
Labels []string
|
||||
MoreResults int
|
||||
Err error
|
||||
}
|
||||
|
||||
func multiSelectWithSearch(p Prompter, prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error) {
|
||||
selectedOptions := defaultValues
|
||||
|
||||
// The optionKeyLabels map is used to uniquely identify optionKeyLabels
|
||||
// and provide optional display labels.
|
||||
optionKeyLabels := make(map[string]string)
|
||||
for _, k := range selectedOptions {
|
||||
optionKeyLabels[k] = k
|
||||
}
|
||||
|
||||
searchResult := searchFunc("")
|
||||
if searchResult.Err != nil {
|
||||
return nil, fmt.Errorf("failed to search: %w", searchResult.Err)
|
||||
}
|
||||
searchResultKeys := searchResult.Keys
|
||||
searchResultLabels := searchResult.Labels
|
||||
moreResults := searchResult.MoreResults
|
||||
|
||||
for i, k := range searchResultKeys {
|
||||
optionKeyLabels[k] = searchResultLabels[i]
|
||||
}
|
||||
|
||||
for {
|
||||
// Build dynamic option list -> search sentinel, selections, search results, persistent options.
|
||||
optionKeys := make([]string, 0, 1+len(selectedOptions)+len(searchResultKeys)+len(persistentValues))
|
||||
optionLabels := make([]string, 0, len(optionKeys))
|
||||
|
||||
// 1. Search sentinel.
|
||||
optionKeys = append(optionKeys, "")
|
||||
if moreResults > 0 {
|
||||
optionLabels = append(optionLabels, fmt.Sprintf("Search (%d more)", moreResults))
|
||||
} else {
|
||||
optionLabels = append(optionLabels, "Search")
|
||||
}
|
||||
|
||||
// 2. Selections
|
||||
for _, k := range selectedOptions {
|
||||
l := optionKeyLabels[k]
|
||||
|
||||
if l == "" {
|
||||
l = k
|
||||
}
|
||||
|
||||
optionKeys = append(optionKeys, k)
|
||||
optionLabels = append(optionLabels, l)
|
||||
}
|
||||
|
||||
// 3. Search results
|
||||
for _, k := range searchResultKeys {
|
||||
// It's already selected or persistent, if we add here we'll have duplicates.
|
||||
if slices.Contains(selectedOptions, k) || slices.Contains(persistentValues, k) {
|
||||
continue
|
||||
}
|
||||
|
||||
l := optionKeyLabels[k]
|
||||
if l == "" {
|
||||
l = k
|
||||
}
|
||||
optionKeys = append(optionKeys, k)
|
||||
optionLabels = append(optionLabels, l)
|
||||
}
|
||||
|
||||
// 4. Persistent options
|
||||
for _, k := range persistentValues {
|
||||
if slices.Contains(selectedOptions, k) {
|
||||
continue
|
||||
}
|
||||
|
||||
l := optionKeyLabels[k]
|
||||
if l == "" {
|
||||
l = k
|
||||
}
|
||||
|
||||
optionKeys = append(optionKeys, k)
|
||||
optionLabels = append(optionLabels, l)
|
||||
}
|
||||
|
||||
selectedOptionLabels := make([]string, len(selectedOptions))
|
||||
for i, k := range selectedOptions {
|
||||
l := optionKeyLabels[k]
|
||||
if l == "" {
|
||||
l = k
|
||||
}
|
||||
selectedOptionLabels[i] = l
|
||||
}
|
||||
|
||||
selectedIdxs, err := p.MultiSelect(prompt, selectedOptionLabels, optionLabels)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pickedSearch := false
|
||||
var newSelectedOptions []string
|
||||
for _, idx := range selectedIdxs {
|
||||
if idx == 0 { // Search sentinel selected
|
||||
pickedSearch = true
|
||||
continue
|
||||
}
|
||||
|
||||
if idx < 0 || idx >= len(optionKeys) {
|
||||
continue
|
||||
}
|
||||
|
||||
key := optionKeys[idx]
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
newSelectedOptions = append(newSelectedOptions, key)
|
||||
}
|
||||
|
||||
selectedOptions = newSelectedOptions
|
||||
for _, k := range selectedOptions {
|
||||
if _, ok := optionKeyLabels[k]; !ok {
|
||||
optionKeyLabels[k] = k
|
||||
}
|
||||
}
|
||||
|
||||
if pickedSearch {
|
||||
query, err := p.Input(searchPrompt, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
searchResult := searchFunc(query)
|
||||
if searchResult.Err != nil {
|
||||
return nil, searchResult.Err
|
||||
}
|
||||
searchResultKeys = searchResult.Keys
|
||||
searchResultLabels = searchResult.Labels
|
||||
moreResults = searchResult.MoreResults
|
||||
|
||||
for i, k := range searchResultKeys {
|
||||
optionKeyLabels[k] = searchResultLabels[i]
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
return selectedOptions, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (p *surveyPrompter) Input(prompt, defaultValue string) (string, error) {
|
||||
return p.prompter.Input(prompt, defaultValue)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ var _ Prompter = &PrompterMock{}
|
|||
// MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) {
|
||||
// panic("mock out the MultiSelect method")
|
||||
// },
|
||||
// MultiSelectWithSearchFunc: func(prompt string, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error) {
|
||||
// panic("mock out the MultiSelectWithSearch method")
|
||||
// },
|
||||
// PasswordFunc: func(prompt string) (string, error) {
|
||||
// panic("mock out the Password method")
|
||||
// },
|
||||
|
|
@ -72,6 +75,9 @@ type PrompterMock struct {
|
|||
// MultiSelectFunc mocks the MultiSelect method.
|
||||
MultiSelectFunc func(prompt string, defaults []string, options []string) ([]int, error)
|
||||
|
||||
// MultiSelectWithSearchFunc mocks the MultiSelectWithSearch method.
|
||||
MultiSelectWithSearchFunc func(prompt string, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error)
|
||||
|
||||
// PasswordFunc mocks the Password method.
|
||||
PasswordFunc func(prompt string) (string, error)
|
||||
|
||||
|
|
@ -123,6 +129,19 @@ type PrompterMock struct {
|
|||
// Options is the options argument value.
|
||||
Options []string
|
||||
}
|
||||
// MultiSelectWithSearch holds details about calls to the MultiSelectWithSearch method.
|
||||
MultiSelectWithSearch []struct {
|
||||
// Prompt is the prompt argument value.
|
||||
Prompt string
|
||||
// SearchPrompt is the searchPrompt argument value.
|
||||
SearchPrompt string
|
||||
// Defaults is the defaults argument value.
|
||||
Defaults []string
|
||||
// PersistentOptions is the persistentOptions argument value.
|
||||
PersistentOptions []string
|
||||
// SearchFunc is the searchFunc argument value.
|
||||
SearchFunc func(string) MultiSelectSearchResult
|
||||
}
|
||||
// Password holds details about calls to the Password method.
|
||||
Password []struct {
|
||||
// Prompt is the prompt argument value.
|
||||
|
|
@ -138,15 +157,16 @@ type PrompterMock struct {
|
|||
Options []string
|
||||
}
|
||||
}
|
||||
lockAuthToken sync.RWMutex
|
||||
lockConfirm sync.RWMutex
|
||||
lockConfirmDeletion sync.RWMutex
|
||||
lockInput sync.RWMutex
|
||||
lockInputHostname sync.RWMutex
|
||||
lockMarkdownEditor sync.RWMutex
|
||||
lockMultiSelect sync.RWMutex
|
||||
lockPassword sync.RWMutex
|
||||
lockSelect sync.RWMutex
|
||||
lockAuthToken sync.RWMutex
|
||||
lockConfirm sync.RWMutex
|
||||
lockConfirmDeletion sync.RWMutex
|
||||
lockInput sync.RWMutex
|
||||
lockInputHostname sync.RWMutex
|
||||
lockMarkdownEditor sync.RWMutex
|
||||
lockMultiSelect sync.RWMutex
|
||||
lockMultiSelectWithSearch sync.RWMutex
|
||||
lockPassword sync.RWMutex
|
||||
lockSelect sync.RWMutex
|
||||
}
|
||||
|
||||
// AuthToken calls AuthTokenFunc.
|
||||
|
|
@ -387,6 +407,54 @@ func (mock *PrompterMock) MultiSelectCalls() []struct {
|
|||
return calls
|
||||
}
|
||||
|
||||
// MultiSelectWithSearch calls MultiSelectWithSearchFunc.
|
||||
func (mock *PrompterMock) MultiSelectWithSearch(prompt string, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error) {
|
||||
if mock.MultiSelectWithSearchFunc == nil {
|
||||
panic("PrompterMock.MultiSelectWithSearchFunc: method is nil but Prompter.MultiSelectWithSearch was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
Prompt string
|
||||
SearchPrompt string
|
||||
Defaults []string
|
||||
PersistentOptions []string
|
||||
SearchFunc func(string) MultiSelectSearchResult
|
||||
}{
|
||||
Prompt: prompt,
|
||||
SearchPrompt: searchPrompt,
|
||||
Defaults: defaults,
|
||||
PersistentOptions: persistentOptions,
|
||||
SearchFunc: searchFunc,
|
||||
}
|
||||
mock.lockMultiSelectWithSearch.Lock()
|
||||
mock.calls.MultiSelectWithSearch = append(mock.calls.MultiSelectWithSearch, callInfo)
|
||||
mock.lockMultiSelectWithSearch.Unlock()
|
||||
return mock.MultiSelectWithSearchFunc(prompt, searchPrompt, defaults, persistentOptions, searchFunc)
|
||||
}
|
||||
|
||||
// MultiSelectWithSearchCalls gets all the calls that were made to MultiSelectWithSearch.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedPrompter.MultiSelectWithSearchCalls())
|
||||
func (mock *PrompterMock) MultiSelectWithSearchCalls() []struct {
|
||||
Prompt string
|
||||
SearchPrompt string
|
||||
Defaults []string
|
||||
PersistentOptions []string
|
||||
SearchFunc func(string) MultiSelectSearchResult
|
||||
} {
|
||||
var calls []struct {
|
||||
Prompt string
|
||||
SearchPrompt string
|
||||
Defaults []string
|
||||
PersistentOptions []string
|
||||
SearchFunc func(string) MultiSelectSearchResult
|
||||
}
|
||||
mock.lockMultiSelectWithSearch.RLock()
|
||||
calls = mock.calls.MultiSelectWithSearch
|
||||
mock.lockMultiSelectWithSearch.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// Password calls PasswordFunc.
|
||||
func (mock *PrompterMock) Password(prompt string) (string, error) {
|
||||
if mock.PasswordFunc == nil {
|
||||
|
|
|
|||
|
|
@ -25,10 +25,11 @@ func NewMockPrompter(t *testing.T) *MockPrompter {
|
|||
type MockPrompter struct {
|
||||
t *testing.T
|
||||
ghPrompter.PrompterMock
|
||||
authTokenStubs []authTokenStub
|
||||
confirmDeletionStubs []confirmDeletionStub
|
||||
inputHostnameStubs []inputHostnameStub
|
||||
markdownEditorStubs []markdownEditorStub
|
||||
authTokenStubs []authTokenStub
|
||||
confirmDeletionStubs []confirmDeletionStub
|
||||
inputHostnameStubs []inputHostnameStub
|
||||
markdownEditorStubs []markdownEditorStub
|
||||
multiSelectWithSearchStubs []multiSelectWithSearchStub
|
||||
}
|
||||
|
||||
type authTokenStub struct {
|
||||
|
|
@ -49,6 +50,10 @@ type markdownEditorStub struct {
|
|||
fn func(string, string, bool) (string, error)
|
||||
}
|
||||
|
||||
type multiSelectWithSearchStub struct {
|
||||
fn func(string, string, []string, []string, func(string) MultiSelectSearchResult) ([]string, error)
|
||||
}
|
||||
|
||||
func (m *MockPrompter) AuthToken() (string, error) {
|
||||
var s authTokenStub
|
||||
if len(m.authTokenStubs) == 0 {
|
||||
|
|
@ -92,6 +97,16 @@ func (m *MockPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed
|
|||
return s.fn(prompt, defaultValue, blankAllowed)
|
||||
}
|
||||
|
||||
func (m *MockPrompter) MultiSelectWithSearch(prompt, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error) {
|
||||
var s multiSelectWithSearchStub
|
||||
if len(m.multiSelectWithSearchStubs) == 0 {
|
||||
return nil, NoSuchPromptErr(prompt)
|
||||
}
|
||||
s = m.multiSelectWithSearchStubs[0]
|
||||
m.multiSelectWithSearchStubs = m.multiSelectWithSearchStubs[1:len(m.multiSelectWithSearchStubs)]
|
||||
return s.fn(prompt, searchPrompt, defaults, persistentOptions, searchFunc)
|
||||
}
|
||||
|
||||
func (m *MockPrompter) RegisterAuthToken(stub func() (string, error)) {
|
||||
m.authTokenStubs = append(m.authTokenStubs, authTokenStub{fn: stub})
|
||||
}
|
||||
|
|
|
|||
BIN
internal/zip/fixtures/myproject.zip
Normal file
BIN
internal/zip/fixtures/myproject.zip
Normal file
Binary file not shown.
|
|
@ -1,4 +1,4 @@
|
|||
package download
|
||||
package zip
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
|
|
@ -17,7 +17,11 @@ const (
|
|||
execMode os.FileMode = 0755
|
||||
)
|
||||
|
||||
func extractZip(zr *zip.Reader, destDir safepaths.Absolute) error {
|
||||
// ExtractZip extracts the contents of a zip archive to destDir.
|
||||
// Files that would result in path traversal are silently skipped.
|
||||
// Files that would produce any other error cause the extraction to be aborted,
|
||||
// and the error is returned.
|
||||
func ExtractZip(zr *zip.Reader, destDir safepaths.Absolute) error {
|
||||
for _, zf := range zr.File {
|
||||
fpath, err := destDir.Join(zf.Name)
|
||||
if err != nil {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package download
|
||||
package zip
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
|
|
@ -19,7 +19,7 @@ func Test_extractZip(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
defer zipFile.Close()
|
||||
|
||||
err = extractZip(&zipFile.Reader, extractPath)
|
||||
err = ExtractZip(&zipFile.Reader, extractPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = os.Stat(filepath.Join(extractPath.String(), "src", "main.go"))
|
||||
|
|
@ -62,6 +62,9 @@ func (ct *capiTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|||
// ID only when performing requests to the Copilot API.
|
||||
if req.URL.Host == capiHost {
|
||||
req.Header.Add("Copilot-Integration-Id", "copilot-4-cli")
|
||||
|
||||
// This is quick fix to ensure that we are not using GitHub API versions while targeting CAPI.
|
||||
req.Header.Set("X-GitHub-Api-Version", "2026-01-09")
|
||||
}
|
||||
return ct.rp.RoundTrip(req)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,94 @@ type SessionError struct {
|
|||
Message string
|
||||
}
|
||||
|
||||
// SessionFields defines the available fields for JSON export of a Session.
|
||||
var SessionFields = []string{
|
||||
"id",
|
||||
"name",
|
||||
"state",
|
||||
"repository",
|
||||
"user",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"completedAt",
|
||||
"pullRequestNumber",
|
||||
"pullRequestUrl",
|
||||
"pullRequestTitle",
|
||||
"pullRequestState",
|
||||
}
|
||||
|
||||
// ExportData implements the exportable interface for JSON output.
|
||||
func (s *Session) ExportData(fields []string) map[string]interface{} {
|
||||
data := make(map[string]interface{}, len(fields))
|
||||
for _, f := range fields {
|
||||
switch f {
|
||||
case "id":
|
||||
data[f] = s.ID
|
||||
case "name":
|
||||
data[f] = s.Name
|
||||
case "state":
|
||||
data[f] = s.State
|
||||
case "repository":
|
||||
if s.PullRequest != nil && s.PullRequest.Repository != nil {
|
||||
data[f] = s.PullRequest.Repository.NameWithOwner
|
||||
} else {
|
||||
data[f] = nil
|
||||
}
|
||||
case "user":
|
||||
if s.User != nil {
|
||||
data[f] = s.User.Login
|
||||
} else {
|
||||
data[f] = nil
|
||||
}
|
||||
case "createdAt":
|
||||
if s.CreatedAt.IsZero() {
|
||||
data[f] = nil
|
||||
} else {
|
||||
data[f] = s.CreatedAt
|
||||
}
|
||||
case "updatedAt":
|
||||
if s.LastUpdatedAt.IsZero() {
|
||||
data[f] = nil
|
||||
} else {
|
||||
data[f] = s.LastUpdatedAt
|
||||
}
|
||||
case "completedAt":
|
||||
if s.CompletedAt.IsZero() {
|
||||
data[f] = nil
|
||||
} else {
|
||||
data[f] = s.CompletedAt
|
||||
}
|
||||
case "pullRequestNumber":
|
||||
if s.PullRequest != nil {
|
||||
data[f] = s.PullRequest.Number
|
||||
} else {
|
||||
data[f] = nil
|
||||
}
|
||||
case "pullRequestUrl":
|
||||
if s.PullRequest != nil {
|
||||
data[f] = s.PullRequest.URL
|
||||
} else {
|
||||
data[f] = nil
|
||||
}
|
||||
case "pullRequestTitle":
|
||||
if s.PullRequest != nil {
|
||||
data[f] = s.PullRequest.Title
|
||||
} else {
|
||||
data[f] = nil
|
||||
}
|
||||
case "pullRequestState":
|
||||
if s.PullRequest != nil {
|
||||
data[f] = s.PullRequest.State
|
||||
} else {
|
||||
data[f] = nil
|
||||
}
|
||||
default:
|
||||
data[f] = nil
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
type resource struct {
|
||||
ID string `json:"id"`
|
||||
UserID uint64 `json:"user_id"`
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ type ListOptions struct {
|
|||
CapiClient func() (capi.CapiClient, error)
|
||||
Web bool
|
||||
Browser browser.Browser
|
||||
Exporter cmdutil.Exporter
|
||||
}
|
||||
|
||||
// NewCmdList creates the list command
|
||||
|
|
@ -54,6 +55,8 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
|
|||
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", defaultLimit, "Maximum number of agent tasks to fetch")
|
||||
cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open agent tasks in the browser")
|
||||
|
||||
cmdutil.AddJSONFlags(cmd, &opts.Exporter, capi.SessionFields)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
|
@ -87,10 +90,14 @@ func listRun(opts *ListOptions) error {
|
|||
|
||||
opts.IO.StopProgressIndicator()
|
||||
|
||||
if len(sessions) == 0 {
|
||||
if len(sessions) == 0 && opts.Exporter == nil {
|
||||
return cmdutil.NewNoResultsError("no agent tasks found")
|
||||
}
|
||||
|
||||
if opts.Exporter != nil {
|
||||
return opts.Exporter.Write(opts.IO, sessions)
|
||||
}
|
||||
|
||||
if err := opts.IO.StartPager(); err == nil {
|
||||
defer opts.IO.StopPager()
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ func Test_listRun(t *testing.T) {
|
|||
capiStubs func(*testing.T, *capi.CapiClientMock)
|
||||
limit int
|
||||
web bool
|
||||
jsonFields []string
|
||||
wantOut string
|
||||
wantErr error
|
||||
wantStderr string
|
||||
|
|
@ -286,6 +287,68 @@ func Test_listRun(t *testing.T) {
|
|||
wantStderr: "Opening https://github.com/copilot/agents in your browser.\n",
|
||||
wantBrowserURL: "https://github.com/copilot/agents",
|
||||
},
|
||||
{
|
||||
name: "json output",
|
||||
tty: false,
|
||||
capiStubs: func(t *testing.T, m *capi.CapiClientMock) {
|
||||
m.ListLatestSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) {
|
||||
return []*capi.Session{
|
||||
{
|
||||
ID: "abc-123",
|
||||
Name: "s1",
|
||||
State: "completed",
|
||||
CreatedAt: sampleDate,
|
||||
LastUpdatedAt: sampleDate,
|
||||
CompletedAt: sampleDate,
|
||||
ResourceType: "pull",
|
||||
User: &api.GitHubUser{Login: "monalisa"},
|
||||
PullRequest: &api.PullRequest{
|
||||
Number: 101,
|
||||
Title: "Fix login bug",
|
||||
State: "MERGED",
|
||||
URL: "https://github.com/OWNER/REPO/pull/101",
|
||||
Repository: &api.PRRepository{
|
||||
NameWithOwner: "OWNER/REPO",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
},
|
||||
jsonFields: []string{"id", "name", "state", "repository", "user", "pullRequestNumber", "pullRequestUrl", "pullRequestTitle", "pullRequestState"},
|
||||
wantOut: "[{\"id\":\"abc-123\",\"name\":\"s1\",\"pullRequestNumber\":101,\"pullRequestState\":\"MERGED\",\"pullRequestTitle\":\"Fix login bug\",\"pullRequestUrl\":\"https://github.com/OWNER/REPO/pull/101\",\"repository\":\"OWNER/REPO\",\"state\":\"completed\",\"user\":\"monalisa\"}]\n",
|
||||
},
|
||||
{
|
||||
name: "json output with no sessions returns empty array",
|
||||
tty: false,
|
||||
capiStubs: func(t *testing.T, m *capi.CapiClientMock) {
|
||||
m.ListLatestSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) {
|
||||
return nil, nil
|
||||
}
|
||||
},
|
||||
jsonFields: []string{"id", "name", "state"},
|
||||
wantOut: "[]\n",
|
||||
},
|
||||
{
|
||||
name: "json output with nil pull request",
|
||||
tty: false,
|
||||
capiStubs: func(t *testing.T, m *capi.CapiClientMock) {
|
||||
m.ListLatestSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) {
|
||||
return []*capi.Session{
|
||||
{
|
||||
ID: "abc-456",
|
||||
Name: "s2",
|
||||
State: "in_progress",
|
||||
CreatedAt: sampleDate,
|
||||
LastUpdatedAt: sampleDate,
|
||||
ResourceType: "pull",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
},
|
||||
jsonFields: []string{"id", "name", "state", "repository", "user", "pullRequestNumber", "pullRequestUrl", "pullRequestTitle", "pullRequestState"},
|
||||
wantOut: "[{\"id\":\"abc-456\",\"name\":\"s2\",\"pullRequestNumber\":null,\"pullRequestState\":null,\"pullRequestTitle\":null,\"pullRequestUrl\":null,\"repository\":null,\"state\":\"in_progress\",\"user\":null}]\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -316,6 +379,12 @@ func Test_listRun(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
if tt.jsonFields != nil {
|
||||
exporter := cmdutil.NewJSONExporter()
|
||||
exporter.SetFields(tt.jsonFields)
|
||||
opts.Exporter = exporter
|
||||
}
|
||||
|
||||
err := listRun(opts)
|
||||
if tt.wantErr != nil {
|
||||
assert.Error(t, err)
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ type ViewOptions struct {
|
|||
Finder prShared.PRFinder
|
||||
Prompter prompter.Prompter
|
||||
Browser browser.Browser
|
||||
Exporter cmdutil.Exporter
|
||||
|
||||
LogRenderer func() shared.LogRenderer
|
||||
Sleep func(d time.Duration)
|
||||
|
|
@ -125,6 +126,8 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
|
|||
cmd.Flags().BoolVar(&opts.Log, "log", false, "Show agent session logs")
|
||||
cmd.Flags().BoolVar(&opts.Follow, "follow", false, "Follow agent session logs")
|
||||
|
||||
cmdutil.AddJSONFlags(cmd, &opts.Exporter, capi.SessionFields)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
|
@ -285,6 +288,10 @@ func viewRun(opts *ViewOptions) error {
|
|||
opts.IO.StopProgressIndicator()
|
||||
}
|
||||
|
||||
if opts.Exporter != nil {
|
||||
return opts.Exporter.Write(opts.IO, session)
|
||||
}
|
||||
|
||||
if opts.Log {
|
||||
return printLogs(opts, capiClient, session.ID)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ func Test_viewRun(t *testing.T) {
|
|||
promptStubs func(*testing.T, *prompter.MockPrompter)
|
||||
capiStubs func(*testing.T, *capi.CapiClientMock)
|
||||
logRendererStubs func(*testing.T, *shared.LogRendererMock)
|
||||
jsonFields []string
|
||||
wantOut string
|
||||
wantErr error
|
||||
wantStderr string
|
||||
|
|
@ -1209,6 +1210,63 @@ func Test_viewRun(t *testing.T) {
|
|||
(rendered:) <raw-logs-two>
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "json output (tty)",
|
||||
tty: true,
|
||||
opts: ViewOptions{
|
||||
SelectorArg: "some-session-id",
|
||||
SessionID: "some-session-id",
|
||||
},
|
||||
capiStubs: func(t *testing.T, m *capi.CapiClientMock) {
|
||||
m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) {
|
||||
return &capi.Session{
|
||||
ID: "some-session-id",
|
||||
Name: "Fix login bug",
|
||||
State: "completed",
|
||||
CreatedAt: sampleDate,
|
||||
LastUpdatedAt: sampleDate,
|
||||
CompletedAt: sampleCompletedAt,
|
||||
ResourceType: "pull",
|
||||
PullRequest: &api.PullRequest{
|
||||
Number: 42,
|
||||
URL: "https://github.com/OWNER/REPO/pull/42",
|
||||
Title: "Fix login bug",
|
||||
State: "MERGED",
|
||||
Repository: &api.PRRepository{
|
||||
NameWithOwner: "OWNER/REPO",
|
||||
},
|
||||
},
|
||||
User: &api.GitHubUser{
|
||||
Login: "testuser",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
},
|
||||
wantOut: "{\"id\":\"some-session-id\",\"name\":\"Fix login bug\",\"pullRequestNumber\":42,\"pullRequestState\":\"MERGED\",\"pullRequestTitle\":\"Fix login bug\",\"pullRequestUrl\":\"https://github.com/OWNER/REPO/pull/42\",\"repository\":\"OWNER/REPO\",\"state\":\"completed\",\"user\":\"testuser\"}\n",
|
||||
jsonFields: []string{"id", "name", "state", "repository", "user", "pullRequestNumber", "pullRequestUrl", "pullRequestTitle", "pullRequestState"},
|
||||
},
|
||||
{
|
||||
name: "json output with nil pull request",
|
||||
tty: false,
|
||||
opts: ViewOptions{
|
||||
SelectorArg: "some-session-id",
|
||||
SessionID: "some-session-id",
|
||||
},
|
||||
capiStubs: func(t *testing.T, m *capi.CapiClientMock) {
|
||||
m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) {
|
||||
return &capi.Session{
|
||||
ID: "some-session-id",
|
||||
Name: "New task",
|
||||
State: "in_progress",
|
||||
CreatedAt: sampleDate,
|
||||
LastUpdatedAt: sampleDate,
|
||||
ResourceType: "pull",
|
||||
}, nil
|
||||
}
|
||||
},
|
||||
wantOut: "{\"id\":\"some-session-id\",\"name\":\"New task\",\"pullRequestNumber\":null,\"pullRequestUrl\":null,\"repository\":null,\"state\":\"in_progress\",\"user\":null}\n",
|
||||
jsonFields: []string{"id", "name", "state", "repository", "user", "pullRequestNumber", "pullRequestUrl"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -1244,6 +1302,12 @@ func Test_viewRun(t *testing.T) {
|
|||
return logRenderer
|
||||
}
|
||||
|
||||
if tt.jsonFields != nil {
|
||||
exporter := cmdutil.NewJSONExporter()
|
||||
exporter.SetFields(tt.jsonFields)
|
||||
opts.Exporter = exporter
|
||||
}
|
||||
|
||||
err := viewRun(&opts)
|
||||
if tt.wantErr != nil {
|
||||
assert.Error(t, err)
|
||||
|
|
|
|||
|
|
@ -456,6 +456,8 @@ func apiRun(opts *ApiOptions) error {
|
|||
return tmpl.Flush()
|
||||
}
|
||||
|
||||
var jsonContentTypeRE = regexp.MustCompile(`[/+]json(;|$)`)
|
||||
|
||||
func processResponse(resp *http.Response, opts *ApiOptions, bodyWriter, headersWriter io.Writer, template *template.Template, isFirstPage, isLastPage bool) (endCursor string, err error) {
|
||||
if opts.ShowResponseHeaders {
|
||||
fmt.Fprintln(headersWriter, resp.Proto, resp.Status)
|
||||
|
|
@ -469,7 +471,7 @@ func processResponse(resp *http.Response, opts *ApiOptions, bodyWriter, headersW
|
|||
var responseBody io.Reader = resp.Body
|
||||
defer resp.Body.Close()
|
||||
|
||||
isJSON, _ := regexp.MatchString(`[/+]json(;|$)`, resp.Header.Get("Content-Type"))
|
||||
isJSON := jsonContentTypeRE.MatchString(resp.Header.Get("Content-Type"))
|
||||
|
||||
var serverError string
|
||||
if isJSON && (opts.RequestPath == "graphql" || resp.StatusCode >= 400) {
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ type BrowseOptions struct {
|
|||
SettingsFlag bool
|
||||
WikiFlag bool
|
||||
ActionsFlag bool
|
||||
BlameFlag bool
|
||||
NoBrowserFlag bool
|
||||
HasRepoOverride bool
|
||||
}
|
||||
|
|
@ -91,6 +92,9 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co
|
|||
# Open main.go at line 312
|
||||
$ gh browse main.go:312
|
||||
|
||||
# Open blame view for main.go at line 312
|
||||
$ gh browse main.go:312 --blame
|
||||
|
||||
# Open main.go with the repository at head of bug-fix branch
|
||||
$ gh browse main.go --branch bug-fix
|
||||
|
||||
|
|
@ -141,6 +145,10 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co
|
|||
return err
|
||||
}
|
||||
|
||||
if opts.BlameFlag && opts.SelectorArg == "" {
|
||||
return cmdutil.FlagErrorf("`--blame` requires a file path argument")
|
||||
}
|
||||
|
||||
if (isNumber(opts.SelectorArg) || isCommit(opts.SelectorArg)) && (opts.Branch != "" || opts.Commit != "") {
|
||||
return cmdutil.FlagErrorf("%q is an invalid argument when using `--branch` or `--commit`", opts.SelectorArg)
|
||||
}
|
||||
|
|
@ -163,6 +171,7 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co
|
|||
cmd.Flags().BoolVarP(&opts.WikiFlag, "wiki", "w", false, "Open repository wiki")
|
||||
cmd.Flags().BoolVarP(&opts.ActionsFlag, "actions", "a", false, "Open repository actions")
|
||||
cmd.Flags().BoolVarP(&opts.SettingsFlag, "settings", "s", false, "Open repository settings")
|
||||
cmd.Flags().BoolVar(&opts.BlameFlag, "blame", false, "Open blame view for a file")
|
||||
cmd.Flags().BoolVarP(&opts.NoBrowserFlag, "no-browser", "n", false, "Print destination URL instead of opening the browser")
|
||||
cmd.Flags().StringVarP(&opts.Commit, "commit", "c", "", "Select another commit by passing in the commit SHA, default is the last commit")
|
||||
cmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "Select another branch by passing in the branch name")
|
||||
|
|
@ -272,9 +281,16 @@ func parseSection(baseRepo ghrepo.Interface, opts *BrowseOptions) (string, error
|
|||
} else {
|
||||
rangeFragment = fmt.Sprintf("L%d", rangeStart)
|
||||
}
|
||||
if opts.BlameFlag {
|
||||
return fmt.Sprintf("blame/%s/%s#%s", escapePath(ref), escapePath(filePath), rangeFragment), nil
|
||||
}
|
||||
return fmt.Sprintf("blob/%s/%s?plain=1#%s", escapePath(ref), escapePath(filePath), rangeFragment), nil
|
||||
}
|
||||
|
||||
if opts.BlameFlag {
|
||||
return fmt.Sprintf("blame/%s/%s", escapePath(ref), escapePath(filePath)), nil
|
||||
}
|
||||
|
||||
return strings.TrimSuffix(fmt.Sprintf("tree/%s/%s", escapePath(ref), escapePath(filePath)), "/"), nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -207,6 +207,29 @@ func TestNewCmdBrowse(t *testing.T) {
|
|||
cli: "de07febc26e19000f8c9e821207f3bc34a3c8038 --commit=12a4",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "blame flag",
|
||||
cli: "main.go --blame",
|
||||
wants: BrowseOptions{
|
||||
BlameFlag: true,
|
||||
SelectorArg: "main.go",
|
||||
},
|
||||
wantsErr: false,
|
||||
},
|
||||
{
|
||||
name: "blame flag without file argument",
|
||||
cli: "--blame",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "blame flag with line number",
|
||||
cli: "main.go:312 --blame",
|
||||
wants: BrowseOptions{
|
||||
BlameFlag: true,
|
||||
SelectorArg: "main.go:312",
|
||||
},
|
||||
wantsErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -239,6 +262,7 @@ func TestNewCmdBrowse(t *testing.T) {
|
|||
assert.Equal(t, tt.wants.SettingsFlag, opts.SettingsFlag)
|
||||
assert.Equal(t, tt.wants.ActionsFlag, opts.ActionsFlag)
|
||||
assert.Equal(t, tt.wants.Commit, opts.Commit)
|
||||
assert.Equal(t, tt.wants.BlameFlag, opts.BlameFlag)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -595,6 +619,61 @@ func Test_runBrowse(t *testing.T) {
|
|||
expectedURL: "https://github.com/bchadwic/test/tree/trunk/77507cd94ccafcf568f8560cfecde965fcfa63e7.txt",
|
||||
wantsErr: false,
|
||||
},
|
||||
{
|
||||
name: "file with blame flag",
|
||||
opts: BrowseOptions{
|
||||
SelectorArg: "path/to/file.txt",
|
||||
BlameFlag: true,
|
||||
},
|
||||
baseRepo: ghrepo.New("owner", "repo"),
|
||||
defaultBranch: "main",
|
||||
expectedURL: "https://github.com/owner/repo/blame/main/path/to/file.txt",
|
||||
wantsErr: false,
|
||||
},
|
||||
{
|
||||
name: "file with blame flag and line number",
|
||||
opts: BrowseOptions{
|
||||
SelectorArg: "path/to/file.txt:42",
|
||||
BlameFlag: true,
|
||||
},
|
||||
baseRepo: ghrepo.New("owner", "repo"),
|
||||
defaultBranch: "main",
|
||||
expectedURL: "https://github.com/owner/repo/blame/main/path/to/file.txt#L42",
|
||||
wantsErr: false,
|
||||
},
|
||||
{
|
||||
name: "file with blame flag and line range",
|
||||
opts: BrowseOptions{
|
||||
SelectorArg: "path/to/file.txt:10-20",
|
||||
BlameFlag: true,
|
||||
},
|
||||
baseRepo: ghrepo.New("owner", "repo"),
|
||||
defaultBranch: "main",
|
||||
expectedURL: "https://github.com/owner/repo/blame/main/path/to/file.txt#L10-L20",
|
||||
wantsErr: false,
|
||||
},
|
||||
{
|
||||
name: "file with blame flag and branch",
|
||||
opts: BrowseOptions{
|
||||
SelectorArg: "main.go:100",
|
||||
BlameFlag: true,
|
||||
Branch: "feature-branch",
|
||||
},
|
||||
baseRepo: ghrepo.New("owner", "repo"),
|
||||
expectedURL: "https://github.com/owner/repo/blame/feature-branch/main.go#L100",
|
||||
wantsErr: false,
|
||||
},
|
||||
{
|
||||
name: "file with blame flag and commit",
|
||||
opts: BrowseOptions{
|
||||
SelectorArg: "src/app.js:50",
|
||||
BlameFlag: true,
|
||||
Commit: "abc123",
|
||||
},
|
||||
baseRepo: ghrepo.New("owner", "repo"),
|
||||
expectedURL: "https://github.com/owner/repo/blame/abc123/src/app.js#L50",
|
||||
wantsErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
|
|||
16
pkg/cmd/cache/delete/delete.go
vendored
16
pkg/cmd/cache/delete/delete.go
vendored
|
|
@ -61,6 +61,9 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
|
|||
# Delete all caches (exit code 1 on no caches)
|
||||
$ gh cache delete --all
|
||||
|
||||
# Delete all caches for a specific ref
|
||||
$ gh cache delete --all --ref refs/pull/<PR-number>/merge
|
||||
|
||||
# Delete all caches (exit code 0 on no caches)
|
||||
$ gh cache delete --all --succeed-on-no-caches
|
||||
`),
|
||||
|
|
@ -76,18 +79,11 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
|
|||
return err
|
||||
}
|
||||
|
||||
if err := cmdutil.MutuallyExclusive(
|
||||
"--ref cannot be used with --all",
|
||||
opts.DeleteAll, opts.Ref != "",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !opts.DeleteAll && opts.SucceedOnNoCaches {
|
||||
return cmdutil.FlagErrorf("--succeed-on-no-caches must be used in conjunction with --all")
|
||||
}
|
||||
|
||||
if opts.Ref != "" && len(args) == 0 {
|
||||
if opts.Ref != "" && len(args) == 0 && !opts.DeleteAll {
|
||||
return cmdutil.FlagErrorf("must provide a cache key")
|
||||
}
|
||||
|
||||
|
|
@ -113,7 +109,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
|
|||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&opts.DeleteAll, "all", "a", false, "Delete all caches")
|
||||
cmd.Flags().BoolVarP(&opts.DeleteAll, "all", "a", false, "Delete all caches, can be used with --ref to delete all caches for a specific ref")
|
||||
cmd.Flags().StringVarP(&opts.Ref, "ref", "r", "", "Delete by cache key and ref, formatted as refs/heads/<branch name> or refs/pull/<number>/merge")
|
||||
cmd.Flags().BoolVar(&opts.SucceedOnNoCaches, "succeed-on-no-caches", false, "Return exit code 0 if no caches found. Must be used in conjunction with `--all`")
|
||||
|
||||
|
|
@ -135,7 +131,7 @@ func deleteRun(opts *DeleteOptions) error {
|
|||
var toDelete []string
|
||||
if opts.DeleteAll {
|
||||
opts.IO.StartProgressIndicator()
|
||||
caches, err := shared.GetCaches(client, repo, shared.GetCachesOptions{Limit: -1})
|
||||
caches, err := shared.GetCaches(client, repo, shared.GetCachesOptions{Limit: -1, Ref: opts.Ref})
|
||||
opts.IO.StopProgressIndicator()
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
82
pkg/cmd/cache/delete/delete_test.go
vendored
82
pkg/cmd/cache/delete/delete_test.go
vendored
|
|
@ -84,9 +84,9 @@ func TestNewCmdDelete(t *testing.T) {
|
|||
wantsErr: "--ref cannot be used with cache ID",
|
||||
},
|
||||
{
|
||||
name: "ref flag with all flag",
|
||||
cli: "--all --ref refs/heads/main",
|
||||
wantsErr: "--ref cannot be used with --all",
|
||||
name: "ref flag with all flag",
|
||||
cli: "--all --ref refs/heads/main",
|
||||
wants: DeleteOptions{DeleteAll: true, Ref: "refs/heads/main"},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -374,6 +374,82 @@ func TestDeleteRun(t *testing.T) {
|
|||
wantErr: true,
|
||||
wantErrMsg: "X Could not find a cache matching existing-cache-key (with ref invalid-ref) in OWNER/REPO",
|
||||
},
|
||||
{
|
||||
name: "deletes all caches with ref",
|
||||
opts: DeleteOptions{DeleteAll: true, Ref: "refs/heads/main"},
|
||||
stubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.QueryMatcher("GET", "repos/OWNER/REPO/actions/caches", url.Values{
|
||||
"ref": []string{"refs/heads/main"},
|
||||
}),
|
||||
httpmock.JSONResponse(shared.CachePayload{
|
||||
ActionsCaches: []shared.Cache{
|
||||
{
|
||||
Id: 123,
|
||||
Key: "foo",
|
||||
Ref: "refs/heads/main",
|
||||
CreatedAt: time.Date(2021, 1, 1, 1, 1, 1, 1, time.UTC),
|
||||
LastAccessedAt: time.Date(2022, 1, 1, 1, 1, 1, 1, time.UTC),
|
||||
},
|
||||
{
|
||||
Id: 456,
|
||||
Key: "bar",
|
||||
Ref: "refs/heads/main",
|
||||
CreatedAt: time.Date(2021, 1, 1, 1, 1, 1, 1, time.UTC),
|
||||
LastAccessedAt: time.Date(2022, 1, 1, 1, 1, 1, 1, time.UTC),
|
||||
},
|
||||
},
|
||||
TotalCount: 2,
|
||||
}),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.REST("DELETE", "repos/OWNER/REPO/actions/caches/123"),
|
||||
httpmock.StatusStringResponse(204, ""),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.REST("DELETE", "repos/OWNER/REPO/actions/caches/456"),
|
||||
httpmock.StatusStringResponse(204, ""),
|
||||
)
|
||||
},
|
||||
tty: true,
|
||||
wantStdout: "✓ Deleted 2 caches from OWNER/REPO\n",
|
||||
},
|
||||
{
|
||||
name: "no caches to delete when deleting all with ref",
|
||||
opts: DeleteOptions{DeleteAll: true, Ref: "refs/heads/main"},
|
||||
stubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.QueryMatcher("GET", "repos/OWNER/REPO/actions/caches", url.Values{
|
||||
"ref": []string{"refs/heads/main"},
|
||||
}),
|
||||
httpmock.JSONResponse(shared.CachePayload{
|
||||
ActionsCaches: []shared.Cache{},
|
||||
TotalCount: 0,
|
||||
}),
|
||||
)
|
||||
},
|
||||
tty: false,
|
||||
wantErr: true,
|
||||
wantErrMsg: "X No caches to delete",
|
||||
},
|
||||
{
|
||||
name: "no caches to delete when deleting all for ref but succeed on no cache tty",
|
||||
opts: DeleteOptions{DeleteAll: true, SucceedOnNoCaches: true, Ref: "refs/heads/main"},
|
||||
stubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.QueryMatcher("GET", "repos/OWNER/REPO/actions/caches", url.Values{
|
||||
"ref": []string{"refs/heads/main"},
|
||||
}),
|
||||
httpmock.JSONResponse(shared.CachePayload{
|
||||
ActionsCaches: []shared.Cache{},
|
||||
TotalCount: 0,
|
||||
}),
|
||||
)
|
||||
},
|
||||
tty: true,
|
||||
wantErr: false,
|
||||
wantStdout: "✓ No caches to delete\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
|
|||
456
pkg/cmd/copilot/copilot.go
Normal file
456
pkg/cmd/copilot/copilot.go
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
package copilot
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/internal/prompter"
|
||||
"github.com/cli/cli/v2/internal/safepaths"
|
||||
"github.com/cli/cli/v2/internal/update"
|
||||
ghzip "github.com/cli/cli/v2/internal/zip"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type CopilotOptions struct {
|
||||
IO *iostreams.IOStreams
|
||||
HttpClient func() (*http.Client, error)
|
||||
Prompter prompter.Prompter
|
||||
|
||||
CopilotArgs []string
|
||||
Remove bool
|
||||
}
|
||||
|
||||
func NewCmdCopilot(f *cmdutil.Factory, runF func(*CopilotOptions) error) *cobra.Command {
|
||||
opts := &CopilotOptions{
|
||||
IO: f.IOStreams,
|
||||
HttpClient: f.HttpClient,
|
||||
Prompter: f.Prompter,
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "copilot [flags] [args]",
|
||||
Short: "Run the GitHub Copilot CLI (preview)",
|
||||
Long: heredoc.Docf(`
|
||||
Runs the GitHub Copilot CLI.
|
||||
|
||||
Executing the Copilot CLI through %[1]sgh%[1]s is currently in preview and subject to change.
|
||||
|
||||
If already installed, %[1]sgh%[1]s will execute the Copilot CLI found in your %[1]sPATH%[1]s.
|
||||
If the Copilot CLI is not installed, it will be downloaded to %[2]s.
|
||||
|
||||
Use %[1]s--remove%[1]s to remove the downloaded Copilot CLI.
|
||||
|
||||
This command is only supported on Windows, Linux, and Darwin, on amd64/x64
|
||||
or arm64 architectures.
|
||||
|
||||
To prevent %[1]sgh%[1]s from interpreting flags intended for Copilot,
|
||||
use %[1]s--%[1]s before Copilot flags and args.
|
||||
|
||||
Learn more at https://gh.io/copilot-cli
|
||||
`, "`", copilotInstallDir()),
|
||||
Example: heredoc.Doc(`
|
||||
# Download and run the Copilot CLI
|
||||
$ gh copilot
|
||||
|
||||
# Run the Copilot CLI
|
||||
$ gh copilot -p "Summarize this week's commits" --allow-tool 'shell(git)'
|
||||
|
||||
# Remove the Copilot CLI (if installed through gh)
|
||||
$ gh copilot --remove
|
||||
|
||||
# Run the Copilot CLI help command
|
||||
$ gh copilot -- --help
|
||||
`),
|
||||
DisableFlagParsing: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
stopParsePos := -1
|
||||
for i, arg := range args {
|
||||
if arg == "--" {
|
||||
stopParsePos = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
ghArgs := args
|
||||
opts.CopilotArgs = args
|
||||
if stopParsePos >= 0 {
|
||||
ghArgs = args[:stopParsePos]
|
||||
opts.CopilotArgs = args[stopParsePos+1:] // +1 to skip the "--" itself
|
||||
}
|
||||
|
||||
if slices.Contains(ghArgs, "--help") || slices.Contains(ghArgs, "-h") {
|
||||
return cmd.Help()
|
||||
}
|
||||
|
||||
if slices.Contains(ghArgs, "--remove") {
|
||||
hasOtherArgs := len(ghArgs) > 1
|
||||
if stopParsePos >= 0 {
|
||||
hasOtherArgs = hasOtherArgs || len(opts.CopilotArgs) > 0
|
||||
}
|
||||
if hasOtherArgs {
|
||||
return cmdutil.FlagErrorf("cannot use --remove with args")
|
||||
}
|
||||
opts.Remove = true
|
||||
opts.CopilotArgs = nil
|
||||
}
|
||||
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
|
||||
return runCopilot(opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
|
||||
// We add this flag, even though flag parsing is disabled for this command
|
||||
// so the flag still appears in the help text.
|
||||
cmd.Flags().Bool("remove", false, "Remove the downloaded Copilot CLI")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runCopilot(opts *CopilotOptions) error {
|
||||
if opts.Remove {
|
||||
if err := removeCopilot(copilotInstallDir()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.IO.IsStdoutTTY() {
|
||||
fmt.Fprintln(opts.IO.ErrOut, "Copilot CLI removed successfully")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
copilotPath := findCopilotBinary()
|
||||
if copilotPath == "" {
|
||||
if opts.IO.CanPrompt() {
|
||||
confirmed, err := opts.Prompter.Confirm("GitHub Copilot CLI is not installed. Would you like to install it?", true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !confirmed {
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s Copilot CLI was not installed", opts.IO.ColorScheme().WarningIcon())
|
||||
return cmdutil.SilentError
|
||||
}
|
||||
} else if !update.IsCI() {
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s Copilot CLI not installed", opts.IO.ColorScheme().WarningIcon())
|
||||
return cmdutil.SilentError
|
||||
}
|
||||
|
||||
httpClient, err := opts.HttpClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
copilotPath, err = downloadCopilot(httpClient, opts.IO, copilotInstallDir(), copilotBinaryPath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
externalCmd := exec.Command(copilotPath, opts.CopilotArgs...)
|
||||
externalCmd.Stdin = opts.IO.In
|
||||
externalCmd.Stdout = opts.IO.Out
|
||||
externalCmd.Stderr = opts.IO.ErrOut
|
||||
externalCmd.Env = append(os.Environ(), "COPILOT_GH=true")
|
||||
|
||||
if err := externalCmd.Run(); err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
// We terminate with os.Exit here, preserving the exit code from Copilot CLI,
|
||||
// and also preventing stdio writes by callers up the stack.
|
||||
os.Exit(exitErr.ExitCode())
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const copilotBinaryName = "copilot"
|
||||
|
||||
func copilotInstallDir() string {
|
||||
return filepath.Join(config.DataDir(), "copilot")
|
||||
}
|
||||
|
||||
func copilotBinaryPath() string {
|
||||
binaryName := copilotBinaryName
|
||||
if runtime.GOOS == "windows" {
|
||||
binaryName += ".exe"
|
||||
}
|
||||
return filepath.Join(copilotInstallDir(), binaryName)
|
||||
}
|
||||
|
||||
// findCopilotBinary returns the path to the Copilot CLI binary, if installed,
|
||||
// with the following order of precedence:
|
||||
// 1. `copilot` in the PATH
|
||||
// 2. `copilot` in gh's data directory
|
||||
//
|
||||
// If not installed, it returns an empty string.
|
||||
func findCopilotBinary() string {
|
||||
if path, err := exec.LookPath(copilotBinaryName); err == nil {
|
||||
return path
|
||||
}
|
||||
|
||||
localPath := copilotBinaryPath()
|
||||
if _, err := os.Stat(localPath); err != nil {
|
||||
return ""
|
||||
}
|
||||
return localPath
|
||||
}
|
||||
|
||||
// downloadCopilot downloads and installs the Copilot CLI to installDir.
|
||||
// It returns the path to the installed Copilot binary.
|
||||
func downloadCopilot(httpClient *http.Client, ios *iostreams.IOStreams, installDir, localPath string) (string, error) {
|
||||
platform := runtime.GOOS
|
||||
if platform == "windows" {
|
||||
platform = "win32"
|
||||
}
|
||||
|
||||
arch := runtime.GOARCH
|
||||
if arch == "amd64" {
|
||||
arch = "x64"
|
||||
}
|
||||
|
||||
if arch != "x64" && arch != "arm64" {
|
||||
return "", fmt.Errorf("unsupported architecture: %s (supported: x64, arm64)", arch)
|
||||
}
|
||||
|
||||
var archiveURL string
|
||||
var archiveName string
|
||||
var isZip bool
|
||||
switch platform {
|
||||
case "win32":
|
||||
archiveName = fmt.Sprintf("copilot-%s-%s.zip", platform, arch)
|
||||
archiveURL = fmt.Sprintf("https://github.com/github/copilot-cli/releases/latest/download/%s", archiveName)
|
||||
isZip = true
|
||||
case "linux", "darwin":
|
||||
archiveName = fmt.Sprintf("copilot-%s-%s.tar.gz", platform, arch)
|
||||
archiveURL = fmt.Sprintf("https://github.com/github/copilot-cli/releases/latest/download/%s", archiveName)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported platform: %s (supported: linux, darwin, windows)", platform)
|
||||
}
|
||||
|
||||
checksumsURL := "https://github.com/github/copilot-cli/releases/latest/download/SHA256SUMS.txt"
|
||||
|
||||
expectedChecksum, err := fetchExpectedChecksum(httpClient, checksumsURL, archiveName)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch checksums: %w", err)
|
||||
}
|
||||
|
||||
ios.StartProgressIndicatorWithLabel(fmt.Sprintf("Downloading Copilot CLI from %s", archiveURL))
|
||||
defer ios.StopProgressIndicator()
|
||||
|
||||
resp, err := httpClient.Get(archiveURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to download: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("download failed with status: %s", resp.Status)
|
||||
}
|
||||
|
||||
// Download to temp file while calculating checksum
|
||||
tmpFile, err := os.CreateTemp("", "copilot-download-*")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
hasher := sha256.New()
|
||||
if _, err := io.Copy(tmpFile, io.TeeReader(resp.Body, hasher)); err != nil {
|
||||
return "", fmt.Errorf("failed to download: %w", err)
|
||||
}
|
||||
|
||||
ios.StopProgressIndicator()
|
||||
|
||||
// Validate checksum
|
||||
actualChecksumHex := hex.EncodeToString(hasher.Sum(nil))
|
||||
if actualChecksumHex != expectedChecksum {
|
||||
return "", fmt.Errorf("checksum mismatch: expected %s, got %s", expectedChecksum, actualChecksumHex)
|
||||
}
|
||||
|
||||
if _, err := tmpFile.Seek(0, io.SeekStart); err != nil {
|
||||
return "", fmt.Errorf("failed to seek temp file: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(installDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create install directory: %w", err)
|
||||
}
|
||||
|
||||
// Extract from the downloaded data
|
||||
if isZip {
|
||||
err = extractZip(tmpFile.Name(), installDir)
|
||||
} else {
|
||||
err = extractTarGz(tmpFile, installDir)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(localPath); err != nil {
|
||||
return "", fmt.Errorf("copilot binary unavailable: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(ios.ErrOut, "%s Copilot CLI installed successfully\n", ios.ColorScheme().SuccessIcon())
|
||||
return localPath, nil
|
||||
}
|
||||
|
||||
// fetchExpectedChecksum downloads the SHA256SUMS.txt file and returns the expected checksum for the given archive name.
|
||||
func fetchExpectedChecksum(httpClient *http.Client, checksumsURL, archiveName string) (string, error) {
|
||||
resp, err := httpClient.Get(checksumsURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("failed to download checksums: %s", resp.Status)
|
||||
}
|
||||
|
||||
// Parse the checksums file. Possible formats are:
|
||||
// - "<checksum> <filename>" (two whitespaces)
|
||||
// - "<checksum> <filename>"
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
checksum := fields[0]
|
||||
filename := fields[1]
|
||||
if filename == archiveName {
|
||||
return checksum, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return "", fmt.Errorf("failed to read checksums: %w", err)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("checksum not found for %s", archiveName)
|
||||
}
|
||||
|
||||
// extractZip reads a ZIP archive at path and extracts its contents into destDir.
|
||||
// It returns an error if the archive cannot be read,
|
||||
// or if any file or directory within the archive cannot be created or written.
|
||||
func extractZip(path, destDir string) error {
|
||||
zipReader, err := zip.OpenReader(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open zip: %w", err)
|
||||
}
|
||||
defer zipReader.Close()
|
||||
|
||||
absPath, err := safepaths.ParseAbsolute(destDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// As of the time of writing, ghzip.ExtractZip will safely skip files that
|
||||
// would result in path traversal. This is an issue for our use-case because
|
||||
// we want to error out before extracting if there's any such file.
|
||||
// To avoid breaking the shared ghzip.ExtractZip code that expects unsafe
|
||||
// paths to be ignored and no error produced, we pre-validate here,
|
||||
// producing an error if any such file is found.
|
||||
for _, f := range zipReader.File {
|
||||
_, err := absPath.Join(f.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := ghzip.ExtractZip(&zipReader.Reader, absPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractTarGz reads a TAR.GZ archive from r and extracts its contents into destDir.
|
||||
// It returns an error if the archive cannot be read,
|
||||
// or if any file or directory within the archive cannot be created or written.
|
||||
func extractTarGz(r io.Reader, destDir string) error {
|
||||
gzr, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create gzip reader: %w", err)
|
||||
}
|
||||
defer gzr.Close()
|
||||
|
||||
absDestDirPath, err := safepaths.ParseAbsolute(destDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tr := tar.NewReader(gzr)
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read tar: %w", err)
|
||||
}
|
||||
|
||||
absFilePath, err := absDestDirPath.Join(header.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := absFilePath.String()
|
||||
|
||||
if header.Typeflag == tar.TypeReg {
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create parent directory: %w", err)
|
||||
}
|
||||
if err := extractFile(target, os.FileMode(header.Mode)&0777, tr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractFile creates a file at target with the given mode and copies content from r.
|
||||
func extractFile(target string, mode os.FileMode, r io.Reader) (err error) {
|
||||
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if cerr := out.Close(); err == nil && cerr != nil {
|
||||
err = fmt.Errorf("failed to close file: %w", cerr)
|
||||
}
|
||||
}()
|
||||
if _, err := io.Copy(out, r); err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeCopilot(installDir string) error {
|
||||
if _, err := os.Stat(installDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to remove Copilot CLI: Copilot CLI not installed through `gh`")
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(installDir); err != nil {
|
||||
return fmt.Errorf("failed to remove Copilot CLI: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
588
pkg/cmd/copilot/copilot_test.go
Normal file
588
pkg/cmd/copilot/copilot_test.go
Normal file
|
|
@ -0,0 +1,588 @@
|
|||
package copilot
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/httpmock"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/google/shlex"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewCmdCopilot(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args string
|
||||
wantOpts CopilotOptions
|
||||
wantErrString string
|
||||
wantHelp bool
|
||||
}{
|
||||
{
|
||||
name: "no argument",
|
||||
args: "",
|
||||
wantOpts: CopilotOptions{
|
||||
CopilotArgs: []string{},
|
||||
},
|
||||
wantErrString: "",
|
||||
},
|
||||
{
|
||||
name: "with arguments",
|
||||
args: "some-arg some-other-arg",
|
||||
wantOpts: CopilotOptions{
|
||||
CopilotArgs: []string{"some-arg", "some-other-arg"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with --remove alone",
|
||||
args: "--remove",
|
||||
wantOpts: CopilotOptions{
|
||||
Remove: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with non-gh flags passed to copilot",
|
||||
args: "-p testing --something-flag",
|
||||
wantOpts: CopilotOptions{
|
||||
CopilotArgs: []string{"-p", "testing", "--something-flag"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with --remove and arguments",
|
||||
args: "--remove some-arg",
|
||||
wantErrString: "cannot use --remove with args",
|
||||
},
|
||||
{
|
||||
name: "with --remove passed to copilot using --",
|
||||
args: "-- --remove",
|
||||
wantOpts: CopilotOptions{
|
||||
CopilotArgs: []string{"--remove"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with --remove and -- alone",
|
||||
args: "--remove --",
|
||||
wantOpts: CopilotOptions{
|
||||
Remove: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with --remove, some invalid arg, and --",
|
||||
args: "--remove invalid-arg --",
|
||||
wantErrString: "cannot use --remove with args",
|
||||
},
|
||||
{
|
||||
name: "with --remove and -- and random arguments",
|
||||
args: "--remove -- some-arg",
|
||||
wantErrString: "cannot use --remove with args",
|
||||
},
|
||||
{
|
||||
name: "with --help, shows gh help",
|
||||
args: "--help",
|
||||
wantErrString: "",
|
||||
wantHelp: true,
|
||||
},
|
||||
{
|
||||
name: "with --help and --, shows copilot help",
|
||||
args: "-- --help",
|
||||
wantOpts: CopilotOptions{
|
||||
CopilotArgs: []string{"--help"},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := &cmdutil.Factory{}
|
||||
|
||||
argv, err := shlex.Split(tt.args)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var gotOpts *CopilotOptions
|
||||
cmd := NewCmdCopilot(f, func(opts *CopilotOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
|
||||
cmd.SetArgs(argv)
|
||||
cmd.SetIn(&bytes.Buffer{})
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
|
||||
_, err = cmd.ExecuteC()
|
||||
if tt.wantErrString != "" {
|
||||
require.EqualError(t, err, tt.wantErrString)
|
||||
return
|
||||
}
|
||||
|
||||
if tt.wantHelp {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantOpts.CopilotArgs, gotOpts.CopilotArgs, "opts.CopilotArgs not as expected")
|
||||
assert.Equal(t, tt.wantOpts.Remove, gotOpts.Remove, "opts.Remove not as expected")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveCopilot(t *testing.T) {
|
||||
t.Run("removes existing install directory", func(t *testing.T) {
|
||||
// Create a temporary directory to simulate the install directory
|
||||
tmpDir := t.TempDir()
|
||||
installDir := filepath.Join(tmpDir, "copilot")
|
||||
require.NoError(t, os.MkdirAll(installDir, 0755), "failed to create test directory")
|
||||
// Create a dummy file in the directory
|
||||
dummyFile := filepath.Join(installDir, "copilot")
|
||||
require.NoError(t, os.WriteFile(dummyFile, []byte("test"), 0755), "failed to create test file")
|
||||
|
||||
err := removeCopilot(installDir)
|
||||
require.NoError(t, err, "unexpected error")
|
||||
|
||||
_, err = os.Stat(installDir)
|
||||
require.True(t, os.IsNotExist(err), "expected install directory to be removed")
|
||||
})
|
||||
|
||||
t.Run("handles non-existent directory", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
installDir := filepath.Join(tmpDir, "copilot")
|
||||
|
||||
require.ErrorContains(t, removeCopilot(installDir), "failed to remove Copilot CLI")
|
||||
})
|
||||
}
|
||||
|
||||
// createTarGzBuffer creates a tar.gz archive in memory with the given files.
|
||||
func createTarGzBuffer(t *testing.T, files map[string][]byte) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
gw := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gw)
|
||||
|
||||
for name, content := range files {
|
||||
hdr := &tar.Header{
|
||||
Name: name,
|
||||
Mode: 0755,
|
||||
Size: int64(len(content)),
|
||||
}
|
||||
require.NoError(t, tw.WriteHeader(hdr), "failed to write tar header")
|
||||
_, err := tw.Write(content)
|
||||
require.NoError(t, err, "failed to write tar content")
|
||||
}
|
||||
|
||||
require.NoError(t, tw.Close(), "failed to close tar writer")
|
||||
require.NoError(t, gw.Close(), "failed to close gzip writer")
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// createZipBuffer creates a zip archive in memory with the given files.
|
||||
func createZipBuffer(t *testing.T, files map[string][]byte) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
for name, content := range files {
|
||||
fw, err := zw.Create(name)
|
||||
require.NoError(t, err, "failed to create zip entry")
|
||||
_, err = fw.Write(content)
|
||||
require.NoError(t, err, "failed to write zip content")
|
||||
}
|
||||
|
||||
require.NoError(t, zw.Close(), "failed to close zip writer")
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestExtractTarGz(t *testing.T) {
|
||||
t.Run("extracts files correctly", func(t *testing.T) {
|
||||
content := []byte("hello world")
|
||||
archive := createTarGzBuffer(t, map[string][]byte{
|
||||
"copilot": content,
|
||||
})
|
||||
|
||||
destDir := t.TempDir()
|
||||
|
||||
err := extractTarGz(bytes.NewReader(archive), destDir)
|
||||
require.NoError(t, err, "extractTarGz() error")
|
||||
|
||||
extracted, err := os.ReadFile(filepath.Join(destDir, "copilot"))
|
||||
require.NoError(t, err, "failed to read extracted file")
|
||||
require.Equal(t, content, extracted, "extracted content mismatch")
|
||||
})
|
||||
|
||||
t.Run("extracts nested files", func(t *testing.T) {
|
||||
content := []byte("nested content")
|
||||
archive := createTarGzBuffer(t, map[string][]byte{
|
||||
"subdir/file.txt": content,
|
||||
})
|
||||
|
||||
destDir := t.TempDir()
|
||||
|
||||
err := extractTarGz(bytes.NewReader(archive), destDir)
|
||||
require.NoError(t, err, "extractTarGz() error")
|
||||
|
||||
extracted, err := os.ReadFile(filepath.Join(destDir, "subdir", "file.txt"))
|
||||
require.NoError(t, err, "failed to read extracted file")
|
||||
require.Equal(t, content, extracted, "extracted content mismatch")
|
||||
})
|
||||
|
||||
t.Run("rejects path traversal", func(t *testing.T) {
|
||||
// Manually create a malicious tar.gz with path traversal
|
||||
var buf bytes.Buffer
|
||||
gw := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gw)
|
||||
|
||||
hdr := &tar.Header{
|
||||
Name: "../evil.txt",
|
||||
Mode: 0755,
|
||||
Size: 4,
|
||||
}
|
||||
_ = tw.WriteHeader(hdr)
|
||||
_, _ = tw.Write([]byte("evil"))
|
||||
_ = tw.Close()
|
||||
_ = gw.Close()
|
||||
|
||||
destDir := t.TempDir()
|
||||
|
||||
err := extractTarGz(bytes.NewReader(buf.Bytes()), destDir)
|
||||
require.Error(t, err, "expected error for path traversal, got nil")
|
||||
})
|
||||
|
||||
t.Run("handles invalid gzip", func(t *testing.T) {
|
||||
destDir := t.TempDir()
|
||||
|
||||
err := extractTarGz(bytes.NewReader([]byte("not valid gzip")), destDir)
|
||||
require.Error(t, err, "expected error for invalid gzip, got nil")
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractZip(t *testing.T) {
|
||||
t.Run("extracts files correctly", func(t *testing.T) {
|
||||
zipDir := t.TempDir()
|
||||
zipPath := filepath.Join(zipDir, "archive.zip")
|
||||
content := []byte("hello world")
|
||||
archive := createZipBuffer(t, map[string][]byte{
|
||||
"copilot.exe": content,
|
||||
})
|
||||
require.NoError(t, os.WriteFile(zipPath, archive, 0x755))
|
||||
|
||||
destDir := t.TempDir()
|
||||
|
||||
err := extractZip(zipPath, destDir)
|
||||
require.NoError(t, err, "extractZip() error")
|
||||
|
||||
extracted, err := os.ReadFile(filepath.Join(destDir, "copilot.exe"))
|
||||
require.NoError(t, err, "failed to read extracted file")
|
||||
require.Equal(t, content, extracted, "extracted content mismatch")
|
||||
})
|
||||
|
||||
t.Run("extracts nested files", func(t *testing.T) {
|
||||
zipDir := t.TempDir()
|
||||
zipPath := filepath.Join(zipDir, "archive.zip")
|
||||
content := []byte("hello world")
|
||||
archive := createZipBuffer(t, map[string][]byte{
|
||||
"subdir/file.txt": content,
|
||||
})
|
||||
require.NoError(t, os.WriteFile(zipPath, archive, 0x755))
|
||||
|
||||
destDir := t.TempDir()
|
||||
|
||||
err := extractZip(zipPath, destDir)
|
||||
require.NoError(t, err, "extractZip() error")
|
||||
|
||||
extracted, err := os.ReadFile(filepath.Join(destDir, "subdir", "file.txt"))
|
||||
require.NoError(t, err, "failed to read extracted file")
|
||||
require.Equal(t, content, extracted, "extracted content mismatch")
|
||||
})
|
||||
|
||||
t.Run("rejects path traversal", func(t *testing.T) {
|
||||
zipDir := t.TempDir()
|
||||
zipPath := filepath.Join(zipDir, "archive.zip")
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
fh := &zip.FileHeader{
|
||||
Name: "../evil.txt",
|
||||
Method: zip.Store,
|
||||
}
|
||||
fw, _ := zw.CreateHeader(fh)
|
||||
_, _ = fw.Write([]byte("evil"))
|
||||
_ = zw.Close()
|
||||
|
||||
require.NoError(t, os.WriteFile(zipPath, buf.Bytes(), 0x755))
|
||||
destDir := t.TempDir()
|
||||
|
||||
err := extractZip(zipPath, destDir)
|
||||
require.Error(t, err, "expected error for path traversal, got nil")
|
||||
})
|
||||
}
|
||||
|
||||
func TestFetchExpectedChecksum(t *testing.T) {
|
||||
t.Run("parses checksums file correctly", func(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
checksums := "abc123def456 copilot-linux-x64.tar.gz\n789xyz copilot-darwin-arm64.tar.gz\n"
|
||||
reg.Register(
|
||||
httpmock.MatchAny,
|
||||
httpmock.StringResponse(checksums),
|
||||
)
|
||||
|
||||
client := &http.Client{Transport: reg}
|
||||
checksum, err := fetchExpectedChecksum(client, "https://example.com/checksums", "copilot-linux-x64.tar.gz")
|
||||
require.NoError(t, err, "unexpected error")
|
||||
require.Equal(t, "abc123def456", checksum, "checksum mismatch")
|
||||
})
|
||||
|
||||
t.Run("returns error for missing archive", func(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
checksums := "abc123 copilot-linux-x64.tar.gz\n"
|
||||
reg.Register(
|
||||
httpmock.MatchAny,
|
||||
httpmock.StringResponse(checksums),
|
||||
)
|
||||
|
||||
client := &http.Client{Transport: reg}
|
||||
_, err := fetchExpectedChecksum(client, "https://example.com/checksums", "copilot-win32-x64.zip")
|
||||
require.Error(t, err, "expected error for missing archive")
|
||||
require.Equal(t, "checksum not found for copilot-win32-x64.zip", err.Error(), "unexpected error")
|
||||
})
|
||||
|
||||
t.Run("handles single space separator", func(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
checksums := "abc123 copilot-darwin-x64.tar.gz\n"
|
||||
reg.Register(
|
||||
httpmock.MatchAny,
|
||||
httpmock.StringResponse(checksums),
|
||||
)
|
||||
|
||||
client := &http.Client{Transport: reg}
|
||||
checksum, err := fetchExpectedChecksum(client, "https://example.com/checksums", "copilot-darwin-x64.tar.gz")
|
||||
require.NoError(t, err, "unexpected error")
|
||||
require.Equal(t, "abc123", checksum, "checksum mismatch")
|
||||
})
|
||||
|
||||
t.Run("handles HTTP error", func(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
reg.Register(
|
||||
httpmock.MatchAny,
|
||||
httpmock.StatusStringResponse(http.StatusNotFound, "not found"),
|
||||
)
|
||||
|
||||
client := &http.Client{Transport: reg}
|
||||
_, err := fetchExpectedChecksum(client, "https://example.com/checksums", "copilot-linux-x64.tar.gz")
|
||||
require.Error(t, err, "expected error for HTTP 404")
|
||||
})
|
||||
}
|
||||
|
||||
func archString() string {
|
||||
arch := runtime.GOARCH
|
||||
if arch == "amd64" {
|
||||
return "x64"
|
||||
}
|
||||
return arch
|
||||
}
|
||||
|
||||
func TestDownloadCopilot(t *testing.T) {
|
||||
// Skip on unsupported architectures
|
||||
if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" {
|
||||
t.Skip("skipping test on unsupported architecture")
|
||||
}
|
||||
|
||||
t.Run("downloads and extracts tar.gz with valid checksum", func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("skipping tar.gz test on windows")
|
||||
}
|
||||
|
||||
ios, _, _, stderr := iostreams.Test()
|
||||
tmpDir := t.TempDir()
|
||||
installDir := filepath.Join(tmpDir, "copilot")
|
||||
localPath := filepath.Join(installDir, "copilot")
|
||||
|
||||
// Create mock archive with copilot binary
|
||||
binaryContent := []byte("#!/bin/sh\necho copilot")
|
||||
archive := createTarGzBuffer(t, map[string][]byte{
|
||||
"copilot": binaryContent,
|
||||
})
|
||||
|
||||
// Calculate checksum
|
||||
checksum := sha256.Sum256(archive)
|
||||
checksumHex := hex.EncodeToString(checksum[:])
|
||||
archiveName := fmt.Sprintf("copilot-%s-%s.tar.gz", runtime.GOOS, archString())
|
||||
checksumFile := fmt.Sprintf("%s %s\n", checksumHex, archiveName)
|
||||
|
||||
reg := &httpmock.Registry{}
|
||||
// Register checksum endpoint
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "github/copilot-cli/releases/latest/download/SHA256SUMS.txt"),
|
||||
httpmock.StringResponse(checksumFile),
|
||||
)
|
||||
// Register archive endpoint
|
||||
reg.Register(
|
||||
httpmock.REST("GET", fmt.Sprintf("github/copilot-cli/releases/latest/download/%s", archiveName)),
|
||||
httpmock.BinaryResponse(archive),
|
||||
)
|
||||
|
||||
httpClient := &http.Client{Transport: reg}
|
||||
|
||||
path, err := downloadCopilot(httpClient, ios, installDir, localPath)
|
||||
require.NoError(t, err, "downloadCopilot() error")
|
||||
require.Equal(t, localPath, path, "downloadCopilot() path mismatch")
|
||||
|
||||
// Verify binary was extracted
|
||||
extracted, err := os.ReadFile(localPath)
|
||||
require.NoError(t, err, "failed to read extracted binary")
|
||||
require.Equal(t, binaryContent, extracted, "extracted content mismatch")
|
||||
|
||||
// Verify output messages
|
||||
require.Contains(t, stderr.String(), "installed successfully", "expected success message in stderr")
|
||||
})
|
||||
|
||||
t.Run("fails with checksum mismatch", func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("skipping tar.gz test on windows")
|
||||
}
|
||||
|
||||
ios, _, _, _ := iostreams.Test()
|
||||
tmpDir := t.TempDir()
|
||||
installDir := filepath.Join(tmpDir, "copilot")
|
||||
localPath := filepath.Join(installDir, "copilot")
|
||||
|
||||
binaryContent := []byte("#!/bin/sh\necho copilot")
|
||||
archive := createTarGzBuffer(t, map[string][]byte{
|
||||
"copilot": binaryContent,
|
||||
})
|
||||
|
||||
// Use wrong checksum
|
||||
archiveName := fmt.Sprintf("copilot-%s-%s.tar.gz", runtime.GOOS, archString())
|
||||
checksumFile := fmt.Sprintf("%s %s\n", "0000000000000000000000000000000000000000000000000000000000000000", archiveName)
|
||||
|
||||
reg := &httpmock.Registry{}
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "github/copilot-cli/releases/latest/download/SHA256SUMS.txt"),
|
||||
httpmock.StringResponse(checksumFile),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.REST("GET", fmt.Sprintf("github/copilot-cli/releases/latest/download/%s", archiveName)),
|
||||
httpmock.BinaryResponse(archive),
|
||||
)
|
||||
|
||||
httpClient := &http.Client{Transport: reg}
|
||||
|
||||
_, err := downloadCopilot(httpClient, ios, installDir, localPath)
|
||||
require.Error(t, err, "expected error for checksum mismatch, got nil")
|
||||
require.Contains(t, err.Error(), "checksum mismatch", "expected checksum mismatch error")
|
||||
})
|
||||
|
||||
t.Run("handles HTTP error on archive download", func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("skipping tar.gz test on windows")
|
||||
}
|
||||
|
||||
ios, _, _, _ := iostreams.Test()
|
||||
tmpDir := t.TempDir()
|
||||
installDir := filepath.Join(tmpDir, "copilot")
|
||||
localPath := filepath.Join(installDir, "copilot")
|
||||
|
||||
archiveName := fmt.Sprintf("copilot-%s-%s.tar.gz", runtime.GOOS, archString())
|
||||
checksumFile := fmt.Sprintf("%s %s\n", "abc123", archiveName)
|
||||
|
||||
reg := &httpmock.Registry{}
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "github/copilot-cli/releases/latest/download/SHA256SUMS.txt"),
|
||||
httpmock.StringResponse(checksumFile),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.REST("GET", fmt.Sprintf("github/copilot-cli/releases/latest/download/%s", archiveName)),
|
||||
httpmock.StatusStringResponse(http.StatusNotFound, "not found"),
|
||||
)
|
||||
|
||||
httpClient := &http.Client{Transport: reg}
|
||||
|
||||
_, err := downloadCopilot(httpClient, ios, installDir, localPath)
|
||||
require.Error(t, err, "expected error for HTTP 404, got nil")
|
||||
require.Contains(t, err.Error(), "download failed", "expected error to contain 'download failed'")
|
||||
})
|
||||
|
||||
t.Run("handles missing binary after extraction", func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("skipping tar.gz test on windows")
|
||||
}
|
||||
|
||||
ios, _, _, _ := iostreams.Test()
|
||||
tmpDir := t.TempDir()
|
||||
installDir := filepath.Join(tmpDir, "copilot")
|
||||
localPath := filepath.Join(installDir, "copilot")
|
||||
|
||||
// Create archive without the expected binary name
|
||||
archive := createTarGzBuffer(t, map[string][]byte{
|
||||
"wrong-name": []byte("content"),
|
||||
})
|
||||
|
||||
checksum := sha256.Sum256(archive)
|
||||
checksumHex := hex.EncodeToString(checksum[:])
|
||||
archiveName := fmt.Sprintf("copilot-%s-%s.tar.gz", runtime.GOOS, archString())
|
||||
checksumFile := fmt.Sprintf("%s %s\n", checksumHex, archiveName)
|
||||
|
||||
reg := &httpmock.Registry{}
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "github/copilot-cli/releases/latest/download/SHA256SUMS.txt"),
|
||||
httpmock.StringResponse(checksumFile),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.REST("GET", fmt.Sprintf("github/copilot-cli/releases/latest/download/%s", archiveName)),
|
||||
httpmock.BinaryResponse(archive),
|
||||
)
|
||||
|
||||
httpClient := &http.Client{Transport: reg}
|
||||
|
||||
_, err := downloadCopilot(httpClient, ios, installDir, localPath)
|
||||
assert.ErrorContains(t, err, "copilot binary unavailable")
|
||||
})
|
||||
|
||||
t.Run("downloads and extracts zip on windows", func(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("skipping zip test on non-windows")
|
||||
}
|
||||
|
||||
ios, _, _, _ := iostreams.Test()
|
||||
tmpDir := t.TempDir()
|
||||
installDir := filepath.Join(tmpDir, "copilot")
|
||||
localPath := filepath.Join(installDir, "copilot.exe")
|
||||
|
||||
binaryContent := []byte("MZ fake exe content")
|
||||
archive := createZipBuffer(t, map[string][]byte{
|
||||
"copilot.exe": binaryContent,
|
||||
})
|
||||
|
||||
checksum := sha256.Sum256(archive)
|
||||
checksumHex := hex.EncodeToString(checksum[:])
|
||||
archiveName := fmt.Sprintf("copilot-%s-%s.zip", "win32", archString())
|
||||
checksumFile := fmt.Sprintf("%s %s\n", checksumHex, archiveName)
|
||||
|
||||
reg := &httpmock.Registry{}
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "github/copilot-cli/releases/latest/download/SHA256SUMS.txt"),
|
||||
httpmock.StringResponse(checksumFile),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.REST("GET", fmt.Sprintf("github/copilot-cli/releases/latest/download/%s", archiveName)),
|
||||
httpmock.BinaryResponse(archive),
|
||||
)
|
||||
|
||||
httpClient := &http.Client{Transport: reg}
|
||||
|
||||
path, err := downloadCopilot(httpClient, ios, installDir, localPath)
|
||||
require.NoError(t, err, "downloadCopilot() error")
|
||||
require.Equal(t, localPath, path, "downloadCopilot() path mismatch")
|
||||
})
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ jobs:
|
|||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: cli/gh-extension-precompile@v2
|
||||
with:
|
||||
generate_attestations: true
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ jobs:
|
|||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: cli/gh-extension-precompile@v2
|
||||
with:
|
||||
build_script_override: "script/build.sh"
|
||||
|
|
|
|||
|
|
@ -734,6 +734,7 @@ func TestPlainHttpClient(t *testing.T) {
|
|||
|
||||
assert.Equal(t, 204, res.StatusCode)
|
||||
assert.Equal(t, []string{"GitHub CLI v1.2.3"}, receivedHeaders.Values("User-Agent"))
|
||||
assert.Equal(t, []string{"2022-11-28"}, receivedHeaders.Values("X-GitHub-Api-Version"))
|
||||
assert.Nil(t, receivedHeaders.Values("Authorization"))
|
||||
assert.Nil(t, receivedHeaders.Values("Content-Type"))
|
||||
assert.Nil(t, receivedHeaders.Values("Accept"))
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/internal/gh"
|
||||
"github.com/cli/cli/v2/internal/prompter"
|
||||
|
|
@ -58,6 +59,28 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
|
|||
cmd := &cobra.Command{
|
||||
Use: "edit {<id> | <url>} [<filename>]",
|
||||
Short: "Edit one of your gists",
|
||||
Example: heredoc.Doc(`
|
||||
# Select a gist to edit interactively
|
||||
$ gh gist edit
|
||||
|
||||
# Edit a gist file in the default editor
|
||||
$ gh gist edit 1234567890abcdef1234567890abcdef
|
||||
|
||||
# Edit a specific file in the gist
|
||||
$ gh gist edit 1234567890abcdef1234567890abcdef --filename hello.py
|
||||
|
||||
# Replace a gist file with content from a local file
|
||||
$ gh gist edit 1234567890abcdef1234567890abcdef --filename hello.py hello.py
|
||||
|
||||
# Add a new file to the gist
|
||||
$ gh gist edit 1234567890abcdef1234567890abcdef --add newfile.py
|
||||
|
||||
# Change the description of the gist
|
||||
$ gh gist edit 1234567890abcdef1234567890abcdef --desc "new description"
|
||||
|
||||
# Remove a file from the gist
|
||||
$ gh gist edit 1234567890abcdef1234567890abcdef --remove hello.py
|
||||
`),
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 2 {
|
||||
return cmdutil.FlagErrorf("too many arguments")
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue,
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// TODO stateReasonCleanup
|
||||
if !features.StateReason {
|
||||
// If StateReason is not supported silently close issue without setting StateReason.
|
||||
reason = ""
|
||||
|
|
|
|||
|
|
@ -186,6 +186,7 @@ func createRun(opts *CreateOptions) (err error) {
|
|||
return err
|
||||
}
|
||||
|
||||
// TODO actorIsAssignableCleanup
|
||||
if issueFeatures.ActorIsAssignable {
|
||||
assignees = copilotReplacer.ReplaceSlice(assignees)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import (
|
|||
ctx "context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/api"
|
||||
|
|
@ -150,21 +152,22 @@ func developRun(opts *DevelopOptions) error {
|
|||
return err
|
||||
}
|
||||
|
||||
opts.IO.StartProgressIndicator()
|
||||
opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Fetching issue #%d", opts.IssueNumber))
|
||||
defer opts.IO.StopProgressIndicator()
|
||||
|
||||
issue, err := shared.FindIssueOrPR(httpClient, baseRepo, opts.IssueNumber, []string{"id", "number"})
|
||||
opts.IO.StopProgressIndicator()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiClient := api.NewClientFromHTTP(httpClient)
|
||||
|
||||
opts.IO.StartProgressIndicator()
|
||||
opts.IO.StartProgressIndicatorWithLabel("Checking linked branch support")
|
||||
err = api.CheckLinkedBranchFeature(apiClient, baseRepo.RepoHost())
|
||||
opts.IO.StopProgressIndicator()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.IO.StopProgressIndicator()
|
||||
|
||||
if opts.List {
|
||||
return developRunList(opts, apiClient, baseRepo, issue)
|
||||
|
|
@ -174,7 +177,6 @@ func developRun(opts *DevelopOptions) error {
|
|||
|
||||
func developRunCreate(opts *DevelopOptions, apiClient *api.Client, issueRepo ghrepo.Interface, issue *api.Issue) error {
|
||||
branchRepo := issueRepo
|
||||
var repoID string
|
||||
if opts.BranchRepo != "" {
|
||||
var err error
|
||||
branchRepo, err = ghrepo.FromFullName(opts.BranchRepo)
|
||||
|
|
@ -183,24 +185,67 @@ func developRunCreate(opts *DevelopOptions, apiClient *api.Client, issueRepo ghr
|
|||
}
|
||||
}
|
||||
|
||||
opts.IO.StartProgressIndicator()
|
||||
repoID, branchID, err := api.FindRepoBranchID(apiClient, branchRepo, opts.BaseBranch)
|
||||
opts.IO.StopProgressIndicator()
|
||||
if err != nil {
|
||||
return err
|
||||
opts.IO.StartProgressIndicatorWithLabel("Preparing linked branch")
|
||||
defer opts.IO.StopProgressIndicator()
|
||||
|
||||
branchName := ""
|
||||
reusedExisting := false
|
||||
if opts.Name != "" {
|
||||
opts.IO.StartProgressIndicatorWithLabel("Checking existing linked branches")
|
||||
branches, err := api.ListLinkedBranches(apiClient, issueRepo, issue.Number)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
branchName = findExistingLinkedBranchName(branches, branchRepo, opts.Name)
|
||||
reusedExisting = branchName != ""
|
||||
}
|
||||
|
||||
repoID := ""
|
||||
branchID := ""
|
||||
baseValidated := false
|
||||
if opts.BaseBranch != "" {
|
||||
opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Validating base branch %q", opts.BaseBranch))
|
||||
foundRepoID, foundBranchID, err := api.FindRepoBranchID(apiClient, branchRepo, opts.BaseBranch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
repoID = foundRepoID
|
||||
branchID = foundBranchID
|
||||
baseValidated = true
|
||||
}
|
||||
|
||||
if branchName == "" {
|
||||
if !baseValidated {
|
||||
opts.IO.StartProgressIndicatorWithLabel("Resolving base branch")
|
||||
foundRepoID, foundBranchID, err := api.FindRepoBranchID(apiClient, branchRepo, opts.BaseBranch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
repoID = foundRepoID
|
||||
branchID = foundBranchID
|
||||
}
|
||||
|
||||
opts.IO.StartProgressIndicatorWithLabel("Creating linked branch")
|
||||
createdBranchName, err := api.CreateLinkedBranch(apiClient, branchRepo.RepoHost(), repoID, issue.ID, branchID, opts.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
branchName = createdBranchName
|
||||
}
|
||||
|
||||
if branchName == "" {
|
||||
return fmt.Errorf("failed to create linked branch: API returned empty branch name")
|
||||
}
|
||||
|
||||
opts.IO.StartProgressIndicator()
|
||||
branchName, err := api.CreateLinkedBranch(apiClient, branchRepo.RepoHost(), repoID, issue.ID, branchID, opts.Name)
|
||||
opts.IO.StopProgressIndicator()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
if reusedExisting && opts.IO.IsStdoutTTY() {
|
||||
fmt.Fprintf(opts.IO.ErrOut, "Using existing linked branch %q\n", branchName)
|
||||
}
|
||||
|
||||
// Remember which branch to target when creating a PR.
|
||||
if opts.BaseBranch != "" {
|
||||
err = opts.GitClient.SetBranchConfig(ctx.Background(), branchName, git.MergeBaseConfig, opts.BaseBranch)
|
||||
if err != nil {
|
||||
if err := opts.GitClient.SetBranchConfig(ctx.Background(), branchName, git.MergeBaseConfig, opts.BaseBranch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
@ -210,13 +255,44 @@ func developRunCreate(opts *DevelopOptions, apiClient *api.Client, issueRepo ghr
|
|||
return checkoutBranch(opts, branchRepo, branchName)
|
||||
}
|
||||
|
||||
func findExistingLinkedBranchName(branches []api.LinkedBranch, branchRepo ghrepo.Interface, branchName string) string {
|
||||
for _, branch := range branches {
|
||||
if branch.BranchName != branchName {
|
||||
continue
|
||||
}
|
||||
linkedRepo, err := linkedBranchRepoFromURL(branch.URL)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if ghrepo.IsSame(linkedRepo, branchRepo) {
|
||||
return branch.BranchName
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func linkedBranchRepoFromURL(branchURL string) (ghrepo.Interface, error) {
|
||||
u, err := url.Parse(branchURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pathParts := strings.SplitN(strings.Trim(u.Path, "/"), "/", 3)
|
||||
if len(pathParts) < 2 {
|
||||
return nil, fmt.Errorf("invalid linked branch URL: %q", branchURL)
|
||||
}
|
||||
u.Path = "/" + strings.Join(pathParts[0:2], "/")
|
||||
return ghrepo.FromURL(u)
|
||||
}
|
||||
|
||||
func developRunList(opts *DevelopOptions, apiClient *api.Client, issueRepo ghrepo.Interface, issue *api.Issue) error {
|
||||
opts.IO.StartProgressIndicator()
|
||||
opts.IO.StartProgressIndicatorWithLabel("Fetching linked branches")
|
||||
defer opts.IO.StopProgressIndicator()
|
||||
|
||||
branches, err := api.ListLinkedBranches(apiClient, issueRepo, issue.Number)
|
||||
opts.IO.StopProgressIndicator()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.IO.StopProgressIndicator()
|
||||
|
||||
if len(branches) == 0 {
|
||||
return cmdutil.NewNoResultsError(fmt.Sprintf("no linked branches found for %s#%d", ghrepo.FullName(issueRepo), issue.Number))
|
||||
|
|
|
|||
|
|
@ -353,6 +353,16 @@ func TestDevelopRun(t *testing.T) {
|
|||
reg.Register(
|
||||
httpmock.GraphQL(`query FindRepoBranchID\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query ListLinkedBranches\b`),
|
||||
httpmock.GraphQLQuery(`
|
||||
{"data":{"repository":{"issue":{"linkedBranches":{"nodes":[]}}}}}
|
||||
`, func(query string, inputs map[string]interface{}) {
|
||||
assert.Equal(t, float64(123), inputs["number"])
|
||||
assert.Equal(t, "OWNER", inputs["owner"])
|
||||
assert.Equal(t, "REPO", inputs["name"])
|
||||
}),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`mutation CreateLinkedBranch\b`),
|
||||
httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":"my-branch"}}}}}`,
|
||||
|
|
@ -370,6 +380,165 @@ func TestDevelopRun(t *testing.T) {
|
|||
},
|
||||
expectedOut: "github.com/OWNER/REPO/tree/my-branch\n",
|
||||
},
|
||||
{
|
||||
name: "develop existing linked branch with name and checkout",
|
||||
opts: &DevelopOptions{
|
||||
Name: "my-branch",
|
||||
BaseBranch: "main",
|
||||
IssueNumber: 123,
|
||||
Checkout: true,
|
||||
},
|
||||
remotes: map[string]string{
|
||||
"origin": "OWNER/REPO",
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query LinkedBranchFeature\b`),
|
||||
httpmock.StringResponse(featureEnabledPayload),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query IssueByNumber\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled":true,"issue":{"id":"SOMEID","number":123,"title":"my issue"}}}}`),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query ListLinkedBranches\b`),
|
||||
httpmock.GraphQLQuery(`
|
||||
{"data":{"repository":{"issue":{"linkedBranches":{"nodes":[{"ref":{"name":"my-branch","repository":{"url":"https://github.com/OWNER/REPO"}}}]}}}}}
|
||||
`, func(query string, inputs map[string]interface{}) {
|
||||
assert.Equal(t, float64(123), inputs["number"])
|
||||
assert.Equal(t, "OWNER", inputs["owner"])
|
||||
assert.Equal(t, "REPO", inputs["name"])
|
||||
}),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query FindRepoBranchID\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`))
|
||||
},
|
||||
runStubs: func(cs *run.CommandStubber) {
|
||||
cs.Register(`git config branch\.my-branch\.gh-merge-base main`, 0, "")
|
||||
cs.Register(`git fetch origin \+refs/heads/my-branch:refs/remotes/origin/my-branch`, 0, "")
|
||||
cs.Register(`git rev-parse --verify refs/heads/my-branch`, 0, "")
|
||||
cs.Register(`git checkout my-branch`, 0, "")
|
||||
cs.Register(`git pull --ff-only origin my-branch`, 0, "")
|
||||
},
|
||||
expectedOut: "github.com/OWNER/REPO/tree/my-branch\n",
|
||||
},
|
||||
{
|
||||
name: "develop existing linked branch with name in tty shows reuse message",
|
||||
opts: &DevelopOptions{
|
||||
Name: "my-branch",
|
||||
BaseBranch: "main",
|
||||
IssueNumber: 123,
|
||||
},
|
||||
tty: true,
|
||||
remotes: map[string]string{
|
||||
"origin": "OWNER/REPO",
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query LinkedBranchFeature\b`),
|
||||
httpmock.StringResponse(featureEnabledPayload),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query IssueByNumber\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled":true,"issue":{"id":"SOMEID","number":123,"title":"my issue"}}}}`),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query ListLinkedBranches\b`),
|
||||
httpmock.GraphQLQuery(`
|
||||
{"data":{"repository":{"issue":{"linkedBranches":{"nodes":[{"ref":{"name":"my-branch","repository":{"url":"https://github.com/OWNER/REPO"}}}]}}}}}
|
||||
`, func(query string, inputs map[string]interface{}) {
|
||||
assert.Equal(t, float64(123), inputs["number"])
|
||||
assert.Equal(t, "OWNER", inputs["owner"])
|
||||
assert.Equal(t, "REPO", inputs["name"])
|
||||
}),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query FindRepoBranchID\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`))
|
||||
},
|
||||
runStubs: func(cs *run.CommandStubber) {
|
||||
cs.Register(`git config branch\.my-branch\.gh-merge-base main`, 0, "")
|
||||
cs.Register(`git fetch origin \+refs/heads/my-branch:refs/remotes/origin/my-branch`, 0, "")
|
||||
},
|
||||
expectedOut: "github.com/OWNER/REPO/tree/my-branch\n",
|
||||
expectedErrOut: "Using existing linked branch \"my-branch\"\n",
|
||||
},
|
||||
{
|
||||
name: "develop existing linked branch with invalid base branch returns an error",
|
||||
opts: &DevelopOptions{
|
||||
Name: "my-branch",
|
||||
BaseBranch: "does-not-exist-branch",
|
||||
IssueNumber: 123,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query LinkedBranchFeature\b`),
|
||||
httpmock.StringResponse(featureEnabledPayload),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query IssueByNumber\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled":true,"issue":{"id":"SOMEID","number":123,"title":"my issue"}}}}`),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query ListLinkedBranches\b`),
|
||||
httpmock.GraphQLQuery(`
|
||||
{"data":{"repository":{"issue":{"linkedBranches":{"nodes":[{"ref":{"name":"my-branch","repository":{"url":"https://github.com/OWNER/REPO"}}}]}}}}}
|
||||
`, func(query string, inputs map[string]interface{}) {
|
||||
assert.Equal(t, float64(123), inputs["number"])
|
||||
assert.Equal(t, "OWNER", inputs["owner"])
|
||||
assert.Equal(t, "REPO", inputs["name"])
|
||||
}),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query FindRepoBranchID\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","defaultBranchRef":{"target":{"oid":"DEFAULTOID"}},"ref":null}}}`),
|
||||
)
|
||||
},
|
||||
wantErr: `could not find branch "does-not-exist-branch" in OWNER/REPO`,
|
||||
},
|
||||
{
|
||||
name: "develop with empty linked branch name response returns an error",
|
||||
opts: &DevelopOptions{
|
||||
Name: "my-branch",
|
||||
BaseBranch: "main",
|
||||
IssueNumber: 123,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query LinkedBranchFeature\b`),
|
||||
httpmock.StringResponse(featureEnabledPayload),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query IssueByNumber\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled":true,"issue":{"id":"SOMEID","number":123,"title":"my issue"}}}}`),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query ListLinkedBranches\b`),
|
||||
httpmock.GraphQLQuery(`
|
||||
{"data":{"repository":{"issue":{"linkedBranches":{"nodes":[]}}}}}
|
||||
`, func(query string, inputs map[string]interface{}) {
|
||||
assert.Equal(t, float64(123), inputs["number"])
|
||||
assert.Equal(t, "OWNER", inputs["owner"])
|
||||
assert.Equal(t, "REPO", inputs["name"])
|
||||
}),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query FindRepoBranchID\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`mutation CreateLinkedBranch\b`),
|
||||
httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":""}}}}}`,
|
||||
func(inputs map[string]interface{}) {
|
||||
assert.Equal(t, "REPOID", inputs["repositoryId"])
|
||||
assert.Equal(t, "SOMEID", inputs["issueId"])
|
||||
assert.Equal(t, "OID", inputs["oid"])
|
||||
assert.Equal(t, "my-branch", inputs["name"])
|
||||
}),
|
||||
)
|
||||
},
|
||||
wantErr: "failed to create linked branch: API returned empty branch name",
|
||||
},
|
||||
{
|
||||
name: "develop new branch outside of local git repo",
|
||||
opts: &DevelopOptions{
|
||||
|
|
@ -426,6 +595,16 @@ func TestDevelopRun(t *testing.T) {
|
|||
httpmock.GraphQL(`query FindRepoBranchID\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query ListLinkedBranches\b`),
|
||||
httpmock.GraphQLQuery(`
|
||||
{"data":{"repository":{"issue":{"linkedBranches":{"nodes":[]}}}}}
|
||||
`, func(query string, inputs map[string]interface{}) {
|
||||
assert.Equal(t, float64(123), inputs["number"])
|
||||
assert.Equal(t, "OWNER", inputs["owner"])
|
||||
assert.Equal(t, "REPO", inputs["name"])
|
||||
}),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`mutation CreateLinkedBranch\b`),
|
||||
httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":"my-branch"}}}}}`,
|
||||
|
|
@ -468,6 +647,16 @@ func TestDevelopRun(t *testing.T) {
|
|||
httpmock.GraphQL(`query FindRepoBranchID\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query ListLinkedBranches\b`),
|
||||
httpmock.GraphQLQuery(`
|
||||
{"data":{"repository":{"issue":{"linkedBranches":{"nodes":[]}}}}}
|
||||
`, func(query string, inputs map[string]interface{}) {
|
||||
assert.Equal(t, float64(123), inputs["number"])
|
||||
assert.Equal(t, "OWNER", inputs["owner"])
|
||||
assert.Equal(t, "REPO", inputs["name"])
|
||||
}),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`mutation CreateLinkedBranch\b`),
|
||||
httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":"my-branch"}}}}}`,
|
||||
|
|
|
|||
|
|
@ -215,6 +215,7 @@ func editRun(opts *EditOptions) error {
|
|||
|
||||
lookupFields := []string{"id", "number", "title", "body", "url"}
|
||||
if editable.Assignees.Edited {
|
||||
// TODO actorIsAssignableCleanup
|
||||
if issueFeatures.ActorIsAssignable {
|
||||
editable.Assignees.ActorAssignees = true
|
||||
lookupFields = append(lookupFields, "assignedActors")
|
||||
|
|
|
|||
|
|
@ -845,7 +845,7 @@ func mockRepoMetadata(_ *testing.T, reg *httpmock.Registry) {
|
|||
{ "data": { "repository": { "suggestedActors": {
|
||||
"nodes": [
|
||||
{ "login": "hubot", "id": "HUBOTID", "__typename": "Bot" },
|
||||
{ "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name", "__typename": "User" }
|
||||
{ "login": "monalisa", "id": "MONAID", "name": "Mona Display Name", "__typename": "User" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package list
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/cli/cli/v2/api"
|
||||
fd "github.com/cli/cli/v2/internal/featuredetection"
|
||||
|
|
@ -9,6 +10,8 @@ import (
|
|||
prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared"
|
||||
)
|
||||
|
||||
var pullRequestSearchQualifierRE = regexp.MustCompile(`(?i)\b(?:is|type):(?:pr|pull-?request)\b`)
|
||||
|
||||
func listIssues(client *api.Client, repo ghrepo.Interface, filters prShared.FilterOptions, limit int) (*api.IssuesAndTotalCount, error) {
|
||||
var states []string
|
||||
switch filters.State {
|
||||
|
|
@ -114,6 +117,10 @@ loop:
|
|||
}
|
||||
|
||||
func searchIssues(client *api.Client, detector fd.Detector, repo ghrepo.Interface, filters prShared.FilterOptions, limit int) (*api.IssuesAndTotalCount, error) {
|
||||
if pullRequestSearchQualifierRE.MatchString(filters.Search) {
|
||||
return nil, fmt.Errorf("cannot use pull request search qualifiers with `gh issue list`; use `gh pr list` instead")
|
||||
}
|
||||
|
||||
// TODO advancedIssueSearchCleanup
|
||||
// We won't need feature detection when GHES 3.17 support ends, since
|
||||
// the advanced issue search is the only available search backend for
|
||||
|
|
@ -164,8 +171,10 @@ func searchIssues(client *api.Client, detector fd.Detector, repo ghrepo.Interfac
|
|||
filters.Repo = ghrepo.FullName(repo)
|
||||
filters.Entity = "issue"
|
||||
|
||||
// TODO advancedIssueSearchCleanup
|
||||
if features.AdvancedIssueSearchAPI {
|
||||
variables["query"] = prShared.SearchQueryBuild(filters, true)
|
||||
// TODO advancedIssueSearchCleanup
|
||||
if features.AdvancedIssueSearchAPIOptIn {
|
||||
variables["type"] = "ISSUE_ADVANCED"
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -214,3 +214,56 @@ func TestSearchIssuesAndAdvancedSearch(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchIssues_rejectsPullRequestQualifiers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
search string
|
||||
}{
|
||||
{
|
||||
name: "is:pr",
|
||||
search: "is:pr",
|
||||
},
|
||||
{
|
||||
name: "type:pr",
|
||||
search: "type:pr",
|
||||
},
|
||||
{
|
||||
name: "type:pull-request",
|
||||
search: "type:pull-request",
|
||||
},
|
||||
{
|
||||
name: "type:pullrequest",
|
||||
search: "type:pullrequest",
|
||||
},
|
||||
{
|
||||
name: "case-insensitive is:PR",
|
||||
search: "is:PR",
|
||||
},
|
||||
{
|
||||
name: "case-insensitive TYPE:Pull-Request",
|
||||
search: "TYPE:Pull-Request",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
defer reg.Verify(t)
|
||||
|
||||
httpClient := &http.Client{Transport: reg}
|
||||
client := api.NewClientFromHTTP(httpClient)
|
||||
|
||||
_, err := searchIssues(
|
||||
client,
|
||||
fd.AdvancedIssueSearchSupportedAsOnlyBackend(),
|
||||
ghrepo.New("OWNER", "REPO"),
|
||||
prShared.FilterOptions{Search: tt.search},
|
||||
30,
|
||||
)
|
||||
|
||||
assert.EqualError(t, err, "cannot use pull request search qualifiers with `gh issue list`; use `gh pr list` instead")
|
||||
assert.Len(t, reg.Requests, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@ func listRun(opts *ListOptions) error {
|
|||
return err
|
||||
}
|
||||
fields := defaultFields
|
||||
// TODO stateReasonCleanup
|
||||
if features.StateReason {
|
||||
fields = append(defaultFields, "stateReason")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ func FindIssueOrPR(httpClient *http.Client, repo ghrepo.Interface, number int, f
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// TODO stateReasonCleanup
|
||||
if !features.StateReason {
|
||||
fieldSet.Remove("stateReason")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,10 +138,14 @@
|
|||
]
|
||||
}
|
||||
],
|
||||
"totalCount": 6
|
||||
"totalCount": 6,
|
||||
"pageInfo": {
|
||||
"hasNextPage": true,
|
||||
"endCursor": "Y3Vyc29yOnYyOjg5"
|
||||
}
|
||||
},
|
||||
"url": "https://github.com/OWNER/REPO/issues/123"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,14 +20,13 @@ func preloadIssueComments(client *http.Client, repo ghrepo.Interface, issue *api
|
|||
} `graphql:"node(id: $id)"`
|
||||
}
|
||||
|
||||
if !issue.Comments.PageInfo.HasNextPage {
|
||||
return nil
|
||||
}
|
||||
|
||||
variables := map[string]interface{}{
|
||||
"id": githubv4.ID(issue.ID),
|
||||
"endCursor": (*githubv4.String)(nil),
|
||||
}
|
||||
if issue.Comments.PageInfo.HasNextPage {
|
||||
variables["endCursor"] = githubv4.String(issue.Comments.PageInfo.EndCursor)
|
||||
} else {
|
||||
issue.Comments.Nodes = issue.Comments.Nodes[0:0]
|
||||
"endCursor": githubv4.String(issue.Comments.PageInfo.EndCursor),
|
||||
}
|
||||
|
||||
gql := api.NewClientFromHTTP(client)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue