Merge branch 'trunk' into build/customize-prefix

This commit is contained in:
Kynan Ware 2026-03-03 20:08:56 -07:00 committed by GitHub
commit 85ae53e702
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
1251 changed files with 7084 additions and 175576 deletions

View file

@ -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

View file

@ -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
View file

@ -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 }}

View file

@ -1,3 +0,0 @@
paths-ignore:
- 'third-party/**'
- 'third-party-licenses.*.md'

View file

@ -2,6 +2,7 @@ name: Bump Go
on:
schedule:
- cron: "0 3 * * *" # 3 AM UTC
workflow_dispatch:
permissions:
contents: write
pull-requests: write

View file

@ -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' }}

View file

@ -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"

View file

@ -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

View file

@ -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.

View file

@ -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}"

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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

View 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
View 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

View 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

View 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

View file

@ -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
View file

@ -18,6 +18,10 @@
# Windows resource files
/cmd/gh/*.syso
# Third-party licenses
/internal/licenses/embed/*/*
!/internal/licenses/embed/*/PLACEHOLDER
# VS Code
.vscode

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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)
})
}
}

View file

@ -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"},

View file

@ -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

View file

@ -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>

View file

@ -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 {

View file

@ -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)
})
}
}

View file

@ -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

View file

@ -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) {

View file

@ -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()

View file

@ -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":

View file

@ -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",

View file

@ -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.

View file

@ -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

View file

@ -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
View file

@ -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
)

1958
go.sum

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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"`

View file

@ -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)
})
}
}

View file

@ -0,0 +1,8 @@
package licenses
import "embed"
const rootDir = "embed/darwin-amd64"
//go:embed all:embed/darwin-amd64
var embedFS embed.FS

View file

@ -0,0 +1,8 @@
package licenses
import "embed"
const rootDir = "embed/darwin-arm64"
//go:embed all:embed/darwin-arm64
var embedFS embed.FS

View 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

View file

@ -0,0 +1,8 @@
package licenses
import "embed"
const rootDir = "embed/linux-386"
//go:embed all:embed/linux-386
var embedFS embed.FS

View file

@ -0,0 +1,8 @@
package licenses
import "embed"
const rootDir = "embed/linux-amd64"
//go:embed all:embed/linux-amd64
var embedFS embed.FS

View file

@ -0,0 +1,8 @@
package licenses
import "embed"
const rootDir = "embed/linux-arm"
//go:embed all:embed/linux-arm
var embedFS embed.FS

View file

@ -0,0 +1,8 @@
package licenses
import "embed"
const rootDir = "embed/linux-arm64"
//go:embed all:embed/linux-arm64
var embedFS embed.FS

View file

@ -0,0 +1,8 @@
package licenses
import "embed"
const rootDir = "embed/windows-386"
//go:embed all:embed/windows-386
var embedFS embed.FS

View file

@ -0,0 +1,8 @@
package licenses
import "embed"
const rootDir = "embed/windows-amd64"
//go:embed all:embed/windows-amd64
var embedFS embed.FS

View file

@ -0,0 +1,8 @@
package licenses
import "embed"
const rootDir = "embed/windows-arm64"
//go:embed all:embed/windows-arm64
var embedFS embed.FS

View 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()
}

View 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)
})
}
}

View file

@ -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...)

View file

@ -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)
}

View file

@ -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 {

View file

@ -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})
}

Binary file not shown.

View file

@ -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 {

View file

@ -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"))

View file

@ -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)
}

View file

@ -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"`

View file

@ -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 {

View file

@ -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)

View file

@ -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)
}

View file

@ -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)

View file

@ -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) {

View file

@ -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
}

View file

@ -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 {

View file

@ -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

View file

@ -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
View 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
}

View 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")
})
}

View file

@ -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

View file

@ -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"

View file

@ -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"))

View file

@ -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")

View file

@ -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 = ""

View file

@ -186,6 +186,7 @@ func createRun(opts *CreateOptions) (err error) {
return err
}
// TODO actorIsAssignableCleanup
if issueFeatures.ActorIsAssignable {
assignees = copilotReplacer.ReplaceSlice(assignees)
}

View file

@ -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))

View file

@ -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"}}}}}`,

View file

@ -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")

View file

@ -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 }
} } } }

View file

@ -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 {

View file

@ -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)
})
}
}

View file

@ -152,6 +152,7 @@ func listRun(opts *ListOptions) error {
return err
}
fields := defaultFields
// TODO stateReasonCleanup
if features.StateReason {
fields = append(defaultFields, "stateReason")
}

View file

@ -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")
}

View file

@ -138,10 +138,14 @@
]
}
],
"totalCount": 6
"totalCount": 6,
"pageInfo": {
"hasNextPage": true,
"endCursor": "Y3Vyc29yOnYyOjg5"
}
},
"url": "https://github.com/OWNER/REPO/issues/123"
}
}
}
}
}

View file

@ -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