Merge branch 'trunk' into feature/pr-diff-exclude

This commit is contained in:
Yuvraj Angad Singh 2026-03-09 19:35:51 +05:30 committed by GitHub
commit 78891fc6e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
331 changed files with 5215 additions and 19468 deletions

View file

@ -24,7 +24,7 @@ We accept pull requests for issues labelled `help wanted`. We encourage issues a
## Building the project
Prerequisites:
- Go 1.25+
- Go 1.26+
Build with:
* Unix-like systems: `make`

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,7 +50,7 @@ jobs:
with:
go-version-file: 'go.mod'
- name: Install GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0
uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0
with:
# 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.
@ -70,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@v6
- uses: actions/upload-artifact@v7
with:
name: linux
if-no-files-found: error
@ -111,7 +111,7 @@ 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 # v6.4.0
uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0
with:
# 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.
@ -150,7 +150,7 @@ jobs:
run: |
shopt -s failglob
script/pkgmacos "$TAG_NAME"
- uses: actions/upload-artifact@v6
- uses: actions/upload-artifact@v7
with:
name: macos
if-no-files-found: error
@ -173,7 +173,7 @@ jobs:
with:
go-version-file: 'go.mod'
- name: Install GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0
uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0
with:
# 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.
@ -263,7 +263,7 @@ jobs:
Get-ChildItem -Path .\dist -Filter *.msi | ForEach-Object {
.\script\sign.ps1 $_.FullName
}
- uses: actions/upload-artifact@v6
- uses: actions/upload-artifact@v7
with:
name: windows
if-no-files-found: error
@ -281,7 +281,7 @@ jobs:
- name: Checkout cli/cli
uses: actions/checkout@v6
- name: Merge built artifacts
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
- name: Checkout documentation site
uses: actions/checkout@v6
with:
@ -334,7 +334,7 @@ jobs:
rpmsign --addsign dist/*.rpm
- name: Attest release artifacts
if: inputs.environment == 'production'
uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-path: "dist/gh_*"
create-storage-record: false # (default: true)

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:
@ -48,18 +48,18 @@ jobs:
- name: golangci-lint
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
with:
version: v2.6.0
version: v2.11.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/v2@3e084b0caf710f7bfead967567539214f598c0a2 # v2.0.1
make licenses-check
# Discover vulnerabilities within Go standard libraries used to build GitHub CLI using govulncheck.
@ -77,7 +77,7 @@ jobs:
# `govulncheck` exits unsuccessfully if vulnerabilities are found, providing results in stdout.
# See https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck#hdr-Exit_codes for more information on exit codes.
#
# On go1.25, To make `-mode binary` work we need to make sure the binary is built with `go build -buildvcs=false`
# On go1.25+, To make `-mode binary` work we need to make sure the binary is built with `go build -buildvcs=false`
# Since our builds do not use `-buildvcs=false`, we run in source mode here instead.
- name: Check Go vulnerabilities
run: |

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

@ -20,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
@ -33,6 +36,10 @@ builds:
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:
@ -42,6 +49,9 @@ builds:
goos: [windows]
goarch: ["386", amd64, arm64]
hooks:
pre:
- cmd: bash ./script/licenses {{ .Os }} {{ .Arch }}
output: true
post:
- cmd: pwsh .\script\sign.ps1 '{{ .Path }}'
output: true

View file

@ -74,7 +74,7 @@ endif
## Install/uninstall tasks are here for use on *nix platform. On Windows, there is no equivalent.
DESTDIR :=
prefix := /usr/local
prefix ?= /usr/local
bindir := ${prefix}/bin
datadir := ${prefix}/share
mandir := ${datadir}/man
@ -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,39 @@ 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()
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 +304,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

@ -129,7 +129,7 @@ func (c Comment) Identifier() string {
}
func (c Comment) AuthorLogin() string {
return c.Author.Login
return c.Author.DisplayName()
}
func (c Comment) Association() string {

View file

@ -234,6 +234,11 @@ type Author struct {
Login string
}
// DisplayName returns a user-friendly name via actorDisplayName.
func (a Author) DisplayName() string {
return actorDisplayName("", a.Login, a.Name)
}
func (author Author) MarshalJSON() ([]byte, error) {
if author.ID == "" {
return json.Marshal(map[string]interface{}{
@ -260,6 +265,11 @@ type CommentAuthor struct {
// } `graphql:"... on User"`
}
// DisplayName returns a user-friendly name via actorDisplayName.
func (a CommentAuthor) DisplayName() string {
return actorDisplayName("", a.Login, "")
}
// IssueCreate creates an issue in a GitHub repository
func IssueCreate(client *Client, repo *Repository, params map[string]interface{}) (*Issue, error) {
query := `

View file

@ -1,12 +1,9 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/cli/cli/v2/internal/ghrepo"
@ -296,68 +293,10 @@ type PullRequestCommitCommit struct {
}
type PullRequestFile struct {
Path string `json:"path"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
}
type ReviewRequests struct {
Nodes []struct {
RequestedReviewer RequestedReviewer
}
}
type RequestedReviewer struct {
TypeName string `json:"__typename"`
Login string `json:"login"`
Name string `json:"name"`
Slug string `json:"slug"`
Organization struct {
Login string `json:"login"`
} `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)
}
return r.Login
}
// 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))
for i, r := range r.Nodes {
logins[i] = r.RequestedReviewer.LoginOrSlug()
}
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
Path string `json:"path"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
ChangeType string `json:"changeType"`
}
func (pr PullRequest) HeadLabel() string {
@ -383,25 +322,6 @@ func (pr PullRequest) IsOpen() bool {
return pr.State == "OPEN"
}
type PullRequestReviewStatus struct {
ChangesRequested bool
Approved bool
ReviewRequired bool
}
func (pr *PullRequest) ReviewStatus() PullRequestReviewStatus {
var status PullRequestReviewStatus
switch pr.ReviewDecision {
case "CHANGES_REQUESTED":
status.ChangesRequested = true
case "APPROVED":
status.Approved = true
case "REVIEW_REQUIRED":
status.ReviewRequired = true
}
return status
}
type PullRequestChecksStatus struct {
Pending int
Failing int
@ -541,18 +461,6 @@ func parseCheckStatusFromCheckConclusionState(state CheckConclusionState) checkS
}
}
func (pr *PullRequest) DisplayableReviews() PullRequestReviews {
published := []PullRequestReview{}
for _, prr := range pr.Reviews.Nodes {
//Dont display pending reviews
//Dont display commenting reviews without top level comment body
if prr.State != "PENDING" && !(prr.State == "COMMENTED" && prr.Body == "") {
published = append(published, prr)
}
}
return PullRequestReviews{Nodes: published, TotalCount: len(published)}
}
// CreatePullRequest creates a pull request in a GitHub repository
func CreatePullRequest(client *Client, repo *Repository, params map[string]interface{}) (*PullRequest, error) {
query := `
@ -616,29 +524,44 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter
}
}
// reviewers are requested in yet another additional mutation
reviewParams := make(map[string]interface{})
if ids, ok := params["userReviewerIds"]; ok && !isBlank(ids) {
reviewParams["userIds"] = ids
}
if ids, ok := params["teamReviewerIds"]; ok && !isBlank(ids) {
reviewParams["teamIds"] = ids
}
// TODO requestReviewsByLoginCleanup
// Request reviewers using either login-based (github.com) or ID-based (GHES) mutation.
// The ID-based path can be removed once GHES supports requestReviewsByLogin.
userLogins, hasUserLogins := params["userReviewerLogins"].([]string)
botLogins, hasBotLogins := params["botReviewerLogins"].([]string)
teamSlugs, hasTeamSlugs := params["teamReviewerSlugs"].([]string)
//TODO: How much work to extract this into own method and use for create and edit?
if len(reviewParams) > 0 {
reviewQuery := `
if hasUserLogins || hasBotLogins || hasTeamSlugs {
// Use login-based mutation (RequestReviewsByLogin) for github.com
err := RequestReviewsByLogin(client, repo, pr.ID, userLogins, botLogins, teamSlugs, true)
if err != nil {
return pr, err
}
} else {
// Use ID-based mutation (requestReviews) for GHES compatibility
reviewParams := make(map[string]interface{})
if ids, ok := params["userReviewerIds"]; ok && !isBlank(ids) {
reviewParams["userIds"] = ids
}
if ids, ok := params["teamReviewerIds"]; ok && !isBlank(ids) {
reviewParams["teamIds"] = ids
}
//TODO: How much work to extract this into own method and use for create and edit?
if len(reviewParams) > 0 {
reviewQuery := `
mutation PullRequestCreateRequestReviews($input: RequestReviewsInput!) {
requestReviews(input: $input) { clientMutationId }
}`
reviewParams["pullRequestId"] = pr.ID
reviewParams["union"] = true
variables := map[string]interface{}{
"input": reviewParams,
}
err := client.GraphQL(repo.RepoHost(), reviewQuery, variables, &result)
if err != nil {
return pr, err
reviewParams["pullRequestId"] = pr.ID
reviewParams["union"] = true
variables := map[string]interface{}{
"input": reviewParams,
}
err := client.GraphQL(repo.RepoHost(), reviewQuery, variables, &result)
if err != nil {
return pr, err
}
}
}
@ -658,145 +581,6 @@ 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
}
// The API requires empty arrays instead of null values
if users == nil {
users = []string{}
}
path := fmt.Sprintf(
"repos/%s/%s/pulls/%d/requested_reviewers",
url.PathEscape(repo.RepoOwner()),
url.PathEscape(repo.RepoName()),
prNumber,
)
body := struct {
Reviewers []string `json:"reviewers"`
TeamReviewers []string `json:"team_reviewers"`
}{
Reviewers: users,
TeamReviewers: extractTeamSlugs(teams),
}
buf := &bytes.Buffer{}
if err := json.NewEncoder(buf).Encode(body); err != nil {
return err
}
// The endpoint responds with the updated pull request object; we don't need it here.
return client.REST(repo.RepoHost(), "POST", path, buf, nil)
}
// 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
}
// The API requires empty arrays instead of null values
if users == nil {
users = []string{}
}
path := fmt.Sprintf(
"repos/%s/%s/pulls/%d/requested_reviewers",
url.PathEscape(repo.RepoOwner()),
url.PathEscape(repo.RepoName()),
prNumber,
)
body := struct {
Reviewers []string `json:"reviewers"`
TeamReviewers []string `json:"team_reviewers"`
}{
Reviewers: users,
TeamReviewers: extractTeamSlugs(teams),
}
buf := &bytes.Buffer{}
if err := json.NewEncoder(buf).Encode(body); err != nil {
return err
}
// The endpoint responds with the updated pull request object; we don't need it here.
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.
@ -889,222 +673,6 @@ func SuggestedAssignableActors(client *Client, repo ghrepo.Interface, assignable
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

@ -1,6 +1,11 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
"github.com/cli/cli/v2/internal/ghrepo"
@ -42,39 +47,12 @@ type PullRequestReview struct {
Commit Commit `json:"commit"`
}
func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *PullRequestReviewInput) error {
var mutation struct {
AddPullRequestReview struct {
ClientMutationID string
} `graphql:"addPullRequestReview(input:$input)"`
}
state := githubv4.PullRequestReviewEventComment
switch input.State {
case ReviewApprove:
state = githubv4.PullRequestReviewEventApprove
case ReviewRequestChanges:
state = githubv4.PullRequestReviewEventRequestChanges
}
body := githubv4.String(input.Body)
variables := map[string]interface{}{
"input": githubv4.AddPullRequestReviewInput{
PullRequestID: pr.ID,
Event: &state,
Body: &body,
},
}
return client.Mutate(repo.RepoHost(), "PullRequestReviewAdd", &mutation, variables)
}
func (prr PullRequestReview) Identifier() string {
return prr.ID
}
func (prr PullRequestReview) AuthorLogin() string {
return prr.Author.Login
return prr.Author.DisplayName()
}
func (prr PullRequestReview) Association() string {
@ -115,3 +93,608 @@ func (prr PullRequestReview) Reactions() ReactionGroups {
func (prr PullRequestReview) Status() string {
return prr.State
}
type PullRequestReviewStatus struct {
ChangesRequested bool
Approved bool
ReviewRequired bool
}
func (pr *PullRequest) ReviewStatus() PullRequestReviewStatus {
var status PullRequestReviewStatus
switch pr.ReviewDecision {
case "CHANGES_REQUESTED":
status.ChangesRequested = true
case "APPROVED":
status.Approved = true
case "REVIEW_REQUIRED":
status.ReviewRequired = true
}
return status
}
func (pr *PullRequest) DisplayableReviews() PullRequestReviews {
published := []PullRequestReview{}
for _, prr := range pr.Reviews.Nodes {
//Dont display pending reviews
//Dont display commenting reviews without top level comment body
if prr.State != "PENDING" && !(prr.State == "COMMENTED" && prr.Body == "") {
published = append(published, prr)
}
}
return PullRequestReviews{Nodes: published, TotalCount: len(published)}
}
type ReviewRequests struct {
Nodes []struct {
RequestedReviewer RequestedReviewer
}
}
type RequestedReviewer struct {
TypeName string `json:"__typename"`
Login string `json:"login"`
Name string `json:"name"`
Slug string `json:"slug"`
Organization struct {
Login string `json:"login"`
} `json:"organization"`
}
const teamTypeName = "Team"
const botTypeName = "Bot"
const userTypeName = "User"
func (r RequestedReviewer) LoginOrSlug() string {
if r.TypeName == teamTypeName {
return fmt.Sprintf("%s/%s", r.Organization.Login, r.Slug)
}
return r.Login
}
// DisplayName returns a user-friendly name for the reviewer via actorDisplayName.
// Teams are handled separately as "org/slug".
func (r RequestedReviewer) DisplayName() string {
if r.TypeName == teamTypeName {
return fmt.Sprintf("%s/%s", r.Organization.Login, r.Slug)
}
return actorDisplayName(r.TypeName, r.Login, r.Name)
}
func (r ReviewRequests) Logins() []string {
logins := make([]string, len(r.Nodes))
for i, r := range r.Nodes {
logins[i] = r.RequestedReviewer.LoginOrSlug()
}
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
}
// ReviewerCandidate represents a potential reviewer for a pull request.
// This can be a User, Bot, or Team. It mirrors AssignableActor but adds
// team support (teams can review but not be assigned) and drops the ID method.
// ReviewerUser and ReviewerBot are thin wrappers around AssignableUser and
// AssignableBot that satisfy this interface.
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 {
return actorDisplayName("Bot", 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() {}
func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *PullRequestReviewInput) error {
var mutation struct {
AddPullRequestReview struct {
ClientMutationID string
} `graphql:"addPullRequestReview(input:$input)"`
}
state := githubv4.PullRequestReviewEventComment
switch input.State {
case ReviewApprove:
state = githubv4.PullRequestReviewEventApprove
case ReviewRequestChanges:
state = githubv4.PullRequestReviewEventRequestChanges
}
body := githubv4.String(input.Body)
variables := map[string]interface{}{
"input": githubv4.AddPullRequestReviewInput{
PullRequestID: pr.ID,
Event: &state,
Body: &body,
},
}
return client.Mutate(repo.RepoHost(), "PullRequestReviewAdd", &mutation, variables)
}
// 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
}
// The API requires empty arrays instead of null values
if users == nil {
users = []string{}
}
path := fmt.Sprintf(
"repos/%s/%s/pulls/%d/requested_reviewers",
url.PathEscape(repo.RepoOwner()),
url.PathEscape(repo.RepoName()),
prNumber,
)
body := struct {
Reviewers []string `json:"reviewers"`
TeamReviewers []string `json:"team_reviewers"`
}{
Reviewers: users,
TeamReviewers: extractTeamSlugs(teams),
}
buf := &bytes.Buffer{}
if err := json.NewEncoder(buf).Encode(body); err != nil {
return err
}
// The endpoint responds with the updated pull request object; we don't need it here.
return client.REST(repo.RepoHost(), "POST", path, buf, nil)
}
// 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
}
// The API requires empty arrays instead of null values
if users == nil {
users = []string{}
}
path := fmt.Sprintf(
"repos/%s/%s/pulls/%d/requested_reviewers",
url.PathEscape(repo.RepoOwner()),
url.PathEscape(repo.RepoName()),
prNumber,
)
body := struct {
Reviewers []string `json:"reviewers"`
TeamReviewers []string `json:"team_reviewers"`
}{
Reviewers: users,
TeamReviewers: extractTeamSlugs(teams),
}
buf := &bytes.Buffer{}
if err := json.NewEncoder(buf).Encode(body); err != nil {
return err
}
// The endpoint responds with the updated pull request object; we don't need it here.
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 be passed without the [bot] suffix; it is appended automatically.
// Team slugs must 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)
}
// 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.
// Organization teams are fetched via repository.owner inline fragment, which
// gracefully returns empty data for personal (User-owned) repos.
// We also fetch unfiltered total counts via aliases for the "X more" display.
type responseData struct {
Node struct {
PullRequest struct {
Author struct {
Login string
}
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 {
Owner struct {
TypeName string `graphql:"__typename"`
Organization struct {
Teams struct {
Nodes []struct {
Slug string
}
} `graphql:"teams(first: 10, query: $query)"`
TeamsTotalCount struct {
TotalCount int
} `graphql:"teamsTotalCount: teams(first: 0)"`
} `graphql:"... on Organization"`
}
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)"`
}
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)
if err != nil {
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.
// Pre-seed seen with the PR author since you cannot review your own PR.
seen := make(map[string]bool)
if authorLogin := result.Node.PullRequest.Author.Login; authorLogin != "" {
seen[authorLogin] = true
}
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.Repository.Owner.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.Repository.Owner.Organization.TeamsTotalCount.TotalCount
return candidates, moreResults, nil
}
// SuggestedReviewerActorsForRepo fetches potential reviewers for a repository.
// Unlike SuggestedReviewerActors, this doesn't require an existing PR - used for gh pr create.
// It combines results from two sources using a cascading quota system:
// - repository collaborators (base quota: 5)
// - organization teams (base quota: 5 + unfilled from collaborators)
//
// This ensures we show up to 10 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 SuggestedReviewerActorsForRepo(client *Client, repo ghrepo.Interface, query string) ([]ReviewerCandidate, int, error) {
type responseData struct {
Viewer struct {
Login string
}
Repository struct {
// HACK: There's no repo-level API to check Copilot reviewer eligibility,
// so we piggyback on an open PR's suggestedReviewerActors to detect
// whether Copilot is available as a reviewer for this repository.
PullRequests struct {
Nodes []struct {
SuggestedActors struct {
Nodes []struct {
Reviewer struct {
TypeName string `graphql:"__typename"`
Bot struct {
Login string
} `graphql:"... on Bot"`
}
}
} `graphql:"suggestedReviewerActors(first: 10)"`
}
} `graphql:"pullRequests(first: 1, states: [OPEN])"`
Owner struct {
TypeName string `graphql:"__typename"`
Organization struct {
Teams struct {
Nodes []struct {
Slug string
}
} `graphql:"teams(first: 10, query: $query)"`
TeamsTotalCount struct {
TotalCount int
} `graphql:"teamsTotalCount: teams(first: 0)"`
} `graphql:"... on Organization"`
}
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)"`
}
variables := map[string]interface{}{
"query": githubv4.String(query),
"owner": githubv4.String(repo.RepoOwner()),
"name": githubv4.String(repo.RepoName()),
}
var result responseData
err := client.Query(repo.RepoHost(), "SuggestedReviewerActorsForRepo", &result, variables)
if err != nil {
return nil, 0, err
}
// Build candidates using cascading quota logic.
// Pre-seed seen with the current user to exclude them from results
// since you cannot review your own PR.
seen := make(map[string]bool)
if result.Viewer.Login != "" {
seen[result.Viewer.Login] = true
}
var candidates []ReviewerCandidate
const baseQuota = 5
// Check for Copilot availability from open PR's suggested reviewers
for _, pr := range result.Repository.PullRequests.Nodes {
for _, actor := range pr.SuggestedActors.Nodes {
if actor.Reviewer.TypeName == "Bot" && actor.Reviewer.Bot.Login == CopilotReviewerLogin {
candidates = append(candidates, NewReviewerBot(CopilotReviewerLogin))
seen[CopilotReviewerLogin] = true
break
}
}
}
// Collaborators
collaboratorsAdded := 0
for _, c := range result.Repository.Collaborators.Nodes {
if collaboratorsAdded >= baseQuota {
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 + (baseQuota - collaboratorsAdded)
teamsAdded := 0
ownerName := repo.RepoOwner()
for _, t := range result.Repository.Owner.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.Repository.Owner.Organization.TeamsTotalCount.TotalCount
return candidates, moreResults, 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
}

View file

@ -160,18 +160,16 @@ func mockReviewerResponse(suggestions, collabs, teams, totalCollabs, totalTeams
return fmt.Sprintf(`{
"data": {
"node": {"suggestedReviewerActors": {"nodes": [%s]}},
"node": {"author": {"login": "testauthor"}, "suggestedReviewerActors": {"nodes": [%s]}},
"repository": {
"owner": {"__typename": "Organization", "teams": {"nodes": [%s]}, "teamsTotalCount": {"totalCount": %d}},
"collaborators": {"nodes": [%s]},
"collaboratorsTotalCount": {"totalCount": %d}
},
"organization": {
"teams": {"nodes": [%s]},
"teamsTotalCount": {"totalCount": %d}
}
}
}`, strings.Join(suggestionNodes, ","), strings.Join(collabNodes, ","), totalCollabs,
strings.Join(teamNodes, ","), totalTeams)
}`, strings.Join(suggestionNodes, ","),
strings.Join(teamNodes, ","), totalTeams,
strings.Join(collabNodes, ","), totalCollabs)
}
func TestSuggestedReviewerActors(t *testing.T) {
@ -235,18 +233,15 @@ func TestSuggestedReviewerActors(t *testing.T) {
httpmock.GraphQL(`query SuggestedReviewerActors\b`),
httpmock.StringResponse(`{
"data": {
"node": {"suggestedReviewerActors": {"nodes": [
"node": {"author": {"login": "testauthor"}, "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": {
"owner": {"__typename": "Organization", "teams": {"nodes": [{"slug": "team1"}]}, "teamsTotalCount": {"totalCount": 3}},
"collaborators": {"nodes": [{"login": "c1", "name": "C1"}]},
"collaboratorsTotalCount": {"totalCount": 5}
},
"organization": {
"teams": {"nodes": [{"slug": "team1"}]},
"teamsTotalCount": {"totalCount": 3}
}
}
}`))
@ -255,6 +250,30 @@ func TestSuggestedReviewerActors(t *testing.T) {
expectedLogins: []string{"s1", "s2", "c1", "OWNER/team1"},
expectedMore: 8,
},
{
name: "author excluded from collaborators",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query SuggestedReviewerActors\b`),
httpmock.StringResponse(`{
"data": {
"node": {"author": {"login": "theauthor"}, "suggestedReviewerActors": {"nodes": [
{"isAuthor": false, "reviewer": {"__typename": "User", "login": "s1", "name": "S1"}}
]}},
"repository": {
"collaborators": {"nodes": [
{"login": "theauthor", "name": "The Author"},
{"login": "c1", "name": "C1"}
]},
"collaboratorsTotalCount": {"totalCount": 5}
}
}
}`))
},
expectedCount: 2,
expectedLogins: []string{"s1", "c1"},
expectedMore: 5,
},
{
name: "deduplication across sources",
httpStubs: func(reg *httpmock.Registry) {
@ -263,19 +282,16 @@ func TestSuggestedReviewerActors(t *testing.T) {
httpmock.GraphQL(`query SuggestedReviewerActors\b`),
httpmock.StringResponse(`{
"data": {
"node": {"suggestedReviewerActors": {"nodes": [
"node": {"author": {"login": "testauthor"}, "suggestedReviewerActors": {"nodes": [
{"isAuthor": false, "reviewer": {"__typename": "User", "login": "shareduser", "name": "Shared"}}
]}},
"repository": {
"owner": {"__typename": "Organization", "teams": {"nodes": [{"slug": "team1"}]}, "teamsTotalCount": {"totalCount": 5}},
"collaborators": {"nodes": [
{"login": "shareduser", "name": "Shared"},
{"login": "c1", "name": "C1"}
]},
"collaboratorsTotalCount": {"totalCount": 10}
},
"organization": {
"teams": {"nodes": [{"slug": "team1"}]},
"teamsTotalCount": {"totalCount": 5}
}
}
}`))
@ -291,16 +307,15 @@ func TestSuggestedReviewerActors(t *testing.T) {
httpmock.GraphQL(`query SuggestedReviewerActors\b`),
httpmock.StringResponse(`{
"data": {
"node": {"suggestedReviewerActors": {"nodes": [
"node": {"author": {"login": "testauthor"}, "suggestedReviewerActors": {"nodes": [
{"isAuthor": false, "reviewer": {"__typename": "User", "login": "s1", "name": "S1"}}
]}},
"repository": {
"owner": {"__typename": "User"},
"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,
@ -314,17 +329,14 @@ func TestSuggestedReviewerActors(t *testing.T) {
httpmock.GraphQL(`query SuggestedReviewerActors\b`),
httpmock.StringResponse(`{
"data": {
"node": {"suggestedReviewerActors": {"nodes": [
"node": {"author": {"login": "testauthor"}, "suggestedReviewerActors": {"nodes": [
{"isAuthor": false, "reviewer": {"__typename": "Bot", "login": "copilot-pull-request-reviewer"}},
{"isAuthor": false, "reviewer": {"__typename": "User", "login": "s1", "name": "S1"}}
]}},
"repository": {
"owner": {"__typename": "Organization", "teams": {"nodes": []}, "teamsTotalCount": {"totalCount": 0}},
"collaborators": {"nodes": []},
"collaboratorsTotalCount": {"totalCount": 5}
},
"organization": {
"teams": {"nodes": []},
"teamsTotalCount": {"totalCount": 0}
}
}
}`))
@ -362,3 +374,195 @@ func TestSuggestedReviewerActors(t *testing.T) {
})
}
}
// mockReviewerResponseForRepo generates a GraphQL response for SuggestedReviewerActorsForRepo tests.
// It creates collaborators (c1, c2...) and teams (team1, team2...).
func mockReviewerResponseForRepo(collabs, teams, totalCollabs, totalTeams int) string {
return mockReviewerResponseForRepoWithCopilot(collabs, teams, totalCollabs, totalTeams, false)
}
// mockReviewerResponseForRepoWithCopilot generates a GraphQL response for SuggestedReviewerActorsForRepo tests.
// If copilotAvailable is true, includes Copilot in the first open PR's suggested reviewers.
func mockReviewerResponseForRepoWithCopilot(collabs, teams, totalCollabs, totalTeams int, copilotAvailable bool) string {
var collabNodes, teamNodes []string
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))
}
pullRequestsJSON := `"pullRequests": {"nodes": []}`
if copilotAvailable {
pullRequestsJSON = `"pullRequests": {"nodes": [{"suggestedReviewerActors": {"nodes": [{"reviewer": {"__typename": "Bot", "login": "copilot-pull-request-reviewer"}}]}}]}`
}
return fmt.Sprintf(`{
"data": {
"viewer": {"login": "testuser"},
"repository": {
%s,
"owner": {"__typename": "Organization", "teams": {"nodes": [%s]}, "teamsTotalCount": {"totalCount": %d}},
"collaborators": {"nodes": [%s]},
"collaboratorsTotalCount": {"totalCount": %d}
}
}
}`, pullRequestsJSON,
strings.Join(teamNodes, ","), totalTeams,
strings.Join(collabNodes, ","), totalCollabs)
}
func TestSuggestedReviewerActorsForRepo(t *testing.T) {
tests := []struct {
name string
httpStubs func(*httpmock.Registry)
expectedCount int
expectedLogins []string
expectedMore int
expectError bool
}{
{
name: "both sources plentiful - 5 each from cascading quota",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`),
httpmock.StringResponse(mockReviewerResponseForRepo(6, 6, 20, 10)))
},
expectedCount: 10,
expectedLogins: []string{"c1", "c2", "c3", "c4", "c5", "OWNER/team1", "OWNER/team2", "OWNER/team3", "OWNER/team4", "OWNER/team5"},
expectedMore: 30,
},
{
name: "few collaborators - teams fill gap",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`),
httpmock.StringResponse(mockReviewerResponseForRepo(2, 10, 2, 15)))
},
expectedCount: 10,
expectedLogins: []string{"c1", "c2", "OWNER/team1", "OWNER/team2", "OWNER/team3", "OWNER/team4", "OWNER/team5", "OWNER/team6", "OWNER/team7", "OWNER/team8"},
expectedMore: 17,
},
{
name: "no collaborators - teams only",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`),
httpmock.StringResponse(mockReviewerResponseForRepo(0, 10, 0, 20)))
},
expectedCount: 10,
expectedLogins: []string{"OWNER/team1", "OWNER/team2", "OWNER/team3", "OWNER/team4", "OWNER/team5", "OWNER/team6", "OWNER/team7", "OWNER/team8", "OWNER/team9", "OWNER/team10"},
expectedMore: 20,
},
{
name: "personal repo - no organization teams",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`),
httpmock.StringResponse(`{
"data": {
"repository": {
"pullRequests": {"nodes": []},
"owner": {"__typename": "User"},
"collaborators": {"nodes": [{"login": "c1", "name": "C1"}]},
"collaboratorsTotalCount": {"totalCount": 3}
}
}
}`))
},
expectedCount: 1,
expectedLogins: []string{"c1"},
expectedMore: 3,
},
{
name: "empty repo",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`),
httpmock.StringResponse(mockReviewerResponseForRepo(0, 0, 0, 0)))
},
expectedCount: 0,
expectedLogins: []string{},
expectedMore: 0,
},
{
name: "copilot available - prepended to candidates",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`),
httpmock.StringResponse(mockReviewerResponseForRepoWithCopilot(3, 2, 5, 5, true)))
},
expectedCount: 6,
expectedLogins: []string{"copilot-pull-request-reviewer", "c1", "c2", "c3", "OWNER/team1", "OWNER/team2"},
expectedMore: 10,
},
{
name: "copilot not available - not included",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`),
httpmock.StringResponse(mockReviewerResponseForRepoWithCopilot(3, 2, 5, 5, false)))
},
expectedCount: 5,
expectedLogins: []string{"c1", "c2", "c3", "OWNER/team1", "OWNER/team2"},
expectedMore: 10,
},
{
name: "viewer excluded from collaborators",
httpStubs: func(reg *httpmock.Registry) {
// c1 matches the viewer login "testuser" won't be in this fixture,
// but we can craft a response where the viewer login matches a collaborator.
reg.Register(
httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`),
httpmock.StringResponse(`{
"data": {
"viewer": {"login": "c2"},
"repository": {
"pullRequests": {"nodes": []},
"owner": {"__typename": "Organization", "teams": {"nodes": []}, "teamsTotalCount": {"totalCount": 0}},
"collaborators": {"nodes": [
{"login": "c1", "name": "C1"},
{"login": "c2", "name": "C2"},
{"login": "c3", "name": "C3"}
]},
"collaboratorsTotalCount": {"totalCount": 3}
}
}
}`))
},
expectedCount: 2,
expectedLogins: []string{"c1", "c3"},
expectedMore: 3,
},
}
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 := SuggestedReviewerActorsForRepo(client, repo, "")
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

@ -147,6 +147,11 @@ type GitHubUser struct {
DatabaseID int64 `json:"databaseId"`
}
// DisplayName returns a user-friendly name via actorDisplayName.
func (u GitHubUser) DisplayName() string {
return actorDisplayName("", u.Login, u.Name)
}
// Actor is a superset of User and Bot, among others.
// At the time of writing, some of these fields
// are not directly supported by the Actor type and
@ -1087,6 +1092,24 @@ const CopilotAssigneeLogin = "copilot-swe-agent"
const CopilotReviewerLogin = "copilot-pull-request-reviewer"
const CopilotActorName = "Copilot"
// actorDisplayName returns a user-friendly display name for any actor.
// It handles bots (e.g. Copilot → "Copilot (AI)"), users with names
// ("login (Name)"), and falls back to just login. Empty typeName is
// treated as a possible bot or user — the login is checked against
// known bot logins first.
func actorDisplayName(typeName, login, name string) string {
if login == CopilotReviewerLogin || login == CopilotAssigneeLogin || login == CopilotActorName {
return fmt.Sprintf("%s (AI)", CopilotActorName)
}
if typeName == botTypeName {
return login
}
if name != "" {
return fmt.Sprintf("%s (%s)", login, name)
}
return login
}
type AssignableActor interface {
DisplayName() string
ID() string
@ -1110,12 +1133,9 @@ func NewAssignableUser(id, login, name string) AssignableUser {
}
}
// DisplayName returns a formatted string that uses Login and Name to be displayed e.g. 'Login (Name)' or 'Login'
// DisplayName returns a user-friendly name via actorDisplayName.
func (u AssignableUser) DisplayName() string {
if u.name != "" {
return fmt.Sprintf("%s (%s)", u.login, u.name)
}
return u.login
return actorDisplayName(userTypeName, u.login, u.name)
}
func (u AssignableUser) ID() string {
@ -1145,10 +1165,7 @@ func NewAssignableBot(id, login string) AssignableBot {
}
func (b AssignableBot) DisplayName() string {
if b.login == CopilotAssigneeLogin {
return fmt.Sprintf("%s (AI)", CopilotActorName)
}
return b.Login()
return actorDisplayName(botTypeName, b.login, "")
}
func (b AssignableBot) ID() string {

View file

@ -563,6 +563,31 @@ func TestDisplayName(t *testing.T) {
}
}
func TestActorDisplayName(t *testing.T) {
tests := []struct {
name string
typeName string
login string
actName string
want string
}{
{name: "copilot reviewer", typeName: "Bot", login: "copilot-pull-request-reviewer", want: "Copilot (AI)"},
{name: "copilot assignee", typeName: "Bot", login: "copilot-swe-agent", want: "Copilot (AI)"},
{name: "copilot without typename", typeName: "", login: "copilot-pull-request-reviewer", want: "Copilot (AI)"},
{name: "copilot actor name login", typeName: "", login: "Copilot", want: "Copilot (AI)"},
{name: "regular bot", typeName: "Bot", login: "dependabot", want: "dependabot"},
{name: "user with name", typeName: "User", login: "octocat", actName: "Mona Lisa", want: "octocat (Mona Lisa)"},
{name: "user without name", typeName: "User", login: "octocat", want: "octocat"},
{name: "unknown type with name", typeName: "", login: "octocat", actName: "Mona Lisa", want: "octocat (Mona Lisa)"},
{name: "empty login", typeName: "", login: "", want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, actorDisplayName(tt.typeName, tt.login, tt.actName))
})
}
}
func TestRepoExists(t *testing.T) {
tests := []struct {
name string

View file

@ -148,7 +148,8 @@ var prFiles = shortenQuery(`
nodes {
additions,
deletions,
path
path,
changeType
}
}
`)
@ -388,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

@ -1,6 +1,6 @@
# Installation from source
1. Verify that you have Go 1.25+ installed
1. Verify that you have Go 1.26+ installed
```sh
$ go version

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

@ -1,6 +1,6 @@
# Installation from source
1. Verify that you have Go 1.25+ installed
1. Verify that you have Go 1.26+ installed
```sh
$ go version

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

29
go.mod
View file

@ -1,6 +1,6 @@
module github.com/cli/cli/v2
go 1.25.6
go 1.26.1
require (
github.com/AlecAivazis/survey/v2 v2.3.7
@ -21,7 +21,7 @@ require (
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/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
@ -50,11 +50,11 @@ require (
github.com/vmihailenco/msgpack/v5 v5.4.1
github.com/yuin/goldmark v1.7.16
github.com/zalando/go-keyring v0.2.6
golang.org/x/crypto v0.46.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.78.0
golang.org/x/term v0.40.0
golang.org/x/text v0.34.0
google.golang.org/grpc v1.79.2
google.golang.org/protobuf v1.36.11
gopkg.in/h2non/gock.v1 v1.1.2
gopkg.in/yaml.v3 v3.0.1
@ -72,6 +72,7 @@ require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/blang/semver v3.5.1+incompatible // indirect
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.10 // indirect
github.com/charmbracelet/colorprofile v0.3.1 // indirect
@ -88,7 +89,7 @@ require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/docker/cli v29.0.3+incompatible // indirect
github.com/docker/cli v29.2.0+incompatible // indirect
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
@ -173,14 +174,14 @@ require (
github.com/yuin/goldmark-emoji v1.0.6 // indirect
go.mongodb.org/mongo-driver v1.17.6 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.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/mod v0.30.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/tools v0.39.0 // 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
)

62
go.sum
View file

@ -102,6 +102,8 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
@ -177,8 +179,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docker/cli v29.0.3+incompatible h1:8J+PZIcF2xLd6h5sHPsp5pvvJA+Sr2wGQxHkRl53a1E=
github.com/docker/cli v29.0.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM=
github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8=
@ -193,8 +195,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
@ -548,16 +550,16 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.step.sm/crypto v0.74.0 h1:/APBEv45yYR4qQFg47HA8w1nesIGcxh44pGyQNw6JRA=
go.step.sm/crypto v0.74.0/go.mod h1:UoXqCAJjjRgzPte0Llaqen7O9P7XjPmgjgTHQGkKCDk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@ -572,20 +574,20 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -604,29 +606,29 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
@ -638,8 +640,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View file

@ -264,7 +264,11 @@ func (fwd *CodespacesPortForwarder) UpdatePortVisibility(ctx context.Context, re
}
// Delete the existing tunnel port to update
err = fwd.connection.TunnelManager.DeleteTunnelPort(ctx, fwd.connection.Tunnel, uint16(remotePort), fwd.connection.Options)
port, err := convertIntToUint16(remotePort)
if err != nil {
return fmt.Errorf("error converting port: %w", err)
}
err = fwd.connection.TunnelManager.DeleteTunnelPort(ctx, fwd.connection.Tunnel, port, fwd.connection.Options)
if err != nil {
return fmt.Errorf("error deleting tunnel port: %w", err)
}

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,17 +16,17 @@ 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 {
StateReason bool
ActorIsAssignable bool
}
var allIssueFeatures = IssueFeatures{
StateReason: true,
ActorIsAssignable: true,
}
@ -57,6 +57,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 +108,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
@ -115,32 +135,9 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) {
return allIssueFeatures, nil
}
features := IssueFeatures{
StateReason: false,
return IssueFeatures{
ActorIsAssignable: false, // replaceActorsForAssignable GraphQL mutation unavailable on GHES
}
var featureDetection struct {
Issue struct {
Fields []struct {
Name string
} `graphql:"fields(includeDeprecated: true)"`
} `graphql:"Issue: __type(name: \"Issue\")"`
}
gql := api.NewClientFromHTTP(d.httpClient)
err := gql.Query(d.host, "Issue_fields", &featureDetection, nil)
if err != nil {
return features, err
}
for _, field := range featureDetection.Issue.Fields {
if field.Name == "stateReason" {
features.StateReason = true
}
}
return features, nil
}, nil
}
func (d *detector) PullRequestFeatures() (PullRequestFeatures, error) {
@ -268,6 +265,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 +429,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

@ -23,7 +23,6 @@ func TestIssueFeatures(t *testing.T) {
name: "github.com",
hostname: "github.com",
wantFeatures: IssueFeatures{
StateReason: true,
ActorIsAssignable: true,
},
wantErr: false,
@ -32,43 +31,24 @@ func TestIssueFeatures(t *testing.T) {
name: "ghec data residency (ghe.com)",
hostname: "stampname.ghe.com",
wantFeatures: IssueFeatures{
StateReason: true,
ActorIsAssignable: true,
},
wantErr: false,
},
{
name: "GHE empty response",
name: "GHE",
hostname: "git.my.org",
queryResponse: map[string]string{
`query Issue_fields\b`: `{"data": {}}`,
},
wantFeatures: IssueFeatures{
StateReason: false,
ActorIsAssignable: false,
},
wantErr: false,
},
{
name: "GHE has state reason field",
hostname: "git.my.org",
queryResponse: map[string]string{
`query Issue_fields\b`: heredoc.Doc(`
{ "data": { "Issue": { "fields": [
{"name": "stateReason"}
] } } }
`),
},
wantFeatures: IssueFeatures{
StateReason: true,
},
wantErr: false,
},
}
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 +566,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 +762,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

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

@ -170,6 +170,7 @@ func runCopilot(opts *CopilotOptions) error {
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 {

View file

@ -268,7 +268,7 @@ func (m *Manager) Install(repo ghrepo.Interface, target string) error {
return err
}
if !hs {
return fmt.Errorf("extension is not installable: no usable release artifact or script found in %s", repo)
return fmt.Errorf("extension is not installable: no usable release artifact or script found in %s", ghrepo.FullName(repo))
}
return m.installGit(repo, target)

View file

@ -867,6 +867,39 @@ func TestManager_Install_git(t *testing.T) {
assert.NoDirExistsf(t, extensionUpdatePath, "update directory should be removed")
}
func TestManager_Install_not_installable(t *testing.T) {
dataDir := t.TempDir()
updateDir := t.TempDir()
reg := httpmock.Registry{}
defer reg.Verify(t)
client := http.Client{Transport: &reg}
ios, _, _, _ := iostreams.Test()
m := newTestManager(dataDir, updateDir, &client, nil, ios)
reg.Register(
httpmock.REST("GET", "repos/owner/gh-some-ext/releases/latest"),
httpmock.JSONResponse(
release{
Assets: []releaseAsset{
{
Name: "not-a-binary",
APIURL: "https://example.com/release/cool",
},
},
}))
reg.Register(
httpmock.REST("GET", "repos/owner/gh-some-ext/contents/gh-some-ext"),
httpmock.StatusStringResponse(404, "not found"))
repo := ghrepo.New("owner", "gh-some-ext")
err := m.Install(repo, "")
assert.EqualError(t, err, "extension is not installable: no usable release artifact or script found in owner/gh-some-ext")
}
func TestManager_Install_git_pinned(t *testing.T) {
dataDir := t.TempDir()
updateDir := t.TempDir()

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

@ -3,10 +3,9 @@ package close
import (
"fmt"
"net/http"
"time"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/api"
fd "github.com/cli/cli/v2/internal/featuredetection"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmd/issue/shared"
prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared"
@ -24,8 +23,7 @@ type CloseOptions struct {
IssueNumber int
Comment string
Reason string
Detector fd.Detector
DuplicateOf string
}
func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Command {
@ -37,7 +35,20 @@ func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Comm
cmd := &cobra.Command{
Use: "close {<number> | <url>}",
Short: "Close issue",
Args: cobra.ExactArgs(1),
Example: heredoc.Doc(`
# Close issue
$ gh issue close 123
# Close issue and add a closing comment
$ gh issue close 123 --comment "Closing this issue"
# Close issue as a duplicate of issue #456
$ gh issue close 123 --duplicate-of 456
# Close issue as not planned
$ gh issue close 123 --reason "not planned"
`),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
issueNumber, baseRepo, err := shared.ParseIssueFromArg(args[0])
if err != nil {
@ -55,6 +66,13 @@ func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Comm
}
opts.IssueNumber = issueNumber
if opts.DuplicateOf != "" {
if opts.Reason == "" {
opts.Reason = "duplicate"
} else if opts.Reason != "duplicate" {
return cmdutil.FlagErrorf("`--duplicate-of` can only be used with `--reason duplicate`")
}
}
if runF != nil {
return runF(opts)
@ -64,13 +82,22 @@ func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Comm
}
cmd.Flags().StringVarP(&opts.Comment, "comment", "c", "", "Leave a closing comment")
cmdutil.StringEnumFlag(cmd, &opts.Reason, "reason", "r", "", []string{"completed", "not planned"}, "Reason for closing")
cmdutil.StringEnumFlag(cmd, &opts.Reason, "reason", "r", "", []string{"completed", "not planned", "duplicate"}, "Reason for closing")
cmd.Flags().StringVar(&opts.DuplicateOf, "duplicate-of", "", "Mark as duplicate of another issue by number or URL")
return cmd
}
func closeRun(opts *CloseOptions) error {
cs := opts.IO.ColorScheme()
closeReason := opts.Reason
if opts.DuplicateOf != "" {
if closeReason == "" {
closeReason = "duplicate"
} else if closeReason != "duplicate" {
return cmdutil.FlagErrorf("`--duplicate-of` can only be used with `--reason duplicate`")
}
}
httpClient, err := opts.HttpClient()
if err != nil {
@ -92,6 +119,32 @@ func closeRun(opts *CloseOptions) error {
return nil
}
var duplicateIssueID string
if opts.DuplicateOf != "" {
if issue.IsPullRequest() {
return cmdutil.FlagErrorf("`--duplicate-of` is only supported for issues")
}
duplicateIssueNumber, duplicateRepo, err := shared.ParseIssueFromArg(opts.DuplicateOf)
if err != nil {
return cmdutil.FlagErrorf("invalid value for `--duplicate-of`: %v", err)
}
duplicateIssueRepo := baseRepo
if parsedRepo, present := duplicateRepo.Value(); present {
duplicateIssueRepo = parsedRepo
}
if ghrepo.IsSame(baseRepo, duplicateIssueRepo) && issue.Number == duplicateIssueNumber {
return cmdutil.FlagErrorf("`--duplicate-of` cannot reference the current issue")
}
duplicateIssue, err := shared.FindIssueOrPR(httpClient, duplicateIssueRepo, duplicateIssueNumber, []string{"id"})
if err != nil {
return err
}
if duplicateIssue.IsPullRequest() {
return cmdutil.FlagErrorf("`--duplicate-of` must reference an issue")
}
duplicateIssueID = duplicateIssue.ID
}
if opts.Comment != "" {
commentOpts := &prShared.CommentableOptions{
Body: opts.Comment,
@ -108,7 +161,7 @@ func closeRun(opts *CloseOptions) error {
}
}
err = apiClose(httpClient, baseRepo, issue, opts.Detector, opts.Reason)
err = apiClose(httpClient, baseRepo, issue, closeReason, duplicateIssueID)
if err != nil {
return err
}
@ -118,31 +171,18 @@ func closeRun(opts *CloseOptions) error {
return nil
}
func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue, detector fd.Detector, reason string) error {
func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue, reason string, duplicateIssueID string) error {
if issue.IsPullRequest() {
return api.PullRequestClose(httpClient, repo, issue.ID)
}
if reason != "" {
if detector == nil {
cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24)
detector = fd.NewDetector(cachedClient, repo.RepoHost())
}
features, err := detector.IssueFeatures()
if err != nil {
return err
}
if !features.StateReason {
// If StateReason is not supported silently close issue without setting StateReason.
reason = ""
}
}
switch reason {
case "":
// If no reason is specified do not set it.
case "not planned":
reason = "NOT_PLANNED"
case "duplicate":
reason = "DUPLICATE"
default:
reason = "COMPLETED"
}
@ -157,8 +197,9 @@ func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue,
variables := map[string]interface{}{
"input": CloseIssueInput{
IssueID: issue.ID,
StateReason: reason,
IssueID: issue.ID,
StateReason: reason,
DuplicateIssueID: duplicateIssueID,
},
}
@ -167,6 +208,7 @@ func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue,
}
type CloseIssueInput struct {
IssueID string `json:"issueId"`
StateReason string `json:"stateReason,omitempty"`
IssueID string `json:"issueId"`
StateReason string `json:"stateReason,omitempty"`
DuplicateIssueID string `json:"duplicateIssueId,omitempty"`
}

View file

@ -5,7 +5,6 @@ import (
"net/http"
"testing"
fd "github.com/cli/cli/v2/internal/featuredetection"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmd/issue/argparsetest"
"github.com/cli/cli/v2/pkg/cmdutil"
@ -44,6 +43,29 @@ func TestNewCmdClose(t *testing.T) {
Reason: "not planned",
},
},
{
name: "reason duplicate",
input: "123 --reason duplicate",
output: CloseOptions{
IssueNumber: 123,
Reason: "duplicate",
},
},
{
name: "duplicate of sets duplicate reason",
input: "123 --duplicate-of 456",
output: CloseOptions{
IssueNumber: 123,
Reason: "duplicate",
DuplicateOf: "456",
},
},
{
name: "duplicate of with invalid reason",
input: "123 --reason completed --duplicate-of 456",
wantErr: true,
errMsg: "`--duplicate-of` can only be used with `--reason duplicate`",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -74,6 +96,7 @@ func TestNewCmdClose(t *testing.T) {
assert.Equal(t, tt.output.IssueNumber, gotOpts.IssueNumber)
assert.Equal(t, tt.output.Comment, gotOpts.Comment)
assert.Equal(t, tt.output.Reason, gotOpts.Reason)
assert.Equal(t, tt.output.DuplicateOf, gotOpts.DuplicateOf)
if tt.expectedBaseRepo != nil {
baseRepo, err := gotOpts.BaseRepo()
require.NoError(t, err)
@ -161,7 +184,6 @@ func TestCloseRun(t *testing.T) {
opts: &CloseOptions{
IssueNumber: 13,
Reason: "not planned",
Detector: &fd.EnabledDetectorMock{},
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
@ -185,11 +207,10 @@ func TestCloseRun(t *testing.T) {
wantStderr: "✓ Closed issue OWNER/REPO#13 (The title of the issue)\n",
},
{
name: "close issue with reason when reason is not supported",
name: "close issue with duplicate reason",
opts: &CloseOptions{
IssueNumber: 13,
Reason: "not planned",
Detector: &fd.DisabledDetectorMock{},
Reason: "duplicate",
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
@ -204,13 +225,115 @@ func TestCloseRun(t *testing.T) {
httpmock.GraphQL(`mutation IssueClose\b`),
httpmock.GraphQLMutation(`{"id": "THE-ID"}`,
func(inputs map[string]interface{}) {
assert.Equal(t, 1, len(inputs))
assert.Equal(t, 2, len(inputs))
assert.Equal(t, "THE-ID", inputs["issueId"])
assert.Equal(t, "DUPLICATE", inputs["stateReason"])
}),
)
},
wantStderr: "✓ Closed issue OWNER/REPO#13 (The title of the issue)\n",
},
{
name: "close issue as duplicate of another issue",
opts: &CloseOptions{
IssueNumber: 13,
DuplicateOf: "99",
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"}
} } }`),
)
reg.Register(
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issue": { "id": "DUPLICATE-ID", "number": 99}
} } }`),
)
reg.Register(
httpmock.GraphQL(`mutation IssueClose\b`),
httpmock.GraphQLMutation(`{"id": "THE-ID"}`,
func(inputs map[string]interface{}) {
assert.Equal(t, 3, len(inputs))
assert.Equal(t, "THE-ID", inputs["issueId"])
assert.Equal(t, "DUPLICATE", inputs["stateReason"])
assert.Equal(t, "DUPLICATE-ID", inputs["duplicateIssueId"])
}),
)
},
wantStderr: "✓ Closed issue OWNER/REPO#13 (The title of the issue)\n",
},
{
name: "duplicate of cannot point to same issue",
opts: &CloseOptions{
IssueNumber: 13,
DuplicateOf: "13",
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"}
} } }`),
)
},
wantErr: true,
errMsg: "`--duplicate-of` cannot reference the current issue",
},
{
name: "duplicate of must reference an issue",
opts: &CloseOptions{
IssueNumber: 13,
DuplicateOf: "99",
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"}
} } }`),
)
reg.Register(
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issue": { "__typename": "PullRequest", "id": "PULL-ID", "number": 99}
} } }`),
)
},
wantErr: true,
errMsg: "`--duplicate-of` must reference an issue",
},
{
name: "duplicate of with invalid format",
opts: &CloseOptions{
IssueNumber: 13,
DuplicateOf: "not-an-issue",
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"}
} } }`),
)
},
wantErr: true,
errMsg: "invalid value for `--duplicate-of`: invalid issue format: \"not-an-issue\"",
},
{
name: "issue already closed",
opts: &CloseOptions{

View file

@ -186,6 +186,7 @@ func createRun(opts *CreateOptions) (err error) {
return err
}
// TODO actorIsAssignableCleanup
if issueFeatures.ActorIsAssignable {
assignees = copilotReplacer.ReplaceSlice(assignees)
}
@ -312,7 +313,7 @@ func createRun(opts *CreateOptions) (err error) {
Repo: baseRepo,
State: &tb,
}
err = prShared.MetadataSurvey(opts.Prompter, opts.IO, baseRepo, fetcher, &tb, projectsV1Support)
err = prShared.MetadataSurvey(opts.Prompter, opts.IO, baseRepo, fetcher, &tb, projectsV1Support, nil)
if err != nil {
return
}

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

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

@ -147,14 +147,7 @@ func listRun(opts *ListOptions) error {
cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24)
opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost())
}
features, err := opts.Detector.IssueFeatures()
if err != nil {
return err
}
fields := defaultFields
if features.StateReason {
fields = append(defaultFields, "stateReason")
}
fields := append(defaultFields, "stateReason")
filterOptions := prShared.FilterOptions{
Entity: "issue",

View file

@ -8,10 +8,8 @@ import (
"regexp"
"strconv"
"strings"
"time"
"github.com/cli/cli/v2/api"
fd "github.com/cli/cli/v2/internal/featuredetection"
"github.com/cli/cli/v2/internal/ghrepo"
o "github.com/cli/cli/v2/pkg/option"
"github.com/cli/cli/v2/pkg/set"
@ -138,17 +136,6 @@ func FindIssuesOrPRs(httpClient *http.Client, repo ghrepo.Interface, issueNumber
func FindIssueOrPR(httpClient *http.Client, repo ghrepo.Interface, number int, fields []string) (*api.Issue, error) {
fieldSet := set.NewStringSet()
fieldSet.AddValues(fields)
if fieldSet.Contains("stateReason") {
cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24)
detector := fd.NewDetector(cachedClient, repo.RepoHost())
features, err := detector.IssueFeatures()
if err != nil {
return nil, err
}
if !features.StateReason {
fieldSet.Remove("stateReason")
}
}
var getProjectItems bool
if fieldSet.Contains("projectItems") {

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)

View file

@ -144,8 +144,6 @@ func viewRun(opts *ViewOptions) error {
}
if lookupFields.Contains("comments") {
// FIXME: this re-fetches the comments connection even though the initial set of 100 were
// fetched in the previous request.
err := preloadIssueComments(httpClient, baseRepo, issue)
if err != nil {
return err
@ -199,7 +197,7 @@ func printRawIssuePreview(out io.Writer, issue *api.Issue) error {
// processing many issues with head and grep.
fmt.Fprintf(out, "title:\t%s\n", issue.Title)
fmt.Fprintf(out, "state:\t%s\n", issue.State)
fmt.Fprintf(out, "author:\t%s\n", issue.Author.Login)
fmt.Fprintf(out, "author:\t%s\n", issue.Author.DisplayName())
fmt.Fprintf(out, "labels:\t%s\n", labels)
fmt.Fprintf(out, "comments:\t%d\n", issue.Comments.TotalCount)
fmt.Fprintf(out, "assignees:\t%s\n", assignees)
@ -224,7 +222,7 @@ func printHumanIssuePreview(opts *ViewOptions, baseRepo ghrepo.Interface, issue
fmt.Fprintf(out,
"%s • %s opened %s • %s\n",
issueStateTitleWithColor(cs, issue),
issue.Author.Login,
issue.Author.DisplayName(),
text.FuzzyAgo(opts.Now(), issue.CreatedAt),
text.Pluralize(issue.Comments.TotalCount, "comment"),
)
@ -300,7 +298,7 @@ func issueAssigneeList(issue api.Issue) string {
AssigneeNames := make([]string, 0, len(issue.Assignees.Nodes))
for _, assignee := range issue.Assignees.Nodes {
AssigneeNames = append(AssigneeNames, assignee.Login)
AssigneeNames = append(AssigneeNames, assignee.DisplayName())
}
list := strings.Join(AssigneeNames, ", ")

View file

@ -0,0 +1,29 @@
package licenses
import (
"fmt"
"github.com/cli/cli/v2/internal/licenses"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/spf13/cobra"
)
func NewCmdLicenses(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "licenses",
Short: "View third-party license information",
Long: "View license information for third-party libraries used in this build of the GitHub CLI.",
RunE: func(cmd *cobra.Command, args []string) error {
io := f.IOStreams
if err := io.StartPager(); err == nil {
defer io.StopPager()
}
_, err := fmt.Fprint(io.Out, licenses.Content())
return err
},
}
cmdutil.DisableAuthCheck(cmd)
return cmd
}

View file

@ -21,6 +21,7 @@ import (
fd "github.com/cli/cli/v2/internal/featuredetection"
"github.com/cli/cli/v2/internal/gh"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/prompter"
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/pkg/cmd/pr/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
@ -214,7 +215,8 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
Upon success, the URL of the created pull request will be printed.
When the current branch isn't fully pushed to a git remote, a prompt will ask where
to push the branch and offer an option to fork the base repository. Use %[1]s--head%[1]s to
to push the branch and offer an option to fork the base repository. Any fork created this
way will only have the default branch of the upstream repository. Use %[1]s--head%[1]s to
explicitly skip any forking or pushing behavior.
%[1]s--head%[1]s supports %[1]s<user>:<branch>%[1]s syntax to select a head repo owned by %[1]s<user>%[1]s.
@ -397,11 +399,38 @@ func createRun(opts *CreateOptions) error {
client := ctx.Client
// Detect ActorIsAssignable feature to determine if we can use search-based
// reviewer selection (github.com) or need to use legacy ID-based selection (GHES)
issueFeatures, err := opts.Detector.IssueFeatures()
if err != nil {
return err
}
var reviewerSearchFunc func(string) prompter.MultiSelectSearchResult
if issueFeatures.ActorIsAssignable {
reviewerSearchFunc = func(query string) prompter.MultiSelectSearchResult {
candidates, moreResults, err := api.SuggestedReviewerActorsForRepo(client, ctx.PRRefs.BaseRepo(), query)
if err != nil {
return prompter.MultiSelectSearchResult{Err: err}
}
keys := make([]string, len(candidates))
labels := make([]string, len(candidates))
for i, c := range candidates {
keys[i] = c.Login()
labels[i] = c.DisplayName()
}
return prompter.MultiSelectSearchResult{Keys: keys, Labels: labels, MoreResults: moreResults}
}
}
state, err := NewIssueState(*ctx, *opts)
if err != nil {
return err
}
if issueFeatures.ActorIsAssignable {
state.ActorReviewers = true
}
var openURL string
if opts.WebMode {
@ -568,7 +597,7 @@ func createRun(opts *CreateOptions) error {
Repo: ctx.PRRefs.BaseRepo(),
State: state,
}
err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.PRRefs.BaseRepo(), fetcher, state, projectsV1Support)
err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.PRRefs.BaseRepo(), fetcher, state, projectsV1Support, reviewerSearchFunc)
if err != nil {
return err
}
@ -652,9 +681,12 @@ func NewIssueState(ctx CreateContext, opts CreateOptions) (*shared.IssueMetadata
return nil, err
}
copilotReplacer := shared.NewCopilotReviewerReplacer()
reviewers := copilotReplacer.ReplaceSlice(opts.Reviewers)
state := &shared.IssueMetadataState{
Type: shared.PRMetadata,
Reviewers: opts.Reviewers,
Reviewers: reviewers,
Assignees: assignees,
Labels: opts.Labels,
ProjectTitles: opts.Projects,
@ -1128,7 +1160,7 @@ func handlePush(opts CreateOptions, ctx CreateContext) error {
forkableRefs, requiresFork := refs.(forkableRefs)
if requiresFork {
opts.IO.StartProgressIndicator()
forkedRepo, err := api.ForkRepo(ctx.Client, forkableRefs.BaseRepo(), "", "", false)
forkedRepo, err := api.ForkRepo(ctx.Client, forkableRefs.BaseRepo(), "", "", true)
opts.IO.StopProgressIndicator()
if err != nil {
return fmt.Errorf("error forking repo: %w", err)

View file

@ -434,92 +434,6 @@ func Test_createRun(t *testing.T) {
},
expectedErrOut: "",
},
{
name: "dry-run-nontty-with-all-opts",
tty: false,
setup: func(opts *CreateOptions, t *testing.T) func() {
opts.TitleProvided = true
opts.BodyProvided = true
opts.Title = "TITLE"
opts.Body = "BODY"
opts.BaseBranch = "trunk"
opts.HeadBranch = "feature"
opts.Assignees = []string{"monalisa"}
opts.Labels = []string{"bug", "todo"}
opts.Projects = []string{"roadmap"}
opts.Reviewers = []string{"hubot", "monalisa", "/core", "/robots"}
opts.Milestone = "big one.oh"
opts.DryRun = true
return func() {}
},
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
reg.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`))
reg.Register(
httpmock.GraphQL(`query RepositoryAssignableUsers\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "assignableUsers": {
"nodes": [
{ "login": "hubot", "id": "HUBOTID", "name": "" },
{ "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query RepositoryLabelList\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "labels": {
"nodes": [
{ "name": "TODO", "id": "TODOID" },
{ "name": "bug", "id": "BUGID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query RepositoryMilestoneList\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "milestones": {
"nodes": [
{ "title": "GA", "id": "GAID" },
{ "title": "Big One.oh", "id": "BIGONEID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query OrganizationTeamList\b`),
httpmock.StringResponse(`
{ "data": { "organization": { "teams": {
"nodes": [
{ "slug": "core", "id": "COREID" },
{ "slug": "robots", "id": "ROBOTID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
mockRetrieveProjects(t, reg)
},
expectedOutputs: []string{
"Would have created a Pull Request with:",
`title: TITLE`,
`draft: false`,
`base: trunk`,
`head: feature`,
`labels: bug, todo`,
`reviewers: hubot, monalisa, /core, /robots`,
`assignees: monalisa`,
`milestones: big one.oh`,
`projects: roadmap`,
`maintainerCanModify: false`,
`body:`,
`BODY`,
``,
},
expectedErrOut: "",
},
{
name: "dry-run-tty-with-default-base",
tty: true,
@ -549,98 +463,6 @@ func Test_createRun(t *testing.T) {
Dry Running pull request for feature into master in OWNER/REPO
`),
},
{
name: "dry-run-tty-with-all-opts",
tty: true,
setup: func(opts *CreateOptions, t *testing.T) func() {
opts.TitleProvided = true
opts.BodyProvided = true
opts.Title = "TITLE"
opts.Body = "BODY"
opts.BaseBranch = "trunk"
opts.HeadBranch = "feature"
opts.Assignees = []string{"monalisa"}
opts.Labels = []string{"bug", "todo"}
opts.Projects = []string{"roadmap"}
opts.Reviewers = []string{"hubot", "monalisa", "/core", "/robots"}
opts.Milestone = "big one.oh"
opts.DryRun = true
return func() {}
},
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
reg.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`))
reg.Register(
httpmock.GraphQL(`query RepositoryAssignableUsers\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "assignableUsers": {
"nodes": [
{ "login": "hubot", "id": "HUBOTID", "name": "" },
{ "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query RepositoryLabelList\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "labels": {
"nodes": [
{ "name": "TODO", "id": "TODOID" },
{ "name": "bug", "id": "BUGID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query RepositoryMilestoneList\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "milestones": {
"nodes": [
{ "title": "GA", "id": "GAID" },
{ "title": "Big One.oh", "id": "BIGONEID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query OrganizationTeamList\b`),
httpmock.StringResponse(`
{ "data": { "organization": { "teams": {
"nodes": [
{ "slug": "core", "id": "COREID" },
{ "slug": "robots", "id": "ROBOTID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
mockRetrieveProjects(t, reg)
},
expectedOutputs: []string{
`Would have created a Pull Request with:`,
`Title: TITLE`,
`Draft: false`,
`Base: trunk`,
`Head: feature`,
`Labels: bug, todo`,
`Reviewers: hubot, monalisa, /core, /robots`,
`Assignees: monalisa`,
`Milestones: big one.oh`,
`Projects: roadmap`,
`MaintainerCanModify: false`,
`Body:`,
``,
` BODY `,
``,
``,
},
expectedErrOut: heredoc.Doc(`
Dry Running pull request for feature into trunk in OWNER/REPO
`),
},
{
@ -894,11 +716,13 @@ func Test_createRun(t *testing.T) {
httpmock.StringResponse(`{"data": {"viewer": {"login": "monalisa"} } }`))
reg.Register(
httpmock.REST("POST", "repos/OWNER/REPO/forks"),
httpmock.StatusStringResponse(201, `
httpmock.RESTPayload(201, `
{ "node_id": "NODEID",
"name": "REPO",
"owner": {"login": "monalisa"}
}`))
}`, func(payload map[string]interface{}) {
assert.Equal(t, true, payload["default_branch_only"])
}))
reg.Register(
httpmock.GraphQL(`mutation PullRequestCreate\b`),
httpmock.GraphQLMutation(`
@ -1087,14 +911,11 @@ func Test_createRun(t *testing.T) {
opts.Assignees = []string{"monalisa"}
opts.Labels = []string{"bug", "todo"}
opts.Projects = []string{"roadmap"}
opts.Reviewers = []string{"hubot", "monalisa", "/core", "/robots"}
opts.Reviewers = []string{"hubot", "monalisa", "OWNER/core", "OWNER/robots"}
opts.Milestone = "big one.oh"
return func() {}
},
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
reg.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`))
reg.Register(
httpmock.GraphQL(`query RepositoryAssignableUsers\b`),
httpmock.StringResponse(`
@ -1128,17 +949,6 @@ func Test_createRun(t *testing.T) {
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query OrganizationTeamList\b`),
httpmock.StringResponse(`
{ "data": { "organization": { "teams": {
"nodes": [
{ "slug": "core", "id": "COREID" },
{ "slug": "robots", "id": "ROBOTID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
mockRetrieveProjects(t, reg)
reg.Register(
httpmock.GraphQL(`mutation PullRequestCreate\b`),
@ -1171,15 +981,15 @@ func Test_createRun(t *testing.T) {
assert.Equal(t, "BIGONEID", inputs["milestoneId"])
}))
reg.Register(
httpmock.GraphQL(`mutation PullRequestCreateRequestReviews\b`),
httpmock.GraphQL(`mutation RequestReviewsByLogin\b`),
httpmock.GraphQLMutation(`
{ "data": { "requestReviews": {
{ "data": { "requestReviewsByLogin": {
"clientMutationId": ""
} } }
`, func(inputs map[string]interface{}) {
assert.Equal(t, "NEWPULLID", inputs["pullRequestId"])
assert.Equal(t, []interface{}{"HUBOTID", "MONAID"}, inputs["userIds"])
assert.Equal(t, []interface{}{"COREID", "ROBOTID"}, inputs["teamIds"])
assert.Equal(t, []interface{}{"hubot", "monalisa"}, inputs["userLogins"])
assert.Equal(t, []interface{}{"OWNER/core", "OWNER/robots"}, inputs["teamSlugs"])
assert.Equal(t, true, inputs["union"])
}))
},
@ -1679,6 +1489,363 @@ func Test_createRun(t *testing.T) {
},
expectedOut: "https://github.com/OWNER/REPO/pull/12\n",
},
{
name: "request reviewers by login",
setup: func(opts *CreateOptions, t *testing.T) func() {
opts.TitleProvided = true
opts.BodyProvided = true
opts.Title = "my title"
opts.Body = "my body"
opts.Reviewers = []string{"hubot", "monalisa", "org/core", "org/robots"}
opts.HeadBranch = "feature"
return func() {}
},
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
reg.Register(
httpmock.GraphQL(`mutation PullRequestCreate\b`),
httpmock.GraphQLMutation(`
{ "data": { "createPullRequest": { "pullRequest": {
"URL": "https://github.com/OWNER/REPO/pull/12",
"id": "NEWPULLID"
} } } }`,
func(input map[string]interface{}) {}))
reg.Register(
httpmock.GraphQL(`mutation RequestReviewsByLogin\b`),
httpmock.GraphQLMutation(`
{ "data": { "requestReviewsByLogin": {
"clientMutationId": ""
} } }
`, func(inputs map[string]interface{}) {
assert.Equal(t, "NEWPULLID", inputs["pullRequestId"])
assert.Equal(t, []interface{}{"hubot", "monalisa"}, inputs["userLogins"])
assert.Equal(t, []interface{}{"org/core", "org/robots"}, inputs["teamSlugs"])
assert.Equal(t, true, inputs["union"])
}))
},
expectedOut: "https://github.com/OWNER/REPO/pull/12\n",
expectedErrOut: "",
},
{
name: "@copilot reviewer resolves to bot login",
setup: func(opts *CreateOptions, t *testing.T) func() {
opts.TitleProvided = true
opts.BodyProvided = true
opts.Title = "my title"
opts.Body = "my body"
opts.Reviewers = []string{"hubot", "@copilot"}
opts.HeadBranch = "feature"
return func() {}
},
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
reg.Register(
httpmock.GraphQL(`mutation PullRequestCreate\b`),
httpmock.GraphQLMutation(`
{ "data": { "createPullRequest": { "pullRequest": {
"URL": "https://github.com/OWNER/REPO/pull/12",
"id": "NEWPULLID"
} } } }`,
func(input map[string]interface{}) {}))
reg.Register(
httpmock.GraphQL(`mutation RequestReviewsByLogin\b`),
httpmock.GraphQLMutation(`
{ "data": { "requestReviewsByLogin": {
"clientMutationId": ""
} } }
`, func(inputs map[string]interface{}) {
assert.Equal(t, "NEWPULLID", inputs["pullRequestId"])
assert.Equal(t, []interface{}{"hubot"}, inputs["userLogins"])
assert.Equal(t, []interface{}{"copilot-pull-request-reviewer[bot]"}, inputs["botLogins"])
assert.Equal(t, true, inputs["union"])
}))
},
expectedOut: "https://github.com/OWNER/REPO/pull/12\n",
expectedErrOut: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
branch := "feature"
reg := &httpmock.Registry{}
reg.StubRepoInfoResponse("OWNER", "REPO", "master")
defer reg.Verify(t)
if tt.httpStubs != nil {
tt.httpStubs(reg, t)
}
pm := &prompter.PrompterMock{}
if tt.promptStubs != nil {
tt.promptStubs(pm)
}
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
if !tt.customBranchConfig {
cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "")
}
if tt.cmdStubs != nil {
tt.cmdStubs(cs)
}
opts := CreateOptions{}
opts.Prompter = pm
ios, _, stdout, stderr := iostreams.Test()
ios.SetStdoutTTY(tt.tty)
ios.SetStdinTTY(tt.tty)
ios.SetStderrTTY(tt.tty)
browser := &browser.Stub{}
opts.IO = ios
opts.Browser = browser
opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
opts.Config = func() (gh.Config, error) {
return config.NewBlankConfig(), nil
}
opts.Remotes = func() (context.Remotes, error) {
return context.Remotes{
{
Remote: &git.Remote{
Name: "origin",
Resolved: "base",
},
Repo: ghrepo.New("OWNER", "REPO"),
},
}, nil
}
opts.Branch = func() (string, error) {
return branch, nil
}
opts.Finder = shared.NewMockFinder(branch, nil, nil)
opts.GitClient = &git.Client{
GhPath: "some/path/gh",
GitPath: "some/path/git",
}
cleanSetup := func() {}
if tt.setup != nil {
cleanSetup = tt.setup(&opts, t)
}
defer cleanSetup()
// All tests in this function use github.com behavior
opts.Detector = &fd.EnabledDetectorMock{}
if opts.HeadBranch == "" {
cs.Register(`git status --porcelain`, 0, "")
}
err := createRun(&opts)
output := &test.CmdOut{
OutBuf: stdout,
ErrBuf: stderr,
BrowsedURL: browser.BrowsedURL(),
}
if tt.wantErr != "" {
assert.EqualError(t, err, tt.wantErr)
} else {
assert.NoError(t, err)
if tt.expectedOut != "" {
assert.Equal(t, tt.expectedOut, output.String())
}
if len(tt.expectedOutputs) > 0 {
assert.Equal(t, tt.expectedOutputs, strings.Split(output.String(), "\n"))
}
assert.Equal(t, tt.expectedErrOut, output.Stderr())
assert.Equal(t, tt.expectedBrowse, output.BrowsedURL)
}
})
}
}
func Test_createRun_GHES(t *testing.T) {
tests := []struct {
name string
setup func(*CreateOptions, *testing.T) func()
cmdStubs func(*run.CommandStubber)
promptStubs func(*prompter.PrompterMock)
httpStubs func(*httpmock.Registry, *testing.T)
expectedOutputs []string
expectedOut string
expectedErrOut string
tty bool
customBranchConfig bool
}{
{
name: "dry-run-nontty-with-all-opts",
tty: false,
setup: func(opts *CreateOptions, t *testing.T) func() {
opts.TitleProvided = true
opts.BodyProvided = true
opts.Title = "TITLE"
opts.Body = "BODY"
opts.BaseBranch = "trunk"
opts.HeadBranch = "feature"
opts.Assignees = []string{"monalisa"}
opts.Labels = []string{"bug", "todo"}
opts.Reviewers = []string{"hubot", "monalisa", "OWNER/core", "OWNER/robots"}
opts.Milestone = "big one.oh"
opts.DryRun = true
return func() {}
},
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
reg.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`))
reg.Register(
httpmock.GraphQL(`query RepositoryAssignableUsers\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "assignableUsers": {
"nodes": [
{ "login": "hubot", "id": "HUBOTID", "name": "" },
{ "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query RepositoryLabelList\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "labels": {
"nodes": [
{ "name": "TODO", "id": "TODOID" },
{ "name": "bug", "id": "BUGID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query RepositoryMilestoneList\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "milestones": {
"nodes": [
{ "title": "GA", "id": "GAID" },
{ "title": "Big One.oh", "id": "BIGONEID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query OrganizationTeamList\b`),
httpmock.StringResponse(`
{ "data": { "organization": { "teams": {
"nodes": [
{ "slug": "core", "id": "COREID" },
{ "slug": "robots", "id": "ROBOTID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
},
expectedOutputs: []string{
"Would have created a Pull Request with:",
`title: TITLE`,
`draft: false`,
`base: trunk`,
`head: feature`,
`labels: bug, todo`,
`reviewers: hubot, monalisa, OWNER/core, OWNER/robots`,
`assignees: monalisa`,
`milestones: big one.oh`,
`maintainerCanModify: false`,
`body:`,
`BODY`,
``,
},
expectedErrOut: "",
},
{
name: "dry-run-tty-with-all-opts",
tty: true,
setup: func(opts *CreateOptions, t *testing.T) func() {
opts.TitleProvided = true
opts.BodyProvided = true
opts.Title = "TITLE"
opts.Body = "BODY"
opts.BaseBranch = "trunk"
opts.HeadBranch = "feature"
opts.Assignees = []string{"monalisa"}
opts.Labels = []string{"bug", "todo"}
opts.Reviewers = []string{"hubot", "monalisa", "OWNER/core", "OWNER/robots"}
opts.Milestone = "big one.oh"
opts.DryRun = true
return func() {}
},
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
reg.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`))
reg.Register(
httpmock.GraphQL(`query RepositoryAssignableUsers\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "assignableUsers": {
"nodes": [
{ "login": "hubot", "id": "HUBOTID", "name": "" },
{ "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query RepositoryLabelList\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "labels": {
"nodes": [
{ "name": "TODO", "id": "TODOID" },
{ "name": "bug", "id": "BUGID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query RepositoryMilestoneList\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "milestones": {
"nodes": [
{ "title": "GA", "id": "GAID" },
{ "title": "Big One.oh", "id": "BIGONEID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query OrganizationTeamList\b`),
httpmock.StringResponse(`
{ "data": { "organization": { "teams": {
"nodes": [
{ "slug": "core", "id": "COREID" },
{ "slug": "robots", "id": "ROBOTID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
},
expectedOutputs: []string{
`Would have created a Pull Request with:`,
`Title: TITLE`,
`Draft: false`,
`Base: trunk`,
`Head: feature`,
`Labels: bug, todo`,
`Reviewers: hubot, monalisa, OWNER/core, OWNER/robots`,
`Assignees: monalisa`,
`Milestones: big one.oh`,
`MaintainerCanModify: false`,
`Body:`,
``,
` BODY `,
``,
``,
},
expectedErrOut: heredoc.Doc(`
Dry Running pull request for feature into trunk in OWNER/REPO
`),
},
{
name: "fetch org teams non-interactively if reviewer contains any team",
setup: func(opts *CreateOptions, t *testing.T) func() {
@ -1951,11 +2118,10 @@ func Test_createRun(t *testing.T) {
}
opts := CreateOptions{}
opts.Detector = &fd.EnabledDetectorMock{}
opts.Detector = &fd.DisabledDetectorMock{}
opts.Prompter = pm
ios, _, stdout, stderr := iostreams.Test()
// TODO do i need to bother with this
ios.SetStdoutTTY(tt.tty)
ios.SetStdinTTY(tt.tty)
ios.SetStderrTTY(tt.tty)
@ -1999,23 +2165,17 @@ func Test_createRun(t *testing.T) {
err := createRun(&opts)
output := &test.CmdOut{
OutBuf: stdout,
ErrBuf: stderr,
BrowsedURL: browser.BrowsedURL(),
OutBuf: stdout,
ErrBuf: stderr,
}
if tt.wantErr != "" {
assert.EqualError(t, err, tt.wantErr)
} else {
assert.NoError(t, err)
if tt.expectedOut != "" {
assert.Equal(t, tt.expectedOut, output.String())
}
if len(tt.expectedOutputs) > 0 {
assert.Equal(t, tt.expectedOutputs, strings.Split(output.String(), "\n"))
}
assert.Equal(t, tt.expectedErrOut, output.Stderr())
assert.Equal(t, tt.expectedBrowse, output.BrowsedURL)
assert.NoError(t, err)
if tt.expectedOut != "" {
assert.Equal(t, tt.expectedOut, output.String())
}
if len(tt.expectedOutputs) > 0 {
assert.Equal(t, tt.expectedOutputs, strings.Split(output.String(), "\n"))
}
assert.Equal(t, tt.expectedErrOut, output.Stderr())
})
}
}

View file

@ -218,7 +218,7 @@ func fetchDiff(httpClient *http.Client, baseRepo ghrepo.Interface, prNumber int,
const lineBufferSize = 4096
var (
colorHeader = []byte("\x1b[1;38m")
colorHeader = []byte("\x1b[1;37m")
colorAddition = []byte("\x1b[32m")
colorRemoval = []byte("\x1b[31m")
colorReset = []byte("\x1b[m")

View file

@ -200,7 +200,7 @@ func Test_diffRun(t *testing.T) {
Patch: false,
},
wantFields: []string{"number"},
wantStdout: fmt.Sprintf(testDiff, "\x1b[m", "\x1b[1;38m", "\x1b[32m", "\x1b[31m"),
wantStdout: fmt.Sprintf(testDiff, "\x1b[m", "\x1b[1;37m", "\x1b[32m", "\x1b[31m"),
httpStubs: func(reg *httpmock.Registry) {
stubDiffRequest(reg, "application/vnd.github.v3.diff", fmt.Sprintf(testDiff, "", "", "", ""))
},
@ -376,7 +376,7 @@ func Test_colorDiffLines(t *testing.T) {
"%[4]s+foo%[2]s\n%[5]s-b%[1]sr%[2]s\n%[3]s+++ baz%[2]s\n",
strings.Repeat("a", 2*lineBufferSize),
"\x1b[m",
"\x1b[1;38m",
"\x1b[1;37m",
"\x1b[32m",
"\x1b[31m",
),

View file

@ -251,6 +251,7 @@ func editRun(opts *EditOptions) error {
return err
}
// TODO actorIsAssignableCleanup
if issueFeatures.ActorIsAssignable {
findOptions.Fields = append(findOptions.Fields, "assignedActors")
} else {
@ -269,6 +270,7 @@ func editRun(opts *EditOptions) error {
editable.Base.Default = pr.BaseRefName
editable.Reviewers.Default = pr.ReviewRequests.DisplayNames()
editable.Reviewers.DefaultLogins = pr.ReviewRequests.Logins()
// TODO actorIsAssignableCleanup
if issueFeatures.ActorIsAssignable {
editable.Assignees.ActorAssignees = true
editable.Assignees.Default = pr.AssignedActors.DisplayNames()
@ -299,6 +301,7 @@ func editRun(opts *EditOptions) error {
// Wire up search functions for assignees and reviewers.
// When these aren't wired up, it triggers a downstream fallback
// to legacy reviewer/assignee fetching.
// TODO actorIsAssignableCleanup
if issueFeatures.ActorIsAssignable {
editable.AssigneeSearchFunc = assigneeSearchFunc(apiClient, repo, &editable, pr.ID)
editable.ReviewerSearchFunc = reviewerSearchFunc(apiClient, repo, &editable, pr.ID)

Some files were not shown because too many files have changed in this diff Show more