Merge branch 'trunk' into feature/pr-diff-exclude
This commit is contained in:
commit
78891fc6e5
331 changed files with 5215 additions and 19468 deletions
2
.github/CONTRIBUTING.md
vendored
2
.github/CONTRIBUTING.md
vendored
|
|
@ -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`
|
||||
|
|
|
|||
28
.github/ISSUE_TEMPLATE/feedback.md
vendored
28
.github/ISSUE_TEMPLATE/feedback.md
vendored
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
name: "\U0001F4E3 Feedback"
|
||||
about: Give us general feedback about the GitHub CLI
|
||||
title: ''
|
||||
labels: feedback
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
# CLI Feedback
|
||||
|
||||
You can use this template to give us structured feedback or just wipe it and leave us a note. Thank you!
|
||||
|
||||
## What have you loved?
|
||||
|
||||
_eg "the nice colors"_
|
||||
|
||||
## What was confusing or gave you pause?
|
||||
|
||||
_eg "it did something unexpected"_
|
||||
|
||||
## Are there features you'd like to see added?
|
||||
|
||||
_eg "gh cli needs mini-games"_
|
||||
|
||||
## Anything else?
|
||||
|
||||
_eg "have a nice day"_
|
||||
17
.github/licenses.tmpl
vendored
17
.github/licenses.tmpl
vendored
|
|
@ -1,13 +1,8 @@
|
|||
# GitHub CLI dependencies
|
||||
GitHub CLI third-party dependencies
|
||||
====================================
|
||||
|
||||
The following open source dependencies are used to build the [cli/cli][] GitHub CLI.
|
||||
The following open source dependencies are used to build the GitHub CLI.
|
||||
|
||||
## Go Packages
|
||||
|
||||
Some packages may only be included on certain architectures or operating systems.
|
||||
|
||||
{{ range . }}
|
||||
- [{{.Name}}](https://pkg.go.dev/{{.Name}}) ([{{.LicenseName}}]({{.LicenseURL}}))
|
||||
{{- end }}
|
||||
|
||||
[cli/cli]: https://github.com/cli/cli
|
||||
{{ range . -}}
|
||||
{{.Name}} ({{.Version}}) - {{.LicenseName}} - {{.LicenseURL}}
|
||||
{{ end }}
|
||||
|
|
|
|||
3
.github/secret_scanning.yml
vendored
3
.github/secret_scanning.yml
vendored
|
|
@ -1,3 +0,0 @@
|
|||
paths-ignore:
|
||||
- 'third-party/**'
|
||||
- 'third-party-licenses.*.md'
|
||||
1
.github/workflows/bump-go.yml
vendored
1
.github/workflows/bump-go.yml
vendored
|
|
@ -2,6 +2,7 @@ name: Bump Go
|
|||
on:
|
||||
schedule:
|
||||
- cron: "0 3 * * *" # 3 AM UTC
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
|
|
|||
16
.github/workflows/deployment.yml
vendored
16
.github/workflows/deployment.yml
vendored
|
|
@ -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)
|
||||
|
|
|
|||
36
.github/workflows/feature-request-comment.yml
vendored
36
.github/workflows/feature-request-comment.yml
vendored
|
|
@ -1,36 +0,0 @@
|
|||
name: Add feature-request comment
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- labeled
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
add-comment-to-feature-request-issues:
|
||||
if: github.event.label.name == 'enhancement'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
NUMBER: ${{ github.event.issue.number }}
|
||||
BODY: >
|
||||
Thank you for your issue! We have categorized it as a feature request,
|
||||
and it has been added to our backlog. In doing so, **we are not
|
||||
committing to implementing this feature at this time**, but, we will
|
||||
consider it for future releases based on community feedback and our own
|
||||
product roadmap.
|
||||
|
||||
|
||||
Unless you see the
|
||||
https://github.com/cli/cli/labels/help%20wanted label, we are
|
||||
not currently looking for external contributions for this feature.
|
||||
|
||||
|
||||
**If you come across this issue and would like to see it implemented,
|
||||
please add a thumbs up!** This will help us prioritize the feature.
|
||||
Please only comment if you have additional information or viewpoints to
|
||||
contribute.
|
||||
steps:
|
||||
- run: gh issue comment "$NUMBER" --body "$BODY"
|
||||
25
.github/workflows/issueauto.yml
vendored
25
.github/workflows/issueauto.yml
vendored
|
|
@ -1,25 +0,0 @@
|
|||
name: Issue Automation
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: none
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
issue-auto:
|
||||
runs-on: ubuntu-latest
|
||||
environment: cli-automation
|
||||
steps:
|
||||
- name: label incoming issue
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ secrets.AUTOMATION_TOKEN }}
|
||||
ISSUENUM: ${{ github.event.issue.number }}
|
||||
ISSUEAUTHOR: ${{ github.event.issue.user.login }}
|
||||
run: |
|
||||
if ! gh api orgs/cli/public_members/$ISSUEAUTHOR --silent 2>/dev/null
|
||||
then
|
||||
gh issue edit $ISSUENUM --add-label "needs-triage"
|
||||
fi
|
||||
16
.github/workflows/lint.yml
vendored
16
.github/workflows/lint.yml
vendored
|
|
@ -8,14 +8,14 @@ on:
|
|||
- go.mod
|
||||
- go.sum
|
||||
- ".github/licenses.tmpl"
|
||||
- "script/licenses*"
|
||||
- "script/licenses"
|
||||
pull_request:
|
||||
paths:
|
||||
- "**.go"
|
||||
- go.mod
|
||||
- go.sum
|
||||
- ".github/licenses.tmpl"
|
||||
- "script/licenses*"
|
||||
- "script/licenses"
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
|
|
@ -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: |
|
||||
|
|
|
|||
46
.github/workflows/pr-help-wanted.yml
vendored
46
.github/workflows/pr-help-wanted.yml
vendored
|
|
@ -1,46 +0,0 @@
|
|||
name: PR Help Wanted Check
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: "Pull Request number to check"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: none
|
||||
issues: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
check-help-wanted:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set PR variables for workflow_dispatch event
|
||||
id: pr-vars-dispatch
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.inputs.pr_number }}
|
||||
run: |
|
||||
# We only need to construct the PR URL from the dispatch event input.
|
||||
echo "pr_url=https://github.com/cli/cli/pull/${PR_NUMBER}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check for issues without help-wanted label
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# These variables are optionally used in the check-help-wanted.sh
|
||||
# script for additional checks; but they are not strictly necessary
|
||||
# for the script to run. This is why we are okay with them being
|
||||
# empty when the event is workflow_dispatch.
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }}
|
||||
PR_AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }}
|
||||
PR_URL: ${{ github.event.pull_request.html_url || steps.pr-vars-dispatch.outputs.pr_url }}
|
||||
run: |
|
||||
# Run the script to check for issues without help-wanted label
|
||||
bash .github/workflows/scripts/check-help-wanted.sh "${PR_URL}"
|
||||
75
.github/workflows/prauto.yml
vendored
75
.github/workflows/prauto.yml
vendored
|
|
@ -1,75 +0,0 @@
|
|||
name: PR Automation
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [ready_for_review, opened, reopened]
|
||||
|
||||
permissions:
|
||||
contents: none
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
pr-auto:
|
||||
runs-on: ubuntu-latest
|
||||
environment: cli-automation
|
||||
steps:
|
||||
- name: lint pr
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ secrets.AUTOMATION_TOKEN }}
|
||||
PRBODY: ${{ github.event.pull_request.body }}
|
||||
PRNUM: ${{ github.event.pull_request.number }}
|
||||
PRHEAD: ${{ github.event.pull_request.head.label }}
|
||||
PRAUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }}
|
||||
if: "!github.event.pull_request.draft"
|
||||
run: |
|
||||
commentPR () {
|
||||
gh pr comment $PRNUM -b "${1}"
|
||||
}
|
||||
|
||||
closePR () {
|
||||
gh pr close $PRNUM
|
||||
}
|
||||
|
||||
colID () {
|
||||
gh api graphql -f query='query($owner:String!, $repo:String!) {
|
||||
repository(owner:$owner, name:$repo) {
|
||||
project(number:1) {
|
||||
columns(first:10) { nodes {id,name} }
|
||||
}
|
||||
}
|
||||
}' -f owner="${GH_REPO%/*}" -f repo="${GH_REPO#*/}" \
|
||||
-q ".data.repository.project.columns.nodes[] | select(.name | startswith(\"$1\")) | .id"
|
||||
}
|
||||
|
||||
if [ "$PR_AUTHOR_TYPE" = "Bot" ] || gh api orgs/cli/public_members/$PRAUTHOR --silent 2>/dev/null
|
||||
then
|
||||
if [ "$PR_AUTHOR_TYPE" != "Bot" ]
|
||||
then
|
||||
gh pr edit $PRNUM --add-assignee $PRAUTHOR
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
gh pr edit $PRNUM --add-label "external"
|
||||
|
||||
if [ "$PRHEAD" = "cli:trunk" ]
|
||||
then
|
||||
closePR
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ $(wc -c <<<"$PRBODY") -lt 10 ]
|
||||
then
|
||||
commentPR "Thanks for the pull request! We're a small team and it's helpful to have context around community submissions in order to review them appropriately. Our automation has closed this pull request since it does not have an adequate description. Please edit the body of this pull request to describe what this does, then reopen it."
|
||||
closePR
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! grep -Eq '(#|issues/)[0-9]+' <<<"$PRBODY"
|
||||
then
|
||||
commentPR "Hi! Thanks for the pull request. Please ensure that this change is linked to an issue by mentioning an issue number in the description of the pull request. If this pull request would close the issue, please put the word 'Fixes' before the issue number somewhere in the pull request body. If this is a tiny change like fixing a typo, feel free to ignore this message."
|
||||
fi
|
||||
|
||||
exit 0
|
||||
105
.github/workflows/scripts/check-help-wanted.sh
vendored
105
.github/workflows/scripts/check-help-wanted.sh
vendored
|
|
@ -1,105 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
PR_URL="$1"
|
||||
|
||||
if [ -z "$PR_URL" ]; then
|
||||
echo "Usage: $0 <PR_URL>"
|
||||
echo ""
|
||||
echo "Check if the PR references any non-help-wanted issues and, if so, comment"
|
||||
echo "on it explaining why the team might close/dismiss it."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Skip if PR is from a bot or org member
|
||||
if [ "$PR_AUTHOR_TYPE" = "Bot" ] || [ "$PR_AUTHOR_ASSOCIATION" = "MEMBER" ] || [ "$PR_AUTHOR_ASSOCIATION" = "OWNER" ]; then
|
||||
echo "Skipping check for PR $PR_URL as it is from a bot ($PR_AUTHOR_TYPE) or an org member ($PR_AUTHOR_ASSOCIATION: MEMBER/OWNER)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Skip if PR is a draft
|
||||
if [ "$(gh pr view "${PR_URL}" --json isDraft --jq '.isDraft')" != "false" ]; then
|
||||
echo "Skipping check for PR $PR_URL as it is a draft"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract PR number from URL for logging
|
||||
PR_NUM="$(basename "$PR_URL")"
|
||||
|
||||
# Extract cli/cli closing issues references from PR
|
||||
CLOSING_ISSUES="$(gh pr view "$PR_URL" --json closingIssuesReferences --jq '.closingIssuesReferences[] | select(.repository.name == "cli" and .repository.owner.login == "cli") | .number')"
|
||||
|
||||
if [ -z "$CLOSING_ISSUES" ]; then
|
||||
echo "No closing issues found for PR #$PR_NUM"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check each closing issue for 'help-wanted' label
|
||||
ISSUES_WITHOUT_HELP_WANTED=()
|
||||
|
||||
for issue_num in $CLOSING_ISSUES; do
|
||||
echo "Checking issue #$issue_num for 'help wanted' label..."
|
||||
|
||||
# Get issue labels
|
||||
LABELS=$(gh issue view "$issue_num" --json labels --jq '.labels[].name')
|
||||
|
||||
# Skip if the issue has the gh-attestion or gh-codespace label
|
||||
# This is because the codeowners for these commands may not be public
|
||||
# cli org members, and so unless we authenticate with a PAT, we can't
|
||||
# know who is an external contributor or not.
|
||||
# So we skip these issues to avoid falsely writing a comment
|
||||
# on each PR opened by these codeowners.
|
||||
if echo "$LABELS" | grep -q -e "gh-attestation" -e "gh-codespace"; then
|
||||
echo "Issue #$issue_num is skipped due to labels"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check if 'help wanted' label exists
|
||||
if ! echo "$LABELS" | grep -qE '^help wanted$'; then
|
||||
ISSUES_WITHOUT_HELP_WANTED+=("$issue_num")
|
||||
echo "Issue #$issue_num does not have 'help wanted' label"
|
||||
else
|
||||
echo "Issue #$issue_num has 'help wanted' label"
|
||||
fi
|
||||
done
|
||||
|
||||
# If we found issues without 'help wanted' label, post a comment
|
||||
if [ ${#ISSUES_WITHOUT_HELP_WANTED[@]} -gt 0 ]; then
|
||||
echo "Found ${#ISSUES_WITHOUT_HELP_WANTED[@]} issues without 'help wanted' label"
|
||||
|
||||
# Build issue list for comment
|
||||
ISSUE_LIST=""
|
||||
for issue_num in "${ISSUES_WITHOUT_HELP_WANTED[@]}"; do
|
||||
ISSUE_LIST="$ISSUE_LIST- #$issue_num"$'\n'
|
||||
done
|
||||
|
||||
# Create comment message
|
||||
gh pr comment "$PR_URL" --body-file - <<EOF
|
||||
Thank you for your pull request! 🎉
|
||||
|
||||
This PR appears to fix the following issues that are not labeled with https://github.com/cli/cli/labels/help%20wanted:
|
||||
|
||||
$ISSUE_LIST
|
||||
As outlined in our [Contributing Guidelines](https://github.com/cli/cli/blob/trunk/.github/CONTRIBUTING.md), we expect that PRs are only created for issues that have been labeled \`help wanted\`.
|
||||
|
||||
While we appreciate your initiative, please note that:
|
||||
|
||||
- **PRs for non-\`help wanted\` issues may not be reviewed immediately** as they might not align with our current priorities
|
||||
- **The issue might already be assigned** to a team member or planned for a specific release
|
||||
- **We may need to close this PR**. For example, if it conflicts with ongoing work or architectural decisions
|
||||
|
||||
**What happens next:**
|
||||
- Our team will review this PR and the associated issues
|
||||
- We may add the \`help wanted\` label to the issues, if appropriate, and review this pull request
|
||||
- In some cases, we may need to close the PR. For example, if it doesn't fit our current roadmap
|
||||
|
||||
Thank you for your understanding and contribution to the project! 🙏
|
||||
|
||||
*This comment was automatically generated by cliAutomation.*
|
||||
EOF
|
||||
|
||||
echo "Posted comment on PR #$PR_NUM"
|
||||
else
|
||||
echo "All closing issues have 'help wanted' label - no action needed"
|
||||
fi
|
||||
|
|
@ -918,7 +918,7 @@ testData:
|
|||
|
||||
We have an automation to nudge on issues waiting for user info (like after one week), and close the issue if there's no further activity (like after one more week).
|
||||
|
||||
- Automatically add the stale label to issues labelled needs-user-input after 30 days of inactivity. When the stale label is added, also post a comment to the issue explaining what this means: the issue will close after 30 days of inactivity; contributors can comment on the issue to remove the stale label and keep it open. Maintainers can also add the keep label to make the stale automation ignore that issue.
|
||||
- Automatically add the stale label to issues labelled more-info-needed after 30 days of inactivity. When the stale label is added, also post a comment to the issue explaining what this means: the issue will close after 30 days of inactivity; contributors can comment on the issue to remove the stale label and keep it open. Maintainers can also add the keep label to make the stale automation ignore that issue.
|
||||
- Automatically close issues labelled stale after they have been stale for 30 days. When the issue is closed, add a comment explaining why this happened. Encourage them to leave a comment if the close was done in error.
|
||||
- The above automation should only act on new issues after the date of the automation's implementation.
|
||||
</BODY>
|
||||
|
|
|
|||
36
.github/workflows/stale-issues.yml
vendored
36
.github/workflows/stale-issues.yml
vendored
|
|
@ -1,36 +0,0 @@
|
|||
name: Marks/closes stale issues
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 3 * * *" # 3 AM UTC
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
mark-stale-issues:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
start-date: "2025-07-10T00:00:00Z" # Skip for issues created before this date
|
||||
days-before-issue-stale: 30
|
||||
only-issue-labels:
|
||||
"needs-triage,needs-user-input" # Only issues with all of these labels can be marked as stale
|
||||
exempt-issue-labels: "keep" # Issues marked with this label should not be marked as stale
|
||||
stale-issue-label: "stale" # Mark stale issues with this label
|
||||
stale-issue-message: |
|
||||
This issue has been automatically marked as stale because it has not had any activity in the last 30 days,
|
||||
and it will be closed in 30 days if no further activity occurs.
|
||||
|
||||
If you think this is a mistake, please comment on this issue to keep it open.
|
||||
|
||||
days-before-issue-close: 30
|
||||
close-issue-reason: "not_planned"
|
||||
close-issue-message: |
|
||||
This issue has been automatically closed due to inactivity.
|
||||
|
||||
If you think this is a mistake, please comment on this issue.
|
||||
|
||||
# Exclude PRs from closing or being marked as stale
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
23
.github/workflows/triage-discussion-label.yml
vendored
Normal file
23
.github/workflows/triage-discussion-label.yml
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
name: Process Discuss Label
|
||||
run-name: ${{ github.event_name == 'issues' && github.event.issue.title || github.event.pull_request.title }}
|
||||
permissions: {}
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- labeled
|
||||
# pull_request_target (not pull_request) to access secrets for fork PRs.
|
||||
# Safe: no PR code is checked out or executed.
|
||||
pull_request_target:
|
||||
types:
|
||||
- labeled
|
||||
|
||||
jobs:
|
||||
discuss:
|
||||
if: github.event.action == 'labeled' && github.event.label.name == 'discuss'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-discuss.yml@main
|
||||
with:
|
||||
target_repo: 'github/cli'
|
||||
cc_team: '@github/cli'
|
||||
environment: cli-discuss-automation
|
||||
secrets:
|
||||
discussion_token: ${{ secrets.CLI_DISCUSSION_TRIAGE_TOKEN }}
|
||||
61
.github/workflows/triage-issues.yml
vendored
Normal file
61
.github/workflows/triage-issues.yml
vendored
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
name: Issue Triaging
|
||||
on:
|
||||
issues:
|
||||
types: [opened, reopened, labeled, unlabeled, closed]
|
||||
|
||||
jobs:
|
||||
label-incoming:
|
||||
if: github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'unlabeled'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-label-incoming.yml@main
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
close-invalid:
|
||||
if: github.event.action == 'labeled'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-close-invalid.yml@main
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
close-suspected-spam:
|
||||
if: github.event.action == 'labeled'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-close-suspected-spam.yml@main
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
close-single-word:
|
||||
if: github.event.action == 'opened'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-close-single-word-issues.yml@main
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
close-off-topic:
|
||||
if: github.event.action == 'labeled'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-close-off-topic.yml@main
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
enhancement-comment:
|
||||
if: github.event.action == 'labeled'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-enhancement-comment.yml@main
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
unable-to-reproduce:
|
||||
if: github.event.action == 'labeled'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-unable-to-reproduce-comment.yml@main
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
remove-needs-triage:
|
||||
if: github.event.action == 'labeled'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-remove-needs-triage.yml@main
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
on-issue-close:
|
||||
if: github.event.action == 'closed'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-on-issue-close.yml@main
|
||||
permissions:
|
||||
issues: write
|
||||
59
.github/workflows/triage-pull-requests.yml
vendored
Normal file
59
.github/workflows/triage-pull-requests.yml
vendored
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
name: PR Triaging
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, edited, labeled, ready_for_review]
|
||||
schedule:
|
||||
- cron: '0 4 * * *' # Daily at 4 AM UTC — close unmet-requirements PRs
|
||||
|
||||
jobs:
|
||||
label-external:
|
||||
if: >-
|
||||
github.event_name == 'pull_request_target' &&
|
||||
(github.event.action == 'opened' || github.event.action == 'reopened')
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-label-external-pr.yml@main
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
repository-projects: read
|
||||
|
||||
close-from-default-branch:
|
||||
if: >-
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.action == 'opened'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-close-from-default-branch.yml@main
|
||||
with:
|
||||
default_branch: trunk
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
check-requirements:
|
||||
if: >-
|
||||
github.event_name == 'pull_request_target' &&
|
||||
(github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'edited')
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-pr-requirements.yml@main
|
||||
permissions:
|
||||
issues: read
|
||||
pull-requests: write
|
||||
|
||||
close-unmet-requirements:
|
||||
if: github.event_name == 'schedule'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-pr-requirements.yml@main
|
||||
permissions:
|
||||
issues: read
|
||||
pull-requests: write
|
||||
|
||||
close-no-help-wanted:
|
||||
if: >-
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.action == 'labeled'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-close-no-help-wanted.yml@main
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
ready-for-review:
|
||||
if: >-
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.action == 'labeled'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-ready-for-review.yml@main
|
||||
permissions:
|
||||
pull-requests: write
|
||||
26
.github/workflows/triage-scheduled-tasks.yml
vendored
Normal file
26
.github/workflows/triage-scheduled-tasks.yml
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
name: Triage Scheduled Tasks
|
||||
on:
|
||||
workflow_dispatch:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
schedule:
|
||||
- cron: '5 * * * *' # Hourly — no-response close
|
||||
- cron: '0 3 * * *' # Daily at 3 AM UTC — stale issues
|
||||
|
||||
jobs:
|
||||
no-response:
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-no-response-close.yml@main
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
stale:
|
||||
if: github.event.schedule == '0 3 * * *'
|
||||
uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-stale-issues.yml@main
|
||||
with:
|
||||
days_before_stale: 30
|
||||
days_before_close: -1
|
||||
start_date: '2025-07-10T00:00:00Z'
|
||||
stale_issue_label: 'stale'
|
||||
exempt_issue_labels: 'keep'
|
||||
permissions:
|
||||
issues: write
|
||||
74
.github/workflows/triage.yml
vendored
74
.github/workflows/triage.yml
vendored
|
|
@ -1,74 +0,0 @@
|
|||
name: Discussion Triage
|
||||
run-name: ${{ github.event_name == 'issues' && github.event.issue.title || github.event.pull_request.title }}
|
||||
permissions: {}
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- labeled
|
||||
pull_request_target:
|
||||
types:
|
||||
- labeled
|
||||
env:
|
||||
TARGET_REPO: github/cli
|
||||
jobs:
|
||||
issue:
|
||||
environment: cli-discuss-automation
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'discuss'
|
||||
steps:
|
||||
- name: Create issue based on source issue
|
||||
env:
|
||||
BODY: ${{ github.event.issue.body }}
|
||||
CREATED: ${{ github.event.issue.created_at }}
|
||||
GH_TOKEN: ${{ secrets.CLI_DISCUSSION_TRIAGE_TOKEN }}
|
||||
LINK: ${{ github.repository }}#${{ github.event.issue.number }}
|
||||
TITLE: ${{ github.event.issue.title }}
|
||||
TRIGGERED_BY: ${{ github.triggering_actor }}
|
||||
run: |
|
||||
# Markdown quote source body by replacing newlines for newlines and markdown quoting
|
||||
BODY="${BODY//$'\n'/$'\n'> }"
|
||||
|
||||
# Create issue using dynamically constructed body within heredoc
|
||||
cat << EOF | gh issue create --title "Triage issue \"$TITLE\"" --body-file - --repo "$TARGET_REPO" --label triage
|
||||
**Title:** $TITLE
|
||||
**Issue:** $LINK
|
||||
**Created:** $CREATED
|
||||
**Triggered by:** @$TRIGGERED_BY
|
||||
|
||||
---
|
||||
|
||||
cc: @github/cli
|
||||
|
||||
> $BODY
|
||||
EOF
|
||||
|
||||
pull_request:
|
||||
runs-on: ubuntu-latest
|
||||
environment: cli-discuss-automation
|
||||
if: github.event_name == 'pull_request_target' && github.event.action == 'labeled' && github.event.label.name == 'discuss'
|
||||
steps:
|
||||
- name: Create issue based on source pull request
|
||||
env:
|
||||
BODY: ${{ github.event.pull_request.body }}
|
||||
CREATED: ${{ github.event.pull_request.created_at }}
|
||||
GH_TOKEN: ${{ secrets.CLI_DISCUSSION_TRIAGE_TOKEN }}
|
||||
LINK: ${{ github.repository }}#${{ github.event.pull_request.number }}
|
||||
TITLE: ${{ github.event.pull_request.title }}
|
||||
TRIGGERED_BY: ${{ github.triggering_actor }}
|
||||
run: |
|
||||
# Markdown quote source body by replacing newlines for newlines and markdown quoting
|
||||
BODY="${BODY//$'\n'/$'\n'> }"
|
||||
|
||||
# Create issue using dynamically constructed body within heredoc
|
||||
cat << EOF | gh issue create --title "Triage PR \"$TITLE\"" --body-file - --repo "$TARGET_REPO" --label triage
|
||||
**Title:** $TITLE
|
||||
**Pull request:** $LINK
|
||||
**Created:** $CREATED
|
||||
**Triggered by:** @$TRIGGERED_BY
|
||||
|
||||
---
|
||||
|
||||
cc: @github/cli
|
||||
|
||||
> $BODY
|
||||
EOF
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -18,6 +18,10 @@
|
|||
# Windows resource files
|
||||
/cmd/gh/*.syso
|
||||
|
||||
# Third-party licenses
|
||||
/internal/licenses/embed/*/*
|
||||
!/internal/licenses/embed/*/PLACEHOLDER
|
||||
|
||||
# VS Code
|
||||
.vscode
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
6
Makefile
6
Makefile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
|
||||
"github.com/cli/cli/v2/pkg/httpmock"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/cli/go-gh/v2/pkg/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
|
@ -245,13 +246,90 @@ func TestHTTPHeaders(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
|
||||
wantHeader := map[string]string{
|
||||
"Accept": "application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview",
|
||||
"Authorization": "token MYTOKEN",
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"User-Agent": "GitHub CLI v1.2.3",
|
||||
"Accept": "application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview",
|
||||
"Authorization": "token MYTOKEN",
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"User-Agent": "GitHub CLI v1.2.3",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
}
|
||||
for name, value := range wantHeader {
|
||||
assert.Equal(t, value, gotReq.Header.Get(name), name)
|
||||
}
|
||||
assert.Equal(t, "", stderr.String())
|
||||
}
|
||||
|
||||
func TestGenerateScopeErrorForGQL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
gqlError *api.GraphQLError
|
||||
wantErr bool
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "missing scope",
|
||||
gqlError: &api.GraphQLError{
|
||||
Errors: []api.GraphQLErrorItem{
|
||||
{
|
||||
Type: "INSUFFICIENT_SCOPES",
|
||||
Message: "The 'addProjectV2ItemById' field requires one of the following scopes: ['project']",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
expected: "error: your authentication token is missing required scopes [project]\n" +
|
||||
"To request it, run: gh auth refresh -s project",
|
||||
},
|
||||
|
||||
{
|
||||
name: "ignore non-scope errors",
|
||||
gqlError: &api.GraphQLError{
|
||||
Errors: []api.GraphQLErrorItem{
|
||||
{
|
||||
Type: "NOT_FOUND",
|
||||
Message: "Could not resolve to a Repository",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := GenerateScopeErrorForGQL(tt.gqlError)
|
||||
if tt.wantErr {
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, tt.expected, err.Error())
|
||||
} else {
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiredScopesFromServerMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
msg string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
msg: "requires one of the following scopes: ['project']",
|
||||
expected: []string{"project"},
|
||||
},
|
||||
{
|
||||
msg: "requires one of the following scopes: ['repo', 'read:org']",
|
||||
expected: []string{"repo", "read:org"},
|
||||
},
|
||||
{
|
||||
msg: "no match here",
|
||||
expected: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.msg, func(t *testing.T) {
|
||||
output := requiredScopesFromServerMessage(tt.msg)
|
||||
assert.Equal(t, tt.expected, output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,32 @@ func TestIssue_ExportData(t *testing.T) {
|
|||
}
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "assignees",
|
||||
fields: []string{"assignees"},
|
||||
inputJSON: heredoc.Doc(`
|
||||
{ "assignees": { "nodes": [
|
||||
{
|
||||
"id": "MDQ6VXNlcjE=",
|
||||
"login": "monalisa",
|
||||
"name": "Mona Lisa",
|
||||
"databaseId": 1234
|
||||
}
|
||||
] } }
|
||||
`),
|
||||
outputJSON: heredoc.Doc(`
|
||||
{
|
||||
"assignees": [
|
||||
{
|
||||
"id": "MDQ6VXNlcjE=",
|
||||
"login": "monalisa",
|
||||
"name": "Mona Lisa",
|
||||
"databaseId": 1234
|
||||
}
|
||||
]
|
||||
}
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "linked pull requests",
|
||||
fields: []string{"closedByPullRequestsReferences"},
|
||||
|
|
@ -316,6 +342,32 @@ func TestPullRequest_ExportData(t *testing.T) {
|
|||
}
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "assignees",
|
||||
fields: []string{"assignees"},
|
||||
inputJSON: heredoc.Doc(`
|
||||
{ "assignees": { "nodes": [
|
||||
{
|
||||
"id": "MDQ6VXNlcjE=",
|
||||
"login": "monalisa",
|
||||
"name": "Mona Lisa",
|
||||
"databaseId": 1234
|
||||
}
|
||||
] } }
|
||||
`),
|
||||
outputJSON: heredoc.Doc(`
|
||||
{
|
||||
"assignees": [
|
||||
{
|
||||
"id": "MDQ6VXNlcjE=",
|
||||
"login": "monalisa",
|
||||
"name": "Mona Lisa",
|
||||
"databaseId": 1234
|
||||
}
|
||||
]
|
||||
}
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "linked issues",
|
||||
fields: []string{"closingIssuesReferences"},
|
||||
|
|
|
|||
|
|
@ -49,7 +49,8 @@ func NewHTTPClient(opts HTTPClientOptions) (*http.Client, error) {
|
|||
}
|
||||
|
||||
headers := map[string]string{
|
||||
userAgent: fmt.Sprintf("GitHub CLI %s", opts.AppVersion),
|
||||
userAgent: fmt.Sprintf("GitHub CLI %s", opts.AppVersion),
|
||||
apiVersion: apiVersionValue,
|
||||
}
|
||||
clientOpts.Headers = headers
|
||||
|
||||
|
|
|
|||
|
|
@ -39,9 +39,10 @@ func TestNewHTTPClient(t *testing.T) {
|
|||
},
|
||||
host: "github.com",
|
||||
wantHeader: map[string][]string{
|
||||
"authorization": {"token MYTOKEN"},
|
||||
"user-agent": {"GitHub CLI v1.2.3"},
|
||||
"accept": {"application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview"},
|
||||
"authorization": {"token MYTOKEN"},
|
||||
"user-agent": {"GitHub CLI v1.2.3"},
|
||||
"x-github-api-version": {"2022-11-28"},
|
||||
"accept": {"application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview"},
|
||||
},
|
||||
wantStderr: "",
|
||||
},
|
||||
|
|
@ -53,9 +54,10 @@ func TestNewHTTPClient(t *testing.T) {
|
|||
},
|
||||
host: "example.com",
|
||||
wantHeader: map[string][]string{
|
||||
"authorization": {"token GHETOKEN"},
|
||||
"user-agent": {"GitHub CLI v1.2.3"},
|
||||
"accept": {"application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview"},
|
||||
"authorization": {"token GHETOKEN"},
|
||||
"user-agent": {"GitHub CLI v1.2.3"},
|
||||
"x-github-api-version": {"2022-11-28"},
|
||||
"accept": {"application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview"},
|
||||
},
|
||||
wantStderr: "",
|
||||
},
|
||||
|
|
@ -68,9 +70,10 @@ func TestNewHTTPClient(t *testing.T) {
|
|||
},
|
||||
host: "github.com",
|
||||
wantHeader: map[string][]string{
|
||||
"authorization": nil, // should not be set
|
||||
"user-agent": {"GitHub CLI v1.2.3"},
|
||||
"accept": {"application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview"},
|
||||
"authorization": nil, // should not be set
|
||||
"user-agent": {"GitHub CLI v1.2.3"},
|
||||
"x-github-api-version": {"2022-11-28"},
|
||||
"accept": {"application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview"},
|
||||
},
|
||||
wantStderr: "",
|
||||
},
|
||||
|
|
@ -83,9 +86,10 @@ func TestNewHTTPClient(t *testing.T) {
|
|||
},
|
||||
host: "example.com",
|
||||
wantHeader: map[string][]string{
|
||||
"authorization": nil, // should not be set
|
||||
"user-agent": {"GitHub CLI v1.2.3"},
|
||||
"accept": {"application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview"},
|
||||
"authorization": nil, // should not be set
|
||||
"user-agent": {"GitHub CLI v1.2.3"},
|
||||
"x-github-api-version": {"2022-11-28"},
|
||||
"accept": {"application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview"},
|
||||
},
|
||||
wantStderr: "",
|
||||
},
|
||||
|
|
@ -98,9 +102,10 @@ func TestNewHTTPClient(t *testing.T) {
|
|||
},
|
||||
host: "github.com",
|
||||
wantHeader: map[string][]string{
|
||||
"authorization": {"token MYTOKEN"},
|
||||
"user-agent": {"GitHub CLI v1.2.3"},
|
||||
"accept": {"application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview"},
|
||||
"authorization": {"token MYTOKEN"},
|
||||
"user-agent": {"GitHub CLI v1.2.3"},
|
||||
"x-github-api-version": {"2022-11-28"},
|
||||
"accept": {"application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview"},
|
||||
},
|
||||
wantStderr: heredoc.Doc(`
|
||||
* Request at <time>
|
||||
|
|
@ -112,6 +117,7 @@ func TestNewHTTPClient(t *testing.T) {
|
|||
> Content-Type: application/json; charset=utf-8
|
||||
> Time-Zone: <timezone>
|
||||
> User-Agent: GitHub CLI v1.2.3
|
||||
> X-Github-Api-Version: 2022-11-28
|
||||
|
||||
< HTTP/1.1 204 No Content
|
||||
< Date: <time>
|
||||
|
|
@ -128,10 +134,11 @@ func TestNewHTTPClient(t *testing.T) {
|
|||
},
|
||||
host: "github.com",
|
||||
wantHeader: map[string][]string{
|
||||
"accept": nil,
|
||||
"authorization": nil,
|
||||
"content-type": nil,
|
||||
"user-agent": {"GitHub CLI v1.2.3"},
|
||||
"accept": nil,
|
||||
"authorization": nil,
|
||||
"content-type": nil,
|
||||
"user-agent": {"GitHub CLI v1.2.3"},
|
||||
"x-github-api-version": {"2022-11-28"},
|
||||
},
|
||||
wantStderr: heredoc.Doc(`
|
||||
* Request at <time>
|
||||
|
|
@ -140,6 +147,7 @@ func TestNewHTTPClient(t *testing.T) {
|
|||
> Host: github.com
|
||||
> Time-Zone: <timezone>
|
||||
> User-Agent: GitHub CLI v1.2.3
|
||||
> X-Github-Api-Version: 2022-11-28
|
||||
|
||||
< HTTP/1.1 204 No Content
|
||||
< Date: <time>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 := `
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
29
go.mod
|
|
@ -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
62
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ func (md *DisabledDetectorMock) ProjectsV1() gh.ProjectsV1Support {
|
|||
return gh.ProjectsV1Unsupported
|
||||
}
|
||||
|
||||
func (md *DisabledDetectorMock) ProjectFeatures() (ProjectFeatures, error) {
|
||||
return ProjectFeatures{}, nil
|
||||
}
|
||||
|
||||
func (md *DisabledDetectorMock) SearchFeatures() (SearchFeatures, error) {
|
||||
return advancedIssueSearchNotSupported, nil
|
||||
}
|
||||
|
|
@ -28,6 +32,10 @@ func (md *DisabledDetectorMock) ReleaseFeatures() (ReleaseFeatures, error) {
|
|||
return ReleaseFeatures{}, nil
|
||||
}
|
||||
|
||||
func (md *DisabledDetectorMock) ActionsFeatures() (ActionsFeatures, error) {
|
||||
return ActionsFeatures{}, nil
|
||||
}
|
||||
|
||||
type EnabledDetectorMock struct{}
|
||||
|
||||
func (md *EnabledDetectorMock) IssueFeatures() (IssueFeatures, error) {
|
||||
|
|
@ -46,6 +54,10 @@ func (md *EnabledDetectorMock) ProjectsV1() gh.ProjectsV1Support {
|
|||
return gh.ProjectsV1Supported
|
||||
}
|
||||
|
||||
func (md *EnabledDetectorMock) ProjectFeatures() (ProjectFeatures, error) {
|
||||
return allProjectFeatures, nil
|
||||
}
|
||||
|
||||
func (md *EnabledDetectorMock) SearchFeatures() (SearchFeatures, error) {
|
||||
return advancedIssueSearchNotSupported, nil
|
||||
}
|
||||
|
|
@ -56,6 +68,12 @@ func (md *EnabledDetectorMock) ReleaseFeatures() (ReleaseFeatures, error) {
|
|||
}, nil
|
||||
}
|
||||
|
||||
func (md *EnabledDetectorMock) ActionsFeatures() (ActionsFeatures, error) {
|
||||
return ActionsFeatures{
|
||||
DispatchRunDetails: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type AdvancedIssueSearchDetectorMock struct {
|
||||
EnabledDetectorMock
|
||||
searchFeatures SearchFeatures
|
||||
|
|
|
|||
|
|
@ -16,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"`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
0
internal/licenses/embed/darwin-amd64/PLACEHOLDER
Normal file
0
internal/licenses/embed/darwin-amd64/PLACEHOLDER
Normal file
0
internal/licenses/embed/darwin-arm64/PLACEHOLDER
Normal file
0
internal/licenses/embed/darwin-arm64/PLACEHOLDER
Normal file
0
internal/licenses/embed/linux-386/PLACEHOLDER
Normal file
0
internal/licenses/embed/linux-386/PLACEHOLDER
Normal file
0
internal/licenses/embed/linux-amd64/PLACEHOLDER
Normal file
0
internal/licenses/embed/linux-amd64/PLACEHOLDER
Normal file
0
internal/licenses/embed/linux-arm/PLACEHOLDER
Normal file
0
internal/licenses/embed/linux-arm/PLACEHOLDER
Normal file
0
internal/licenses/embed/linux-arm64/PLACEHOLDER
Normal file
0
internal/licenses/embed/linux-arm64/PLACEHOLDER
Normal file
0
internal/licenses/embed/windows-386/PLACEHOLDER
Normal file
0
internal/licenses/embed/windows-386/PLACEHOLDER
Normal file
0
internal/licenses/embed/windows-amd64/PLACEHOLDER
Normal file
0
internal/licenses/embed/windows-amd64/PLACEHOLDER
Normal file
0
internal/licenses/embed/windows-arm64/PLACEHOLDER
Normal file
0
internal/licenses/embed/windows-arm64/PLACEHOLDER
Normal file
8
internal/licenses/embed_darwin_amd64.go
Normal file
8
internal/licenses/embed_darwin_amd64.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package licenses
|
||||
|
||||
import "embed"
|
||||
|
||||
const rootDir = "embed/darwin-amd64"
|
||||
|
||||
//go:embed all:embed/darwin-amd64
|
||||
var embedFS embed.FS
|
||||
8
internal/licenses/embed_darwin_arm64.go
Normal file
8
internal/licenses/embed_darwin_arm64.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package licenses
|
||||
|
||||
import "embed"
|
||||
|
||||
const rootDir = "embed/darwin-arm64"
|
||||
|
||||
//go:embed all:embed/darwin-arm64
|
||||
var embedFS embed.FS
|
||||
15
internal/licenses/embed_default.go
Normal file
15
internal/licenses/embed_default.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// This file is necessary to allow building on platforms that we do not have
|
||||
// official release builds for. Without this, `go build` or `go install` calls
|
||||
// would fail due to undefined symbols that are expected to be included in the
|
||||
// build.
|
||||
|
||||
//go:build !(darwin && (amd64 || arm64)) && !(linux && (386 || amd64 || arm || arm64)) && !(windows && (386 || amd64 || arm64))
|
||||
|
||||
package licenses
|
||||
|
||||
import "embed"
|
||||
|
||||
const rootDir = ""
|
||||
|
||||
// embedFS is left empty to indicate there's no embedded content.
|
||||
var embedFS embed.FS
|
||||
8
internal/licenses/embed_linux_386.go
Normal file
8
internal/licenses/embed_linux_386.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package licenses
|
||||
|
||||
import "embed"
|
||||
|
||||
const rootDir = "embed/linux-386"
|
||||
|
||||
//go:embed all:embed/linux-386
|
||||
var embedFS embed.FS
|
||||
8
internal/licenses/embed_linux_amd64.go
Normal file
8
internal/licenses/embed_linux_amd64.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package licenses
|
||||
|
||||
import "embed"
|
||||
|
||||
const rootDir = "embed/linux-amd64"
|
||||
|
||||
//go:embed all:embed/linux-amd64
|
||||
var embedFS embed.FS
|
||||
8
internal/licenses/embed_linux_arm.go
Normal file
8
internal/licenses/embed_linux_arm.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package licenses
|
||||
|
||||
import "embed"
|
||||
|
||||
const rootDir = "embed/linux-arm"
|
||||
|
||||
//go:embed all:embed/linux-arm
|
||||
var embedFS embed.FS
|
||||
8
internal/licenses/embed_linux_arm64.go
Normal file
8
internal/licenses/embed_linux_arm64.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package licenses
|
||||
|
||||
import "embed"
|
||||
|
||||
const rootDir = "embed/linux-arm64"
|
||||
|
||||
//go:embed all:embed/linux-arm64
|
||||
var embedFS embed.FS
|
||||
8
internal/licenses/embed_windows_386.go
Normal file
8
internal/licenses/embed_windows_386.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package licenses
|
||||
|
||||
import "embed"
|
||||
|
||||
const rootDir = "embed/windows-386"
|
||||
|
||||
//go:embed all:embed/windows-386
|
||||
var embedFS embed.FS
|
||||
8
internal/licenses/embed_windows_amd64.go
Normal file
8
internal/licenses/embed_windows_amd64.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package licenses
|
||||
|
||||
import "embed"
|
||||
|
||||
const rootDir = "embed/windows-amd64"
|
||||
|
||||
//go:embed all:embed/windows-amd64
|
||||
var embedFS embed.FS
|
||||
8
internal/licenses/embed_windows_arm64.go
Normal file
8
internal/licenses/embed_windows_arm64.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package licenses
|
||||
|
||||
import "embed"
|
||||
|
||||
const rootDir = "embed/windows-arm64"
|
||||
|
||||
//go:embed all:embed/windows-arm64
|
||||
var embedFS embed.FS
|
||||
85
internal/licenses/licenses.go
Normal file
85
internal/licenses/licenses.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package licenses
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Content returns the full license report, including the main report and all
|
||||
// third-party licenses.
|
||||
func Content() string {
|
||||
return content(embedFS, rootDir)
|
||||
}
|
||||
|
||||
func content(embedFS fs.ReadFileFS, rootDir string) string {
|
||||
var b strings.Builder
|
||||
|
||||
reportPath := path.Join(rootDir, "report.txt")
|
||||
thirdPartyPath := path.Join(rootDir, "third-party")
|
||||
|
||||
report, err := fs.ReadFile(embedFS, reportPath)
|
||||
if err != nil {
|
||||
return "License information is only available in official release builds.\n"
|
||||
}
|
||||
|
||||
b.Write(report)
|
||||
b.WriteString("\n")
|
||||
|
||||
// Walk the third-party directory and output each license/notice file
|
||||
// grouped by module path.
|
||||
type moduleFiles struct {
|
||||
path string
|
||||
files []string
|
||||
}
|
||||
|
||||
thirdPartyFS, err := fs.Sub(embedFS, thirdPartyPath)
|
||||
if err != nil {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
modules := map[string]*moduleFiles{}
|
||||
fs.WalkDir(thirdPartyFS, ".", func(filePath string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read embedded file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
dir := path.Dir(filePath)
|
||||
if _, ok := modules[dir]; !ok {
|
||||
modules[dir] = &moduleFiles{path: dir}
|
||||
}
|
||||
modules[dir].files = append(modules[dir].files, filePath)
|
||||
return nil
|
||||
})
|
||||
|
||||
// Sort modules by path for deterministic output
|
||||
sorted := make([]string, 0, len(modules))
|
||||
for k := range modules {
|
||||
sorted = append(sorted, k)
|
||||
}
|
||||
sort.Strings(sorted)
|
||||
|
||||
for _, modPath := range sorted {
|
||||
mod := modules[modPath]
|
||||
b.WriteString("================================================================================\n")
|
||||
fmt.Fprintf(&b, "%s\n", mod.path)
|
||||
b.WriteString("================================================================================\n\n")
|
||||
|
||||
for _, filePath := range mod.files {
|
||||
data, err := fs.ReadFile(thirdPartyFS, filePath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
b.Write(data)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
160
internal/licenses/licenses_test.go
Normal file
160
internal/licenses/licenses_test.go
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
package licenses
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestContent(t *testing.T) {
|
||||
// This test is to ensure that we don't accidentally commit actual license
|
||||
// files in the repo. The embedded content is only included in release builds,
|
||||
// so in a normal test build we should get a default message.
|
||||
require.Equal(t, "License information is only available in official release builds.\n", Content())
|
||||
}
|
||||
|
||||
func TestContent_tableTests(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fsys fstest.MapFS
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "report only",
|
||||
fsys: fstest.MapFS{
|
||||
"embed/os-arch/PLACEHOLDER": &fstest.MapFile{}, // Checked-in placeholder, so it's always there.
|
||||
"embed/os-arch/report.txt": &fstest.MapFile{Data: []byte("dep1 (v1.0.0) - MIT - https://example.com\n")},
|
||||
},
|
||||
expected: heredoc.Doc(`
|
||||
dep1 (v1.0.0) - MIT - https://example.com
|
||||
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "empty third-party dir",
|
||||
fsys: fstest.MapFS{
|
||||
"embed/os-arch/PLACEHOLDER": &fstest.MapFile{}, // Checked-in placeholder, so it's always there.
|
||||
"embed/os-arch/report.txt": &fstest.MapFile{Data: []byte("dep1 (v1.0.0) - MIT - https://example.com\n")},
|
||||
"embed/os-arch/third-party": &fstest.MapFile{Data: []byte{}, Mode: fs.ModeDir},
|
||||
},
|
||||
expected: heredoc.Doc(`
|
||||
dep1 (v1.0.0) - MIT - https://example.com
|
||||
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "unknown file at root ignored",
|
||||
fsys: fstest.MapFS{
|
||||
"embed/os-arch/PLACEHOLDER": &fstest.MapFile{}, // Checked-in placeholder, so it's always there.
|
||||
"embed/os-arch/report.txt": &fstest.MapFile{Data: []byte("dep1 (v1.0.0) - MIT - https://example.com\n")},
|
||||
"embed/os-arch/unknown": &fstest.MapFile{
|
||||
Data: []byte("MIT License\n\nCopyright (c) 2024"),
|
||||
},
|
||||
},
|
||||
expected: heredoc.Doc(`
|
||||
dep1 (v1.0.0) - MIT - https://example.com
|
||||
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "unknown directory at root ignored",
|
||||
fsys: fstest.MapFS{
|
||||
"embed/os-arch/PLACEHOLDER": &fstest.MapFile{}, // Checked-in placeholder, so it's always there.
|
||||
"embed/os-arch/report.txt": &fstest.MapFile{Data: []byte("dep1 (v1.0.0) - MIT - https://example.com\n")},
|
||||
"embed/os-arch/unknown/example.com/mod/LICENSE": &fstest.MapFile{
|
||||
Data: []byte("MIT License\n\nCopyright (c) 2024"),
|
||||
},
|
||||
},
|
||||
expected: heredoc.Doc(`
|
||||
dep1 (v1.0.0) - MIT - https://example.com
|
||||
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "single module",
|
||||
fsys: fstest.MapFS{
|
||||
"embed/os-arch/PLACEHOLDER": &fstest.MapFile{}, // Checked-in placeholder, so it's always there.
|
||||
"embed/os-arch/report.txt": &fstest.MapFile{Data: []byte("example.com/mod (v1.0.0) - MIT - https://example.com\n")},
|
||||
"embed/os-arch/third-party/example.com/mod/LICENSE": &fstest.MapFile{
|
||||
Data: []byte("MIT License\n\nCopyright (c) 2024"),
|
||||
},
|
||||
},
|
||||
expected: heredoc.Doc(`
|
||||
example.com/mod (v1.0.0) - MIT - https://example.com
|
||||
|
||||
================================================================================
|
||||
example.com/mod
|
||||
================================================================================
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024
|
||||
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "multiple modules sorted alphabetically",
|
||||
fsys: fstest.MapFS{
|
||||
"embed/os-arch/PLACEHOLDER": &fstest.MapFile{}, // Checked-in placeholder, so it's always there.
|
||||
"embed/os-arch/report.txt": &fstest.MapFile{Data: []byte("example.com/mod (v1.0.0) - MIT - https://example.com\n")},
|
||||
"embed/os-arch/third-party/github.com/zzz/pkg/LICENSE": &fstest.MapFile{
|
||||
Data: []byte("ZZZ License"),
|
||||
},
|
||||
"embed/os-arch/third-party/github.com/aaa/pkg/LICENSE": &fstest.MapFile{
|
||||
Data: []byte("AAA License"),
|
||||
},
|
||||
},
|
||||
expected: heredoc.Doc(`
|
||||
example.com/mod (v1.0.0) - MIT - https://example.com
|
||||
|
||||
================================================================================
|
||||
github.com/aaa/pkg
|
||||
================================================================================
|
||||
|
||||
AAA License
|
||||
|
||||
================================================================================
|
||||
github.com/zzz/pkg
|
||||
================================================================================
|
||||
|
||||
ZZZ License
|
||||
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "license and notice files",
|
||||
fsys: fstest.MapFS{
|
||||
"embed/os-arch/PLACEHOLDER": &fstest.MapFile{}, // Checked-in placeholder, so it's always there.
|
||||
"embed/os-arch/report.txt": &fstest.MapFile{Data: []byte("example.com/mod (v1.0.0) - MIT - https://example.com\n")},
|
||||
"embed/os-arch/third-party/example.com/mod/LICENSE": &fstest.MapFile{
|
||||
Data: []byte("Apache License 2.0"),
|
||||
},
|
||||
"embed/os-arch/third-party/example.com/mod/NOTICE": &fstest.MapFile{
|
||||
Data: []byte("Copyright 2024 Example Corp"),
|
||||
},
|
||||
},
|
||||
expected: heredoc.Doc(`
|
||||
example.com/mod (v1.0.0) - MIT - https://example.com
|
||||
|
||||
================================================================================
|
||||
example.com/mod
|
||||
================================================================================
|
||||
|
||||
Apache License 2.0
|
||||
|
||||
Copyright 2024 Example Corp
|
||||
|
||||
`),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := content(tt.fsys, "embed/os-arch")
|
||||
require.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -62,6 +62,9 @@ func (ct *capiTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|||
// ID only when performing requests to the Copilot API.
|
||||
if req.URL.Host == capiHost {
|
||||
req.Header.Add("Copilot-Integration-Id", "copilot-4-cli")
|
||||
|
||||
// This is quick fix to ensure that we are not using GitHub API versions while targeting CAPI.
|
||||
req.Header.Set("X-GitHub-Api-Version", "2026-01-09")
|
||||
}
|
||||
return ct.rp.RoundTrip(req)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,94 @@ type SessionError struct {
|
|||
Message string
|
||||
}
|
||||
|
||||
// SessionFields defines the available fields for JSON export of a Session.
|
||||
var SessionFields = []string{
|
||||
"id",
|
||||
"name",
|
||||
"state",
|
||||
"repository",
|
||||
"user",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"completedAt",
|
||||
"pullRequestNumber",
|
||||
"pullRequestUrl",
|
||||
"pullRequestTitle",
|
||||
"pullRequestState",
|
||||
}
|
||||
|
||||
// ExportData implements the exportable interface for JSON output.
|
||||
func (s *Session) ExportData(fields []string) map[string]interface{} {
|
||||
data := make(map[string]interface{}, len(fields))
|
||||
for _, f := range fields {
|
||||
switch f {
|
||||
case "id":
|
||||
data[f] = s.ID
|
||||
case "name":
|
||||
data[f] = s.Name
|
||||
case "state":
|
||||
data[f] = s.State
|
||||
case "repository":
|
||||
if s.PullRequest != nil && s.PullRequest.Repository != nil {
|
||||
data[f] = s.PullRequest.Repository.NameWithOwner
|
||||
} else {
|
||||
data[f] = nil
|
||||
}
|
||||
case "user":
|
||||
if s.User != nil {
|
||||
data[f] = s.User.Login
|
||||
} else {
|
||||
data[f] = nil
|
||||
}
|
||||
case "createdAt":
|
||||
if s.CreatedAt.IsZero() {
|
||||
data[f] = nil
|
||||
} else {
|
||||
data[f] = s.CreatedAt
|
||||
}
|
||||
case "updatedAt":
|
||||
if s.LastUpdatedAt.IsZero() {
|
||||
data[f] = nil
|
||||
} else {
|
||||
data[f] = s.LastUpdatedAt
|
||||
}
|
||||
case "completedAt":
|
||||
if s.CompletedAt.IsZero() {
|
||||
data[f] = nil
|
||||
} else {
|
||||
data[f] = s.CompletedAt
|
||||
}
|
||||
case "pullRequestNumber":
|
||||
if s.PullRequest != nil {
|
||||
data[f] = s.PullRequest.Number
|
||||
} else {
|
||||
data[f] = nil
|
||||
}
|
||||
case "pullRequestUrl":
|
||||
if s.PullRequest != nil {
|
||||
data[f] = s.PullRequest.URL
|
||||
} else {
|
||||
data[f] = nil
|
||||
}
|
||||
case "pullRequestTitle":
|
||||
if s.PullRequest != nil {
|
||||
data[f] = s.PullRequest.Title
|
||||
} else {
|
||||
data[f] = nil
|
||||
}
|
||||
case "pullRequestState":
|
||||
if s.PullRequest != nil {
|
||||
data[f] = s.PullRequest.State
|
||||
} else {
|
||||
data[f] = nil
|
||||
}
|
||||
default:
|
||||
data[f] = nil
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
type resource struct {
|
||||
ID string `json:"id"`
|
||||
UserID uint64 `json:"user_id"`
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ type ListOptions struct {
|
|||
CapiClient func() (capi.CapiClient, error)
|
||||
Web bool
|
||||
Browser browser.Browser
|
||||
Exporter cmdutil.Exporter
|
||||
}
|
||||
|
||||
// NewCmdList creates the list command
|
||||
|
|
@ -54,6 +55,8 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
|
|||
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", defaultLimit, "Maximum number of agent tasks to fetch")
|
||||
cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open agent tasks in the browser")
|
||||
|
||||
cmdutil.AddJSONFlags(cmd, &opts.Exporter, capi.SessionFields)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
|
@ -87,10 +90,14 @@ func listRun(opts *ListOptions) error {
|
|||
|
||||
opts.IO.StopProgressIndicator()
|
||||
|
||||
if len(sessions) == 0 {
|
||||
if len(sessions) == 0 && opts.Exporter == nil {
|
||||
return cmdutil.NewNoResultsError("no agent tasks found")
|
||||
}
|
||||
|
||||
if opts.Exporter != nil {
|
||||
return opts.Exporter.Write(opts.IO, sessions)
|
||||
}
|
||||
|
||||
if err := opts.IO.StartPager(); err == nil {
|
||||
defer opts.IO.StopPager()
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ func Test_listRun(t *testing.T) {
|
|||
capiStubs func(*testing.T, *capi.CapiClientMock)
|
||||
limit int
|
||||
web bool
|
||||
jsonFields []string
|
||||
wantOut string
|
||||
wantErr error
|
||||
wantStderr string
|
||||
|
|
@ -286,6 +287,68 @@ func Test_listRun(t *testing.T) {
|
|||
wantStderr: "Opening https://github.com/copilot/agents in your browser.\n",
|
||||
wantBrowserURL: "https://github.com/copilot/agents",
|
||||
},
|
||||
{
|
||||
name: "json output",
|
||||
tty: false,
|
||||
capiStubs: func(t *testing.T, m *capi.CapiClientMock) {
|
||||
m.ListLatestSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) {
|
||||
return []*capi.Session{
|
||||
{
|
||||
ID: "abc-123",
|
||||
Name: "s1",
|
||||
State: "completed",
|
||||
CreatedAt: sampleDate,
|
||||
LastUpdatedAt: sampleDate,
|
||||
CompletedAt: sampleDate,
|
||||
ResourceType: "pull",
|
||||
User: &api.GitHubUser{Login: "monalisa"},
|
||||
PullRequest: &api.PullRequest{
|
||||
Number: 101,
|
||||
Title: "Fix login bug",
|
||||
State: "MERGED",
|
||||
URL: "https://github.com/OWNER/REPO/pull/101",
|
||||
Repository: &api.PRRepository{
|
||||
NameWithOwner: "OWNER/REPO",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
},
|
||||
jsonFields: []string{"id", "name", "state", "repository", "user", "pullRequestNumber", "pullRequestUrl", "pullRequestTitle", "pullRequestState"},
|
||||
wantOut: "[{\"id\":\"abc-123\",\"name\":\"s1\",\"pullRequestNumber\":101,\"pullRequestState\":\"MERGED\",\"pullRequestTitle\":\"Fix login bug\",\"pullRequestUrl\":\"https://github.com/OWNER/REPO/pull/101\",\"repository\":\"OWNER/REPO\",\"state\":\"completed\",\"user\":\"monalisa\"}]\n",
|
||||
},
|
||||
{
|
||||
name: "json output with no sessions returns empty array",
|
||||
tty: false,
|
||||
capiStubs: func(t *testing.T, m *capi.CapiClientMock) {
|
||||
m.ListLatestSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) {
|
||||
return nil, nil
|
||||
}
|
||||
},
|
||||
jsonFields: []string{"id", "name", "state"},
|
||||
wantOut: "[]\n",
|
||||
},
|
||||
{
|
||||
name: "json output with nil pull request",
|
||||
tty: false,
|
||||
capiStubs: func(t *testing.T, m *capi.CapiClientMock) {
|
||||
m.ListLatestSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) {
|
||||
return []*capi.Session{
|
||||
{
|
||||
ID: "abc-456",
|
||||
Name: "s2",
|
||||
State: "in_progress",
|
||||
CreatedAt: sampleDate,
|
||||
LastUpdatedAt: sampleDate,
|
||||
ResourceType: "pull",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
},
|
||||
jsonFields: []string{"id", "name", "state", "repository", "user", "pullRequestNumber", "pullRequestUrl", "pullRequestTitle", "pullRequestState"},
|
||||
wantOut: "[{\"id\":\"abc-456\",\"name\":\"s2\",\"pullRequestNumber\":null,\"pullRequestState\":null,\"pullRequestTitle\":null,\"pullRequestUrl\":null,\"repository\":null,\"state\":\"in_progress\",\"user\":null}]\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -316,6 +379,12 @@ func Test_listRun(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
if tt.jsonFields != nil {
|
||||
exporter := cmdutil.NewJSONExporter()
|
||||
exporter.SetFields(tt.jsonFields)
|
||||
opts.Exporter = exporter
|
||||
}
|
||||
|
||||
err := listRun(opts)
|
||||
if tt.wantErr != nil {
|
||||
assert.Error(t, err)
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ type ViewOptions struct {
|
|||
Finder prShared.PRFinder
|
||||
Prompter prompter.Prompter
|
||||
Browser browser.Browser
|
||||
Exporter cmdutil.Exporter
|
||||
|
||||
LogRenderer func() shared.LogRenderer
|
||||
Sleep func(d time.Duration)
|
||||
|
|
@ -125,6 +126,8 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
|
|||
cmd.Flags().BoolVar(&opts.Log, "log", false, "Show agent session logs")
|
||||
cmd.Flags().BoolVar(&opts.Follow, "follow", false, "Follow agent session logs")
|
||||
|
||||
cmdutil.AddJSONFlags(cmd, &opts.Exporter, capi.SessionFields)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
|
@ -285,6 +288,10 @@ func viewRun(opts *ViewOptions) error {
|
|||
opts.IO.StopProgressIndicator()
|
||||
}
|
||||
|
||||
if opts.Exporter != nil {
|
||||
return opts.Exporter.Write(opts.IO, session)
|
||||
}
|
||||
|
||||
if opts.Log {
|
||||
return printLogs(opts, capiClient, session.ID)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ func Test_viewRun(t *testing.T) {
|
|||
promptStubs func(*testing.T, *prompter.MockPrompter)
|
||||
capiStubs func(*testing.T, *capi.CapiClientMock)
|
||||
logRendererStubs func(*testing.T, *shared.LogRendererMock)
|
||||
jsonFields []string
|
||||
wantOut string
|
||||
wantErr error
|
||||
wantStderr string
|
||||
|
|
@ -1209,6 +1210,63 @@ func Test_viewRun(t *testing.T) {
|
|||
(rendered:) <raw-logs-two>
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "json output (tty)",
|
||||
tty: true,
|
||||
opts: ViewOptions{
|
||||
SelectorArg: "some-session-id",
|
||||
SessionID: "some-session-id",
|
||||
},
|
||||
capiStubs: func(t *testing.T, m *capi.CapiClientMock) {
|
||||
m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) {
|
||||
return &capi.Session{
|
||||
ID: "some-session-id",
|
||||
Name: "Fix login bug",
|
||||
State: "completed",
|
||||
CreatedAt: sampleDate,
|
||||
LastUpdatedAt: sampleDate,
|
||||
CompletedAt: sampleCompletedAt,
|
||||
ResourceType: "pull",
|
||||
PullRequest: &api.PullRequest{
|
||||
Number: 42,
|
||||
URL: "https://github.com/OWNER/REPO/pull/42",
|
||||
Title: "Fix login bug",
|
||||
State: "MERGED",
|
||||
Repository: &api.PRRepository{
|
||||
NameWithOwner: "OWNER/REPO",
|
||||
},
|
||||
},
|
||||
User: &api.GitHubUser{
|
||||
Login: "testuser",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
},
|
||||
wantOut: "{\"id\":\"some-session-id\",\"name\":\"Fix login bug\",\"pullRequestNumber\":42,\"pullRequestState\":\"MERGED\",\"pullRequestTitle\":\"Fix login bug\",\"pullRequestUrl\":\"https://github.com/OWNER/REPO/pull/42\",\"repository\":\"OWNER/REPO\",\"state\":\"completed\",\"user\":\"testuser\"}\n",
|
||||
jsonFields: []string{"id", "name", "state", "repository", "user", "pullRequestNumber", "pullRequestUrl", "pullRequestTitle", "pullRequestState"},
|
||||
},
|
||||
{
|
||||
name: "json output with nil pull request",
|
||||
tty: false,
|
||||
opts: ViewOptions{
|
||||
SelectorArg: "some-session-id",
|
||||
SessionID: "some-session-id",
|
||||
},
|
||||
capiStubs: func(t *testing.T, m *capi.CapiClientMock) {
|
||||
m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) {
|
||||
return &capi.Session{
|
||||
ID: "some-session-id",
|
||||
Name: "New task",
|
||||
State: "in_progress",
|
||||
CreatedAt: sampleDate,
|
||||
LastUpdatedAt: sampleDate,
|
||||
ResourceType: "pull",
|
||||
}, nil
|
||||
}
|
||||
},
|
||||
wantOut: "{\"id\":\"some-session-id\",\"name\":\"New task\",\"pullRequestNumber\":null,\"pullRequestUrl\":null,\"repository\":null,\"state\":\"in_progress\",\"user\":null}\n",
|
||||
jsonFields: []string{"id", "name", "state", "repository", "user", "pullRequestNumber", "pullRequestUrl"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -1244,6 +1302,12 @@ func Test_viewRun(t *testing.T) {
|
|||
return logRenderer
|
||||
}
|
||||
|
||||
if tt.jsonFields != nil {
|
||||
exporter := cmdutil.NewJSONExporter()
|
||||
exporter.SetFields(tt.jsonFields)
|
||||
opts.Exporter = exporter
|
||||
}
|
||||
|
||||
err := viewRun(&opts)
|
||||
if tt.wantErr != nil {
|
||||
assert.Error(t, err)
|
||||
|
|
|
|||
|
|
@ -456,6 +456,8 @@ func apiRun(opts *ApiOptions) error {
|
|||
return tmpl.Flush()
|
||||
}
|
||||
|
||||
var jsonContentTypeRE = regexp.MustCompile(`[/+]json(;|$)`)
|
||||
|
||||
func processResponse(resp *http.Response, opts *ApiOptions, bodyWriter, headersWriter io.Writer, template *template.Template, isFirstPage, isLastPage bool) (endCursor string, err error) {
|
||||
if opts.ShowResponseHeaders {
|
||||
fmt.Fprintln(headersWriter, resp.Proto, resp.Status)
|
||||
|
|
@ -469,7 +471,7 @@ func processResponse(resp *http.Response, opts *ApiOptions, bodyWriter, headersW
|
|||
var responseBody io.Reader = resp.Body
|
||||
defer resp.Body.Close()
|
||||
|
||||
isJSON, _ := regexp.MatchString(`[/+]json(;|$)`, resp.Header.Get("Content-Type"))
|
||||
isJSON := jsonContentTypeRE.MatchString(resp.Header.Get("Content-Type"))
|
||||
|
||||
var serverError string
|
||||
if isJSON && (opts.RequestPath == "graphql" || resp.StatusCode >= 400) {
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ type BrowseOptions struct {
|
|||
SettingsFlag bool
|
||||
WikiFlag bool
|
||||
ActionsFlag bool
|
||||
BlameFlag bool
|
||||
NoBrowserFlag bool
|
||||
HasRepoOverride bool
|
||||
}
|
||||
|
|
@ -91,6 +92,9 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co
|
|||
# Open main.go at line 312
|
||||
$ gh browse main.go:312
|
||||
|
||||
# Open blame view for main.go at line 312
|
||||
$ gh browse main.go:312 --blame
|
||||
|
||||
# Open main.go with the repository at head of bug-fix branch
|
||||
$ gh browse main.go --branch bug-fix
|
||||
|
||||
|
|
@ -141,6 +145,10 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co
|
|||
return err
|
||||
}
|
||||
|
||||
if opts.BlameFlag && opts.SelectorArg == "" {
|
||||
return cmdutil.FlagErrorf("`--blame` requires a file path argument")
|
||||
}
|
||||
|
||||
if (isNumber(opts.SelectorArg) || isCommit(opts.SelectorArg)) && (opts.Branch != "" || opts.Commit != "") {
|
||||
return cmdutil.FlagErrorf("%q is an invalid argument when using `--branch` or `--commit`", opts.SelectorArg)
|
||||
}
|
||||
|
|
@ -163,6 +171,7 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co
|
|||
cmd.Flags().BoolVarP(&opts.WikiFlag, "wiki", "w", false, "Open repository wiki")
|
||||
cmd.Flags().BoolVarP(&opts.ActionsFlag, "actions", "a", false, "Open repository actions")
|
||||
cmd.Flags().BoolVarP(&opts.SettingsFlag, "settings", "s", false, "Open repository settings")
|
||||
cmd.Flags().BoolVar(&opts.BlameFlag, "blame", false, "Open blame view for a file")
|
||||
cmd.Flags().BoolVarP(&opts.NoBrowserFlag, "no-browser", "n", false, "Print destination URL instead of opening the browser")
|
||||
cmd.Flags().StringVarP(&opts.Commit, "commit", "c", "", "Select another commit by passing in the commit SHA, default is the last commit")
|
||||
cmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "Select another branch by passing in the branch name")
|
||||
|
|
@ -272,9 +281,16 @@ func parseSection(baseRepo ghrepo.Interface, opts *BrowseOptions) (string, error
|
|||
} else {
|
||||
rangeFragment = fmt.Sprintf("L%d", rangeStart)
|
||||
}
|
||||
if opts.BlameFlag {
|
||||
return fmt.Sprintf("blame/%s/%s#%s", escapePath(ref), escapePath(filePath), rangeFragment), nil
|
||||
}
|
||||
return fmt.Sprintf("blob/%s/%s?plain=1#%s", escapePath(ref), escapePath(filePath), rangeFragment), nil
|
||||
}
|
||||
|
||||
if opts.BlameFlag {
|
||||
return fmt.Sprintf("blame/%s/%s", escapePath(ref), escapePath(filePath)), nil
|
||||
}
|
||||
|
||||
return strings.TrimSuffix(fmt.Sprintf("tree/%s/%s", escapePath(ref), escapePath(filePath)), "/"), nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -207,6 +207,29 @@ func TestNewCmdBrowse(t *testing.T) {
|
|||
cli: "de07febc26e19000f8c9e821207f3bc34a3c8038 --commit=12a4",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "blame flag",
|
||||
cli: "main.go --blame",
|
||||
wants: BrowseOptions{
|
||||
BlameFlag: true,
|
||||
SelectorArg: "main.go",
|
||||
},
|
||||
wantsErr: false,
|
||||
},
|
||||
{
|
||||
name: "blame flag without file argument",
|
||||
cli: "--blame",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "blame flag with line number",
|
||||
cli: "main.go:312 --blame",
|
||||
wants: BrowseOptions{
|
||||
BlameFlag: true,
|
||||
SelectorArg: "main.go:312",
|
||||
},
|
||||
wantsErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -239,6 +262,7 @@ func TestNewCmdBrowse(t *testing.T) {
|
|||
assert.Equal(t, tt.wants.SettingsFlag, opts.SettingsFlag)
|
||||
assert.Equal(t, tt.wants.ActionsFlag, opts.ActionsFlag)
|
||||
assert.Equal(t, tt.wants.Commit, opts.Commit)
|
||||
assert.Equal(t, tt.wants.BlameFlag, opts.BlameFlag)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -595,6 +619,61 @@ func Test_runBrowse(t *testing.T) {
|
|||
expectedURL: "https://github.com/bchadwic/test/tree/trunk/77507cd94ccafcf568f8560cfecde965fcfa63e7.txt",
|
||||
wantsErr: false,
|
||||
},
|
||||
{
|
||||
name: "file with blame flag",
|
||||
opts: BrowseOptions{
|
||||
SelectorArg: "path/to/file.txt",
|
||||
BlameFlag: true,
|
||||
},
|
||||
baseRepo: ghrepo.New("owner", "repo"),
|
||||
defaultBranch: "main",
|
||||
expectedURL: "https://github.com/owner/repo/blame/main/path/to/file.txt",
|
||||
wantsErr: false,
|
||||
},
|
||||
{
|
||||
name: "file with blame flag and line number",
|
||||
opts: BrowseOptions{
|
||||
SelectorArg: "path/to/file.txt:42",
|
||||
BlameFlag: true,
|
||||
},
|
||||
baseRepo: ghrepo.New("owner", "repo"),
|
||||
defaultBranch: "main",
|
||||
expectedURL: "https://github.com/owner/repo/blame/main/path/to/file.txt#L42",
|
||||
wantsErr: false,
|
||||
},
|
||||
{
|
||||
name: "file with blame flag and line range",
|
||||
opts: BrowseOptions{
|
||||
SelectorArg: "path/to/file.txt:10-20",
|
||||
BlameFlag: true,
|
||||
},
|
||||
baseRepo: ghrepo.New("owner", "repo"),
|
||||
defaultBranch: "main",
|
||||
expectedURL: "https://github.com/owner/repo/blame/main/path/to/file.txt#L10-L20",
|
||||
wantsErr: false,
|
||||
},
|
||||
{
|
||||
name: "file with blame flag and branch",
|
||||
opts: BrowseOptions{
|
||||
SelectorArg: "main.go:100",
|
||||
BlameFlag: true,
|
||||
Branch: "feature-branch",
|
||||
},
|
||||
baseRepo: ghrepo.New("owner", "repo"),
|
||||
expectedURL: "https://github.com/owner/repo/blame/feature-branch/main.go#L100",
|
||||
wantsErr: false,
|
||||
},
|
||||
{
|
||||
name: "file with blame flag and commit",
|
||||
opts: BrowseOptions{
|
||||
SelectorArg: "src/app.js:50",
|
||||
BlameFlag: true,
|
||||
Commit: "abc123",
|
||||
},
|
||||
baseRepo: ghrepo.New("owner", "repo"),
|
||||
expectedURL: "https://github.com/owner/repo/blame/abc123/src/app.js#L50",
|
||||
wantsErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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: ®}
|
||||
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -734,6 +734,7 @@ func TestPlainHttpClient(t *testing.T) {
|
|||
|
||||
assert.Equal(t, 204, res.StatusCode)
|
||||
assert.Equal(t, []string{"GitHub CLI v1.2.3"}, receivedHeaders.Values("User-Agent"))
|
||||
assert.Equal(t, []string{"2022-11-28"}, receivedHeaders.Values("X-GitHub-Api-Version"))
|
||||
assert.Nil(t, receivedHeaders.Values("Authorization"))
|
||||
assert.Nil(t, receivedHeaders.Values("Content-Type"))
|
||||
assert.Nil(t, receivedHeaders.Values("Accept"))
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/internal/gh"
|
||||
"github.com/cli/cli/v2/internal/prompter"
|
||||
|
|
@ -58,6 +59,28 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
|
|||
cmd := &cobra.Command{
|
||||
Use: "edit {<id> | <url>} [<filename>]",
|
||||
Short: "Edit one of your gists",
|
||||
Example: heredoc.Doc(`
|
||||
# Select a gist to edit interactively
|
||||
$ gh gist edit
|
||||
|
||||
# Edit a gist file in the default editor
|
||||
$ gh gist edit 1234567890abcdef1234567890abcdef
|
||||
|
||||
# Edit a specific file in the gist
|
||||
$ gh gist edit 1234567890abcdef1234567890abcdef --filename hello.py
|
||||
|
||||
# Replace a gist file with content from a local file
|
||||
$ gh gist edit 1234567890abcdef1234567890abcdef --filename hello.py hello.py
|
||||
|
||||
# Add a new file to the gist
|
||||
$ gh gist edit 1234567890abcdef1234567890abcdef --add newfile.py
|
||||
|
||||
# Change the description of the gist
|
||||
$ gh gist edit 1234567890abcdef1234567890abcdef --desc "new description"
|
||||
|
||||
# Remove a file from the gist
|
||||
$ gh gist edit 1234567890abcdef1234567890abcdef --remove hello.py
|
||||
`),
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 2 {
|
||||
return cmdutil.FlagErrorf("too many arguments")
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import (
|
|||
ctx "context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/api"
|
||||
|
|
@ -150,21 +152,22 @@ func developRun(opts *DevelopOptions) error {
|
|||
return err
|
||||
}
|
||||
|
||||
opts.IO.StartProgressIndicator()
|
||||
opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Fetching issue #%d", opts.IssueNumber))
|
||||
defer opts.IO.StopProgressIndicator()
|
||||
|
||||
issue, err := shared.FindIssueOrPR(httpClient, baseRepo, opts.IssueNumber, []string{"id", "number"})
|
||||
opts.IO.StopProgressIndicator()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiClient := api.NewClientFromHTTP(httpClient)
|
||||
|
||||
opts.IO.StartProgressIndicator()
|
||||
opts.IO.StartProgressIndicatorWithLabel("Checking linked branch support")
|
||||
err = api.CheckLinkedBranchFeature(apiClient, baseRepo.RepoHost())
|
||||
opts.IO.StopProgressIndicator()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.IO.StopProgressIndicator()
|
||||
|
||||
if opts.List {
|
||||
return developRunList(opts, apiClient, baseRepo, issue)
|
||||
|
|
@ -174,7 +177,6 @@ func developRun(opts *DevelopOptions) error {
|
|||
|
||||
func developRunCreate(opts *DevelopOptions, apiClient *api.Client, issueRepo ghrepo.Interface, issue *api.Issue) error {
|
||||
branchRepo := issueRepo
|
||||
var repoID string
|
||||
if opts.BranchRepo != "" {
|
||||
var err error
|
||||
branchRepo, err = ghrepo.FromFullName(opts.BranchRepo)
|
||||
|
|
@ -183,24 +185,67 @@ func developRunCreate(opts *DevelopOptions, apiClient *api.Client, issueRepo ghr
|
|||
}
|
||||
}
|
||||
|
||||
opts.IO.StartProgressIndicator()
|
||||
repoID, branchID, err := api.FindRepoBranchID(apiClient, branchRepo, opts.BaseBranch)
|
||||
opts.IO.StopProgressIndicator()
|
||||
if err != nil {
|
||||
return err
|
||||
opts.IO.StartProgressIndicatorWithLabel("Preparing linked branch")
|
||||
defer opts.IO.StopProgressIndicator()
|
||||
|
||||
branchName := ""
|
||||
reusedExisting := false
|
||||
if opts.Name != "" {
|
||||
opts.IO.StartProgressIndicatorWithLabel("Checking existing linked branches")
|
||||
branches, err := api.ListLinkedBranches(apiClient, issueRepo, issue.Number)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
branchName = findExistingLinkedBranchName(branches, branchRepo, opts.Name)
|
||||
reusedExisting = branchName != ""
|
||||
}
|
||||
|
||||
repoID := ""
|
||||
branchID := ""
|
||||
baseValidated := false
|
||||
if opts.BaseBranch != "" {
|
||||
opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Validating base branch %q", opts.BaseBranch))
|
||||
foundRepoID, foundBranchID, err := api.FindRepoBranchID(apiClient, branchRepo, opts.BaseBranch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
repoID = foundRepoID
|
||||
branchID = foundBranchID
|
||||
baseValidated = true
|
||||
}
|
||||
|
||||
if branchName == "" {
|
||||
if !baseValidated {
|
||||
opts.IO.StartProgressIndicatorWithLabel("Resolving base branch")
|
||||
foundRepoID, foundBranchID, err := api.FindRepoBranchID(apiClient, branchRepo, opts.BaseBranch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
repoID = foundRepoID
|
||||
branchID = foundBranchID
|
||||
}
|
||||
|
||||
opts.IO.StartProgressIndicatorWithLabel("Creating linked branch")
|
||||
createdBranchName, err := api.CreateLinkedBranch(apiClient, branchRepo.RepoHost(), repoID, issue.ID, branchID, opts.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
branchName = createdBranchName
|
||||
}
|
||||
|
||||
if branchName == "" {
|
||||
return fmt.Errorf("failed to create linked branch: API returned empty branch name")
|
||||
}
|
||||
|
||||
opts.IO.StartProgressIndicator()
|
||||
branchName, err := api.CreateLinkedBranch(apiClient, branchRepo.RepoHost(), repoID, issue.ID, branchID, opts.Name)
|
||||
opts.IO.StopProgressIndicator()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
if reusedExisting && opts.IO.IsStdoutTTY() {
|
||||
fmt.Fprintf(opts.IO.ErrOut, "Using existing linked branch %q\n", branchName)
|
||||
}
|
||||
|
||||
// Remember which branch to target when creating a PR.
|
||||
if opts.BaseBranch != "" {
|
||||
err = opts.GitClient.SetBranchConfig(ctx.Background(), branchName, git.MergeBaseConfig, opts.BaseBranch)
|
||||
if err != nil {
|
||||
if err := opts.GitClient.SetBranchConfig(ctx.Background(), branchName, git.MergeBaseConfig, opts.BaseBranch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
@ -210,13 +255,44 @@ func developRunCreate(opts *DevelopOptions, apiClient *api.Client, issueRepo ghr
|
|||
return checkoutBranch(opts, branchRepo, branchName)
|
||||
}
|
||||
|
||||
func findExistingLinkedBranchName(branches []api.LinkedBranch, branchRepo ghrepo.Interface, branchName string) string {
|
||||
for _, branch := range branches {
|
||||
if branch.BranchName != branchName {
|
||||
continue
|
||||
}
|
||||
linkedRepo, err := linkedBranchRepoFromURL(branch.URL)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if ghrepo.IsSame(linkedRepo, branchRepo) {
|
||||
return branch.BranchName
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func linkedBranchRepoFromURL(branchURL string) (ghrepo.Interface, error) {
|
||||
u, err := url.Parse(branchURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pathParts := strings.SplitN(strings.Trim(u.Path, "/"), "/", 3)
|
||||
if len(pathParts) < 2 {
|
||||
return nil, fmt.Errorf("invalid linked branch URL: %q", branchURL)
|
||||
}
|
||||
u.Path = "/" + strings.Join(pathParts[0:2], "/")
|
||||
return ghrepo.FromURL(u)
|
||||
}
|
||||
|
||||
func developRunList(opts *DevelopOptions, apiClient *api.Client, issueRepo ghrepo.Interface, issue *api.Issue) error {
|
||||
opts.IO.StartProgressIndicator()
|
||||
opts.IO.StartProgressIndicatorWithLabel("Fetching linked branches")
|
||||
defer opts.IO.StopProgressIndicator()
|
||||
|
||||
branches, err := api.ListLinkedBranches(apiClient, issueRepo, issue.Number)
|
||||
opts.IO.StopProgressIndicator()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.IO.StopProgressIndicator()
|
||||
|
||||
if len(branches) == 0 {
|
||||
return cmdutil.NewNoResultsError(fmt.Sprintf("no linked branches found for %s#%d", ghrepo.FullName(issueRepo), issue.Number))
|
||||
|
|
|
|||
|
|
@ -353,6 +353,16 @@ func TestDevelopRun(t *testing.T) {
|
|||
reg.Register(
|
||||
httpmock.GraphQL(`query FindRepoBranchID\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query ListLinkedBranches\b`),
|
||||
httpmock.GraphQLQuery(`
|
||||
{"data":{"repository":{"issue":{"linkedBranches":{"nodes":[]}}}}}
|
||||
`, func(query string, inputs map[string]interface{}) {
|
||||
assert.Equal(t, float64(123), inputs["number"])
|
||||
assert.Equal(t, "OWNER", inputs["owner"])
|
||||
assert.Equal(t, "REPO", inputs["name"])
|
||||
}),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`mutation CreateLinkedBranch\b`),
|
||||
httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":"my-branch"}}}}}`,
|
||||
|
|
@ -370,6 +380,165 @@ func TestDevelopRun(t *testing.T) {
|
|||
},
|
||||
expectedOut: "github.com/OWNER/REPO/tree/my-branch\n",
|
||||
},
|
||||
{
|
||||
name: "develop existing linked branch with name and checkout",
|
||||
opts: &DevelopOptions{
|
||||
Name: "my-branch",
|
||||
BaseBranch: "main",
|
||||
IssueNumber: 123,
|
||||
Checkout: true,
|
||||
},
|
||||
remotes: map[string]string{
|
||||
"origin": "OWNER/REPO",
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query LinkedBranchFeature\b`),
|
||||
httpmock.StringResponse(featureEnabledPayload),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query IssueByNumber\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled":true,"issue":{"id":"SOMEID","number":123,"title":"my issue"}}}}`),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query ListLinkedBranches\b`),
|
||||
httpmock.GraphQLQuery(`
|
||||
{"data":{"repository":{"issue":{"linkedBranches":{"nodes":[{"ref":{"name":"my-branch","repository":{"url":"https://github.com/OWNER/REPO"}}}]}}}}}
|
||||
`, func(query string, inputs map[string]interface{}) {
|
||||
assert.Equal(t, float64(123), inputs["number"])
|
||||
assert.Equal(t, "OWNER", inputs["owner"])
|
||||
assert.Equal(t, "REPO", inputs["name"])
|
||||
}),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query FindRepoBranchID\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`))
|
||||
},
|
||||
runStubs: func(cs *run.CommandStubber) {
|
||||
cs.Register(`git config branch\.my-branch\.gh-merge-base main`, 0, "")
|
||||
cs.Register(`git fetch origin \+refs/heads/my-branch:refs/remotes/origin/my-branch`, 0, "")
|
||||
cs.Register(`git rev-parse --verify refs/heads/my-branch`, 0, "")
|
||||
cs.Register(`git checkout my-branch`, 0, "")
|
||||
cs.Register(`git pull --ff-only origin my-branch`, 0, "")
|
||||
},
|
||||
expectedOut: "github.com/OWNER/REPO/tree/my-branch\n",
|
||||
},
|
||||
{
|
||||
name: "develop existing linked branch with name in tty shows reuse message",
|
||||
opts: &DevelopOptions{
|
||||
Name: "my-branch",
|
||||
BaseBranch: "main",
|
||||
IssueNumber: 123,
|
||||
},
|
||||
tty: true,
|
||||
remotes: map[string]string{
|
||||
"origin": "OWNER/REPO",
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query LinkedBranchFeature\b`),
|
||||
httpmock.StringResponse(featureEnabledPayload),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query IssueByNumber\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled":true,"issue":{"id":"SOMEID","number":123,"title":"my issue"}}}}`),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query ListLinkedBranches\b`),
|
||||
httpmock.GraphQLQuery(`
|
||||
{"data":{"repository":{"issue":{"linkedBranches":{"nodes":[{"ref":{"name":"my-branch","repository":{"url":"https://github.com/OWNER/REPO"}}}]}}}}}
|
||||
`, func(query string, inputs map[string]interface{}) {
|
||||
assert.Equal(t, float64(123), inputs["number"])
|
||||
assert.Equal(t, "OWNER", inputs["owner"])
|
||||
assert.Equal(t, "REPO", inputs["name"])
|
||||
}),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query FindRepoBranchID\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`))
|
||||
},
|
||||
runStubs: func(cs *run.CommandStubber) {
|
||||
cs.Register(`git config branch\.my-branch\.gh-merge-base main`, 0, "")
|
||||
cs.Register(`git fetch origin \+refs/heads/my-branch:refs/remotes/origin/my-branch`, 0, "")
|
||||
},
|
||||
expectedOut: "github.com/OWNER/REPO/tree/my-branch\n",
|
||||
expectedErrOut: "Using existing linked branch \"my-branch\"\n",
|
||||
},
|
||||
{
|
||||
name: "develop existing linked branch with invalid base branch returns an error",
|
||||
opts: &DevelopOptions{
|
||||
Name: "my-branch",
|
||||
BaseBranch: "does-not-exist-branch",
|
||||
IssueNumber: 123,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query LinkedBranchFeature\b`),
|
||||
httpmock.StringResponse(featureEnabledPayload),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query IssueByNumber\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled":true,"issue":{"id":"SOMEID","number":123,"title":"my issue"}}}}`),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query ListLinkedBranches\b`),
|
||||
httpmock.GraphQLQuery(`
|
||||
{"data":{"repository":{"issue":{"linkedBranches":{"nodes":[{"ref":{"name":"my-branch","repository":{"url":"https://github.com/OWNER/REPO"}}}]}}}}}
|
||||
`, func(query string, inputs map[string]interface{}) {
|
||||
assert.Equal(t, float64(123), inputs["number"])
|
||||
assert.Equal(t, "OWNER", inputs["owner"])
|
||||
assert.Equal(t, "REPO", inputs["name"])
|
||||
}),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query FindRepoBranchID\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","defaultBranchRef":{"target":{"oid":"DEFAULTOID"}},"ref":null}}}`),
|
||||
)
|
||||
},
|
||||
wantErr: `could not find branch "does-not-exist-branch" in OWNER/REPO`,
|
||||
},
|
||||
{
|
||||
name: "develop with empty linked branch name response returns an error",
|
||||
opts: &DevelopOptions{
|
||||
Name: "my-branch",
|
||||
BaseBranch: "main",
|
||||
IssueNumber: 123,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query LinkedBranchFeature\b`),
|
||||
httpmock.StringResponse(featureEnabledPayload),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query IssueByNumber\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{ "hasIssuesEnabled":true,"issue":{"id":"SOMEID","number":123,"title":"my issue"}}}}`),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query ListLinkedBranches\b`),
|
||||
httpmock.GraphQLQuery(`
|
||||
{"data":{"repository":{"issue":{"linkedBranches":{"nodes":[]}}}}}
|
||||
`, func(query string, inputs map[string]interface{}) {
|
||||
assert.Equal(t, float64(123), inputs["number"])
|
||||
assert.Equal(t, "OWNER", inputs["owner"])
|
||||
assert.Equal(t, "REPO", inputs["name"])
|
||||
}),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query FindRepoBranchID\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`mutation CreateLinkedBranch\b`),
|
||||
httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":""}}}}}`,
|
||||
func(inputs map[string]interface{}) {
|
||||
assert.Equal(t, "REPOID", inputs["repositoryId"])
|
||||
assert.Equal(t, "SOMEID", inputs["issueId"])
|
||||
assert.Equal(t, "OID", inputs["oid"])
|
||||
assert.Equal(t, "my-branch", inputs["name"])
|
||||
}),
|
||||
)
|
||||
},
|
||||
wantErr: "failed to create linked branch: API returned empty branch name",
|
||||
},
|
||||
{
|
||||
name: "develop new branch outside of local git repo",
|
||||
opts: &DevelopOptions{
|
||||
|
|
@ -426,6 +595,16 @@ func TestDevelopRun(t *testing.T) {
|
|||
httpmock.GraphQL(`query FindRepoBranchID\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query ListLinkedBranches\b`),
|
||||
httpmock.GraphQLQuery(`
|
||||
{"data":{"repository":{"issue":{"linkedBranches":{"nodes":[]}}}}}
|
||||
`, func(query string, inputs map[string]interface{}) {
|
||||
assert.Equal(t, float64(123), inputs["number"])
|
||||
assert.Equal(t, "OWNER", inputs["owner"])
|
||||
assert.Equal(t, "REPO", inputs["name"])
|
||||
}),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`mutation CreateLinkedBranch\b`),
|
||||
httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":"my-branch"}}}}}`,
|
||||
|
|
@ -468,6 +647,16 @@ func TestDevelopRun(t *testing.T) {
|
|||
httpmock.GraphQL(`query FindRepoBranchID\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{"id":"REPOID","ref":{"target":{"oid":"OID"}}}}}`),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query ListLinkedBranches\b`),
|
||||
httpmock.GraphQLQuery(`
|
||||
{"data":{"repository":{"issue":{"linkedBranches":{"nodes":[]}}}}}
|
||||
`, func(query string, inputs map[string]interface{}) {
|
||||
assert.Equal(t, float64(123), inputs["number"])
|
||||
assert.Equal(t, "OWNER", inputs["owner"])
|
||||
assert.Equal(t, "REPO", inputs["name"])
|
||||
}),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`mutation CreateLinkedBranch\b`),
|
||||
httpmock.GraphQLMutation(`{"data":{"createLinkedBranch":{"linkedBranch":{"id":"2","ref":{"name":"my-branch"}}}}}`,
|
||||
|
|
|
|||
|
|
@ -215,6 +215,7 @@ func editRun(opts *EditOptions) error {
|
|||
|
||||
lookupFields := []string{"id", "number", "title", "body", "url"}
|
||||
if editable.Assignees.Edited {
|
||||
// TODO actorIsAssignableCleanup
|
||||
if issueFeatures.ActorIsAssignable {
|
||||
editable.Assignees.ActorAssignees = true
|
||||
lookupFields = append(lookupFields, "assignedActors")
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package list
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/cli/cli/v2/api"
|
||||
fd "github.com/cli/cli/v2/internal/featuredetection"
|
||||
|
|
@ -9,6 +10,8 @@ import (
|
|||
prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared"
|
||||
)
|
||||
|
||||
var pullRequestSearchQualifierRE = regexp.MustCompile(`(?i)\b(?:is|type):(?:pr|pull-?request)\b`)
|
||||
|
||||
func listIssues(client *api.Client, repo ghrepo.Interface, filters prShared.FilterOptions, limit int) (*api.IssuesAndTotalCount, error) {
|
||||
var states []string
|
||||
switch filters.State {
|
||||
|
|
@ -114,6 +117,10 @@ loop:
|
|||
}
|
||||
|
||||
func searchIssues(client *api.Client, detector fd.Detector, repo ghrepo.Interface, filters prShared.FilterOptions, limit int) (*api.IssuesAndTotalCount, error) {
|
||||
if pullRequestSearchQualifierRE.MatchString(filters.Search) {
|
||||
return nil, fmt.Errorf("cannot use pull request search qualifiers with `gh issue list`; use `gh pr list` instead")
|
||||
}
|
||||
|
||||
// TODO advancedIssueSearchCleanup
|
||||
// We won't need feature detection when GHES 3.17 support ends, since
|
||||
// the advanced issue search is the only available search backend for
|
||||
|
|
@ -164,8 +171,10 @@ func searchIssues(client *api.Client, detector fd.Detector, repo ghrepo.Interfac
|
|||
filters.Repo = ghrepo.FullName(repo)
|
||||
filters.Entity = "issue"
|
||||
|
||||
// TODO advancedIssueSearchCleanup
|
||||
if features.AdvancedIssueSearchAPI {
|
||||
variables["query"] = prShared.SearchQueryBuild(filters, true)
|
||||
// TODO advancedIssueSearchCleanup
|
||||
if features.AdvancedIssueSearchAPIOptIn {
|
||||
variables["type"] = "ISSUE_ADVANCED"
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -214,3 +214,56 @@ func TestSearchIssuesAndAdvancedSearch(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchIssues_rejectsPullRequestQualifiers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
search string
|
||||
}{
|
||||
{
|
||||
name: "is:pr",
|
||||
search: "is:pr",
|
||||
},
|
||||
{
|
||||
name: "type:pr",
|
||||
search: "type:pr",
|
||||
},
|
||||
{
|
||||
name: "type:pull-request",
|
||||
search: "type:pull-request",
|
||||
},
|
||||
{
|
||||
name: "type:pullrequest",
|
||||
search: "type:pullrequest",
|
||||
},
|
||||
{
|
||||
name: "case-insensitive is:PR",
|
||||
search: "is:PR",
|
||||
},
|
||||
{
|
||||
name: "case-insensitive TYPE:Pull-Request",
|
||||
search: "TYPE:Pull-Request",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
defer reg.Verify(t)
|
||||
|
||||
httpClient := &http.Client{Transport: reg}
|
||||
client := api.NewClientFromHTTP(httpClient)
|
||||
|
||||
_, err := searchIssues(
|
||||
client,
|
||||
fd.AdvancedIssueSearchSupportedAsOnlyBackend(),
|
||||
ghrepo.New("OWNER", "REPO"),
|
||||
prShared.FilterOptions{Search: tt.search},
|
||||
30,
|
||||
)
|
||||
|
||||
assert.EqualError(t, err, "cannot use pull request search qualifiers with `gh issue list`; use `gh pr list` instead")
|
||||
assert.Len(t, reg.Requests, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -138,10 +138,14 @@
|
|||
]
|
||||
}
|
||||
],
|
||||
"totalCount": 6
|
||||
"totalCount": 6,
|
||||
"pageInfo": {
|
||||
"hasNextPage": true,
|
||||
"endCursor": "Y3Vyc29yOnYyOjg5"
|
||||
}
|
||||
},
|
||||
"url": "https://github.com/OWNER/REPO/issues/123"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,14 +20,13 @@ func preloadIssueComments(client *http.Client, repo ghrepo.Interface, issue *api
|
|||
} `graphql:"node(id: $id)"`
|
||||
}
|
||||
|
||||
if !issue.Comments.PageInfo.HasNextPage {
|
||||
return nil
|
||||
}
|
||||
|
||||
variables := map[string]interface{}{
|
||||
"id": githubv4.ID(issue.ID),
|
||||
"endCursor": (*githubv4.String)(nil),
|
||||
}
|
||||
if issue.Comments.PageInfo.HasNextPage {
|
||||
variables["endCursor"] = githubv4.String(issue.Comments.PageInfo.EndCursor)
|
||||
} else {
|
||||
issue.Comments.Nodes = issue.Comments.Nodes[0:0]
|
||||
"endCursor": githubv4.String(issue.Comments.PageInfo.EndCursor),
|
||||
}
|
||||
|
||||
gql := api.NewClientFromHTTP(client)
|
||||
|
|
|
|||
|
|
@ -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, ", ")
|
||||
|
|
|
|||
29
pkg/cmd/licenses/licenses.go
Normal file
29
pkg/cmd/licenses/licenses.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue