Merge branch 'trunk' into install_solus

This commit is contained in:
Kynan Ware 2025-06-23 09:42:11 -06:00 committed by GitHub
commit 8541d6e290
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
182 changed files with 9528 additions and 2154 deletions

View file

@ -1,5 +1,5 @@
{
"image": "mcr.microsoft.com/devcontainers/go:1.23",
"image": "mcr.microsoft.com/devcontainers/go:1.24",
"features": {
"ghcr.io/devcontainers/features/sshd:1": {}
},

4
.github/CODEOWNERS vendored
View file

@ -5,6 +5,10 @@ internal/codespaces/ @cli/codespaces
# Limit Package Security team ownership to the attestation command package and related integration tests
pkg/cmd/attestation/ @cli/package-security
pkg/cmd/release/attestation @cli/package-security
pkg/cmd/release/verify @cli/package-security
pkg/cmd/release/verify-asset @cli/package-security
test/integration/attestation-cmd @cli/package-security
pkg/cmd/attestation/verification/embed/tuf-repo.github.com/ @cli/tuf-root-reviewers

View file

@ -24,7 +24,7 @@ We accept pull requests for bug fixes and features where we've discussed the app
## Building the project
Prerequisites:
- Go 1.23+
- Go 1.24+
Build with:
* Unix-like systems: `make`

3
.github/secret_scanning.yml vendored Normal file
View file

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

View file

@ -18,21 +18,32 @@ permissions:
jobs:
CodeQL-Build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: ['go', 'actions']
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Setup Go
if: matrix.language == 'go'
uses: actions/setup-go@v5
with:
go-version-file: "go.mod"
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: go
languages: ${{ matrix.language }}
queries: security-and-quality
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: 'go.mod'
config: |
paths-ignore:
- 'third-party/**'
- 'third-party-licenses.*.md'
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"

View file

@ -309,7 +309,7 @@ jobs:
rpmsign --addsign dist/*.rpm
- name: Attest release artifacts
if: inputs.environment == 'production'
uses: actions/attest-build-provenance@bd77c077858b8d561b7a36cbe48ef4cc642ca39d # v2.2.2
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0
with:
subject-path: "dist/gh_*"
- name: Run createrepo
@ -384,7 +384,7 @@ jobs:
git diff --name-status @{upstream}..
fi
- name: Bump homebrew-core formula
uses: mislav/bump-homebrew-formula-action@942e550c6344cfdb9e1ab29b9bb9bf0c43efa19b
uses: mislav/bump-homebrew-formula-action@8e2baa47daaa8db10fcdeb04105dfa6850eb0d68
if: inputs.environment == 'production' && !contains(inputs.tag_name, '-')
with:
formula-name: gh

View file

@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Bump homebrew-core formula
uses: mislav/bump-homebrew-formula-action@942e550c6344cfdb9e1ab29b9bb9bf0c43efa19b
uses: mislav/bump-homebrew-formula-action@8e2baa47daaa8db10fcdeb04105dfa6850eb0d68
if: inputs.environment == 'production' && !contains(inputs.tag_name, '-')
with:
formula-name: gh

View file

@ -27,17 +27,7 @@ jobs:
with:
go-version-file: 'go.mod'
- name: Verify dependencies
run: |
go mod verify
go mod download
LINT_VERSION=1.63.4
curl -fsSL https://github.com/golangci/golangci-lint/releases/download/v${LINT_VERSION}/golangci-lint-${LINT_VERSION}-linux-amd64.tar.gz | \
tar xz --strip-components 1 --wildcards \*/golangci-lint
mkdir -p bin && mv golangci-lint bin/
- name: Run checks
- name: Ensure go.mod and go.sum are up to date
run: |
STATUS=0
assert-nothing-changed() {
@ -49,10 +39,10 @@ jobs:
STATUS=1
fi
}
assert-nothing-changed go fmt ./...
assert-nothing-changed go mod tidy
bin/golangci-lint run --out-format=colored-line-number --timeout=3m || STATUS=$?
exit $STATUS
- name: golangci-lint
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
with:
version: v2.1.6

27
.github/workflows/pr-help-wanted.yml vendored Normal file
View file

@ -0,0 +1,27 @@
name: PR Help Wanted Check
on:
pull_request_target:
types: [opened]
permissions:
contents: none
issues: read
pull-requests: write
jobs:
check-help-wanted:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Check for issues without help-wanted label
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
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 }}
if: "!github.event.pull_request.draft"
run: |
# Run the script to check for issues without help-wanted label
bash .github/workflows/scripts/check-help-wanted.sh ${{ github.event.pull_request.html_url }}

View file

@ -0,0 +1,99 @@
#!/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
# 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 -q "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 \`help wanted\`:
$ISSUE_LIST
As outlined in our [Contributing Guidelines](https://github.com/cli/cli/blob/trunk/.github/CONTRIBUTING.md), we expect that PRs are only created for issues that have been labeled \`help wanted\`.
While we appreciate your initiative, please note that:
- **PRs for non-\`help wanted\` issues may not be reviewed immediately** as they might not align with our current priorities
- **The issue might already be assigned** to a team member or planned for a specific release
- **We may need to close this PR**. For example, if it conflicts with ongoing work or architectural decisions
**What happens next:**
- Our team will review this PR and the associated issues
- We may add the \`help wanted\` label to the issues, if appropriate, and review this pull request
- In some cases, we may need to close the PR. For example, if it doesn't fit our current roadmap
Thank you for your understanding and contribution to the project! 🙏
*This comment was automatically generated by cliAutomation.*
EOF
echo "Posted comment on PR #$PR_NUM"
else
echo "All closing issues have 'help wanted' label - no action needed"
fi

View file

@ -1,7 +1,17 @@
version: "2"
linters:
enable:
- gofmt
- nolintlint
- nolintlint
disable:
# The following linters are disabled purely because this config was migrated to v2 where they are in the default
# set, and we should have separate work to enable them if we truly want them.
- staticcheck
- errcheck
formatters:
enable:
- gofmt
issues:
max-issues-per-linter: 0

View file

@ -12,7 +12,7 @@ For [installation options see below](#installation), for usage instructions [see
## Contributing
If anything feels off, or if you feel that some functionality is missing, please check out the [contributing page][contributing]. There you will find instructions for sharing your feedback, building the tool locally, and submitting pull requests to the project.
If anything feels off or if you feel that some functionality is missing, please check out the [contributing page][contributing]. There you will find instructions for sharing your feedback, building the tool locally, and submitting pull requests to the project.
If you are a hubber and are interested in shipping new commands for the CLI, check out our [doc on internal contributions][intake-doc].
@ -58,7 +58,7 @@ Additional Conda installation options available on the [gh-feedstock page](https
| ----------------------------------- | ---------------- |
| `curl -sS https://webi.sh/gh \| sh` | `webi gh@stable` |
For more information about the Webi installer see [its homepage](https://webinstall.dev/).
For more information about the Webi installer, see [its homepage](https://webinstall.dev/).
#### Flox
@ -127,9 +127,9 @@ Download packaged binaries from the [releases page][].
#### Verification of binaries
Since version 2.50.0 `gh` has been producing [Build Provenance Attestation](https://github.blog/changelog/2024-06-25-artifact-attestations-is-generally-available/) enabling a cryptographically verifiable paper-trail back to the origin GitHub repository, git revision and build instructions used. The build provenance attestations are signed and relies on Public Good [Sigstore](https://www.sigstore.dev/) for PKI.
Since version 2.50.0, `gh` has been producing [Build Provenance Attestation](https://github.blog/changelog/2024-06-25-artifact-attestations-is-generally-available/), enabling a cryptographically verifiable paper-trail back to the origin GitHub repository, git revision, and build instructions used. The build provenance attestations are signed and rely on Public Good [Sigstore](https://www.sigstore.dev/) for PKI.
There are two common ways to verify a downloaded release, depending if `gh` is already installed or not. If `gh` is installed, it's trivial to verify a new release:
There are two common ways to verify a downloaded release, depending on whether `gh` is already installed or not. If `gh` is installed, it's trivial to verify a new release:
- **Option 1: Using `gh` if already installed:**

View file

@ -0,0 +1,50 @@
skip 'it creates a fork owned by the user running the test'
env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
env FORK=${REPO}-fork
# Use gh as a credential helper
exec gh auth setup-git
# Get the current username for the fork owner
exec gh api user --jq .login
stdout2env USER
# Create a repository with a file so it has a default branch
exec gh repo create ${ORG}/${REPO} --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes ${ORG}/${REPO}
# Create a user fork of repository. This will be owned by USER.
exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK}
sleep 5
# Defer repo cleanup of fork
defer gh repo delete --yes ${USER}/${FORK}
# Retrieve fork repository information
exec gh repo view ${USER}/${FORK} --json id --jq '.id'
stdout2env FORK_ID
exec gh repo clone ${USER}/${FORK}
cd ${FORK}
# Prepare a branch to commit
exec git checkout -b feature/branch
exec git commit --allow-empty -m 'Upstream Commit'
# Push without setting an upstream (-u or config)
exec git push upstream feature/branch
# Prepare an additional commit
exec git commit --allow-empty -m 'Fork Commit'
# Push without setting an upstream (-u or config)
exec git push origin feature/branch
# Create the PR
exec gh pr create --title 'Feature Title' --body 'Feature Body'
stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1
# Check the PR is indeed created
exec gh pr view ${USER}:feature/branch --json headRefName,headRepository,baseRefName,isCrossRepository
stdout {"baseRefName":"main","headRefName":"feature/branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true}

View file

@ -1,3 +1,5 @@
skip 'it creates a fork owned by the user running the test'
env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
env FORK=${REPO}-fork

View file

@ -0,0 +1,50 @@
skip 'it creates a fork owned by the user running the test'
skip 'this never worked, but could be fixed if we fixed show-refs'
# Setup environment variables used for testscript
env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
env FORK=${REPO}-fork
# Use gh as a credential helper
exec gh auth setup-git
# Get the current username for the fork owner
exec gh api user --jq .login
stdout2env USER
# Create a repository to act as upstream with a file so it has a default branch
exec gh repo create ${ORG}/${REPO} --add-readme --private
# Defer repo cleanup of upstream
defer gh repo delete --yes ${ORG}/${REPO}
# Create a user fork of repository. This will be owned by USER.
exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK}
sleep 5
# Defer repo cleanup of fork
defer gh repo delete --yes ${USER}/${FORK}
# Retrieve fork repository information
exec gh repo view ${USER}/${FORK} --json id --jq '.id'
stdout2env FORK_ID
# Clone the repo
exec gh repo clone ${USER}/${FORK}
cd ${FORK}
# Configure push.default so that it should use the merge ref
exec git config push.default upstream
# But prepare a branch that doesn't have a tracking merge ref
exec git checkout -b feature-branch
exec git commit --allow-empty -m 'Empty Commit'
exec git push origin feature-branch
# Create the PR
exec gh pr create --title 'Feature Title' --body 'Feature Body'
stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1
# Assert that the PR was created with the correct head repository and refs
exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository
stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true}

View file

@ -0,0 +1,33 @@
skip 'it creates a fork owned by the user running the test'
# Setup environment variables used for testscript
env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
# Use gh as a credential helper
exec gh auth setup-git
# Get the current username for the fork owner
exec gh api user --jq .login
stdout2env USER
# Create a repository to act as upstream with a file so it has a default branch
exec gh repo create ${ORG}/${REPO} --add-readme --private
# Defer repo cleanup of upstream
defer gh repo delete --yes ${ORG}/${REPO}
# Clone the repo
exec gh repo clone ${ORG}/${REPO}
cd ${REPO}
# Configure push.default so that it should use the merge ref
exec git config push.default upstream
# But prepare a branch that doesn't have a tracking merge ref
exec git checkout -b feature-branch
exec git commit --allow-empty -m 'Empty Commit'
exec git push origin feature-branch
# Create the PR
exec gh pr create --title 'Feature Title' --body 'Feature Body'
stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1

View file

@ -0,0 +1,46 @@
skip 'it creates a fork owned by the user running the test'
# Setup environment variables used for testscript
env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
env FORK=${REPO}-fork
# Use gh as a credential helper
exec gh auth setup-git
# Get the current username for the fork owner
exec gh api user --jq .login
stdout2env USER
# Create a repository to act as upstream with a file so it has a default branch
exec gh repo create ${ORG}/${REPO} --add-readme --private
# Defer repo cleanup of upstream
defer gh repo delete --yes ${ORG}/${REPO}
# Create a user fork of repository. This will be owned by USER.
exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK}
sleep 5
# Defer repo cleanup of fork
defer gh repo delete --yes ${USER}/${FORK}
# Retrieve fork repository information
exec gh repo view ${USER}/${FORK} --json id --jq '.id'
stdout2env FORK_ID
# Clone the repo
exec gh repo clone ${USER}/${FORK}
cd ${FORK}
# Prepare a branch where changes are pulled from the upstream default branch but pushed to fork
exec git checkout -b feature/branch
exec git commit --allow-empty -m 'Empty Commit'
exec git push -u origin feature/branch
# Create the PR spanning upstream and fork repositories
exec gh pr create --title 'Feature Title' --body 'Feature Body'
stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1
# Assert that the PR was created with the correct head repository and refs
exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository
stdout {"baseRefName":"main","headRefName":"feature/branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true}

View file

@ -3,7 +3,7 @@ exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Attempt to rename the repo with a slash in the name
! exec gh repo rename $ORG/new-name --repo=$ORG/$SCRIPT_NAME-$RANDOM_STRING --yes
stderr 'New repository name cannot contain '/' character - to transfer a repository to a new owner, you must follow additional steps on GitHub.com. For more information on transferring repository ownership, see <https://docs.github.com/en/repositories/creating-and-managing-repositories/transferring-a-repository>.'
stderr 'New repository name cannot contain \''/\'' character - to transfer a repository to a new owner, you must follow additional steps on <github.com>. For more information on transferring repository ownership, see <https://docs.github.com/en/repositories/creating-and-managing-repositories/transferring-a-repository>.'
# Defer repo deletion
defer gh repo delete $ORG/$SCRIPT_NAME-$RANDOM_STRING --yes

View file

@ -7,7 +7,7 @@ defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Ensure that no default is set
cd $SCRIPT_NAME-$RANDOM_STRING
exec gh repo set-default --view
stderr 'no default repository has been set; use `gh repo set-default` to select one'
stderr 'No default remote repository has been set. To learn more about the default repository, run: gh repo set-default --help'
# Set the default
exec gh repo set-default $ORG/$SCRIPT_NAME-$RANDOM_STRING

View file

@ -28,6 +28,24 @@ func (issue *Issue) ExportData(fields []string) map[string]interface{} {
})
}
data[f] = items
case "closedByPullRequestsReferences":
items := make([]map[string]interface{}, 0, len(issue.ClosedByPullRequestsReferences.Nodes))
for _, n := range issue.ClosedByPullRequestsReferences.Nodes {
items = append(items, map[string]interface{}{
"id": n.ID,
"number": n.Number,
"url": n.URL,
"repository": map[string]interface{}{
"id": n.Repository.ID,
"name": n.Repository.Name,
"owner": map[string]interface{}{
"id": n.Repository.Owner.ID,
"login": n.Repository.Owner.Login,
},
},
})
}
data[f] = items
default:
sf := fieldByName(v, f)
data[f] = sf.Interface()
@ -139,6 +157,24 @@ func (pr *PullRequest) ExportData(fields []string) map[string]interface{} {
}
}
data[f] = &requests
case "closingIssuesReferences":
items := make([]map[string]interface{}, 0, len(pr.ClosingIssuesReferences.Nodes))
for _, n := range pr.ClosingIssuesReferences.Nodes {
items = append(items, map[string]interface{}{
"id": n.ID,
"number": n.Number,
"url": n.URL,
"repository": map[string]interface{}{
"id": n.Repository.ID,
"name": n.Repository.Name,
"owner": map[string]interface{}{
"id": n.Repository.Owner.ID,
"login": n.Repository.Owner.Login,
},
},
})
}
data[f] = items
default:
sf := fieldByName(v, f)
data[f] = sf.Interface()

View file

@ -107,6 +107,70 @@ func TestIssue_ExportData(t *testing.T) {
}
`),
},
{
name: "linked pull requests",
fields: []string{"closedByPullRequestsReferences"},
inputJSON: heredoc.Doc(`
{ "closedByPullRequestsReferences": { "nodes": [
{
"id": "I_123",
"number": 123,
"url": "https://github.com/cli/cli/pull/123",
"repository": {
"id": "R_123",
"name": "cli",
"owner": {
"id": "O_123",
"login": "cli"
}
}
},
{
"id": "I_456",
"number": 456,
"url": "https://github.com/cli/cli/pull/456",
"repository": {
"id": "R_456",
"name": "cli",
"owner": {
"id": "O_456",
"login": "cli"
}
}
}
] } }
`),
outputJSON: heredoc.Doc(`
{ "closedByPullRequestsReferences": [
{
"id": "I_123",
"number": 123,
"repository": {
"id": "R_123",
"name": "cli",
"owner": {
"id": "O_123",
"login": "cli"
}
},
"url": "https://github.com/cli/cli/pull/123"
},
{
"id": "I_456",
"number": 456,
"repository": {
"id": "R_456",
"name": "cli",
"owner": {
"id": "O_456",
"login": "cli"
}
},
"url": "https://github.com/cli/cli/pull/456"
}
] }
`),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -120,7 +184,14 @@ func TestIssue_ExportData(t *testing.T) {
enc := json.NewEncoder(&buf)
enc.SetIndent("", "\t")
require.NoError(t, enc.Encode(exported))
assert.Equal(t, tt.outputJSON, buf.String())
var gotData interface{}
dec = json.NewDecoder(&buf)
require.NoError(t, dec.Decode(&gotData))
var expectData interface{}
require.NoError(t, json.Unmarshal([]byte(tt.outputJSON), &expectData))
assert.Equal(t, expectData, gotData)
})
}
}
@ -245,6 +316,70 @@ func TestPullRequest_ExportData(t *testing.T) {
}
`),
},
{
name: "linked issues",
fields: []string{"closingIssuesReferences"},
inputJSON: heredoc.Doc(`
{ "closingIssuesReferences": { "nodes": [
{
"id": "I_123",
"number": 123,
"url": "https://github.com/cli/cli/issues/123",
"repository": {
"id": "R_123",
"name": "cli",
"owner": {
"id": "O_123",
"login": "cli"
}
}
},
{
"id": "I_456",
"number": 456,
"url": "https://github.com/cli/cli/issues/456",
"repository": {
"id": "R_456",
"name": "cli",
"owner": {
"id": "O_456",
"login": "cli"
}
}
}
] } }
`),
outputJSON: heredoc.Doc(`
{ "closingIssuesReferences": [
{
"id": "I_123",
"number": 123,
"repository": {
"id": "R_123",
"name": "cli",
"owner": {
"id": "O_123",
"login": "cli"
}
},
"url": "https://github.com/cli/cli/issues/123"
},
{
"id": "I_456",
"number": 456,
"repository": {
"id": "R_456",
"name": "cli",
"owner": {
"id": "O_456",
"login": "cli"
}
},
"url": "https://github.com/cli/cli/issues/456"
}
] }
`),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -44,6 +44,10 @@ type CommentCreateInput struct {
SubjectId string
}
type CommentDeleteInput struct {
CommentId string
}
type CommentUpdateInput struct {
Body string
CommentId string
@ -99,6 +103,27 @@ func CommentUpdate(client *Client, repoHost string, params CommentUpdateInput) (
return mutation.UpdateIssueComment.IssueComment.URL, nil
}
func CommentDelete(client *Client, repoHost string, params CommentDeleteInput) error {
var mutation struct {
DeleteIssueComment struct {
ClientMutationID string
} `graphql:"deleteIssueComment(input: $input)"`
}
variables := map[string]interface{}{
"input": githubv4.DeleteIssueCommentInput{
ID: githubv4.ID(params.CommentId),
},
}
err := client.Mutate(repoHost, "CommentDelete", &mutation, variables)
if err != nil {
return err
}
return nil
}
func (c Comment) Identifier() string {
return c.ID
}

View file

@ -38,12 +38,35 @@ type Issue struct {
Comments Comments
Author Author
Assignees Assignees
AssignedActors AssignedActors
Labels Labels
ProjectCards ProjectCards
ProjectItems ProjectItems
Milestone *Milestone
ReactionGroups ReactionGroups
IsPinned bool
ClosedByPullRequestsReferences ClosedByPullRequestsReferences
}
type ClosedByPullRequestsReferences struct {
Nodes []struct {
ID string
Number int
URL string
Repository struct {
ID string
Name string
Owner struct {
ID string
Login string
}
}
}
PageInfo struct {
HasNextPage bool
EndCursor string
}
}
// return values for Issue.Typename
@ -69,6 +92,61 @@ func (a Assignees) Logins() []string {
return logins
}
type AssignedActors struct {
Nodes []Actor
TotalCount int
}
func (a AssignedActors) Logins() []string {
logins := make([]string, len(a.Nodes))
for i, a := range a.Nodes {
logins[i] = a.Login
}
return logins
}
// DisplayNames returns a list of display names for the assigned actors.
func (a AssignedActors) DisplayNames() []string {
// These display names are used for populating the "default" assigned actors
// from the AssignedActors type. But, this is only one piece of the puzzle
// as later, other queries will fetch the full list of possible assignable
// actors from the repository, and the two lists will be reconciled.
//
// It's important that the display names are the same between the defaults
// (the values returned here) and the full list (the values returned by
// other repository queries). Any discrepancy would result in an
// "invalid default", which means an assigned actor will not be matched
// to an assignable actor and not presented as a "default" selection.
// Not being presented as a default would cause the actor to be potentially
// unassigned if the edits were submitted.
//
// To prevent this, we need shared logic to look up an actor's display name.
// However, our API types between assignedActors and the full list of
// assignableActors are different. So, as an attempt to maintain
// consistency we convert the assignedActors to the same types as the
// repository's assignableActors, treating the assignableActors DisplayName
// methods as the sources of truth.
// TODO KW: make this comment less of a wall of text if needed.
var displayNames []string
for _, a := range a.Nodes {
if a.TypeName == "User" {
u := NewAssignableUser(
a.ID,
a.Login,
a.Name,
)
displayNames = append(displayNames, u.DisplayName())
} else if a.TypeName == "Bot" {
b := NewAssignableBot(
a.ID,
a.Login,
)
displayNames = append(displayNames, b.DisplayName())
}
}
return displayNames
}
type Labels struct {
Nodes []IssueLabel
TotalCount int

View file

@ -84,15 +84,28 @@ type PullRequest struct {
}
Assignees Assignees
Labels Labels
ProjectCards ProjectCards
ProjectItems ProjectItems
Milestone *Milestone
Comments Comments
ReactionGroups ReactionGroups
Reviews PullRequestReviews
LatestReviews PullRequestReviews
ReviewRequests ReviewRequests
AssignedActors AssignedActors
// AssignedActorsUsed is a GIGANTIC hack to carry around whether we expected AssignedActors to be requested
// on this PR. This is required because the Feature Detection of support for AssignedActors occurs inside the
// PR Finder, but knowledge of support is required at the command level. However, we can't easily construct
// the feature detector at the command level because it needs knowledge of the BaseRepo, which is only available
// inside the PR Finder. This is bad and we should feel bad.
//
// The right solution is to extract argument parsing from the PR Finder into each command, so that we have access
// to the BaseRepo and can construct the feature detector there. This is what happens in the issue commands with
// `shared.ParseIssueFromArg`.
AssignedActorsUsed bool
Labels Labels
ProjectCards ProjectCards
ProjectItems ProjectItems
Milestone *Milestone
Comments Comments
ReactionGroups ReactionGroups
Reviews PullRequestReviews
LatestReviews PullRequestReviews
ReviewRequests ReviewRequests
ClosingIssuesReferences ClosingIssuesReferences
}
type StatusCheckRollupNode struct {
@ -107,6 +120,26 @@ type CommitStatusCheckRollup struct {
Contexts CheckContexts
}
type ClosingIssuesReferences struct {
Nodes []struct {
ID string
Number int
URL string
Repository struct {
ID string
Name string
Owner struct {
ID string
Login string
}
}
}
PageInfo struct {
HasNextPage bool
EndCursor string
}
}
// https://docs.github.com/en/graphql/reference/enums#checkrunstate
type CheckRunState string

View file

@ -12,6 +12,7 @@ import (
"strings"
"time"
"github.com/cli/cli/v2/internal/gh"
"github.com/cli/cli/v2/internal/ghinstance"
"golang.org/x/sync/errgroup"
@ -145,6 +146,18 @@ type GitHubUser struct {
Name string `json:"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
// instead are only available on the User or Bot types
// directly.
type Actor struct {
ID string `json:"id"`
Login string `json:"login"`
Name string `json:"name"`
TypeName string `json:"__typename"`
}
// BranchRef is the branch name in a GitHub repository
type BranchRef struct {
Name string `json:"name"`
@ -673,13 +686,14 @@ func RepoFindForks(client *Client, repo ghrepo.Interface, limit int) ([]*Reposit
}
type RepoMetadataResult struct {
CurrentLogin string
AssignableUsers []RepoAssignee
Labels []RepoLabel
Projects []RepoProject
ProjectsV2 []ProjectV2
Milestones []RepoMilestone
Teams []OrgTeam
CurrentLogin string
AssignableUsers []AssignableUser
AssignableActors []AssignableActor
Labels []RepoLabel
Projects []RepoProject
ProjectsV2 []ProjectV2
Milestones []RepoMilestone
Teams []OrgTeam
}
func (m *RepoMetadataResult) MembersToIDs(names []string) ([]string, error) {
@ -687,12 +701,30 @@ func (m *RepoMetadataResult) MembersToIDs(names []string) ([]string, error) {
for _, assigneeLogin := range names {
found := false
for _, u := range m.AssignableUsers {
if strings.EqualFold(assigneeLogin, u.Login) {
ids = append(ids, u.ID)
if strings.EqualFold(assigneeLogin, u.Login()) {
ids = append(ids, u.ID())
found = true
break
}
}
// Look for ID in assignable actors if not found in assignable users
if !found {
for _, a := range m.AssignableActors {
if strings.EqualFold(assigneeLogin, a.Login()) {
ids = append(ids, a.ID())
found = true
break
}
if strings.EqualFold(assigneeLogin, a.DisplayName()) {
ids = append(ids, a.ID())
found = true
break
}
}
}
// And if we still didn't find an ID, return an error
if !found {
return nil, fmt.Errorf("'%s' not found", assigneeLogin)
}
@ -737,34 +769,37 @@ func (m *RepoMetadataResult) LabelsToIDs(names []string) ([]string, error) {
return ids, nil
}
// ProjectsToIDs returns two arrays:
// ProjectsTitlesToIDs returns two arrays:
// - the first contains IDs of projects V1
// - the second contains IDs of projects V2
// - if neither project V1 or project V2 can be found with a given name, then an error is returned
func (m *RepoMetadataResult) ProjectsToIDs(names []string) ([]string, []string, error) {
func (m *RepoMetadataResult) ProjectsTitlesToIDs(titles []string) ([]string, []string, error) {
var ids []string
var idsV2 []string
for _, projectName := range names {
id, found := m.projectNameToID(projectName)
for _, title := range titles {
id, found := m.v1ProjectNameToID(title)
if found {
ids = append(ids, id)
continue
}
idV2, found := m.projectV2TitleToID(projectName)
idV2, found := m.v2ProjectTitleToID(title)
if found {
idsV2 = append(idsV2, idV2)
continue
}
return nil, nil, fmt.Errorf("'%s' not found", projectName)
return nil, nil, fmt.Errorf("'%s' not found", title)
}
return ids, idsV2, nil
}
func (m *RepoMetadataResult) projectNameToID(projectName string) (string, bool) {
// We use the word "titles" when referring to v1 and v2 projects.
// In reality, v1 projects really have "names", so there is a bit of a
// mismatch we just need to gloss over.
func (m *RepoMetadataResult) v1ProjectNameToID(name string) (string, bool) {
for _, p := range m.Projects {
if strings.EqualFold(projectName, p.Name) {
if strings.EqualFold(name, p.Name) {
return p.ID, true
}
}
@ -772,9 +807,9 @@ func (m *RepoMetadataResult) projectNameToID(projectName string) (string, bool)
return "", false
}
func (m *RepoMetadataResult) projectV2TitleToID(projectTitle string) (string, bool) {
func (m *RepoMetadataResult) v2ProjectTitleToID(title string) (string, bool) {
for _, p := range m.ProjectsV2 {
if strings.EqualFold(projectTitle, p.Title) {
if strings.EqualFold(title, p.Title) {
return p.ID, true
}
}
@ -782,35 +817,54 @@ func (m *RepoMetadataResult) projectV2TitleToID(projectTitle string) (string, bo
return "", false
}
func ProjectsToPaths(projects []RepoProject, projectsV2 []ProjectV2, names []string) ([]string, error) {
var paths []string
for _, projectName := range names {
found := false
for _, p := range projects {
if strings.EqualFold(projectName, p.Name) {
// format of ResourcePath: /OWNER/REPO/projects/PROJECT_NUMBER or /orgs/ORG/projects/PROJECT_NUMBER or /users/USER/projects/PROJECT_NUBER
// required format of path: OWNER/REPO/PROJECT_NUMBER or ORG/PROJECT_NUMBER or USER/PROJECT_NUMBER
var path string
pathParts := strings.Split(p.ResourcePath, "/")
if pathParts[1] == "orgs" || pathParts[1] == "users" {
path = fmt.Sprintf("%s/%s", pathParts[2], pathParts[4])
} else {
path = fmt.Sprintf("%s/%s/%s", pathParts[1], pathParts[2], pathParts[4])
func ProjectTitlesToPaths(client *Client, repo ghrepo.Interface, titles []string, projectsV1Support gh.ProjectsV1Support) ([]string, error) {
paths := make([]string, 0, len(titles))
matchedPaths := map[string]struct{}{}
// TODO: ProjectsV1Cleanup
// At this point, we only know the names that the user has provided, so we can't push this conditional up the stack.
// First we'll try to match against v1 projects, if supported
if projectsV1Support == gh.ProjectsV1Supported {
v1Projects, err := v1Projects(client, repo)
if err != nil {
return nil, err
}
for _, title := range titles {
for _, p := range v1Projects {
if strings.EqualFold(title, p.Name) {
pathParts := strings.Split(p.ResourcePath, "/")
var path string
if pathParts[1] == "orgs" || pathParts[1] == "users" {
path = fmt.Sprintf("%s/%s", pathParts[2], pathParts[4])
} else {
path = fmt.Sprintf("%s/%s/%s", pathParts[1], pathParts[2], pathParts[4])
}
paths = append(paths, path)
matchedPaths[title] = struct{}{}
break
}
paths = append(paths, path)
found = true
break
}
}
if found {
}
// Then we'll try to match against v2 projects
v2Projects, err := v2Projects(client, repo)
if err != nil {
return nil, err
}
for _, title := range titles {
// If we already found a v1 project with this name, skip it
if _, ok := matchedPaths[title]; ok {
continue
}
for _, p := range projectsV2 {
if strings.EqualFold(projectName, p.Title) {
// format of ResourcePath: /OWNER/REPO/projects/PROJECT_NUMBER or /orgs/ORG/projects/PROJECT_NUMBER or /users/USER/projects/PROJECT_NUBER
// required format of path: OWNER/REPO/PROJECT_NUMBER or ORG/PROJECT_NUMBER or USER/PROJECT_NUMBER
var path string
found := false
for _, p := range v2Projects {
if strings.EqualFold(title, p.Title) {
pathParts := strings.Split(p.ResourcePath, "/")
var path string
if pathParts[1] == "orgs" || pathParts[1] == "users" {
path = fmt.Sprintf("%s/%s", pathParts[2], pathParts[4])
} else {
@ -821,10 +875,12 @@ func ProjectsToPaths(projects []RepoProject, projectsV2 []ProjectV2, names []str
break
}
}
if !found {
return nil, fmt.Errorf("'%s' not found", projectName)
return nil, fmt.Errorf("'%s' not found", title)
}
}
return paths, nil
}
@ -860,11 +916,13 @@ func (m *RepoMetadataResult) Merge(m2 *RepoMetadataResult) {
}
type RepoMetadataInput struct {
Assignees bool
Reviewers bool
Labels bool
Projects bool
Milestones bool
Assignees bool
ActorAssignees bool
Reviewers bool
Labels bool
ProjectsV1 bool
ProjectsV2 bool
Milestones bool
}
// RepoMetadata pre-fetches the metadata for attaching to issues and pull requests
@ -873,15 +931,39 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput
var g errgroup.Group
if input.Assignees || input.Reviewers {
g.Go(func() error {
users, err := RepoAssignableUsers(client, repo)
if err != nil {
err = fmt.Errorf("error fetching assignees: %w", err)
}
result.AssignableUsers = users
return err
})
if input.ActorAssignees {
g.Go(func() error {
actors, err := RepoAssignableActors(client, repo)
if err != nil {
return fmt.Errorf("error fetching assignable actors: %w", err)
}
result.AssignableActors = actors
// Filter actors for users to use for pull request reviewers,
// skip retrieving the same info through RepoAssignableUsers().
var users []AssignableUser
for _, a := range actors {
if _, ok := a.(AssignableUser); !ok {
continue
}
users = append(users, a.(AssignableUser))
}
result.AssignableUsers = users
return nil
})
} else {
// Not using Actors, fetch legacy assignable users.
g.Go(func() error {
users, err := RepoAssignableUsers(client, repo)
if err != nil {
err = fmt.Errorf("error fetching assignable users: %w", err)
}
result.AssignableUsers = users
return err
})
}
}
if input.Reviewers {
g.Go(func() error {
teams, err := OrganizationTeams(client, repo)
@ -894,6 +976,7 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput
return nil
})
}
if input.Reviewers {
g.Go(func() error {
login, err := CurrentLoginName(client, repo.RepoHost())
@ -904,6 +987,7 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput
return err
})
}
if input.Labels {
g.Go(func() error {
labels, err := RepoLabels(client, repo)
@ -914,13 +998,23 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput
return err
})
}
if input.Projects {
if input.ProjectsV1 {
g.Go(func() error {
var err error
result.Projects, result.ProjectsV2, err = relevantProjects(client, repo)
result.Projects, err = v1Projects(client, repo)
return err
})
}
if input.ProjectsV2 {
g.Go(func() error {
var err error
result.ProjectsV2, err = v2Projects(client, repo)
return err
})
}
if input.Milestones {
g.Go(func() error {
milestones, err := RepoMilestones(client, repo, "open")
@ -943,7 +1037,8 @@ type RepoResolveInput struct {
Assignees []string
Reviewers []string
Labels []string
Projects []string
ProjectsV1 bool
ProjectsV2 bool
Milestones []string
}
@ -970,7 +1065,8 @@ func RepoResolveMetadataIDs(client *Client, repo ghrepo.Interface, input RepoRes
// there is no way to look up projects nor milestones by name, so preload them all
mi := RepoMetadataInput{
Projects: len(input.Projects) > 0,
ProjectsV1: input.ProjectsV1,
ProjectsV2: input.ProjectsV2,
Milestones: len(input.Milestones) > 0,
}
result, err := RepoMetadata(client, repo, mi)
@ -1029,12 +1125,16 @@ func RepoResolveMetadataIDs(client *Client, repo ghrepo.Interface, input RepoRes
result.Teams = append(result.Teams, t)
}
default:
user := RepoAssignee{}
user := struct {
Id string
Login string
Name string
}{}
err := json.Unmarshal(v, &user)
if err != nil {
return result, err
}
result.AssignableUsers = append(result.AssignableUsers, user)
result.AssignableUsers = append(result.AssignableUsers, NewAssignableUser(user.Id, user.Login, user.Name))
}
}
@ -1086,26 +1186,99 @@ func RepoProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error)
return projects, nil
}
type RepoAssignee struct {
ID string
Login string
Name string
// Expected login for Copilot when retrieved as an Actor
// This is returned from assignable actors and issue/pr assigned actors.
// We use this to check if the actor is Copilot.
const CopilotActorLogin = "copilot-swe-agent"
type AssignableActor interface {
DisplayName() string
ID() string
Login() string
sealedAssignableActor()
}
// Always a user
type AssignableUser struct {
id string
login string
name string
}
func NewAssignableUser(id, login, name string) AssignableUser {
return AssignableUser{
id: id,
login: login,
name: name,
}
}
// DisplayName returns a formatted string that uses Login and Name to be displayed e.g. 'Login (Name)' or 'Login'
func (ra RepoAssignee) DisplayName() string {
if ra.Name != "" {
return fmt.Sprintf("%s (%s)", ra.Login, ra.Name)
func (u AssignableUser) DisplayName() string {
if u.name != "" {
return fmt.Sprintf("%s (%s)", u.login, u.name)
}
return ra.Login
return u.login
}
func (u AssignableUser) ID() string {
return u.id
}
func (u AssignableUser) Login() string {
return u.login
}
func (u AssignableUser) Name() string {
return u.name
}
func (u AssignableUser) sealedAssignableActor() {}
type AssignableBot struct {
id string
login string
}
func NewAssignableBot(id, login string) AssignableBot {
return AssignableBot{
id: id,
login: login,
}
}
func (b AssignableBot) DisplayName() string {
if b.login == CopilotActorLogin {
return "Copilot (AI)"
}
return b.Login()
}
func (b AssignableBot) ID() string {
return b.id
}
func (b AssignableBot) Login() string {
return b.login
}
func (b AssignableBot) Name() string {
return ""
}
func (b AssignableBot) sealedAssignableActor() {}
// RepoAssignableUsers fetches all the assignable users for a repository
func RepoAssignableUsers(client *Client, repo ghrepo.Interface) ([]RepoAssignee, error) {
func RepoAssignableUsers(client *Client, repo ghrepo.Interface) ([]AssignableUser, error) {
type responseData struct {
Repository struct {
AssignableUsers struct {
Nodes []RepoAssignee
Nodes []struct {
ID string
Login string
Name string
}
PageInfo struct {
HasNextPage bool
EndCursor string
@ -1120,7 +1293,7 @@ func RepoAssignableUsers(client *Client, repo ghrepo.Interface) ([]RepoAssignee,
"endCursor": (*githubv4.String)(nil),
}
var users []RepoAssignee
var users []AssignableUser
for {
var query responseData
err := client.Query(repo.RepoHost(), "RepositoryAssignableUsers", &query, variables)
@ -1128,7 +1301,15 @@ func RepoAssignableUsers(client *Client, repo ghrepo.Interface) ([]RepoAssignee,
return nil, err
}
users = append(users, query.Repository.AssignableUsers.Nodes...)
for _, node := range query.Repository.AssignableUsers.Nodes {
user := AssignableUser{
id: node.ID,
login: node.Login,
name: node.Name,
}
users = append(users, user)
}
if !query.Repository.AssignableUsers.PageInfo.HasNextPage {
break
}
@ -1138,6 +1319,72 @@ func RepoAssignableUsers(client *Client, repo ghrepo.Interface) ([]RepoAssignee,
return users, nil
}
// RepoAssignableActors fetches all the assignable actors for a repository on
// GitHub hosts that support Actor assignees.
func RepoAssignableActors(client *Client, repo ghrepo.Interface) ([]AssignableActor, error) {
type responseData struct {
Repository struct {
SuggestedActors struct {
Nodes []struct {
User struct {
ID string
Login string
Name string
TypeName string `graphql:"__typename"`
} `graphql:"... on User"`
Bot struct {
ID string
Login string
TypeName string `graphql:"__typename"`
} `graphql:"... on Bot"`
}
PageInfo struct {
HasNextPage bool
EndCursor string
}
} `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}
variables := map[string]interface{}{
"owner": githubv4.String(repo.RepoOwner()),
"name": githubv4.String(repo.RepoName()),
"endCursor": (*githubv4.String)(nil),
}
var actors []AssignableActor
for {
var query responseData
err := client.Query(repo.RepoHost(), "RepositoryAssignableActors", &query, variables)
if err != nil {
return nil, err
}
for _, node := range query.Repository.SuggestedActors.Nodes {
if node.User.TypeName == "User" {
actor := AssignableUser{
id: node.User.ID,
login: node.User.Login,
name: node.User.Name,
}
actors = append(actors, actor)
} else if node.Bot.TypeName == "Bot" {
actor := AssignableBot{
id: node.Bot.ID,
login: node.Bot.Login,
}
actors = append(actors, actor)
}
}
if !query.Repository.SuggestedActors.PageInfo.HasNextPage {
break
}
variables["endCursor"] = githubv4.String(query.Repository.SuggestedActors.PageInfo.EndCursor)
}
return actors, nil
}
type RepoLabel struct {
ID string
Name string
@ -1237,26 +1484,12 @@ func RepoMilestones(client *Client, repo ghrepo.Interface, state string) ([]Repo
return milestones, nil
}
func ProjectNamesToPaths(client *Client, repo ghrepo.Interface, projectNames []string) ([]string, error) {
projects, projectsV2, err := relevantProjects(client, repo)
if err != nil {
return nil, err
}
return ProjectsToPaths(projects, projectsV2, projectNames)
}
// RelevantProjects retrieves set of Projects and ProjectsV2 relevant to given repository:
// v1Projects retrieves set of RepoProjects relevant to given repository:
// - Projects for repository
// - Projects for repository organization, if it belongs to one
// - ProjectsV2 owned by current user
// - ProjectsV2 linked to repository
// - ProjectsV2 owned by repository organization, if it belongs to one
func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []ProjectV2, error) {
func v1Projects(client *Client, repo ghrepo.Interface) ([]RepoProject, error) {
var repoProjects []RepoProject
var orgProjects []RepoProject
var userProjectsV2 []ProjectV2
var repoProjectsV2 []ProjectV2
var orgProjectsV2 []ProjectV2
g, _ := errgroup.WithContext(context.Background())
@ -1268,6 +1501,7 @@ func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []P
}
return err
})
g.Go(func() error {
var err error
orgProjects, err = OrganizationProjects(client, repo)
@ -1277,6 +1511,29 @@ func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []P
}
return nil
})
if err := g.Wait(); err != nil {
return nil, err
}
projects := make([]RepoProject, 0, len(repoProjects)+len(orgProjects))
projects = append(projects, repoProjects...)
projects = append(projects, orgProjects...)
return projects, nil
}
// v2Projects retrieves set of ProjectV2 relevant to given repository:
// - ProjectsV2 owned by current user
// - ProjectsV2 linked to repository
// - ProjectsV2 owned by repository organization, if it belongs to one
func v2Projects(client *Client, repo ghrepo.Interface) ([]ProjectV2, error) {
var userProjectsV2 []ProjectV2
var repoProjectsV2 []ProjectV2
var orgProjectsV2 []ProjectV2
g, _ := errgroup.WithContext(context.Background())
g.Go(func() error {
var err error
userProjectsV2, err = CurrentUserProjectsV2(client, repo.RepoHost())
@ -1286,6 +1543,7 @@ func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []P
}
return nil
})
g.Go(func() error {
var err error
repoProjectsV2, err = RepoProjectsV2(client, repo)
@ -1295,6 +1553,7 @@ func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []P
}
return nil
})
g.Go(func() error {
var err error
orgProjectsV2, err = OrganizationProjectsV2(client, repo)
@ -1308,13 +1567,9 @@ func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []P
})
if err := g.Wait(); err != nil {
return nil, nil, err
return nil, err
}
projects := make([]RepoProject, 0, len(repoProjects)+len(orgProjects))
projects = append(projects, repoProjects...)
projects = append(projects, orgProjects...)
// ProjectV2 might appear across multiple queries so use a map to keep them deduplicated.
m := make(map[string]ProjectV2, len(userProjectsV2)+len(repoProjectsV2)+len(orgProjectsV2))
for _, p := range userProjectsV2 {
@ -1331,7 +1586,7 @@ func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []P
projectsV2 = append(projectsV2, p)
}
return projects, projectsV2, nil
return projectsV2, nil
}
func CreateRepoTransformToV4(apiClient *Client, hostname string, method string, path string, body io.Reader) (*Repository, error) {

View file

@ -8,6 +8,7 @@ import (
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/gh"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/stretchr/testify/assert"
@ -44,7 +45,8 @@ func Test_RepoMetadata(t *testing.T) {
Assignees: true,
Reviewers: true,
Labels: true,
Projects: true,
ProjectsV1: true,
ProjectsV2: true,
Milestones: true,
}
@ -185,7 +187,7 @@ func Test_RepoMetadata(t *testing.T) {
expectedProjectIDs := []string{"TRIAGEID", "ROADMAPID"}
expectedProjectV2IDs := []string{"TRIAGEV2ID", "ROADMAPV2ID", "MONALISAV2ID"}
projectIDs, projectV2IDs, err := result.ProjectsToIDs([]string{"triage", "roadmap", "triagev2", "roadmapv2", "monalisav2"})
projectIDs, projectV2IDs, err := result.ProjectsTitlesToIDs([]string{"triage", "roadmap", "triagev2", "roadmapv2", "monalisav2"})
if err != nil {
t.Errorf("error resolving projects: %v", err)
}
@ -211,37 +213,16 @@ func Test_RepoMetadata(t *testing.T) {
}
}
func Test_ProjectsToPaths(t *testing.T) {
expectedProjectPaths := []string{"OWNER/REPO/PROJECT_NUMBER", "ORG/PROJECT_NUMBER", "OWNER/REPO/PROJECT_NUMBER_2"}
projects := []RepoProject{
{ID: "id1", Name: "My Project", ResourcePath: "/OWNER/REPO/projects/PROJECT_NUMBER"},
{ID: "id2", Name: "Org Project", ResourcePath: "/orgs/ORG/projects/PROJECT_NUMBER"},
{ID: "id3", Name: "Project", ResourcePath: "/orgs/ORG/projects/PROJECT_NUMBER_2"},
}
projectsV2 := []ProjectV2{
{ID: "id4", Title: "My Project V2", ResourcePath: "/OWNER/REPO/projects/PROJECT_NUMBER_2"},
{ID: "id5", Title: "Org Project V2", ResourcePath: "/orgs/ORG/projects/PROJECT_NUMBER_3"},
}
projectNames := []string{"My Project", "Org Project", "My Project V2"}
projectPaths, err := ProjectsToPaths(projects, projectsV2, projectNames)
if err != nil {
t.Errorf("error resolving projects: %v", err)
}
if !sliceEqual(projectPaths, expectedProjectPaths) {
t.Errorf("expected projects %v, got %v", expectedProjectPaths, projectPaths)
}
}
func Test_ProjectNamesToPaths(t *testing.T) {
http := &httpmock.Registry{}
client := newTestClient(http)
t.Run("when projectsV1 is supported, requests them", func(t *testing.T) {
http := &httpmock.Registry{}
client := newTestClient(http)
repo, _ := ghrepo.FromFullName("OWNER/REPO")
repo, _ := ghrepo.FromFullName("OWNER/REPO")
http.Register(
httpmock.GraphQL(`query RepositoryProjectList\b`),
httpmock.StringResponse(`
http.Register(
httpmock.GraphQL(`query RepositoryProjectList\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "projects": {
"nodes": [
{ "name": "Cleanup", "id": "CLEANUPID", "resourcePath": "/OWNER/REPO/projects/1" },
@ -250,9 +231,9 @@ func Test_ProjectNamesToPaths(t *testing.T) {
"pageInfo": { "hasNextPage": false }
} } } }
`))
http.Register(
httpmock.GraphQL(`query OrganizationProjectList\b`),
httpmock.StringResponse(`
http.Register(
httpmock.GraphQL(`query OrganizationProjectList\b`),
httpmock.StringResponse(`
{ "data": { "organization": { "projects": {
"nodes": [
{ "name": "Triage", "id": "TRIAGEID", "resourcePath": "/orgs/ORG/projects/1" }
@ -260,9 +241,9 @@ func Test_ProjectNamesToPaths(t *testing.T) {
"pageInfo": { "hasNextPage": false }
} } } }
`))
http.Register(
httpmock.GraphQL(`query RepositoryProjectV2List\b`),
httpmock.StringResponse(`
http.Register(
httpmock.GraphQL(`query RepositoryProjectV2List\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "projectsV2": {
"nodes": [
{ "title": "CleanupV2", "id": "CLEANUPV2ID", "resourcePath": "/OWNER/REPO/projects/3" },
@ -271,9 +252,9 @@ func Test_ProjectNamesToPaths(t *testing.T) {
"pageInfo": { "hasNextPage": false }
} } } }
`))
http.Register(
httpmock.GraphQL(`query OrganizationProjectV2List\b`),
httpmock.StringResponse(`
http.Register(
httpmock.GraphQL(`query OrganizationProjectV2List\b`),
httpmock.StringResponse(`
{ "data": { "organization": { "projectsV2": {
"nodes": [
{ "title": "TriageV2", "id": "TRIAGEV2ID", "resourcePath": "/orgs/ORG/projects/2" }
@ -281,9 +262,9 @@ func Test_ProjectNamesToPaths(t *testing.T) {
"pageInfo": { "hasNextPage": false }
} } } }
`))
http.Register(
httpmock.GraphQL(`query UserProjectV2List\b`),
httpmock.StringResponse(`
http.Register(
httpmock.GraphQL(`query UserProjectV2List\b`),
httpmock.StringResponse(`
{ "data": { "viewer": { "projectsV2": {
"nodes": [
{ "title": "MonalisaV2", "id": "MONALISAV2ID", "resourcePath": "/users/MONALISA/projects/5" }
@ -292,15 +273,110 @@ func Test_ProjectNamesToPaths(t *testing.T) {
} } } }
`))
projectPaths, err := ProjectNamesToPaths(client, repo, []string{"Triage", "Roadmap", "TriageV2", "RoadmapV2", "MonalisaV2"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
projectPaths, err := ProjectTitlesToPaths(client, repo, []string{"Triage", "Roadmap", "TriageV2", "RoadmapV2", "MonalisaV2"}, gh.ProjectsV1Supported)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expectedProjectPaths := []string{"ORG/1", "OWNER/REPO/2", "ORG/2", "OWNER/REPO/4", "MONALISA/5"}
if !sliceEqual(projectPaths, expectedProjectPaths) {
t.Errorf("expected projects paths %v, got %v", expectedProjectPaths, projectPaths)
}
expectedProjectPaths := []string{"ORG/1", "OWNER/REPO/2", "ORG/2", "OWNER/REPO/4", "MONALISA/5"}
if !sliceEqual(projectPaths, expectedProjectPaths) {
t.Errorf("expected projects paths %v, got %v", expectedProjectPaths, projectPaths)
}
})
t.Run("when projectsV1 is not supported, does not request them", func(t *testing.T) {
http := &httpmock.Registry{}
client := newTestClient(http)
repo, _ := ghrepo.FromFullName("OWNER/REPO")
http.Exclude(
t,
httpmock.GraphQL(`query RepositoryProjectList\b`),
)
http.Exclude(
t,
httpmock.GraphQL(`query OrganizationProjectList\b`),
)
http.Register(
httpmock.GraphQL(`query RepositoryProjectV2List\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "projectsV2": {
"nodes": [
{ "title": "CleanupV2", "id": "CLEANUPV2ID", "resourcePath": "/OWNER/REPO/projects/3" },
{ "title": "RoadmapV2", "id": "ROADMAPV2ID", "resourcePath": "/OWNER/REPO/projects/4" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
http.Register(
httpmock.GraphQL(`query OrganizationProjectV2List\b`),
httpmock.StringResponse(`
{ "data": { "organization": { "projectsV2": {
"nodes": [
{ "title": "TriageV2", "id": "TRIAGEV2ID", "resourcePath": "/orgs/ORG/projects/2" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
http.Register(
httpmock.GraphQL(`query UserProjectV2List\b`),
httpmock.StringResponse(`
{ "data": { "viewer": { "projectsV2": {
"nodes": [
{ "title": "MonalisaV2", "id": "MONALISAV2ID", "resourcePath": "/users/MONALISA/projects/5" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
projectPaths, err := ProjectTitlesToPaths(client, repo, []string{"TriageV2", "RoadmapV2", "MonalisaV2"}, gh.ProjectsV1Unsupported)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expectedProjectPaths := []string{"ORG/2", "OWNER/REPO/4", "MONALISA/5"}
if !sliceEqual(projectPaths, expectedProjectPaths) {
t.Errorf("expected projects paths %v, got %v", expectedProjectPaths, projectPaths)
}
})
t.Run("when a project is not found, returns an error", func(t *testing.T) {
http := &httpmock.Registry{}
client := newTestClient(http)
repo, _ := ghrepo.FromFullName("OWNER/REPO")
// No projects found
http.Register(
httpmock.GraphQL(`query RepositoryProjectV2List\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "projectsV2": {
"nodes": [],
"pageInfo": { "hasNextPage": false }
} } } }
`))
http.Register(
httpmock.GraphQL(`query OrganizationProjectV2List\b`),
httpmock.StringResponse(`
{ "data": { "organization": { "projectsV2": {
"nodes": [],
"pageInfo": { "hasNextPage": false }
} } } }
`))
http.Register(
httpmock.GraphQL(`query UserProjectV2List\b`),
httpmock.StringResponse(`
{ "data": { "viewer": { "projectsV2": {
"nodes": [],
"pageInfo": { "hasNextPage": false }
} } } }
`))
_, err := ProjectTitlesToPaths(client, repo, []string{"TriageV2"}, gh.ProjectsV1Unsupported)
require.Equal(t, err, fmt.Errorf("'TriageV2' not found"))
})
}
func Test_RepoResolveMetadataIDs(t *testing.T) {
@ -385,6 +461,78 @@ t001: team(slug:"robots"){id,slug}
}
}
func TestMembersToIDs(t *testing.T) {
t.Parallel()
t.Run("finds ids in assignable users", func(t *testing.T) {
t.Parallel()
repoMetadataResult := RepoMetadataResult{
AssignableUsers: []AssignableUser{
NewAssignableUser("MONAID", "monalisa", ""),
NewAssignableUser("MONAID2", "monalisa2", ""),
},
AssignableActors: []AssignableActor{
NewAssignableBot("HUBOTID", "hubot"),
},
}
ids, err := repoMetadataResult.MembersToIDs([]string{"monalisa"})
require.NoError(t, err)
require.Equal(t, []string{"MONAID"}, ids)
})
t.Run("finds ids by assignable actor logins", func(t *testing.T) {
t.Parallel()
repoMetadataResult := RepoMetadataResult{
AssignableActors: []AssignableActor{
NewAssignableBot("HUBOTID", "hubot"),
NewAssignableUser("MONAID", "monalisa", ""),
},
}
ids, err := repoMetadataResult.MembersToIDs([]string{"monalisa"})
require.NoError(t, err)
require.Equal(t, []string{"MONAID"}, ids)
})
t.Run("finds ids by assignable actor display names", func(t *testing.T) {
t.Parallel()
repoMetadataResult := RepoMetadataResult{
AssignableActors: []AssignableActor{
NewAssignableUser("MONAID", "monalisa", "mona"),
},
}
ids, err := repoMetadataResult.MembersToIDs([]string{"monalisa (mona)"})
require.NoError(t, err)
require.Equal(t, []string{"MONAID"}, ids)
})
t.Run("when a name appears in both assignable users and actors, the id is only returned once", func(t *testing.T) {
t.Parallel()
repoMetadataResult := RepoMetadataResult{
AssignableUsers: []AssignableUser{
NewAssignableUser("MONAID", "monalisa", ""),
},
AssignableActors: []AssignableActor{
NewAssignableUser("MONAID", "monalisa", ""),
},
}
ids, err := repoMetadataResult.MembersToIDs([]string{"monalisa"})
require.NoError(t, err)
require.Equal(t, []string{"MONAID"}, ids)
})
t.Run("when id is not found, returns an error", func(t *testing.T) {
t.Parallel()
repoMetadataResult := RepoMetadataResult{}
_, err := repoMetadataResult.MembersToIDs([]string{"monalisa"})
require.Error(t, err)
})
}
func sliceEqual(a, b []string) bool {
if len(a) != len(b) {
return false
@ -450,17 +598,17 @@ func Test_RepoMilestones(t *testing.T) {
func TestDisplayName(t *testing.T) {
tests := []struct {
name string
assignee RepoAssignee
assignee AssignableUser
want string
}{
{
name: "assignee with name",
assignee: RepoAssignee{"123", "octocat123", "Octavious Cath"},
assignee: AssignableUser{"123", "octocat123", "Octavious Cath"},
want: "octocat123 (Octavious Cath)",
},
{
name: "assignee without name",
assignee: RepoAssignee{"123", "octocat123", ""},
assignee: AssignableUser{"123", "octocat123", ""},
want: "octocat123",
},
}

View file

@ -20,6 +20,25 @@ func shortenQuery(q string) string {
return strings.Map(squeeze, q)
}
var assignedActors = shortenQuery(`
assignedActors(first: 10) {
nodes {
...on User {
id,
login,
name,
__typename
}
...on Bot {
id,
login,
__typename
}
},
totalCount
}
`)
var issueComments = shortenQuery(`
comments(first: 100) {
nodes {
@ -56,6 +75,25 @@ var issueCommentLast = shortenQuery(`
}
`)
var issueClosedByPullRequestsReferences = shortenQuery(`
closedByPullRequestsReferences(first: 100) {
nodes {
id,
number,
url,
repository {
id,
name,
owner {
id,
login
}
}
}
pageInfo{hasNextPage,endCursor}
}
`)
var prReviewRequests = shortenQuery(`
reviewRequests(first: 100) {
nodes {
@ -132,6 +170,25 @@ var prCommits = shortenQuery(`
}
`)
var prClosingIssuesReferences = shortenQuery(`
closingIssuesReferences(first: 100) {
nodes {
id,
number,
url,
repository {
id,
name,
owner {
id,
login
}
}
}
pageInfo{hasNextPage,endCursor}
}
`)
var autoMergeRequest = shortenQuery(`
autoMergeRequest {
authorEmail,
@ -277,6 +334,7 @@ var sharedIssuePRFields = []string{
var issueOnlyFields = []string{
"isPinned",
"stateReason",
"closedByPullRequestsReferences",
}
var IssueFields = append(sharedIssuePRFields, issueOnlyFields...)
@ -287,6 +345,7 @@ var PullRequestFields = append(sharedIssuePRFields,
"baseRefName",
"baseRefOid",
"changedFiles",
"closingIssuesReferences",
"commits",
"deletions",
"files",
@ -326,6 +385,8 @@ func IssueGraphQL(fields []string) string {
q = append(q, `headRepository{id,name}`)
case "assignees":
q = append(q, `assignees(first:100){nodes{id,login,name},totalCount}`)
case "assignedActors":
q = append(q, assignedActors)
case "labels":
q = append(q, `labels(first:100){nodes{id,name,description,color},totalCount}`)
case "projectCards":
@ -366,6 +427,10 @@ func IssueGraphQL(fields []string) string {
q = append(q, StatusCheckRollupGraphQLWithoutCountByState(""))
case "statusCheckRollupWithCountByState": // pseudo-field
q = append(q, StatusCheckRollupGraphQLWithCountByState())
case "closingIssuesReferences":
q = append(q, prClosingIssuesReferences)
case "closedByPullRequestsReferences":
q = append(q, issueClosedByPullRequestsReferences)
default:
q = append(q, field)
}

View file

@ -19,6 +19,7 @@ Install:
&& out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \
&& cat $out | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \
&& sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& sudo mkdir -p -m 755 /etc/apt/sources.list.d \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& sudo apt update \
&& sudo apt install gh -y
@ -163,6 +164,20 @@ Or via [pkg(8)](https://www.freebsd.org/cgi/man.cgi?pkg(8)):
pkg install gh
```
### MidnightBSD
MidnightBSD users can install from [mports](https://www.midnightbsd.org/documentation/mports/index.html)
```bash
cd /usr/mports/devel/gh/ && make install clean
```
Or via [mport(1)](http://man.midnightbsd.org/cgi-bin/man.cgi/mport):
```bash
mport install gh
```
### NetBSD/pkgsrc
NetBSD users and those on [platforms supported by pkgsrc](https://pkgsrc.org/#index4h1) can install the [gh package](https://pkgsrc.se/net/gh):

View file

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

View file

@ -518,15 +518,56 @@ func (r RemoteTrackingRef) String() string {
// ParseRemoteTrackingRef parses a string of the form "refs/remotes/<remote>/<branch>" into
// a RemoteTrackingBranch struct. If the string does not match this format, an error is returned.
//
// For now, we assume that refnames are of the format "<remote>/<branch>", where
// the remote is a single path component, and branch may have many path components e.g.
// "origin/my/branch" is valid as: {Remote: "origin", Branch: "my/branch"}
// but "my/origin/branch" would parse incorrectly as: {Remote: "my", Branch: "origin/branch"}
// I don't believe there is a way to fix this without providing the list of remotes to this function.
//
// It becomes particularly confusing if you have something like:
//
// ```
// [remote "foo"]
// url = https://github.com/williammartin/test-repo.git
// fetch = +refs/heads/*:refs/remotes/foo/*
// [remote "foo/bar"]
// url = https://github.com/williammartin/test-repo.git
// fetch = +refs/heads/*:refs/remotes/foo/bar/*
// [branch "bar/baz"]
// remote = foo
// merge = refs/heads/bar/baz
// [branch "baz"]
// remote = foo/bar
// merge = refs/heads/baz
// ```
//
// These @{push} refs would resolve identically:
//
// ```
// ➜ git rev-parse --symbolic-full-name baz@{push}
// refs/remotes/foo/bar/baz
// ➜ git rev-parse --symbolic-full-name bar/baz@{push}
// refs/remotes/foo/bar/baz
// ```
//
// When using this ref, git assumes it means `remote: foo` `branch: bar/baz`.
func ParseRemoteTrackingRef(s string) (RemoteTrackingRef, error) {
parts := strings.Split(s, "/")
if len(parts) != 4 || parts[0] != "refs" || parts[1] != "remotes" {
prefix := "refs/remotes/"
if !strings.HasPrefix(s, prefix) {
return RemoteTrackingRef{}, fmt.Errorf("remote tracking branch must have format refs/remotes/<remote>/<branch> but was: %s", s)
}
refName := strings.TrimPrefix(s, prefix)
refNameParts := strings.SplitN(refName, "/", 2)
if len(refNameParts) != 2 {
return RemoteTrackingRef{}, fmt.Errorf("remote tracking branch must have format refs/remotes/<remote>/<branch> but was: %s", s)
}
return RemoteTrackingRef{
Remote: parts[2],
Branch: parts[3],
Remote: refNameParts[0],
Branch: refNameParts[1],
}, nil
}

View file

@ -1151,13 +1151,38 @@ func TestRemoteTrackingRef(t *testing.T) {
wantError error
}{
{
name: "valid remote tracking ref",
name: "valid remote tracking ref without slash in branch name",
remoteTrackingRef: "refs/remotes/origin/branchName",
wantRemoteTrackingRef: RemoteTrackingRef{
Remote: "origin",
Branch: "branchName",
},
},
{
name: "valid remote tracking ref with slash in branch name",
remoteTrackingRef: "refs/remotes/origin/branch/name",
wantRemoteTrackingRef: RemoteTrackingRef{
Remote: "origin",
Branch: "branch/name",
},
},
// TODO: Uncomment when we support slashes in remote names
// {
// name: "valid remote tracking ref with slash in remote name",
// remoteTrackingRef: "refs/remotes/my/origin/branchName",
// wantRemoteTrackingRef: RemoteTrackingRef{
// Remote: "my/origin",
// Branch: "branchName",
// },
// },
// {
// name: "valid remote tracking ref with slash in remote name and branch name",
// remoteTrackingRef: "refs/remotes/my/origin/branch/name",
// wantRemoteTrackingRef: RemoteTrackingRef{
// Remote: "my/origin",
// Branch: "branch/name",
// },
// },
{
name: "incorrect parts",
remoteTrackingRef: "refs/remotes/origin",

75
go.mod
View file

@ -1,8 +1,8 @@
module github.com/cli/cli/v2
go 1.23.0
go 1.24
toolchain go1.23.5
toolchain go1.24.4
require (
github.com/AlecAivazis/survey/v2 v2.3.7
@ -10,29 +10,30 @@ require (
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2
github.com/briandowns/spinner v1.18.1
github.com/cenkalti/backoff/v4 v4.3.0
github.com/cenkalti/backoff/v5 v5.0.2
github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3
github.com/charmbracelet/huh v0.6.1-0.20250409210615-c5906631cbb5
github.com/charmbracelet/huh v0.7.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc
github.com/cli/go-gh/v2 v2.12.0
github.com/cli/go-gh/v2 v2.12.1
github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24
github.com/cli/oauth v1.1.1
github.com/cli/safeexec v1.0.1
github.com/cpuguy83/go-md2man/v2 v2.0.6
github.com/cpuguy83/go-md2man/v2 v2.0.7
github.com/creack/pty v1.1.24
github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7
github.com/distribution/reference v0.6.0
github.com/gabriel-vasile/mimetype v1.4.8
github.com/gabriel-vasile/mimetype v1.4.9
github.com/gdamore/tcell/v2 v2.5.4
github.com/golang/snappy v0.0.4
github.com/google/go-cmp v0.7.0
github.com/google/go-containerregistry v0.20.3
github.com/google/go-containerregistry v0.20.6
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/go-multierror v1.1.1
github.com/hashicorp/go-version v1.3.0
github.com/henvic/httpretty v0.1.4
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec
github.com/in-toto/attestation v1.1.1
github.com/in-toto/attestation v1.1.2
github.com/joho/godotenv v1.5.1
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/mattn/go-colorable v0.1.14
@ -43,17 +44,19 @@ require (
github.com/opentracing/opentracing-go v1.2.0
github.com/rivo/tview v0.0.0-20221029100920-c4a7e501810d
github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc
github.com/sigstore/protobuf-specs v0.4.1
github.com/sigstore/sigstore-go v0.7.2
github.com/sigstore/protobuf-specs v0.4.3
github.com/sigstore/sigstore-go v1.0.0
github.com/spf13/cobra v1.9.1
github.com/spf13/pflag v1.0.6
github.com/stretchr/testify v1.10.0
github.com/theupdateframework/go-tuf/v2 v2.1.1
github.com/yuin/goldmark v1.7.12
github.com/zalando/go-keyring v0.2.5
golang.org/x/crypto v0.37.0
golang.org/x/sync v0.13.0
golang.org/x/term v0.31.0
golang.org/x/text v0.24.0
google.golang.org/grpc v1.71.1
golang.org/x/crypto v0.39.0
golang.org/x/sync v0.15.0
golang.org/x/term v0.32.0
golang.org/x/text v0.26.0
google.golang.org/grpc v1.72.2
google.golang.org/protobuf v1.36.6
gopkg.in/h2non/gock.v1 v1.1.2
gopkg.in/yaml.v3 v3.0.1
@ -83,13 +86,13 @@ require (
github.com/cli/shurcooL-graphql v0.0.4 // indirect
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
github.com/cyberphone/json-canonicalization v0.0.0-20220623050100-57a0ce2678a7 // indirect
github.com/danieljoos/wincred v1.2.1 // indirect
github.com/danieljoos/wincred v1.2.2 // indirect
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.0 // indirect
github.com/docker/cli v27.5.0+incompatible // indirect
github.com/docker/cli v28.2.2+incompatible // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/docker-credential-helpers v0.8.2 // indirect
github.com/docker/docker-credential-helpers v0.9.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fatih/color v1.16.0 // indirect
@ -97,7 +100,7 @@ require (
github.com/gdamore/encoding v1.0.0 // indirect
github.com/go-chi/chi v4.1.2+incompatible // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/analysis v0.23.0 // indirect
github.com/go-openapi/errors v0.22.1 // indirect
@ -123,7 +126,7 @@ require (
github.com/itchyny/timefmt-go v0.1.5 // indirect
github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
@ -141,7 +144,7 @@ require (
github.com/muesli/termenv v0.16.0 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
@ -154,9 +157,9 @@ require (
github.com/shibumi/go-pathspec v1.3.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect
github.com/sigstore/rekor v1.3.9 // indirect
github.com/sigstore/sigstore v1.9.1 // indirect
github.com/sigstore/timestamp-authority v1.2.5 // indirect
github.com/sigstore/rekor v1.3.10 // indirect
github.com/sigstore/sigstore v1.9.4 // indirect
github.com/sigstore/timestamp-authority v1.2.7 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
@ -165,27 +168,25 @@ require (
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/theupdateframework/go-tuf v0.7.0 // indirect
github.com/theupdateframework/go-tuf/v2 v2.0.2 // indirect
github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e // indirect
github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect
github.com/transparency-dev/merkle v0.0.2 // indirect
github.com/vbatts/tar-split v0.11.6 // indirect
github.com/vbatts/tar-split v0.12.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.8 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect
go.mongodb.org/mongo-driver v1.14.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.34.0 // indirect
go.opentelemetry.io/otel/metric v1.34.0 // indirect
go.opentelemetry.io/otel/trace v1.34.0 // indirect
go.opentelemetry.io/otel v1.36.0 // indirect
go.opentelemetry.io/otel/metric v1.36.0 // indirect
go.opentelemetry.io/otel/trace v1.36.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/tools v0.29.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 // indirect
golang.org/x/exp v0.0.0-20240531132922-fd00a4e0eefc // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect
k8s.io/klog/v2 v2.130.1 // indirect
)

269
go.sum
View file

@ -1,17 +1,17 @@
cloud.google.com/go v0.118.3 h1:jsypSnrE/w4mJysioGdMBg4MiW/hHx/sArFpaBWHdME=
cloud.google.com/go v0.118.3/go.mod h1:Lhs3YLnBlwJ4KA6nuObNMZ/fCbOQBPuWKPoE0Wa/9Vc=
cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps=
cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8=
cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M=
cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc=
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
cloud.google.com/go/iam v1.4.1 h1:cFC25Nv+u5BkTR/BT1tXdoF2daiVbZ1RLx2eqfQ9RMM=
cloud.google.com/go/iam v1.4.1/go.mod h1:2vUEJpUG3Q9p2UdsyksaKpDzlwOrnMzS30isdReIcLM=
cloud.google.com/go/kms v1.21.1 h1:r1Auo+jlfJSf8B7mUnVw5K0fI7jWyoUy65bV53VjKyk=
cloud.google.com/go/kms v1.21.1/go.mod h1:s0wCyByc9LjTdCjG88toVs70U9W+cc6RKFc8zAqX7nE=
cloud.google.com/go/longrunning v0.6.5 h1:sD+t8DO8j4HKW4QfouCklg7ZC1qC4uzVZt8iz3uTW+Q=
cloud.google.com/go/longrunning v0.6.5/go.mod h1:Et04XK+0TTLKa5IPYryKf5DkpwImy6TluQ1QTLwlKmY=
cloud.google.com/go v0.120.0 h1:wc6bgG9DHyKqF5/vQvX1CiZrtHnxJjBlKUyF9nP6meA=
cloud.google.com/go v0.120.0/go.mod h1:/beW32s8/pGRuj4IILWQNd4uuebeT4dkOhKmkfit64Q=
cloud.google.com/go/auth v0.16.0 h1:Pd8P1s9WkcrBE2n/PhAwKsdrR35V3Sg2II9B+ndM3CU=
cloud.google.com/go/auth v0.16.0/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
cloud.google.com/go/iam v1.5.0 h1:QlLcVMhbLGOjRcGe6VTGGTyQib8dRLK2B/kYNV0+2xs=
cloud.google.com/go/iam v1.5.0/go.mod h1:U+DOtKQltF/LxPEtcDLoobcsZMilSRwR7mgNL7knOpo=
cloud.google.com/go/kms v1.21.2 h1:c/PRUSMNQ8zXrc1sdAUnsenWWaNXN+PzTXfXOcSFdoE=
cloud.google.com/go/kms v1.21.2/go.mod h1:8wkMtHV/9Z8mLXEXr1GK7xPSBdi6knuLXIhqjuWcI6w=
cloud.google.com/go/longrunning v0.6.6 h1:XJNDo5MUfMM05xK3ewpbSdmt7R2Zw+aQEMbdQR65Rbw=
cloud.google.com/go/longrunning v0.6.6/go.mod h1:hyeGJUrPHcx0u2Uu1UFSoYZLn4lkMrccJig0t4FI7yw=
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
@ -20,18 +20,18 @@ github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjq
github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1 h1:DSDNVxqkoXJiko6x8a90zidoYqnYYa6c1MTzDKzKkTo=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1/go.mod h1:zGqV2R4Cr/k8Uye5w+dgQ06WJtEcbQG/8J7BB6hnCr4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2 h1:F0gBpfdPLGsw+nsgk6aqqkZS1jiixa5WwFe3fk/T3Ys=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2/go.mod h1:SqINnQ9lVVdRlyC8cd1lCI0SdX4n2paeABd2K8ggfnE=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0 h1:OVoM452qUFBrX+URdH3VpR299ma4kfom0yB0URYky9g=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0/go.mod h1:kUjrAo8bgEwLeZ/CmHqNl3Z/kPm7y6FKfxxK0izYUg4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
github.com/AzureAD/microsoft-authentication-library-for-go v1.3.3 h1:H5xDQaE3XowWfhZRUpnfC+rGZMEVoSiji+b+/HFAPU4=
github.com/AzureAD/microsoft-authentication-library-for-go v1.3.3/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
@ -58,10 +58,10 @@ github.com/aws/aws-sdk-go v1.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk
github.com/aws/aws-sdk-go v1.55.6/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
github.com/aws/aws-sdk-go-v2/config v1.29.10 h1:yNjgjiGBp4GgaJrGythyBXg2wAs+Im9fSWIUwvi1CAc=
github.com/aws/aws-sdk-go-v2/config v1.29.10/go.mod h1:A0mbLXSdtob/2t59n1X0iMkPQ5d+YzYZB4rwu7SZ7aA=
github.com/aws/aws-sdk-go-v2/credentials v1.17.63 h1:rv1V3kIJ14pdmTu01hwcMJ0WAERensSiD9rEWEBb1Tk=
github.com/aws/aws-sdk-go-v2/credentials v1.17.63/go.mod h1:EJj+yDf0txT26Ulo0VWTavBl31hOsaeuMxIHu2m0suY=
github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM=
github.com/aws/aws-sdk-go-v2/config v1.29.14/go.mod h1:wVPHWcIFv3WO89w0rE10gzf17ZYy+UVS1Geq8Iei34g=
github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM=
github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q=
@ -74,14 +74,14 @@ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY=
github.com/aws/aws-sdk-go-v2/service/kms v1.38.1 h1:tecq7+mAav5byF+Mr+iONJnCBf4B4gon8RSp4BrweSc=
github.com/aws/aws-sdk-go-v2/service/kms v1.38.1/go.mod h1:cQn6tAF77Di6m4huxovNM7NVAozWTZLsDRp9t8Z/WYk=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.1 h1:8JdC7Gr9NROg1Rusk25IcZeTO59zLxsKgE0gkh5O6h0=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.1/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.2 h1:wK8O+j2dOolmpNVY1EWIbLgxrGCHJKVPm08Hv/u80M8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.2/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.17 h1:PZV5W8yk4OtH1JAuhV2PXwwO9v5G5Aoj+eMCn4T+1Kc=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.17/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4=
github.com/aws/aws-sdk-go-v2/service/kms v1.38.3 h1:RivOtUH3eEu6SWnUMFHKAW4MqDOzWn1vGQ3S38Y5QMg=
github.com/aws/aws-sdk-go-v2/service/kms v1.38.3/go.mod h1:cQn6tAF77Di6m4huxovNM7NVAozWTZLsDRp9t8Z/WYk=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4=
github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@ -100,6 +100,8 @@ github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=
github.com/cenkalti/backoff/v5 v5.0.2/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.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
@ -110,25 +112,33 @@ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4p
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3 h1:hx6E25SvI2WiZdt/gxINcYBnHD7PE2Vr9auqwg5B05g=
github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3/go.mod h1:ihVqv4/YOY5Fweu1cxajuQrwJFh3zU4Ukb4mHVNjq3s=
github.com/charmbracelet/huh v0.6.1-0.20250409210615-c5906631cbb5 h1:uOnMxWghHfEYm2DPMeIHHAEirV/TduBVC9ZRXGcX9Q8=
github.com/charmbracelet/huh v0.6.1-0.20250409210615-c5906631cbb5/go.mod h1:xl27E/xNaX3WwdkqpvBwjJcGWhupkU52CWLC5hReBTw=
github.com/charmbracelet/huh v0.7.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc=
github.com/charmbracelet/huh v0.7.0/go.mod h1:UGC3DZHlgOKHvHC07a5vHag41zzhpPFj34U92sOmyuk=
github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc h1:nFRtCfZu/zkltd2lsLUPlVNv3ej/Atod9hcdbRZtlys=
github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
github.com/cli/browser v1.0.0/go.mod h1:IEWkHYbLjkhtjwwWlwTHW2lGxeS5gezEQBMLTwDHf5Q=
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
github.com/cli/go-gh/v2 v2.12.0 h1:PIurZ13fXbWDbr2//6ws4g4zDbryO+iDuTpiHgiV+6k=
github.com/cli/go-gh/v2 v2.12.0/go.mod h1:+5aXmEOJsH9fc9mBHfincDwnS02j2AIA/DsTH0Bk5uw=
github.com/cli/go-gh/v2 v2.12.1 h1:SVt1/afj5FRAythyMV3WJKaUfDNsxXTIe7arZbwTWKA=
github.com/cli/go-gh/v2 v2.12.1/go.mod h1:+5aXmEOJsH9fc9mBHfincDwnS02j2AIA/DsTH0Bk5uw=
github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24 h1:QDrhR4JA2n3ij9YQN0u5ZeuvRIIvsUGmf5yPlTS0w8E=
github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24/go.mod h1:rr9GNING0onuVw8MnracQHn7PcchnFlP882Y0II2KZk=
github.com/cli/oauth v1.1.1 h1:459gD3hSjlKX9B1uXBuiAMdpXBUQ9QGf/NDcCpoQxPs=
@ -142,15 +152,16 @@ github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUo
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4=
github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8=
github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU=
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/cyberphone/json-canonicalization v0.0.0-20220623050100-57a0ce2678a7 h1:vU+EP9ZuFUCYE0NYLwTSob+3LNEJATzNfP/DC7SWGWI=
github.com/cyberphone/json-canonicalization v0.0.0-20220623050100-57a0ce2678a7/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw=
github.com/danieljoos/wincred v1.2.1 h1:dl9cBrupW8+r5250DYkYxocLeZ1Y4vB1kxgtjxw8GQs=
github.com/danieljoos/wincred v1.2.1/go.mod h1:uGaFL9fDn3OLTvzCGulzE+SzjEe5NGlh5FdCcyfPwps=
github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@ -164,12 +175,12 @@ 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.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docker/cli v27.5.0+incompatible h1:aMphQkcGtpHixwwhAXJT1rrK/detk2JIvDaFkLctbGM=
github.com/docker/cli v27.5.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/cli v28.2.2+incompatible h1:qzx5BNUDFqlvyq4AHzdNB7gSyVTmU4cgsyN9SdInc1A=
github.com/docker/cli v28.2.2+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.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo=
github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M=
github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8=
github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
@ -183,8 +194,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/gdamore/tcell/v2 v2.5.4 h1:TGU4tSjD3sCL788vFNeJnTdzpNKIw1H5dgLnJRQVv/k=
@ -194,8 +205,8 @@ github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxm
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU=
@ -218,8 +229,8 @@ github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZ
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58=
github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI=
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
@ -236,16 +247,14 @@ github.com/google/certificate-transparency-go v1.3.1 h1:akbcTfQg0iZlANZLn0L9xOeW
github.com/google/certificate-transparency-go v1.3.1/go.mod h1:gg+UQlx6caKEDQ9EElFOujyxEQEfOiQzAt6782Bvi8k=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-containerregistry v0.20.3 h1:oNx7IdTI936V8CQRveCjaxOiegWwvM7kqkbXTpyiovI=
github.com/google/go-containerregistry v0.20.3/go.mod h1:w00pIgBRDVUDFM6bq+Qx8lwNWK+cxgCuX1vd3PIBDNI=
github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU=
github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/tink/go v1.7.0 h1:6Eox8zONGebBFcCBqkVmt60LaWZa6xg1cl/DwAh/J1w=
github.com/google/tink/go v1.7.0/go.mod h1:GAUOd+QE3pgj9q8VKIGTCP33c/B7eb4NhxLcgTJZStM=
github.com/google/trillian v1.7.1 h1:+zX8jLM3524bAMPS+VxaDIDgsMv3/ty6DuLWerHXcek=
github.com/google/trillian v1.7.1/go.mod h1:E1UMAHqpZCA8AQdrKdWmHmtUfSeiD0sDWD1cv00Xa+c=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@ -295,8 +304,8 @@ github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb
github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/in-toto/attestation v1.1.1 h1:QD3d+oATQ0dFsWoNh5oT0udQ3tUrOsZZ0Fc3tSgWbzI=
github.com/in-toto/attestation v1.1.1/go.mod h1:Dcq1zVwA2V7Qin8I7rgOi+i837wEf/mOZwRm047Sjys=
github.com/in-toto/attestation v1.1.2 h1:MBFn6lsMq6dptQZJBhalXTcWMb/aJy3V+GX3VYj/V1E=
github.com/in-toto/attestation v1.1.2/go.mod h1:gYFddHMZj3DiQ0b62ltNi1Vj5rC879bTmBbrv9CRHpM=
github.com/in-toto/in-toto-golang v0.9.0 h1:tHny7ac4KgtsfrG6ybU8gVOZux2H8jN05AXJ9EBM1XU=
github.com/in-toto/in-toto-golang v0.9.0/go.mod h1:xsBVrVsHNsB61++S6Dy2vWosKhuA3lUTQd+eF9HdeMo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@ -329,8 +338,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@ -393,8 +402,8 @@ github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
@ -406,8 +415,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
@ -446,24 +455,24 @@ github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc h1:vH0NQbIDk+mJL
github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8=
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0=
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE=
github.com/sigstore/protobuf-specs v0.4.1 h1:5SsMqZbdkcO/DNHudaxuCUEjj6x29tS2Xby1BxGU7Zc=
github.com/sigstore/protobuf-specs v0.4.1/go.mod h1:+gXR+38nIa2oEupqDdzg4qSBT0Os+sP7oYv6alWewWc=
github.com/sigstore/rekor v1.3.9 h1:sUjRpKVh/hhgqGMs0t+TubgYsksArZ6poLEC3MsGAzU=
github.com/sigstore/rekor v1.3.9/go.mod h1:xThNUhm6eNEmkJ/SiU/FVU7pLY2f380fSDZFsdDWlcM=
github.com/sigstore/sigstore v1.9.1 h1:bNMsfFATsMPaagcf+uppLk4C9rQZ2dh5ysmCxQBYWaw=
github.com/sigstore/sigstore v1.9.1/go.mod h1:zUoATYzR1J3rLNp3jmp4fzIJtWdhC3ZM6MnpcBtnsE4=
github.com/sigstore/sigstore-go v0.7.2 h1:CN4xPasChSEb0QBMxMW5dLcXdA9KD4QiRyVnMkhXj6U=
github.com/sigstore/sigstore-go v0.7.2/go.mod h1:AIRj4I3LC82qd07VFm3T2zXYiddxeBV1k/eoS8nTz0E=
github.com/sigstore/sigstore/pkg/signature/kms/aws v1.9.1 h1:/YcNq687WnXpIRXl04nLfJX741G4iW+w+7Nem2Zy0f4=
github.com/sigstore/sigstore/pkg/signature/kms/aws v1.9.1/go.mod h1:ApL9RpKsi7gkSYN0bMNdm/3jZ9EefxMmfYHfUmq2ZYM=
github.com/sigstore/sigstore/pkg/signature/kms/azure v1.9.1 h1:FnusXyTIInnwfIOzzl5PFilRm1I97dxMSOcCkZBu9Kc=
github.com/sigstore/sigstore/pkg/signature/kms/azure v1.9.1/go.mod h1:d5m5LOa/69a+t2YC9pDPwS1n2i/PhqB4cUKbpVDlKKE=
github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.9.1 h1:LFiYK1DEWQ6Hf/nroFzBMM+s5rVSjVL45Alpb5Ctl5A=
github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.9.1/go.mod h1:GFyFmDsE2wDuIHZD+4+JErGpA0S4zJsKNz5l2JVJd8s=
github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.9.1 h1:sIW6xe4yU5eIMH8fve2C78d+r29KmHnIb+7po+80bsY=
github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.9.1/go.mod h1:3pNf99GnK9eu3XUa5ebHzgEQSVYf9hqAoPFwbwD6O6M=
github.com/sigstore/timestamp-authority v1.2.5 h1:W22JmwRv1Salr/NFFuP7iJuhytcZszQjldoB8GiEdnw=
github.com/sigstore/timestamp-authority v1.2.5/go.mod h1:gWPKWq4HMWgPCETre0AakgBzcr9DRqHrsgbrRqsigOs=
github.com/sigstore/protobuf-specs v0.4.3 h1:kRgJ+ciznipH9xhrkAbAEHuuxD3GhYnGC873gZpjJT4=
github.com/sigstore/protobuf-specs v0.4.3/go.mod h1:+gXR+38nIa2oEupqDdzg4qSBT0Os+sP7oYv6alWewWc=
github.com/sigstore/rekor v1.3.10 h1:/mSvRo4MZ/59ECIlARhyykAlQlkmeAQpvBPlmJtZOCU=
github.com/sigstore/rekor v1.3.10/go.mod h1:JvryKJ40O0XA48MdzYUPu0y4fyvqt0C4iSY7ri9iu3A=
github.com/sigstore/sigstore v1.9.4 h1:64+OGed80+A4mRlNzRd055vFcgBeDghjZw24rPLZgDU=
github.com/sigstore/sigstore v1.9.4/go.mod h1:Q7tGTC3gbtK7c3jcxEmGc2MmK4rRpIRzi3bxRFWKvEY=
github.com/sigstore/sigstore-go v1.0.0 h1:4N07S2zLxf09nTRwaPKyAxbKzpM8WJYUS8lWWaYxneU=
github.com/sigstore/sigstore-go v1.0.0/go.mod h1:UYsZ/XHE4eltv1o1Lu+n6poW1Z5to3f0+emvfXNxIN8=
github.com/sigstore/sigstore/pkg/signature/kms/aws v1.9.4 h1:kQqUJ1VuWdJltMkinFXAHTlJrzMRPoNgL+dy6WyJ/dA=
github.com/sigstore/sigstore/pkg/signature/kms/aws v1.9.4/go.mod h1:9miLz7c69vj/7VH7UpCKHDia41HCTIDJWJWf4Ex5yUk=
github.com/sigstore/sigstore/pkg/signature/kms/azure v1.9.4 h1:MHRm7YQuF4zFyoXRLgUdLaNxqVO6JlLGnkDUI9fm9ow=
github.com/sigstore/sigstore/pkg/signature/kms/azure v1.9.4/go.mod h1:899VNYSSnQ0QtcuhkW0gznzxn0cqhowTL3nzc/xnym8=
github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.9.4 h1:C2nSyTmTxpuamUmLCWWZwz+0Y1IQIig9XwAJ4UAn/SI=
github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.9.4/go.mod h1:vjDahU0sEw/WMkKkygZNH72EMg86iaFNLAaJFXhItXU=
github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.9.4 h1:t9yfb6yteIDv8CNRT6OHdqgTV6TSj+CdOtZP9dVhpsQ=
github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.9.4/go.mod h1:m7sQxVJmDa+rsmS1m6biQxaLX83pzNS7ThUEyjOqkCU=
github.com/sigstore/timestamp-authority v1.2.7 h1:HP/VT4wnL4uzP0fVo3eHXlt0reuNgW3PLt78+BV0I5I=
github.com/sigstore/timestamp-authority v1.2.7/go.mod h1:te4ThQ3Q/CX1bzVsf5mMN0K7Z/cgc2OcoEGxAJiFqqI=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
@ -490,28 +499,30 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI=
github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug=
github.com/theupdateframework/go-tuf/v2 v2.0.2 h1:PyNnjV9BJNzN1ZE6BcWK+5JbF+if370jjzO84SS+Ebo=
github.com/theupdateframework/go-tuf/v2 v2.0.2/go.mod h1:baB22nBHeHBCeuGZcIlctNq4P61PcOdyARlplg5xmLA=
github.com/theupdateframework/go-tuf/v2 v2.1.1 h1:OWcoHItwsGO+7m0wLa7FDWPR4oB1cj0zOr1kosE4G+I=
github.com/theupdateframework/go-tuf/v2 v2.1.1/go.mod h1:V675cQGhZONR0OGQ8r1feO0uwtsTBYPDWHzAAPn5rjE=
github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e h1:BuzhfgfWQbX0dWzYzT1zsORLnHRv3bcRcsaUk0VmXA8=
github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI=
github.com/tink-crypto/tink-go-awskms/v2 v2.1.0 h1:N9UxlsOzu5mttdjhxkDLbzwtEecuXmlxZVo/ds7JKJI=
github.com/tink-crypto/tink-go-awskms/v2 v2.1.0/go.mod h1:PxSp9GlOkKL9rlybW804uspnHuO9nbD98V/fDX4uSis=
github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0 h1:3B9i6XBXNTRspfkTC0asN5W0K6GhOSgcujNiECNRNb0=
github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0/go.mod h1:jY5YN2BqD/KSCHM9SqZPIpJNG/u3zwfLXHgws4x2IRw=
github.com/tink-crypto/tink-go/v2 v2.3.0 h1:4/TA0lw0lA/iVKBL9f8R5eP7397bfc4antAMXF5JRhs=
github.com/tink-crypto/tink-go/v2 v2.3.0/go.mod h1:kfPOtXIadHlekBTeBtJrHWqoGL+Fm3JQg0wtltPuxLU=
github.com/tink-crypto/tink-go-hcvault/v2 v2.3.0 h1:6nAX1aRGnkg2SEUMwO5toB2tQkP0Jd6cbmZ/K5Le1V0=
github.com/tink-crypto/tink-go-hcvault/v2 v2.3.0/go.mod h1:HOC5NWW1wBI2Vke1FGcRBvDATkEYE7AUDiYbXqi2sBw=
github.com/tink-crypto/tink-go/v2 v2.4.0 h1:8VPZeZI4EeZ8P/vB6SIkhlStrJfivTJn+cQ4dtyHNh0=
github.com/tink-crypto/tink-go/v2 v2.4.0/go.mod h1:l//evrF2Y3MjdbpNDNGnKgCpo5zSmvUvnQ4MU+yE2sw=
github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0=
github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs=
github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4=
github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A=
github.com/vbatts/tar-split v0.11.6 h1:4SjTW5+PU11n6fZenf2IPoV8/tz3AaYHMWjf23envGs=
github.com/vbatts/tar-split v0.11.6/go.mod h1:dqKNtesIOr2j2Qv3W/cHjnvk9I8+G7oAkFDFN6TCBEI=
github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo=
github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY=
github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
github.com/zalando/go-keyring v0.2.5 h1:Bc2HHpjALryKD62ppdEzaFG6VxL6Bc+5v0LYpN8Lba8=
@ -520,22 +531,22 @@ go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd
go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
go.step.sm/crypto v0.60.0 h1:UgSw8DFG5xUOGB3GUID17UA32G4j1iNQ4qoMhBmsVFw=
go.step.sm/crypto v0.60.0/go.mod h1:Ep83Lv818L4gV0vhFTdPWRKnL6/5fRMpi8SaoP5ArSw=
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
go.step.sm/crypto v0.63.0 h1:U1QGELQqJ85oDfeNFE2V52cow1rvy0m3MekG3wFmyXY=
go.step.sm/crypto v0.63.0/go.mod h1:aj3LETmCZeSil1DMq3BlbhDBcN86+mmKrHZtXWyc0L4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
@ -544,24 +555,24 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
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.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw=
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/exp v0.0.0-20240531132922-fd00a4e0eefc h1:O9NuF4s+E/PvMIy+9IUZB9znFwUIXEWSstNjek6VpVg=
golang.org/x/exp v0.0.0-20240531132922-fd00a4e0eefc/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
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.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -572,37 +583,37 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
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.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
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.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.227.0 h1:QvIHF9IuyG6d6ReE+BNd11kIB8hZvjN8Z5xY5t21zYc=
google.golang.org/api v0.227.0/go.mod h1:EIpaG6MbTgQarWF5xJvX0eOJPK9n/5D4Bynb9j2HXvQ=
google.golang.org/api v0.230.0 h1:2u1hni3E+UXAXrONrrkfWpi/V6cyKVAbfGVeGtC3OxM=
google.golang.org/api v0.230.0/go.mod h1:aqvtoMk7YkiXx+6U12arQFExiRV9D/ekvMCwCd/TksQ=
google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE=
google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE=
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950=
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 h1:iK2jbkWL86DXjEx0qiHcRE9dE4/Ahua5k6V8OWFb//c=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I=
google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI=
google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e h1:UdXH7Kzbj+Vzastr5nVfccbmFsmYNygVLSPk1pEfDoY=
google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e/go.mod h1:085qFyf2+XaZlRdCgKNCIZ3afY2p4HHZdoIRpId8F4A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e h1:ztQaXfzEXTmCBvbtWYRhJxW+0iJcz2qXfd38/e9l7bA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8=
google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View file

@ -14,18 +14,23 @@ import (
ghConfig "github.com/cli/go-gh/v2/pkg/config"
)
// Important: some of the following configuration settings are used outside of `cli/cli`,
// they are defined here to avoid `cli/cli` being changed unexpectedly.
const (
accessibleColorsKey = "accessible_colors" // used by cli/go-gh to enable the use of customizable, accessible 4-bit colors.
accessiblePrompterKey = "accessible_prompter"
aliasesKey = "aliases"
browserKey = "browser"
browserKey = "browser" // used by cli/go-gh to open URLs in web browsers
colorLabelsKey = "color_labels"
editorKey = "editor"
editorKey = "editor" // used by cli/go-gh to open interactive text editor
gitProtocolKey = "git_protocol"
hostsKey = "hosts"
hostsKey = "hosts" // used by cli/go-gh to locate authenticated host tokens
httpUnixSocketKey = "http_unix_socket"
oauthTokenKey = "oauth_token"
oauthTokenKey = "oauth_token" // used by cli/go-gh to locate authenticated host tokens
pagerKey = "pager"
promptKey = "prompt"
preferEditorPromptKey = "prefer_editor_prompt"
spinnerKey = "spinner"
userKey = "user"
usersKey = "users"
versionKey = "version"
@ -109,6 +114,16 @@ func (c *cfg) Authentication() gh.AuthConfig {
return &AuthConfig{cfg: c.cfg}
}
func (c *cfg) AccessibleColors(hostname string) gh.ConfigEntry {
// Intentionally panic if there is no user provided value or default value (which would be a programmer error)
return c.GetOrDefault(hostname, accessibleColorsKey).Unwrap()
}
func (c *cfg) AccessiblePrompter(hostname string) gh.ConfigEntry {
// Intentionally panic if there is no user provided value or default value (which would be a programmer error)
return c.GetOrDefault(hostname, accessiblePrompterKey).Unwrap()
}
func (c *cfg) Browser(hostname string) gh.ConfigEntry {
// Intentionally panic if there is no user provided value or default value (which would be a programmer error)
return c.GetOrDefault(hostname, browserKey).Unwrap()
@ -149,6 +164,11 @@ func (c *cfg) PreferEditorPrompt(hostname string) gh.ConfigEntry {
return c.GetOrDefault(hostname, preferEditorPromptKey).Unwrap()
}
func (c *cfg) Spinner(hostname string) gh.ConfigEntry {
// Intentionally panic if there is no user provided value or default value (which would be a programmer error)
return c.GetOrDefault(hostname, spinnerKey).Unwrap()
}
func (c *cfg) Version() o.Option[string] {
return c.get("", versionKey)
}
@ -540,6 +560,12 @@ http_unix_socket:
browser:
# Whether to display labels using their RGB hex color codes in terminals that support truecolor. Supported values: enabled, disabled
color_labels: disabled
# Whether customizable, 4-bit accessible colors should be used. Supported values: enabled, disabled
accessible_colors: disabled
# Whether an accessible prompter should be used. Supported values: enabled, disabled
accessible_prompter: disabled
# Whether to use a animated spinner as a progress indicator. If disabled, a textual progress indicator is used instead. Supported values: enabled, disabled
spinner: enabled
`
type ConfigOption struct {
@ -619,6 +645,33 @@ var Options = []ConfigOption{
return c.ColorLabels(hostname).Value
},
},
{
Key: accessibleColorsKey,
Description: "whether customizable, 4-bit accessible colors should be used",
DefaultValue: "disabled",
AllowedValues: []string{"enabled", "disabled"},
CurrentValue: func(c gh.Config, hostname string) string {
return c.AccessibleColors(hostname).Value
},
},
{
Key: accessiblePrompterKey,
Description: "whether an accessible prompter should be used",
DefaultValue: "disabled",
AllowedValues: []string{"enabled", "disabled"},
CurrentValue: func(c gh.Config, hostname string) string {
return c.AccessiblePrompter(hostname).Value
},
},
{
Key: spinnerKey,
Description: "whether to use a animated spinner as a progress indicator",
DefaultValue: "enabled",
AllowedValues: []string{"enabled", "disabled"},
CurrentValue: func(c gh.Config, hostname string) string {
return c.Spinner(hostname).Value
},
},
}
func HomeDirPath(subdir string) (string, error) {

View file

@ -52,6 +52,12 @@ func NewFromString(cfgStr string) *ghmock.ConfigMock {
},
}
}
mock.AccessibleColorsFunc = func(hostname string) gh.ConfigEntry {
return cfg.AccessibleColors(hostname)
}
mock.AccessiblePrompterFunc = func(hostname string) gh.ConfigEntry {
return cfg.AccessiblePrompter(hostname)
}
mock.BrowserFunc = func(hostname string) gh.ConfigEntry {
return cfg.Browser(hostname)
}
@ -76,6 +82,9 @@ func NewFromString(cfgStr string) *ghmock.ConfigMock {
mock.PreferEditorPromptFunc = func(hostname string) gh.ConfigEntry {
return cfg.PreferEditorPrompt(hostname)
}
mock.SpinnerFunc = func(hostname string) gh.ConfigEntry {
return cfg.Spinner(hostname)
}
mock.VersionFunc = func() o.Option[string] {
return cfg.Version()
}

View file

@ -142,8 +142,7 @@ func genMarkdownCustom(cmd *cobra.Command, w io.Writer, linkHandler func(string)
fmt.Fprintf(w, "```\n%s\n```\n\n", cmd.UseLine())
}
if hasLong {
longWithEscapedPipe := strings.ReplaceAll(cmd.Long, "|", "&#124;")
fmt.Fprintf(w, "%s\n\n", longWithEscapedPipe)
fmt.Fprintf(w, "%s\n\n", cmd.Long)
}
for _, g := range root.GroupedCommands(cmd) {

View file

@ -1,5 +1,7 @@
package featuredetection
import "github.com/cli/cli/v2/internal/gh"
type DisabledDetectorMock struct{}
func (md *DisabledDetectorMock) IssueFeatures() (IssueFeatures, error) {
@ -14,6 +16,10 @@ func (md *DisabledDetectorMock) RepositoryFeatures() (RepositoryFeatures, error)
return RepositoryFeatures{}, nil
}
func (md *DisabledDetectorMock) ProjectsV1() gh.ProjectsV1Support {
return gh.ProjectsV1Unsupported
}
type EnabledDetectorMock struct{}
func (md *EnabledDetectorMock) IssueFeatures() (IssueFeatures, error) {
@ -27,3 +33,7 @@ func (md *EnabledDetectorMock) PullRequestFeatures() (PullRequestFeatures, error
func (md *EnabledDetectorMock) RepositoryFeatures() (RepositoryFeatures, error) {
return allRepositoryFeatures, nil
}
func (md *EnabledDetectorMock) ProjectsV1() gh.ProjectsV1Support {
return gh.ProjectsV1Supported
}

View file

@ -4,6 +4,7 @@ import (
"net/http"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/gh"
"golang.org/x/sync/errgroup"
ghauth "github.com/cli/go-gh/v2/pkg/auth"
@ -13,14 +14,17 @@ type Detector interface {
IssueFeatures() (IssueFeatures, error)
PullRequestFeatures() (PullRequestFeatures, error)
RepositoryFeatures() (RepositoryFeatures, error)
ProjectsV1() gh.ProjectsV1Support
}
type IssueFeatures struct {
StateReason bool
StateReason bool
ActorIsAssignable bool
}
var allIssueFeatures = IssueFeatures{
StateReason: true,
StateReason: true,
ActorIsAssignable: true,
}
type PullRequestFeatures struct {
@ -68,7 +72,8 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) {
}
features := IssueFeatures{
StateReason: false,
StateReason: false,
ActorIsAssignable: false, // replaceActorsForAssignable GraphQL mutation unavailable on GHES
}
var featureDetection struct {
@ -199,3 +204,13 @@ func (d *detector) RepositoryFeatures() (RepositoryFeatures, error) {
return features, nil
}
func (d *detector) ProjectsV1() gh.ProjectsV1Support {
// Currently, projects v1 support is entirely dependent on the host. As this is deprecated in GHES,
// we will do feature detection on whether the GHES version has support.
if ghauth.IsEnterprise(d.host) {
return gh.ProjectsV1Supported
}
return gh.ProjectsV1Unsupported
}

View file

@ -5,8 +5,10 @@ import (
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/gh"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIssueFeatures(t *testing.T) {
@ -21,7 +23,8 @@ func TestIssueFeatures(t *testing.T) {
name: "github.com",
hostname: "github.com",
wantFeatures: IssueFeatures{
StateReason: true,
StateReason: true,
ActorIsAssignable: true,
},
wantErr: false,
},
@ -29,7 +32,8 @@ func TestIssueFeatures(t *testing.T) {
name: "ghec data residency (ghe.com)",
hostname: "stampname.ghe.com",
wantFeatures: IssueFeatures{
StateReason: true,
StateReason: true,
ActorIsAssignable: true,
},
wantErr: false,
},
@ -40,7 +44,8 @@ func TestIssueFeatures(t *testing.T) {
`query Issue_fields\b`: `{"data": {}}`,
},
wantFeatures: IssueFeatures{
StateReason: false,
StateReason: false,
ActorIsAssignable: false,
},
wantErr: false,
},
@ -366,3 +371,19 @@ func TestRepositoryFeatures(t *testing.T) {
})
}
}
func TestProjectV1Support(t *testing.T) {
t.Parallel()
t.Run("when the host is enterprise, project v1 is supported", func(t *testing.T) {
detector := detector{host: "my.ghes.com"}
isProjectV1Supported := detector.ProjectsV1()
require.Equal(t, gh.ProjectsV1Supported, isProjectV1Supported)
})
t.Run("when the host is not enterprise, project v1 is not supported", func(t *testing.T) {
detector := detector{host: "github.com"}
isProjectV1Supported := detector.ProjectsV1()
require.Equal(t, gh.ProjectsV1Unsupported, isProjectV1Supported)
})
}

View file

@ -35,6 +35,10 @@ type Config interface {
// Set provides primitive access for setting configuration values, optionally scoped by host.
Set(hostname string, key string, value string)
// AccessibleColors returns the configured accessible_colors setting, optionally scoped by host.
AccessibleColors(hostname string) ConfigEntry
// AccessiblePrompter returns the configured accessible_prompter setting, optionally scoped by host.
AccessiblePrompter(hostname string) ConfigEntry
// Browser returns the configured browser, optionally scoped by host.
Browser(hostname string) ConfigEntry
// ColorLabels returns the configured color_label setting, optionally scoped by host.
@ -51,6 +55,8 @@ type Config interface {
Prompt(hostname string) ConfigEntry
// PreferEditorPrompt returns the configured editor-based prompt, optionally scoped by host.
PreferEditorPrompt(hostname string) ConfigEntry
// Spinner returns the configured spinner setting, optionally scoped by host.
Spinner(hostname string) ConfigEntry
// Aliases provides persistent storage and modification of command aliases.
Aliases() AliasConfig

View file

@ -19,6 +19,12 @@ var _ gh.Config = &ConfigMock{}
//
// // make and configure a mocked gh.Config
// mockedConfig := &ConfigMock{
// AccessibleColorsFunc: func(hostname string) gh.ConfigEntry {
// panic("mock out the AccessibleColors method")
// },
// AccessiblePrompterFunc: func(hostname string) gh.ConfigEntry {
// panic("mock out the AccessiblePrompter method")
// },
// AliasesFunc: func() gh.AliasConfig {
// panic("mock out the Aliases method")
// },
@ -61,6 +67,9 @@ var _ gh.Config = &ConfigMock{}
// SetFunc: func(hostname string, key string, value string) {
// panic("mock out the Set method")
// },
// SpinnerFunc: func(hostname string) gh.ConfigEntry {
// panic("mock out the Spinner method")
// },
// VersionFunc: func() o.Option[string] {
// panic("mock out the Version method")
// },
@ -74,6 +83,12 @@ var _ gh.Config = &ConfigMock{}
//
// }
type ConfigMock struct {
// AccessibleColorsFunc mocks the AccessibleColors method.
AccessibleColorsFunc func(hostname string) gh.ConfigEntry
// AccessiblePrompterFunc mocks the AccessiblePrompter method.
AccessiblePrompterFunc func(hostname string) gh.ConfigEntry
// AliasesFunc mocks the Aliases method.
AliasesFunc func() gh.AliasConfig
@ -116,6 +131,9 @@ type ConfigMock struct {
// SetFunc mocks the Set method.
SetFunc func(hostname string, key string, value string)
// SpinnerFunc mocks the Spinner method.
SpinnerFunc func(hostname string) gh.ConfigEntry
// VersionFunc mocks the Version method.
VersionFunc func() o.Option[string]
@ -124,6 +142,16 @@ type ConfigMock struct {
// calls tracks calls to the methods.
calls struct {
// AccessibleColors holds details about calls to the AccessibleColors method.
AccessibleColors []struct {
// Hostname is the hostname argument value.
Hostname string
}
// AccessiblePrompter holds details about calls to the AccessiblePrompter method.
AccessiblePrompter []struct {
// Hostname is the hostname argument value.
Hostname string
}
// Aliases holds details about calls to the Aliases method.
Aliases []struct {
}
@ -194,6 +222,11 @@ type ConfigMock struct {
// Value is the value argument value.
Value string
}
// Spinner holds details about calls to the Spinner method.
Spinner []struct {
// Hostname is the hostname argument value.
Hostname string
}
// Version holds details about calls to the Version method.
Version []struct {
}
@ -201,6 +234,8 @@ type ConfigMock struct {
Write []struct {
}
}
lockAccessibleColors sync.RWMutex
lockAccessiblePrompter sync.RWMutex
lockAliases sync.RWMutex
lockAuthentication sync.RWMutex
lockBrowser sync.RWMutex
@ -215,10 +250,75 @@ type ConfigMock struct {
lockPreferEditorPrompt sync.RWMutex
lockPrompt sync.RWMutex
lockSet sync.RWMutex
lockSpinner sync.RWMutex
lockVersion sync.RWMutex
lockWrite sync.RWMutex
}
// AccessibleColors calls AccessibleColorsFunc.
func (mock *ConfigMock) AccessibleColors(hostname string) gh.ConfigEntry {
if mock.AccessibleColorsFunc == nil {
panic("ConfigMock.AccessibleColorsFunc: method is nil but Config.AccessibleColors was just called")
}
callInfo := struct {
Hostname string
}{
Hostname: hostname,
}
mock.lockAccessibleColors.Lock()
mock.calls.AccessibleColors = append(mock.calls.AccessibleColors, callInfo)
mock.lockAccessibleColors.Unlock()
return mock.AccessibleColorsFunc(hostname)
}
// AccessibleColorsCalls gets all the calls that were made to AccessibleColors.
// Check the length with:
//
// len(mockedConfig.AccessibleColorsCalls())
func (mock *ConfigMock) AccessibleColorsCalls() []struct {
Hostname string
} {
var calls []struct {
Hostname string
}
mock.lockAccessibleColors.RLock()
calls = mock.calls.AccessibleColors
mock.lockAccessibleColors.RUnlock()
return calls
}
// AccessiblePrompter calls AccessiblePrompterFunc.
func (mock *ConfigMock) AccessiblePrompter(hostname string) gh.ConfigEntry {
if mock.AccessiblePrompterFunc == nil {
panic("ConfigMock.AccessiblePrompterFunc: method is nil but Config.AccessiblePrompter was just called")
}
callInfo := struct {
Hostname string
}{
Hostname: hostname,
}
mock.lockAccessiblePrompter.Lock()
mock.calls.AccessiblePrompter = append(mock.calls.AccessiblePrompter, callInfo)
mock.lockAccessiblePrompter.Unlock()
return mock.AccessiblePrompterFunc(hostname)
}
// AccessiblePrompterCalls gets all the calls that were made to AccessiblePrompter.
// Check the length with:
//
// len(mockedConfig.AccessiblePrompterCalls())
func (mock *ConfigMock) AccessiblePrompterCalls() []struct {
Hostname string
} {
var calls []struct {
Hostname string
}
mock.lockAccessiblePrompter.RLock()
calls = mock.calls.AccessiblePrompter
mock.lockAccessiblePrompter.RUnlock()
return calls
}
// Aliases calls AliasesFunc.
func (mock *ConfigMock) Aliases() gh.AliasConfig {
if mock.AliasesFunc == nil {
@ -664,6 +764,38 @@ func (mock *ConfigMock) SetCalls() []struct {
return calls
}
// Spinner calls SpinnerFunc.
func (mock *ConfigMock) Spinner(hostname string) gh.ConfigEntry {
if mock.SpinnerFunc == nil {
panic("ConfigMock.SpinnerFunc: method is nil but Config.Spinner was just called")
}
callInfo := struct {
Hostname string
}{
Hostname: hostname,
}
mock.lockSpinner.Lock()
mock.calls.Spinner = append(mock.calls.Spinner, callInfo)
mock.lockSpinner.Unlock()
return mock.SpinnerFunc(hostname)
}
// SpinnerCalls gets all the calls that were made to Spinner.
// Check the length with:
//
// len(mockedConfig.SpinnerCalls())
func (mock *ConfigMock) SpinnerCalls() []struct {
Hostname string
} {
var calls []struct {
Hostname string
}
mock.lockSpinner.RLock()
calls = mock.calls.Spinner
mock.lockSpinner.RUnlock()
return calls
}
// Version calls VersionFunc.
func (mock *ConfigMock) Version() o.Option[string] {
if mock.VersionFunc == nil {

23
internal/gh/projects.go Normal file
View file

@ -0,0 +1,23 @@
package gh
// ProjectsV1Support provides type safety and readability around whether or not Projects v1 is supported
// by the targeted host.
//
// It is a sealed type to ensure that consumers must use the exported ProjectsV1Supported and ProjectsV1Unsupported
// variables to get an instance of the type.
type ProjectsV1Support interface {
sealed()
}
type projectsV1Supported struct{}
func (projectsV1Supported) sealed() {}
type projectsV1Unsupported struct{}
func (projectsV1Unsupported) sealed() {}
var (
ProjectsV1Supported ProjectsV1Support = projectsV1Supported{}
ProjectsV1Unsupported ProjectsV1Support = projectsV1Unsupported{}
)

View file

@ -29,14 +29,6 @@ import (
"github.com/spf13/cobra"
)
// updaterEnabled is a statically linked build property set in gh formula within homebrew/homebrew-core
// used to control whether users are notified of newer GitHub CLI releases.
// This needs to be set to 'cli/cli' as it affects where update.CheckForUpdate() checks for releases.
// It is unclear whether this means that only homebrew builds will check for updates or not.
// Development builds leave this empty as impossible to determine if newer or not.
// For more information, <https://github.com/Homebrew/homebrew-core/blob/master/Formula/g/gh.rb>.
var updaterEnabled = ""
type exitCode int
const (

View file

@ -0,0 +1,6 @@
//go:build !updateable
package ghcmd
// See update_enabled.go comment for more information.
var updaterEnabled = ""

View file

@ -0,0 +1,18 @@
//go:build updateable
package ghcmd
// `updateable` is a build tag set in the gh formula within homebrew/homebrew-core
// and is used to control whether users are notified of newer GitHub CLI releases.
//
// Currently, updaterEnabled needs to be set to 'cli/cli' as it affects where
// update.CheckForUpdate() checks for releases. It is unclear to what extent
// this updaterEnabled is being used by unofficial forks or builds, so we decided
// to leave it available for injection as a string variable for now.
//
// Development builds do not generate update messages by default.
//
// For more information, see:
// - the Homebrew formula for gh: <https://github.com/Homebrew/homebrew-core/blob/master/Formula/g/gh.rb>.
// - a discussion about adding this build tag: <https://github.com/cli/cli/pull/11024#discussion_r2107597618>.
var updaterEnabled = "cli/cli"

View file

@ -5,12 +5,14 @@ package prompter_test
import (
"fmt"
"io"
"slices"
"strings"
"testing"
"time"
"github.com/Netflix/go-expect"
"github.com/cli/cli/v2/internal/prompter"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/creack/pty"
"github.com/hinshun/vt10x"
"github.com/stretchr/testify/assert"
@ -31,13 +33,16 @@ import (
// are sufficient to ensure that the accessible prompter behaves roughly as expected
// but doesn't mandate that prompts always look exactly the same.
func TestAccessiblePrompter(t *testing.T) {
beforePasswordSendTimeout := 100 * time.Microsecond
t.Run("Select", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
p := newTestAccessiblePrompter(t, console)
go func() {
// Wait for prompt to appear
_, err := console.ExpectString("Choose:")
_, err := console.ExpectString("Input a number between 1 and 3:")
require.NoError(t, err)
// Select option 1
@ -50,13 +55,80 @@ func TestAccessiblePrompter(t *testing.T) {
assert.Equal(t, 0, selectValue)
})
t.Run("MultiSelect", func(t *testing.T) {
t.Run("Select - blank input returns default value", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
p := newTestAccessiblePrompter(t, console)
dummyDefaultValue := "12345abcdefg"
options := []string{"1", "2", dummyDefaultValue}
go func() {
// Wait for prompt to appear
_, err := console.ExpectString("Select a number")
_, err := console.ExpectString("Input a number between 1 and 3:")
require.NoError(t, err)
// Just press enter to accept the default
_, err = console.SendLine("")
require.NoError(t, err)
}()
selectValue, err := p.Select("Select a number", dummyDefaultValue, options)
require.NoError(t, err)
expectedIndex := slices.Index(options, dummyDefaultValue)
assert.Equal(t, expectedIndex, selectValue)
})
t.Run("Select - default value is in prompt and in readable format", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAccessiblePrompter(t, console)
dummyDefaultValue := "12345abcdefg"
options := []string{"1", "2", dummyDefaultValue}
go func() {
// Wait for prompt to appear
_, err := console.ExpectString("Select a number (default: 12345abcdefg)")
require.NoError(t, err)
// Just press enter to accept the default
_, err = console.SendLine("")
require.NoError(t, err)
}()
selectValue, err := p.Select("Select a number", dummyDefaultValue, options)
require.NoError(t, err)
expectedIndex := slices.Index(options, dummyDefaultValue)
assert.Equal(t, expectedIndex, selectValue)
})
t.Run("Select - invalid defaults are excluded from prompt", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAccessiblePrompter(t, console)
dummyDefaultValue := "foo"
options := []string{"1", "2"}
go func() {
// Wait for prompt to appear without the invalid default value
_, err := console.ExpectString("Select a number \r\n")
require.NoError(t, err)
// Select option 2
_, err = console.SendLine("2")
require.NoError(t, err)
}()
selectValue, err := p.Select("Select a number", dummyDefaultValue, options)
require.NoError(t, err)
assert.Equal(t, 1, selectValue)
})
t.Run("MultiSelect", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAccessiblePrompter(t, console)
go func() {
// Wait for prompt to appear
_, err := console.ExpectString("Input a number between 0 and 3:")
require.NoError(t, err)
// Select options 1 and 2
@ -75,9 +147,86 @@ func TestAccessiblePrompter(t *testing.T) {
assert.Equal(t, []int{0, 1}, multiSelectValue)
})
t.Run("MultiSelect - default values are respected by being pre-selected", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAccessiblePrompter(t, console)
go func() {
// Wait for prompt to appear
_, err := console.ExpectString("Select a number")
require.NoError(t, err)
// Don't select anything because the default should be selected.
// This confirms selections
_, err = console.SendLine("0")
require.NoError(t, err)
}()
multiSelectValue, err := p.MultiSelect("Select a number", []string{"2"}, []string{"1", "2", "3"})
require.NoError(t, err)
assert.Equal(t, []int{1}, multiSelectValue)
})
t.Run("MultiSelect - default value is in prompt and in readable format", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAccessiblePrompter(t, console)
dummyDefaultValues := []string{"foo", "bar"}
options := []string{"1", "2"}
options = append(options, dummyDefaultValues...)
go func() {
// Wait for prompt to appear
_, err := console.ExpectString("Select a number (defaults: foo, bar)")
require.NoError(t, err)
// Don't select anything because the defaults should be selected.
// This confirms selections
_, err = console.SendLine("0")
require.NoError(t, err)
}()
multiSelectValues, err := p.MultiSelect("Select a number", dummyDefaultValues, options)
require.NoError(t, err)
var expectedIndices []int
// Get the indices of the default values within the options slice
// as that's what we expect the prompter to return when no selections are made.
for _, defaultValue := range dummyDefaultValues {
expectedIndices = append(expectedIndices, slices.Index(options, defaultValue))
}
assert.Equal(t, expectedIndices, multiSelectValues)
})
t.Run("MultiSelect - invalid defaults are excluded from prompt", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAccessiblePrompter(t, console)
dummyDefaultValues := []string{"foo", "bar"}
options := []string{"1", "2"}
go func() {
// Wait for prompt to appear without the invalid default values
_, err := console.ExpectString("Select a number \r\n")
require.NoError(t, err)
// Not selecting anything will fail because there are no defaults.
_, err = console.SendLine("2")
require.NoError(t, err)
// This confirms selections
_, err = console.SendLine("0")
require.NoError(t, err)
}()
multiSelectValues, err := p.MultiSelect("Select a number", dummyDefaultValues, options)
require.NoError(t, err)
assert.Equal(t, []int{1}, multiSelectValues)
})
t.Run("Input", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
p := newTestAccessiblePrompter(t, console)
dummyText := "12345abcdefg"
go func() {
@ -97,7 +246,7 @@ func TestAccessiblePrompter(t *testing.T) {
t.Run("Input - blank input returns default value", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
p := newTestAccessiblePrompter(t, console)
dummyDefaultValue := "12345abcdefg"
go func() {
@ -115,9 +264,29 @@ func TestAccessiblePrompter(t *testing.T) {
assert.Equal(t, dummyDefaultValue, inputValue)
})
t.Run("Input - default value is in prompt and in readable format", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAccessiblePrompter(t, console)
dummyDefaultValue := "12345abcdefg"
go func() {
// Wait for prompt to appear
_, err := console.ExpectString("Enter some characters (default: 12345abcdefg)")
require.NoError(t, err)
// Enter nothing
_, err = console.SendLine("")
require.NoError(t, err)
}()
inputValue, err := p.Input("Enter some characters", dummyDefaultValue)
require.NoError(t, err)
assert.Equal(t, dummyDefaultValue, inputValue)
})
t.Run("Password", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
p := newTestAccessiblePrompter(t, console)
dummyPassword := "12345abcdefg"
go func() {
@ -125,6 +294,9 @@ func TestAccessiblePrompter(t *testing.T) {
_, err := console.ExpectString("Enter password")
require.NoError(t, err)
// Wait to ensure huh has time to set the echo mode
time.Sleep(beforePasswordSendTimeout)
// Enter a number
_, err = console.SendLine(dummyPassword)
require.NoError(t, err)
@ -133,11 +305,21 @@ func TestAccessiblePrompter(t *testing.T) {
passwordValue, err := p.Password("Enter password")
require.NoError(t, err)
require.Equal(t, dummyPassword, passwordValue)
// Ensure the dummy password is not printed to the screen,
// asserting that echo mode is disabled.
//
// Note that since console.ExpectString returns successful if the
// expected string matches any part of the stream, we have to use an
// anchored regexp (i.e., with ^ and $) to make sure the password/token
// is not printed at all.
_, err = console.Expect(expect.RegexpPattern("^ \r\n\r\n$"))
require.NoError(t, err)
})
t.Run("Confirm", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
p := newTestAccessiblePrompter(t, console)
go func() {
// Wait for prompt to appear
@ -156,7 +338,7 @@ func TestAccessiblePrompter(t *testing.T) {
t.Run("Confirm - blank input returns default", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
p := newTestAccessiblePrompter(t, console)
go func() {
// Wait for prompt to appear
@ -173,9 +355,29 @@ func TestAccessiblePrompter(t *testing.T) {
require.Equal(t, false, confirmValue)
})
t.Run("Confirm - default value is in prompt and in readable format", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAccessiblePrompter(t, console)
defaultValue := true
go func() {
// Wait for prompt to appear
_, err := console.ExpectString("Are you sure (default: yes)")
require.NoError(t, err)
// Enter nothing
_, err = console.SendLine("")
require.NoError(t, err)
}()
confirmValue, err := p.Confirm("Are you sure", defaultValue)
require.NoError(t, err)
require.Equal(t, defaultValue, confirmValue)
})
t.Run("AuthToken", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
p := newTestAccessiblePrompter(t, console)
dummyAuthToken := "12345abcdefg"
go func() {
@ -183,6 +385,9 @@ func TestAccessiblePrompter(t *testing.T) {
_, err := console.ExpectString("Paste your authentication token:")
require.NoError(t, err)
// Wait to ensure huh has time to set the echo mode
time.Sleep(beforePasswordSendTimeout)
// Enter some dummy auth token
_, err = console.SendLine(dummyAuthToken)
require.NoError(t, err)
@ -191,11 +396,21 @@ func TestAccessiblePrompter(t *testing.T) {
authValue, err := p.AuthToken()
require.NoError(t, err)
require.Equal(t, dummyAuthToken, authValue)
// Ensure the dummy password is not printed to the screen,
// asserting that echo mode is disabled.
//
// Note that since console.ExpectString returns successful if the
// expected string matches any part of the stream, we have to use an
// anchored regexp (i.e., with ^ and $) to make sure the password/token
// is not printed at all.
_, err = console.Expect(expect.RegexpPattern("^ \r\n\r\n$"))
require.NoError(t, err)
})
t.Run("AuthToken - blank input returns error", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
p := newTestAccessiblePrompter(t, console)
dummyAuthTokenForAfterFailure := "12345abcdefg"
go func() {
@ -211,6 +426,13 @@ func TestAccessiblePrompter(t *testing.T) {
_, err = console.ExpectString("token is required")
require.NoError(t, err)
// Wait for the retry prompt
_, err = console.ExpectString("Paste your authentication token:")
require.NoError(t, err)
// Wait to ensure huh has time to set the echo mode
time.Sleep(beforePasswordSendTimeout)
// Now enter some dummy auth token to return control back to the test
_, err = console.SendLine(dummyAuthTokenForAfterFailure)
require.NoError(t, err)
@ -219,11 +441,21 @@ func TestAccessiblePrompter(t *testing.T) {
authValue, err := p.AuthToken()
require.NoError(t, err)
require.Equal(t, dummyAuthTokenForAfterFailure, authValue)
// Ensure the dummy password is not printed to the screen,
// asserting that echo mode is disabled.
//
// Note that since console.ExpectString returns successful if the
// expected string matches any part of the stream, we have to use an
// anchored regexp (i.e., with ^ and $) to make sure the password/token
// is not printed at all.
_, err = console.Expect(expect.RegexpPattern("^ \r\n\r\n$"))
require.NoError(t, err)
})
t.Run("ConfirmDeletion", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
p := newTestAccessiblePrompter(t, console)
requiredValue := "test"
go func() {
@ -243,7 +475,7 @@ func TestAccessiblePrompter(t *testing.T) {
t.Run("ConfirmDeletion - bad input", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
p := newTestAccessiblePrompter(t, console)
requiredValue := "test"
badInputValue := "garbage"
@ -272,7 +504,7 @@ func TestAccessiblePrompter(t *testing.T) {
t.Run("InputHostname", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
p := newTestAccessiblePrompter(t, console)
hostname := "example.com"
go func() {
@ -292,7 +524,7 @@ func TestAccessiblePrompter(t *testing.T) {
t.Run("MarkdownEditor - blank allowed with blank input returns blank", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
p := newTestAccessiblePrompter(t, console)
go func() {
// Wait for prompt to appear
@ -311,7 +543,7 @@ func TestAccessiblePrompter(t *testing.T) {
t.Run("MarkdownEditor - blank disallowed with default value returns default value", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
p := newTestAccessiblePrompter(t, console)
defaultValue := "12345abcdefg"
go func() {
@ -324,7 +556,7 @@ func TestAccessiblePrompter(t *testing.T) {
require.NoError(t, err)
// Expect a notice to enter something valid since blank is disallowed.
_, err = console.ExpectString("invalid input. please try again")
_, err = console.ExpectString("Invalid: must be between 1 and 1")
require.NoError(t, err)
// Send a 1 to select to open the editor. This will immediately exit
@ -339,7 +571,7 @@ func TestAccessiblePrompter(t *testing.T) {
t.Run("MarkdownEditor - blank disallowed no default value returns error", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
p := newTestAccessiblePrompter(t, console)
go func() {
// Wait for prompt to appear
@ -351,7 +583,7 @@ func TestAccessiblePrompter(t *testing.T) {
require.NoError(t, err)
// Expect a notice to enter something valid since blank is disallowed.
_, err = console.ExpectString("invalid input. please try again")
_, err = console.ExpectString("Invalid: must be between 1 and 1")
require.NoError(t, err)
// Send a 1 to select to open the editor since skip is invalid and
@ -419,21 +651,40 @@ func newTestVirtualTerminal(t *testing.T) *expect.Console {
return console
}
func newTestAcessiblePrompter(t *testing.T, console *expect.Console) prompter.Prompter {
func newTestVirtualTerminalIOStreams(t *testing.T, console *expect.Console) *iostreams.IOStreams {
t.Helper()
io := &iostreams.IOStreams{
In: console.Tty(),
Out: console.Tty(),
ErrOut: console.Tty(),
}
io.SetStdinTTY(false)
io.SetStdoutTTY(false)
io.SetStderrTTY(false)
return io
}
// `echo` is chosen as the editor command because it immediately returns
// a success exit code, returns an empty string, doesn't require any user input,
// and since this file is only built on Linux, it is near guaranteed to be available.
var editorCmd = "echo"
func newTestAccessiblePrompter(t *testing.T, console *expect.Console) prompter.Prompter {
t.Helper()
t.Setenv("GH_ACCESSIBLE_PROMPTER", "true")
// `echo`` is chose as the editor command because it immediately returns
// a success exit code, returns an empty string, doesn't require any user input,
// and since this file is only built on Linux, it is near guaranteed to be available.
return prompter.New("echo", console.Tty(), console.Tty(), console.Tty())
io := newTestVirtualTerminalIOStreams(t, console)
io.SetAccessiblePrompterEnabled(true)
return prompter.New(editorCmd, io)
}
func newTestSurveyPrompter(t *testing.T, console *expect.Console) prompter.Prompter {
t.Helper()
t.Setenv("GH_ACCESSIBLE_PROMPTER", "false")
return prompter.New("echo", console.Tty(), console.Tty(), console.Tty())
io := newTestVirtualTerminalIOStreams(t, console)
io.SetAccessiblePrompterEnabled(false)
return prompter.New(editorCmd, io)
}
// failOnExpectError adds an observer that will fail the test in a standardised way

View file

@ -2,13 +2,13 @@ package prompter
import (
"fmt"
"os"
"slices"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/charmbracelet/huh"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/pkg/surveyext"
ghPrompter "github.com/cli/go-gh/v2/pkg/prompter"
)
@ -43,24 +43,21 @@ type Prompter interface {
MarkdownEditor(prompt string, defaultValue string, blankAllowed bool) (string, error)
}
func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWriter, stderr ghPrompter.FileWriter) Prompter {
accessiblePrompterValue, accessiblePrompterIsSet := os.LookupEnv("GH_ACCESSIBLE_PROMPTER")
falseyValues := []string{"false", "0", "no", ""}
if accessiblePrompterIsSet && !slices.Contains(falseyValues, accessiblePrompterValue) {
func New(editorCmd string, io *iostreams.IOStreams) Prompter {
if io.AccessiblePrompterEnabled() {
return &accessiblePrompter{
stdin: stdin,
stdout: stdout,
stderr: stderr,
stdin: io.In,
stdout: io.Out,
stderr: io.ErrOut,
editorCmd: editorCmd,
}
}
return &surveyPrompter{
prompter: ghPrompter.New(stdin, stdout, stderr),
stdin: stdin,
stdout: stdout,
stderr: stderr,
prompter: ghPrompter.New(io.In, io.Out, io.ErrOut),
stdin: io.In,
stdout: io.Out,
stderr: io.ErrOut,
editorCmd: editorCmd,
}
}
@ -80,10 +77,40 @@ func (p *accessiblePrompter) newForm(groups ...*huh.Group) *huh.Form {
WithOutput(p.stdout)
}
func (p *accessiblePrompter) Select(prompt, _ string, options []string) (int, error) {
// addDefaultsToPrompt adds default values to the prompt string.
func (p *accessiblePrompter) addDefaultsToPrompt(prompt string, defaultValues []string) string {
// Removing empty defaults from the slice.
defaultValues = slices.DeleteFunc(defaultValues, func(s string) bool {
return s == ""
})
// Pluralizing the prompt if there are multiple default values.
if len(defaultValues) == 1 {
prompt = fmt.Sprintf("%s (default: %s)", prompt, defaultValues[0])
} else if len(defaultValues) > 1 {
prompt = fmt.Sprintf("%s (defaults: %s)", prompt, strings.Join(defaultValues, ", "))
}
// Zero-length defaultValues means return prompt unchanged.
return prompt
}
func (p *accessiblePrompter) Select(prompt, defaultValue string, options []string) (int, error) {
var result int
// Remove invalid default values from the defaults slice.
if !slices.Contains(options, defaultValue) {
defaultValue = ""
}
prompt = p.addDefaultsToPrompt(prompt, []string{defaultValue})
formOptions := []huh.Option[int]{}
for i, o := range options {
// If this option is the default value, assign its index
// to the result variable. huh will treat it as a default selection.
if defaultValue == o {
result = i
}
formOptions = append(formOptions, huh.NewOption(o, i))
}
@ -102,8 +129,22 @@ func (p *accessiblePrompter) Select(prompt, _ string, options []string) (int, er
func (p *accessiblePrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) {
var result []int
// Remove invalid default values from the defaults slice.
defaults = slices.DeleteFunc(defaults, func(s string) bool {
return !slices.Contains(options, s)
})
prompt = p.addDefaultsToPrompt(prompt, defaults)
formOptions := make([]huh.Option[int], len(options))
for i, o := range options {
// If this option is in the defaults slice,
// let's add its index to the result slice and huh
// will treat it as a default selection.
if slices.Contains(defaults, o) {
result = append(result, i)
}
formOptions[i] = huh.NewOption(o, i)
}
@ -126,7 +167,7 @@ func (p *accessiblePrompter) MultiSelect(prompt string, defaults []string, optio
func (p *accessiblePrompter) Input(prompt, defaultValue string) (string, error) {
result := defaultValue
prompt = fmt.Sprintf("%s (%s)", prompt, defaultValue)
prompt = p.addDefaultsToPrompt(prompt, []string{defaultValue})
form := p.newForm(
huh.NewGroup(
huh.NewInput().
@ -141,10 +182,12 @@ func (p *accessiblePrompter) Input(prompt, defaultValue string) (string, error)
func (p *accessiblePrompter) Password(prompt string) (string, error) {
var result string
// EchoMode(huh.EchoModePassword) doesn't have any effect in accessible mode.
// EchoModePassword is not used as password masking is unsupported in huh.
// EchoModeNone and EchoModePassword have the same effect of hiding user input.
form := p.newForm(
huh.NewGroup(
huh.NewInput().
EchoMode(huh.EchoModeNone).
Title(prompt).
Value(&result),
),
@ -160,6 +203,13 @@ func (p *accessiblePrompter) Password(prompt string) (string, error) {
func (p *accessiblePrompter) Confirm(prompt string, defaultValue bool) (bool, error) {
result := defaultValue
if defaultValue {
prompt = p.addDefaultsToPrompt(prompt, []string{"yes"})
} else {
prompt = p.addDefaultsToPrompt(prompt, []string{"no"})
}
form := p.newForm(
huh.NewGroup(
huh.NewConfirm().
@ -167,6 +217,7 @@ func (p *accessiblePrompter) Confirm(prompt string, defaultValue bool) (bool, er
Value(&result),
),
)
if err := form.Run(); err != nil {
return false, err
}
@ -175,9 +226,12 @@ func (p *accessiblePrompter) Confirm(prompt string, defaultValue bool) (bool, er
func (p *accessiblePrompter) AuthToken() (string, error) {
var result string
// EchoModeNone and EchoModePassword both result in disabling echo mode
// as password masking is outside of VT100 spec.
form := p.newForm(
huh.NewGroup(
huh.NewInput().
EchoMode(huh.EchoModeNone).
Title("Paste your authentication token:").
// Note: if this validation fails, the prompt loops.
Validate(func(input string) error {
@ -187,8 +241,6 @@ func (p *accessiblePrompter) AuthToken() (string, error) {
return nil
}).
Value(&result),
// This doesn't have any effect in accessible mode.
// EchoMode(huh.EchoModePassword),
),
)

View file

@ -20,28 +20,28 @@ var _ Prompter = &PrompterMock{}
// AuthTokenFunc: func() (string, error) {
// panic("mock out the AuthToken method")
// },
// ConfirmFunc: func(s string, b bool) (bool, error) {
// ConfirmFunc: func(prompt string, defaultValue bool) (bool, error) {
// panic("mock out the Confirm method")
// },
// ConfirmDeletionFunc: func(s string) error {
// ConfirmDeletionFunc: func(requiredValue string) error {
// panic("mock out the ConfirmDeletion method")
// },
// InputFunc: func(s1 string, s2 string) (string, error) {
// InputFunc: func(prompt string, defaultValue string) (string, error) {
// panic("mock out the Input method")
// },
// InputHostnameFunc: func() (string, error) {
// panic("mock out the InputHostname method")
// },
// MarkdownEditorFunc: func(s1 string, s2 string, b bool) (string, error) {
// MarkdownEditorFunc: func(prompt string, defaultValue string, blankAllowed bool) (string, error) {
// panic("mock out the MarkdownEditor method")
// },
// MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) {
// panic("mock out the MultiSelect method")
// },
// PasswordFunc: func(s string) (string, error) {
// PasswordFunc: func(prompt string) (string, error) {
// panic("mock out the Password method")
// },
// SelectFunc: func(s1 string, s2 string, strings []string) (int, error) {
// SelectFunc: func(prompt string, defaultValue string, options []string) (int, error) {
// panic("mock out the Select method")
// },
// }
@ -55,28 +55,28 @@ type PrompterMock struct {
AuthTokenFunc func() (string, error)
// ConfirmFunc mocks the Confirm method.
ConfirmFunc func(s string, b bool) (bool, error)
ConfirmFunc func(prompt string, defaultValue bool) (bool, error)
// ConfirmDeletionFunc mocks the ConfirmDeletion method.
ConfirmDeletionFunc func(s string) error
ConfirmDeletionFunc func(requiredValue string) error
// InputFunc mocks the Input method.
InputFunc func(s1 string, s2 string) (string, error)
InputFunc func(prompt string, defaultValue string) (string, error)
// InputHostnameFunc mocks the InputHostname method.
InputHostnameFunc func() (string, error)
// MarkdownEditorFunc mocks the MarkdownEditor method.
MarkdownEditorFunc func(s1 string, s2 string, b bool) (string, error)
MarkdownEditorFunc func(prompt string, defaultValue string, blankAllowed bool) (string, error)
// MultiSelectFunc mocks the MultiSelect method.
MultiSelectFunc func(prompt string, defaults []string, options []string) ([]int, error)
// PasswordFunc mocks the Password method.
PasswordFunc func(s string) (string, error)
PasswordFunc func(prompt string) (string, error)
// SelectFunc mocks the Select method.
SelectFunc func(s1 string, s2 string, strings []string) (int, error)
SelectFunc func(prompt string, defaultValue string, options []string) (int, error)
// calls tracks calls to the methods.
calls struct {
@ -85,34 +85,34 @@ type PrompterMock struct {
}
// Confirm holds details about calls to the Confirm method.
Confirm []struct {
// S is the s argument value.
S string
// B is the b argument value.
B bool
// Prompt is the prompt argument value.
Prompt string
// DefaultValue is the defaultValue argument value.
DefaultValue bool
}
// ConfirmDeletion holds details about calls to the ConfirmDeletion method.
ConfirmDeletion []struct {
// S is the s argument value.
S string
// RequiredValue is the requiredValue argument value.
RequiredValue string
}
// Input holds details about calls to the Input method.
Input []struct {
// S1 is the s1 argument value.
S1 string
// S2 is the s2 argument value.
S2 string
// Prompt is the prompt argument value.
Prompt string
// DefaultValue is the defaultValue argument value.
DefaultValue string
}
// InputHostname holds details about calls to the InputHostname method.
InputHostname []struct {
}
// MarkdownEditor holds details about calls to the MarkdownEditor method.
MarkdownEditor []struct {
// S1 is the s1 argument value.
S1 string
// S2 is the s2 argument value.
S2 string
// B is the b argument value.
B bool
// Prompt is the prompt argument value.
Prompt string
// DefaultValue is the defaultValue argument value.
DefaultValue string
// BlankAllowed is the blankAllowed argument value.
BlankAllowed bool
}
// MultiSelect holds details about calls to the MultiSelect method.
MultiSelect []struct {
@ -125,17 +125,17 @@ type PrompterMock struct {
}
// Password holds details about calls to the Password method.
Password []struct {
// S is the s argument value.
S string
// Prompt is the prompt argument value.
Prompt string
}
// Select holds details about calls to the Select method.
Select []struct {
// S1 is the s1 argument value.
S1 string
// S2 is the s2 argument value.
S2 string
// Strings is the strings argument value.
Strings []string
// Prompt is the prompt argument value.
Prompt string
// DefaultValue is the defaultValue argument value.
DefaultValue string
// Options is the options argument value.
Options []string
}
}
lockAuthToken sync.RWMutex
@ -177,21 +177,21 @@ func (mock *PrompterMock) AuthTokenCalls() []struct {
}
// Confirm calls ConfirmFunc.
func (mock *PrompterMock) Confirm(s string, b bool) (bool, error) {
func (mock *PrompterMock) Confirm(prompt string, defaultValue bool) (bool, error) {
if mock.ConfirmFunc == nil {
panic("PrompterMock.ConfirmFunc: method is nil but Prompter.Confirm was just called")
}
callInfo := struct {
S string
B bool
Prompt string
DefaultValue bool
}{
S: s,
B: b,
Prompt: prompt,
DefaultValue: defaultValue,
}
mock.lockConfirm.Lock()
mock.calls.Confirm = append(mock.calls.Confirm, callInfo)
mock.lockConfirm.Unlock()
return mock.ConfirmFunc(s, b)
return mock.ConfirmFunc(prompt, defaultValue)
}
// ConfirmCalls gets all the calls that were made to Confirm.
@ -199,12 +199,12 @@ func (mock *PrompterMock) Confirm(s string, b bool) (bool, error) {
//
// len(mockedPrompter.ConfirmCalls())
func (mock *PrompterMock) ConfirmCalls() []struct {
S string
B bool
Prompt string
DefaultValue bool
} {
var calls []struct {
S string
B bool
Prompt string
DefaultValue bool
}
mock.lockConfirm.RLock()
calls = mock.calls.Confirm
@ -213,19 +213,19 @@ func (mock *PrompterMock) ConfirmCalls() []struct {
}
// ConfirmDeletion calls ConfirmDeletionFunc.
func (mock *PrompterMock) ConfirmDeletion(s string) error {
func (mock *PrompterMock) ConfirmDeletion(requiredValue string) error {
if mock.ConfirmDeletionFunc == nil {
panic("PrompterMock.ConfirmDeletionFunc: method is nil but Prompter.ConfirmDeletion was just called")
}
callInfo := struct {
S string
RequiredValue string
}{
S: s,
RequiredValue: requiredValue,
}
mock.lockConfirmDeletion.Lock()
mock.calls.ConfirmDeletion = append(mock.calls.ConfirmDeletion, callInfo)
mock.lockConfirmDeletion.Unlock()
return mock.ConfirmDeletionFunc(s)
return mock.ConfirmDeletionFunc(requiredValue)
}
// ConfirmDeletionCalls gets all the calls that were made to ConfirmDeletion.
@ -233,10 +233,10 @@ func (mock *PrompterMock) ConfirmDeletion(s string) error {
//
// len(mockedPrompter.ConfirmDeletionCalls())
func (mock *PrompterMock) ConfirmDeletionCalls() []struct {
S string
RequiredValue string
} {
var calls []struct {
S string
RequiredValue string
}
mock.lockConfirmDeletion.RLock()
calls = mock.calls.ConfirmDeletion
@ -245,21 +245,21 @@ func (mock *PrompterMock) ConfirmDeletionCalls() []struct {
}
// Input calls InputFunc.
func (mock *PrompterMock) Input(s1 string, s2 string) (string, error) {
func (mock *PrompterMock) Input(prompt string, defaultValue string) (string, error) {
if mock.InputFunc == nil {
panic("PrompterMock.InputFunc: method is nil but Prompter.Input was just called")
}
callInfo := struct {
S1 string
S2 string
Prompt string
DefaultValue string
}{
S1: s1,
S2: s2,
Prompt: prompt,
DefaultValue: defaultValue,
}
mock.lockInput.Lock()
mock.calls.Input = append(mock.calls.Input, callInfo)
mock.lockInput.Unlock()
return mock.InputFunc(s1, s2)
return mock.InputFunc(prompt, defaultValue)
}
// InputCalls gets all the calls that were made to Input.
@ -267,12 +267,12 @@ func (mock *PrompterMock) Input(s1 string, s2 string) (string, error) {
//
// len(mockedPrompter.InputCalls())
func (mock *PrompterMock) InputCalls() []struct {
S1 string
S2 string
Prompt string
DefaultValue string
} {
var calls []struct {
S1 string
S2 string
Prompt string
DefaultValue string
}
mock.lockInput.RLock()
calls = mock.calls.Input
@ -308,23 +308,23 @@ func (mock *PrompterMock) InputHostnameCalls() []struct {
}
// MarkdownEditor calls MarkdownEditorFunc.
func (mock *PrompterMock) MarkdownEditor(s1 string, s2 string, b bool) (string, error) {
func (mock *PrompterMock) MarkdownEditor(prompt string, defaultValue string, blankAllowed bool) (string, error) {
if mock.MarkdownEditorFunc == nil {
panic("PrompterMock.MarkdownEditorFunc: method is nil but Prompter.MarkdownEditor was just called")
}
callInfo := struct {
S1 string
S2 string
B bool
Prompt string
DefaultValue string
BlankAllowed bool
}{
S1: s1,
S2: s2,
B: b,
Prompt: prompt,
DefaultValue: defaultValue,
BlankAllowed: blankAllowed,
}
mock.lockMarkdownEditor.Lock()
mock.calls.MarkdownEditor = append(mock.calls.MarkdownEditor, callInfo)
mock.lockMarkdownEditor.Unlock()
return mock.MarkdownEditorFunc(s1, s2, b)
return mock.MarkdownEditorFunc(prompt, defaultValue, blankAllowed)
}
// MarkdownEditorCalls gets all the calls that were made to MarkdownEditor.
@ -332,14 +332,14 @@ func (mock *PrompterMock) MarkdownEditor(s1 string, s2 string, b bool) (string,
//
// len(mockedPrompter.MarkdownEditorCalls())
func (mock *PrompterMock) MarkdownEditorCalls() []struct {
S1 string
S2 string
B bool
Prompt string
DefaultValue string
BlankAllowed bool
} {
var calls []struct {
S1 string
S2 string
B bool
Prompt string
DefaultValue string
BlankAllowed bool
}
mock.lockMarkdownEditor.RLock()
calls = mock.calls.MarkdownEditor
@ -388,19 +388,19 @@ func (mock *PrompterMock) MultiSelectCalls() []struct {
}
// Password calls PasswordFunc.
func (mock *PrompterMock) Password(s string) (string, error) {
func (mock *PrompterMock) Password(prompt string) (string, error) {
if mock.PasswordFunc == nil {
panic("PrompterMock.PasswordFunc: method is nil but Prompter.Password was just called")
}
callInfo := struct {
S string
Prompt string
}{
S: s,
Prompt: prompt,
}
mock.lockPassword.Lock()
mock.calls.Password = append(mock.calls.Password, callInfo)
mock.lockPassword.Unlock()
return mock.PasswordFunc(s)
return mock.PasswordFunc(prompt)
}
// PasswordCalls gets all the calls that were made to Password.
@ -408,10 +408,10 @@ func (mock *PrompterMock) Password(s string) (string, error) {
//
// len(mockedPrompter.PasswordCalls())
func (mock *PrompterMock) PasswordCalls() []struct {
S string
Prompt string
} {
var calls []struct {
S string
Prompt string
}
mock.lockPassword.RLock()
calls = mock.calls.Password
@ -420,23 +420,23 @@ func (mock *PrompterMock) PasswordCalls() []struct {
}
// Select calls SelectFunc.
func (mock *PrompterMock) Select(s1 string, s2 string, strings []string) (int, error) {
func (mock *PrompterMock) Select(prompt string, defaultValue string, options []string) (int, error) {
if mock.SelectFunc == nil {
panic("PrompterMock.SelectFunc: method is nil but Prompter.Select was just called")
}
callInfo := struct {
S1 string
S2 string
Strings []string
Prompt string
DefaultValue string
Options []string
}{
S1: s1,
S2: s2,
Strings: strings,
Prompt: prompt,
DefaultValue: defaultValue,
Options: options,
}
mock.lockSelect.Lock()
mock.calls.Select = append(mock.calls.Select, callInfo)
mock.lockSelect.Unlock()
return mock.SelectFunc(s1, s2, strings)
return mock.SelectFunc(prompt, defaultValue, options)
}
// SelectCalls gets all the calls that were made to Select.
@ -444,14 +444,14 @@ func (mock *PrompterMock) Select(s1 string, s2 string, strings []string) (int, e
//
// len(mockedPrompter.SelectCalls())
func (mock *PrompterMock) SelectCalls() []struct {
S1 string
S2 string
Strings []string
Prompt string
DefaultValue string
Options []string
} {
var calls []struct {
S1 string
S2 string
Strings []string
Prompt string
DefaultValue string
Options []string
}
mock.lockSelect.RLock()
calls = mock.calls.Select

View file

@ -141,6 +141,18 @@ func IndexFor(options []string, answer string) (int, error) {
return -1, NoSuchAnswerErr(answer, options)
}
func IndexesFor(options []string, answers ...string) ([]int, error) {
indexes := make([]int, len(answers))
for i, answer := range answers {
index, err := IndexFor(options, answer)
if err != nil {
return nil, err
}
indexes[i] = index
}
return indexes, nil
}
func NoSuchPromptErr(prompt string) error {
return fmt.Errorf("no such prompt '%s'", prompt)
}

View file

@ -0,0 +1,142 @@
package accessibility
import (
"fmt"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/browser"
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/spf13/cobra"
)
const (
webURL = "https://accessibility.github.com/conformance/cli/"
)
type AccessibilityOptions struct {
IO *iostreams.IOStreams
Browser browser.Browser
Web bool
}
func NewCmdAccessibility(f *cmdutil.Factory) *cobra.Command {
opts := AccessibilityOptions{
IO: f.IOStreams,
Browser: f.Browser,
}
cmd := &cobra.Command{
Use: "accessibility",
Aliases: []string{"a11y"},
Short: "Learn about GitHub CLI's accessibility experiences",
Long: longDescription(opts.IO),
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
if opts.Web {
if opts.IO.IsStdoutTTY() {
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(webURL))
}
return opts.Browser.Browse(webURL)
}
return cmd.Help()
},
Example: heredoc.Doc(`
# Open the GitHub Accessibility site in your browser
$ gh accessibility --web
# Display color using customizable, 4-bit accessible colors
$ gh config set accessible_colors enabled
# Use input prompts without redrawing the screen
$ gh config set accessible_prompter enabled
# Disable motion-based spinners for progress indicators in favor of text
$ gh config set spinner disabled
`),
}
cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open the GitHub Accessibility site in your browser")
cmdutil.DisableAuthCheck(cmd)
return cmd
}
func longDescription(io *iostreams.IOStreams) string {
cs := io.ColorScheme()
title := cs.Bold("Learn about GitHub CLI's accessibility experiences")
color := cs.Bold("Customizable and contrasting colors")
prompter := cs.Bold("Non-interactive user input prompting")
spinner := cs.Bold("Text-based spinners")
feedback := cs.Bold("Join the conversation")
return heredoc.Docf(`
%[2]s
As the home for all developers, we want every developer to feel welcome in our
community and be empowered to contribute to the future of global software
development with everything GitHub has to offer including the GitHub CLI.
%[3]s
Text interfaces often use color for various purposes, but insufficient contrast
or customizability can leave some users unable to benefit.
For a more accessible experience, the GitHub CLI can use color palettes
based on terminal background appearance and limit colors to 4-bit ANSI color
palettes, which users can customize within terminal preferences.
With this new experience, the GitHub CLI provides multiple options to address
color usage:
1. The GitHub CLI will use 4-bit color palette for increased color contrast based
on dark and light backgrounds including rendering Markdown based on the
GitHub Primer design system.
To enable this experience, use one of the following methods:
- Run %[1]sgh config set accessible_colors enabled%[1]s
- Set %[1]sGH_ACCESSIBLE_COLORS=enabled%[1]s environment variable
2. The GitHub CLI will display issue and pull request labels' custom RGB colors
in terminals with true color support.
To enable this experience, use one of the following methods:
- Run %[1]sgh config set color_labels enabled%[1]s
- Set %[1]sGH_COLOR_LABELS=enabled%[1]s environment variable
%[4]s
Interactive text user interfaces manipulate the terminal cursor to redraw parts
of the screen, which can be difficult for speech synthesizers or braille displays
to accurately detect and read.
For a more accessible experience, the GitHub CLI can provide a similar experience using
non-interactive prompts for user input.
To enable this experience, use one of the following methods:
- Run %[1]sgh config set accessible_prompter enabled%[1]s
- Set %[1]sGH_ACCESSIBLE_PROMPTER=enabled%[1]s environment variable
%[5]s
Motion-based spinners communicate in-progress activity by manipulating the
terminal cursor to create a spinning effect, which may cause discomfort to users
with motion sensitivity or miscommunicate information to speech synthesizers.
For a more accessible experience, this interactivity can be disabled in favor
of text-based progress indicators.
To enable this experience, use one of the following methods:
- Run %[1]sgh config set spinner disabled%[1]s
- Set %[1]sGH_SPINNER_DISABLED=yes%[1]s environment variable
%[6]s
We invite you to join us in improving GitHub CLI accessibility by sharing your
feedback and ideas through GitHub Accessibility feedback channels:
%[7]s
`, "`", title, color, prompter, spinner, feedback, webURL)
}

View file

@ -1,7 +1,10 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"github.com/sigstore/sigstore-go/pkg/bundle"
)
@ -20,3 +23,35 @@ type Attestation struct {
type AttestationsResponse struct {
Attestations []*Attestation `json:"attestations"`
}
type IntotoStatement struct {
PredicateType string `json:"predicateType"`
}
func FilterAttestations(predicateType string, attestations []*Attestation) ([]*Attestation, error) {
filteredAttestations := []*Attestation{}
for _, each := range attestations {
dsseEnvelope := each.Bundle.GetDsseEnvelope()
if dsseEnvelope != nil {
if dsseEnvelope.PayloadType != "application/vnd.in-toto+json" {
// Don't fail just because an entry isn't intoto
continue
}
var intotoStatement IntotoStatement
if err := json.Unmarshal([]byte(dsseEnvelope.Payload), &intotoStatement); err != nil {
// Don't fail just because a single entry can't be unmarshalled
continue
}
if intotoStatement.PredicateType == predicateType {
filteredAttestations = append(filteredAttestations, each)
}
}
}
if len(filteredAttestations) == 0 {
return nil, fmt.Errorf("no attestations found with predicate type: %s", predicateType)
}
return filteredAttestations, nil
}

View file

@ -27,6 +27,28 @@ const (
// Allow injecting backoff interval in tests.
var getAttestationRetryInterval = time.Millisecond * 200
// FetchParams are the parameters for fetching attestations from the GitHub API
type FetchParams struct {
Digest string
Limit int
Owner string
PredicateType string
Repo string
}
func (p *FetchParams) Validate() error {
if p.Digest == "" {
return fmt.Errorf("digest must be provided")
}
if p.Limit <= 0 || p.Limit > maxLimitForFlag {
return fmt.Errorf("limit must be greater than 0 and less than or equal to %d", maxLimitForFlag)
}
if p.Repo == "" && p.Owner == "" {
return fmt.Errorf("owner or repo must be provided")
}
return nil
}
// githubApiClient makes REST calls to the GitHub API
type githubApiClient interface {
REST(hostname, method, p string, body io.Reader, data interface{}) error
@ -39,8 +61,7 @@ type httpClient interface {
}
type Client interface {
GetByRepoAndDigest(repo, digest string, limit int) ([]*Attestation, error)
GetByOwnerAndDigest(owner, digest string, limit int) ([]*Attestation, error)
GetByDigest(params FetchParams) ([]*Attestation, error)
GetTrustDomain() (string, error)
}
@ -60,22 +81,11 @@ func NewLiveClient(hc *http.Client, host string, l *ioconfig.Handler) *LiveClien
}
}
// GetByRepoAndDigest fetches the attestation by repo and digest
func (c *LiveClient) GetByRepoAndDigest(repo, digest string, limit int) ([]*Attestation, error) {
c.logger.VerbosePrintf("Fetching attestations for artifact digest %s\n\n", digest)
url := fmt.Sprintf(GetAttestationByRepoAndSubjectDigestPath, repo, digest)
return c.getByURL(url, limit)
}
// GetByOwnerAndDigest fetches attestation by owner and digest
func (c *LiveClient) GetByOwnerAndDigest(owner, digest string, limit int) ([]*Attestation, error) {
c.logger.VerbosePrintf("Fetching attestations for artifact digest %s\n\n", digest)
url := fmt.Sprintf(GetAttestationByOwnerAndSubjectDigestPath, owner, digest)
return c.getByURL(url, limit)
}
func (c *LiveClient) getByURL(url string, limit int) ([]*Attestation, error) {
attestations, err := c.getAttestations(url, limit)
// GetByDigest fetches the attestation by digest and either owner or repo
// depending on which is provided
func (c *LiveClient) GetByDigest(params FetchParams) ([]*Attestation, error) {
c.logger.VerbosePrintf("Fetching attestations for artifact digest %s\n\n", params.Digest)
attestations, err := c.getAttestations(params)
if err != nil {
return nil, err
}
@ -88,40 +98,52 @@ func (c *LiveClient) getByURL(url string, limit int) ([]*Attestation, error) {
return bundles, nil
}
// GetTrustDomain returns the current trust domain. If the default is used
// the empty string is returned
func (c *LiveClient) GetTrustDomain() (string, error) {
return c.getTrustDomain(MetaPath)
}
func (c *LiveClient) getAttestations(url string, limit int) ([]*Attestation, error) {
perPage := limit
if perPage <= 0 || perPage > maxLimitForFlag {
return nil, fmt.Errorf("limit must be greater than 0 and less than or equal to %d", maxLimitForFlag)
func (c *LiveClient) buildRequestURL(params FetchParams) (string, error) {
if err := params.Validate(); err != nil {
return "", err
}
var url string
if params.Repo != "" {
// check if Repo is set first because if Repo has been set, Owner will be set using the value of Repo.
// If Repo is not set, the field will remain empty. It will not be populated using the value of Owner.
url = fmt.Sprintf(GetAttestationByRepoAndSubjectDigestPath, params.Repo, params.Digest)
} else {
url = fmt.Sprintf(GetAttestationByOwnerAndSubjectDigestPath, params.Owner, params.Digest)
}
perPage := params.Limit
if perPage > maxLimitForFetch {
perPage = maxLimitForFetch
}
// ref: https://github.com/cli/go-gh/blob/d32c104a9a25c9de3d7c7b07a43ae0091441c858/example_gh_test.go#L96
url = fmt.Sprintf("%s?per_page=%d", url, perPage)
if params.PredicateType != "" {
url = fmt.Sprintf("%s&predicate_type=%s", url, params.PredicateType)
}
return url, nil
}
func (c *LiveClient) getAttestations(params FetchParams) ([]*Attestation, error) {
url, err := c.buildRequestURL(params)
if err != nil {
return nil, err
}
var attestations []*Attestation
var resp AttestationsResponse
bo := backoff.NewConstantBackOff(getAttestationRetryInterval)
// if no attestation or less than limit, then keep fetching
for url != "" && len(attestations) < limit {
for url != "" && len(attestations) < params.Limit {
err := backoff.Retry(func() error {
newURL, restErr := c.githubAPI.RESTWithNext(c.host, http.MethodGet, url, nil, &resp)
if restErr != nil {
if shouldRetry(restErr) {
return restErr
} else {
return backoff.Permanent(restErr)
}
return backoff.Permanent(restErr)
}
url = newURL
@ -140,8 +162,8 @@ func (c *LiveClient) getAttestations(url string, limit int) ([]*Attestation, err
return nil, ErrNoAttestationsFound
}
if len(attestations) > limit {
return attestations[:limit], nil
if len(attestations) > params.Limit {
return attestations[:params.Limit], nil
}
return attestations, nil
@ -241,6 +263,12 @@ func shouldRetry(err error) bool {
return false
}
// GetTrustDomain returns the current trust domain. If the default is used
// the empty string is returned
func (c *LiveClient) GetTrustDomain() (string, error) {
return c.getTrustDomain(MetaPath)
}
func (c *LiveClient) getTrustDomain(url string) (string, error) {
var resp MetaResponse

View file

@ -42,78 +42,75 @@ func NewClientWithMockGHClient(hasNextPage bool) Client {
}
}
var testFetchParamsWithOwner = FetchParams{
Digest: testDigest,
Limit: DefaultLimit,
Owner: testOwner,
PredicateType: "https://slsa.dev/provenance/v1",
}
var testFetchParamsWithRepo = FetchParams{
Digest: testDigest,
Limit: DefaultLimit,
Repo: testRepo,
PredicateType: "https://slsa.dev/provenance/v1",
}
type getByTestCase struct {
name string
params FetchParams
limit int
expectedAttestations int
hasNextPage bool
}
var getByTestCases = []getByTestCase{
{
name: "get by digest with owner",
params: testFetchParamsWithOwner,
expectedAttestations: 5,
},
{
name: "get by digest with repo",
params: testFetchParamsWithRepo,
expectedAttestations: 5,
},
{
name: "get by digest with attestations greater than limit",
params: testFetchParamsWithRepo,
limit: 3,
expectedAttestations: 3,
},
{
name: "get by digest with next page",
params: testFetchParamsWithRepo,
expectedAttestations: 10,
hasNextPage: true,
},
{
name: "greater than limit with next page",
params: testFetchParamsWithRepo,
limit: 7,
expectedAttestations: 7,
hasNextPage: true,
},
}
func TestGetByDigest(t *testing.T) {
c := NewClientWithMockGHClient(false)
attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, DefaultLimit)
require.NoError(t, err)
for _, tc := range getByTestCases {
t.Run(tc.name, func(t *testing.T) {
c := NewClientWithMockGHClient(tc.hasNextPage)
require.Equal(t, 5, len(attestations))
bundle := (attestations)[0].Bundle
require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json")
if tc.limit > 0 {
tc.params.Limit = tc.limit
}
attestations, err := c.GetByDigest(tc.params)
require.NoError(t, err)
attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, DefaultLimit)
require.NoError(t, err)
require.Equal(t, 5, len(attestations))
bundle = (attestations)[0].Bundle
require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json")
}
func TestGetByDigestGreaterThanLimit(t *testing.T) {
c := NewClientWithMockGHClient(false)
limit := 3
// The method should return five results when the limit is not set
attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, limit)
require.NoError(t, err)
require.Equal(t, 3, len(attestations))
bundle := (attestations)[0].Bundle
require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json")
attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, limit)
require.NoError(t, err)
require.Equal(t, len(attestations), limit)
bundle = (attestations)[0].Bundle
require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json")
}
func TestGetByDigestWithNextPage(t *testing.T) {
c := NewClientWithMockGHClient(true)
attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, DefaultLimit)
require.NoError(t, err)
require.Equal(t, len(attestations), 10)
bundle := (attestations)[0].Bundle
require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json")
attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, DefaultLimit)
require.NoError(t, err)
require.Equal(t, len(attestations), 10)
bundle = (attestations)[0].Bundle
require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json")
}
func TestGetByDigestGreaterThanLimitWithNextPage(t *testing.T) {
c := NewClientWithMockGHClient(true)
limit := 7
// The method should return five results when the limit is not set
attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, limit)
require.NoError(t, err)
require.Equal(t, len(attestations), limit)
bundle := (attestations)[0].Bundle
require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json")
attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, limit)
require.NoError(t, err)
require.Equal(t, len(attestations), limit)
bundle = (attestations)[0].Bundle
require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json")
require.Equal(t, tc.expectedAttestations, len(attestations))
bundle := (attestations)[0].Bundle
require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json")
})
}
}
func TestGetByDigest_NoAttestationsFound(t *testing.T) {
@ -130,12 +127,7 @@ func TestGetByDigest_NoAttestationsFound(t *testing.T) {
logger: io.NewTestHandler(),
}
attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, DefaultLimit)
require.Error(t, err)
require.IsType(t, ErrNoAttestationsFound, err)
require.Nil(t, attestations)
attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, DefaultLimit)
attestations, err := c.GetByDigest(testFetchParamsWithRepo)
require.Error(t, err)
require.IsType(t, ErrNoAttestationsFound, err)
require.Nil(t, attestations)
@ -153,11 +145,7 @@ func TestGetByDigest_Error(t *testing.T) {
logger: io.NewTestHandler(),
}
attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, DefaultLimit)
require.Error(t, err)
require.Nil(t, attestations)
attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, DefaultLimit)
attestations, err := c.GetByDigest(testFetchParamsWithRepo)
require.Error(t, err)
require.Nil(t, attestations)
}
@ -362,7 +350,8 @@ func TestGetAttestationsRetries(t *testing.T) {
logger: io.NewTestHandler(),
}
attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, DefaultLimit)
testFetchParamsWithRepo.Limit = 30
attestations, err := c.GetByDigest(testFetchParamsWithRepo)
require.NoError(t, err)
// assert the error path was executed; because this is a paged
@ -373,17 +362,6 @@ func TestGetAttestationsRetries(t *testing.T) {
require.Equal(t, len(attestations), 10)
bundle := (attestations)[0].Bundle
require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json")
// same test as above, but for GetByOwnerAndDigest:
attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, DefaultLimit)
require.NoError(t, err)
// because we haven't reset the mock, we have added 2 more failed requests
fetcher.AssertNumberOfCalls(t, "FlakyOnRESTSuccessWithNextPage:error", 4)
require.Equal(t, len(attestations), 10)
bundle = (attestations)[0].Bundle
require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json")
}
// test total retries
@ -401,7 +379,7 @@ func TestGetAttestationsMaxRetries(t *testing.T) {
logger: io.NewTestHandler(),
}
_, err := c.GetByRepoAndDigest(testRepo, testDigest, DefaultLimit)
_, err := c.GetByDigest(testFetchParamsWithRepo)
require.Error(t, err)
fetcher.AssertNumberOfCalls(t, "OnREST500Error", 4)

View file

@ -6,58 +6,60 @@ import (
"github.com/cli/cli/v2/pkg/cmd/attestation/test/data"
)
type MockClient struct {
OnGetByRepoAndDigest func(repo, digest string, limit int) ([]*Attestation, error)
OnGetByOwnerAndDigest func(owner, digest string, limit int) ([]*Attestation, error)
OnGetTrustDomain func() (string, error)
}
func (m MockClient) GetByRepoAndDigest(repo, digest string, limit int) ([]*Attestation, error) {
return m.OnGetByRepoAndDigest(repo, digest, limit)
}
func (m MockClient) GetByOwnerAndDigest(owner, digest string, limit int) ([]*Attestation, error) {
return m.OnGetByOwnerAndDigest(owner, digest, limit)
}
func (m MockClient) GetTrustDomain() (string, error) {
return m.OnGetTrustDomain()
func makeTestReleaseAttestation() Attestation {
return Attestation{
Bundle: data.GitHubReleaseBundle(nil),
BundleURL: "https://example.com",
}
}
func makeTestAttestation() Attestation {
return Attestation{Bundle: data.SigstoreBundle(nil), BundleURL: "https://example.com"}
}
func OnGetByRepoAndDigestSuccess(repo, digest string, limit int) ([]*Attestation, error) {
type MockClient struct {
OnGetByDigest func(params FetchParams) ([]*Attestation, error)
OnGetTrustDomain func() (string, error)
}
func (m MockClient) GetByDigest(params FetchParams) ([]*Attestation, error) {
return m.OnGetByDigest(params)
}
func (m MockClient) GetTrustDomain() (string, error) {
return m.OnGetTrustDomain()
}
func OnGetByDigestSuccess(params FetchParams) ([]*Attestation, error) {
att1 := makeTestAttestation()
att2 := makeTestAttestation()
return []*Attestation{&att1, &att2}, nil
att3 := makeTestReleaseAttestation()
attestations := []*Attestation{&att1, &att2}
if params.PredicateType != "" {
if params.PredicateType == "https://in-toto.io/attestation/release/v0.1" {
attestations = append(attestations, &att3)
}
return FilterAttestations(params.PredicateType, attestations)
}
return attestations, nil
}
func OnGetByRepoAndDigestFailure(repo, digest string, limit int) ([]*Attestation, error) {
return nil, fmt.Errorf("failed to fetch by repo and digest")
}
func OnGetByOwnerAndDigestSuccess(owner, digest string, limit int) ([]*Attestation, error) {
att1 := makeTestAttestation()
att2 := makeTestAttestation()
return []*Attestation{&att1, &att2}, nil
}
func OnGetByOwnerAndDigestFailure(owner, digest string, limit int) ([]*Attestation, error) {
return nil, fmt.Errorf("failed to fetch by owner and digest")
func OnGetByDigestFailure(params FetchParams) ([]*Attestation, error) {
if params.Repo != "" {
return nil, fmt.Errorf("failed to fetch attestations from %s", params.Repo)
}
return nil, fmt.Errorf("failed to fetch attestations from %s", params.Owner)
}
func NewTestClient() *MockClient {
return &MockClient{
OnGetByRepoAndDigest: OnGetByRepoAndDigestSuccess,
OnGetByOwnerAndDigest: OnGetByOwnerAndDigestSuccess,
OnGetByDigest: OnGetByDigestSuccess,
}
}
func NewFailTestClient() *MockClient {
return &MockClient{
OnGetByRepoAndDigest: OnGetByRepoAndDigestFailure,
OnGetByOwnerAndDigest: OnGetByOwnerAndDigestFailure,
OnGetByDigest: OnGetByDigestFailure,
}
}

View file

@ -54,6 +54,13 @@ func normalizeReference(reference string, pathSeparator rune) (normalized string
return filepath.Clean(reference), fileArtifactType, nil
}
func NewDigestedArtifactForRelease(digest string, digestAlg string) (artifact *DigestedArtifact) {
return &DigestedArtifact{
digest: digest,
digestAlg: digestAlg,
}
}
func NewDigestedArtifact(client oci.Client, reference, digestAlg string) (artifact *DigestedArtifact, err error) {
normalized, artifactType, err := normalizeReference(reference, os.PathSeparator)
if err != nil {

View file

@ -10,7 +10,7 @@ import (
func digestLocalFileArtifact(filename, digestAlg string) (*DigestedArtifact, error) {
data, err := os.Open(filename)
if err != nil {
return nil, fmt.Errorf("failed to get open local artifact: %v", err)
return nil, fmt.Errorf("failed to open local artifact: %v", err)
}
defer data.Close()
digest, err := digest.CalculateDigestWithAlgorithm(data, digestAlg)

View file

@ -0,0 +1,23 @@
package artifact
import (
"testing"
"github.com/cli/cli/v2/pkg/cmd/attestation/test"
"github.com/stretchr/testify/require"
)
func Test_digestLocalFileArtifact_withRealZip(t *testing.T) {
// Path to the test artifact
artifactPath := test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip")
// Calculate expected digest using the same algorithm as the function under test
expectedDigest := "e15b593c6ab8d7725a3cc82226ef816cac6bf9c70eed383bd459295cc65f5ec3"
// Call the function under test
artifact, err := digestLocalFileArtifact(artifactPath, "sha256")
require.NoError(t, err)
require.Equal(t, "file://"+artifactPath, artifact.URL)
require.Equal(t, expectedDigest, artifact.digest)
require.Equal(t, "sha256", artifact.digestAlg)
}

View file

@ -9,7 +9,6 @@ import (
"github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci"
"github.com/cli/cli/v2/pkg/cmd/attestation/auth"
"github.com/cli/cli/v2/pkg/cmd/attestation/io"
"github.com/cli/cli/v2/pkg/cmd/attestation/verification"
"github.com/cli/cli/v2/pkg/cmdutil"
ghauth "github.com/cli/go-gh/v2/pkg/auth"
@ -127,13 +126,16 @@ func runDownload(opts *Options) error {
opts.Logger.VerbosePrintf("Downloading trusted metadata for artifact %s\n\n", opts.ArtifactPath)
params := verification.FetchRemoteAttestationsParams{
if opts.APIClient == nil {
return fmt.Errorf("no APIClient provided")
}
params := api.FetchParams{
Digest: artifact.DigestWithAlg(),
Limit: opts.Limit,
Owner: opts.Owner,
Repo: opts.Repo,
}
attestations, err := verification.GetRemoteAttestations(opts.APIClient, params)
attestations, err := opts.APIClient.GetByDigest(params)
if err != nil {
if errors.Is(err, api.ErrNoAttestationsFound) {
fmt.Fprintf(opts.Logger.IO.Out, "No attestations found for %s\n", opts.ArtifactPath)
@ -144,10 +146,9 @@ func runDownload(opts *Options) error {
// Apply predicate type filter to returned attestations
if opts.PredicateType != "" {
filteredAttestations := verification.FilterAttestations(opts.PredicateType, attestations)
if len(filteredAttestations) == 0 {
return fmt.Errorf("no attestations found with predicate type: %s", opts.PredicateType)
filteredAttestations, err := api.FilterAttestations(opts.PredicateType, attestations)
if err != nil {
return fmt.Errorf("failed to filter attestations: %v", err)
}
attestations = filteredAttestations

View file

@ -275,7 +275,7 @@ func TestRunDownload(t *testing.T) {
t.Run("no attestations found", func(t *testing.T) {
opts := baseOpts
opts.APIClient = api.MockClient{
OnGetByOwnerAndDigest: func(repo, digest string, limit int) ([]*api.Attestation, error) {
OnGetByDigest: func(params api.FetchParams) ([]*api.Attestation, error) {
return nil, api.ErrNoAttestationsFound
},
}
@ -291,7 +291,7 @@ func TestRunDownload(t *testing.T) {
t.Run("failed to fetch attestations", func(t *testing.T) {
opts := baseOpts
opts.APIClient = api.MockClient{
OnGetByOwnerAndDigest: func(repo, digest string, limit int) ([]*api.Attestation, error) {
OnGetByDigest: func(params api.FetchParams) ([]*api.Attestation, error) {
return nil, fmt.Errorf("failed to fetch attestations")
},
}

View file

@ -77,13 +77,18 @@ func NewInspectCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
opts.Hostname, _ = ghauth.DefaultHost()
}
err := auth.IsHostSupported(opts.Hostname)
if err := auth.IsHostSupported(opts.Hostname); err != nil {
return err
}
hc, err := f.HttpClient()
if err != nil {
return err
}
config := verification.SigstoreConfig{
Logger: opts.Logger,
HttpClient: hc,
Logger: opts.Logger,
}
if ghauth.IsTenancy(opts.Hostname) {

View file

@ -0,0 +1,45 @@
//go:build integration
package inspect
import (
"bytes"
"fmt"
"net/http"
"strings"
"testing"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/stretchr/testify/assert"
)
func TestNewInspectCmd_PrintOutputJSONFormat(t *testing.T) {
testIO, _, _, _ := iostreams.Test()
f := &cmdutil.Factory{
IOStreams: testIO,
HttpClient: func() (*http.Client, error) {
return http.DefaultClient, nil
},
}
t.Run("Print output in JSON format", func(t *testing.T) {
var opts *Options
cmd := NewInspectCmd(f, func(o *Options) error {
opts = o
return nil
})
argv := strings.Split(fmt.Sprintf("%s --format json", bundlePath), " ")
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
_, err := cmd.ExecuteC()
assert.NoError(t, err)
assert.Equal(t, bundlePath, opts.BundlePath)
assert.NotNil(t, opts.Logger)
assert.NotNil(t, opts.exporter)
})
}

View file

@ -1,11 +1,7 @@
package inspect
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci"
@ -14,7 +10,6 @@ import (
"github.com/cli/cli/v2/pkg/cmd/attestation/verification"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/stretchr/testify/assert"
@ -26,66 +21,7 @@ const (
SigstoreSanRegex = "^https://github.com/sigstore/sigstore-js/"
)
var (
bundlePath = test.NormalizeRelativePath("../test/data/sigstore-js-2.1.0-bundle.json")
)
func TestNewInspectCmd(t *testing.T) {
testIO, _, _, _ := iostreams.Test()
f := &cmdutil.Factory{
IOStreams: testIO,
HttpClient: func() (*http.Client, error) {
reg := &httpmock.Registry{}
client := &http.Client{}
httpmock.ReplaceTripper(client, reg)
return client, nil
},
}
testcases := []struct {
name string
cli string
wants Options
wantsErr bool
wantsExporter bool
}{
{
name: "Prints output in JSON format",
cli: fmt.Sprintf("%s --format json", bundlePath),
wants: Options{
BundlePath: bundlePath,
SigstoreVerifier: verification.NewMockSigstoreVerifier(t),
},
wantsExporter: true,
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
var opts *Options
cmd := NewInspectCmd(f, func(o *Options) error {
opts = o
return nil
})
argv := strings.Split(tc.cli, " ")
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
_, err := cmd.ExecuteC()
if tc.wantsErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tc.wants.BundlePath, opts.BundlePath)
assert.NotNil(t, opts.Logger)
assert.Equal(t, tc.wantsExporter, opts.exporter != nil)
})
}
}
var bundlePath = test.NormalizeRelativePath("../test/data/sigstore-js-2.1.0-bundle.json")
func TestRunInspect(t *testing.T) {
opts := Options{

View file

@ -10,6 +10,9 @@ import (
//go:embed sigstore-js-2.1.0-bundle.json
var SigstoreBundleRaw []byte
//go:embed github_release_bundle.json
var GitHubReleaseBundleRaw []byte
// SigstoreBundle returns a test sigstore-go bundle.Bundle
func SigstoreBundle(t *testing.T) *bundle.Bundle {
b := &bundle.Bundle{}
@ -19,3 +22,12 @@ func SigstoreBundle(t *testing.T) *bundle.Bundle {
}
return b
}
func GitHubReleaseBundle(t *testing.T) *bundle.Bundle {
b := &bundle.Bundle{}
err := b.UnmarshalJSON(GitHubReleaseBundleRaw)
if err != nil {
t.Fatalf("failed to unmarshal GitHub release bundle: %v", err)
}
return b
}

View file

@ -0,0 +1,24 @@
{
"mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json",
"verificationMaterial": {
"timestampVerificationData": {
"rfc3161Timestamps": [
{
"signedTimestamp": "MIIC0DADAgEAMIICxwYJKoZIhvcNAQcCoIICuDCCArQCAQMxDTALBglghkgBZQMEAgIwgbwGCyqGSIb3DQEJEAEEoIGsBIGpMIGmAgEBBgkrBgEEAYO/MAIwMTANBglghkgBZQMEAgEFAAQg1c3kQQpo4Adf2E+nx78lNg8EjRSLpIRERpPF0HIavogCFQCOfZuxr0DOc1LsM+y+sjQCMFrtbxgPMjAyNTA1MzAyMDEzMzlaMAMCAQGgNqQ0MDIxFTATBgNVBAoTDEdpdEh1YiwgSW5jLjEZMBcGA1UEAxMQVFNBIFRpbWVzdGFtcGluZ6AAMYIB3TCCAdkCAQEwSjAyMRUwEwYDVQQKEwxHaXRIdWIsIEluYy4xGTAXBgNVBAMTEFRTQSBpbnRlcm1lZGlhdGUCFB+7MIjE5/rL4XA4fNDnmXHA04+wMAsGCWCGSAFlAwQCAqCCAQUwGgYJKoZIhvcNAQkDMQ0GCyqGSIb3DQEJEAEEMBwGCSqGSIb3DQEJBTEPFw0yNTA1MzAyMDEzMzlaMD8GCSqGSIb3DQEJBDEyBDDkw1fXMZ6l/uWne+PcdzhLl2ckTZftcUuHcnYCwjhyYMeGOcgbpNNMDem46JCxItwwgYcGCyqGSIb3DQEJEAIvMXgwdjB0MHIEIHuISsKSyiJtlhGjT+RyS+tYQ7iwCMsMCTGmz2NK3D7DME4wNqQ0MDIxFTATBgNVBAoTDEdpdEh1YiwgSW5jLjEZMBcGA1UEAxMQVFNBIGludGVybWVkaWF0ZQIUH7swiMTn+svhcDh80OeZccDTj7AwCgYIKoZIzj0EAwMEZjBkAjBhE76zis18/xOtQdx6rJUuaRoZCflXHCjH6BqEk1B29r9C8STztZhAKalXL+Wy4rsCMFFaGPKF1uOl5JADiKMg5/7chJbWrfwyO9oa0tbmvcGrtBCdFeJ1Ic0tIi1sOVvq5Q=="
}
]
},
"certificate": {
"rawBytes": "MIICKjCCAbCgAwIBAgIUaa62dj98DUB+TpyvKtVaR4vGSM0wCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZGdWxjaW8gSW50ZXJtZWRpYXRlIGwxMB4XDTI1MDMxMDE1MDMwMloXDTI2MDMxMDE1MDMwMlowKjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMREwDwYDVQQDEwhBdHRlc3RlcjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABIMB7plPnZvBRlC2lvAocKTAqAPMJqstEqYk26e9vDJDC1yqoiHxZfPV4W/1RqUMZD1dFKm9t4RiSmm73/QnQKajgaUwgaIwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMDMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFOqaGpr5SbdYk5CQXsmmDZCBHR+XMB8GA1UdIwQYMBaAFMDhuFKkS08+3no4EQbPSY6hRZszMC0GA1UdEQQmMCSGImh0dHBzOi8vZG90Y29tLnJlbGVhc2VzLmdpdGh1Yi5jb20wCgYIKoZIzj0EAwMDaAAwZQIwWFdF6xcXazHVPHEAtd1SeaizLdY1erRl5hK+XlwhfpnasQHHZ9bdu4Zj8ARhW/AhAjEArujhmJGo7Fi4/Ek1RN8bufs6UhIQneQd/pxE8QdorwZkj2C8nf2EzrUYzlxKfktC"
}
},
"dsseEnvelope": {
"payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCAic3ViamVjdCI6W3sidXJpIjoicGtnOmdpdGh1Yi9iZGVoYW1lci9kZWxtZUB2NiIsICJkaWdlc3QiOnsic2hhMSI6ImM1ZTE3YTYyZTA2YTFkMjAxNTcwMjQ5YzYxZmFlNTMxZTkyNDRlMWIifX0sIHsibmFtZSI6ImFydGlmYWN0LnppcCIsICJkaWdlc3QiOnsic2hhMjU2IjoiZTE1YjU5M2M2YWI4ZDc3MjVhM2NjODIyMjZlZjgxNmNhYzZiZjljNzBlZWQzODNiZDQ1OTI5NWNjNjVmNWVjMyJ9fV0sICJwcmVkaWNhdGVUeXBlIjoiaHR0cHM6Ly9pbi10b3RvLmlvL2F0dGVzdGF0aW9uL3JlbGVhc2UvdjAuMSIsICJwcmVkaWNhdGUiOnsib3duZXJJZCI6IjM5ODAyNyIsICJwdXJsIjoicGtnOmdpdGh1Yi9iZGVoYW1lci9kZWxtZUB2NiIsICJyZWxlYXNlSWQiOiIyMjIxNTg2NzEiLCAicmVwb3NpdG9yeSI6ImJkZWhhbWVyL2RlbG1lIiwgInJlcG9zaXRvcnlJZCI6IjkwNTk4ODA0NCIsICJ0YWciOiJ2NiJ9fQ==",
"payloadType": "application/vnd.in-toto+json",
"signatures": [
{
"sig": "MEUCIGq+T2g2gV2+lcmgyCaVPrjO1tj86RxitwiEOjU5dH/GAiEAvKaT/7H0sIVdAY7EzLq1IFaF8LmlW6eV68eZQvtuA0c="
}
]
}
}

View file

@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"github.com/cli/cli/v2/pkg/cmd/attestation/api"
@ -68,6 +69,10 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com
return err
}
hc, err := f.HttpClient()
if err != nil {
return err
}
if ghauth.IsTenancy(opts.Hostname) {
c, err := f.Config()
if err != nil {
@ -77,11 +82,6 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com
if !c.Authentication().HasActiveToken(opts.Hostname) {
return fmt.Errorf("not authenticated with %s", opts.Hostname)
}
hc, err := f.HttpClient()
if err != nil {
return err
}
logger := io.NewHandler(f.IOStreams)
apiClient := api.NewLiveClient(hc, opts.Hostname, logger)
td, err := apiClient.GetTrustDomain()
@ -95,7 +95,7 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com
return runF(opts)
}
if err := getTrustedRoot(tuf.New, opts); err != nil {
if err := getTrustedRoot(tuf.New, opts, hc); err != nil {
return fmt.Errorf("Failed to verify the TUF repository: %w", err)
}
@ -118,11 +118,11 @@ type tufConfig struct {
targets []string
}
func getTrustedRoot(makeTUF tufClientInstantiator, opts *Options) error {
func getTrustedRoot(makeTUF tufClientInstantiator, opts *Options, hc *http.Client) error {
var tufOptions []tufConfig
var defaultTR = "trusted_root.json"
tufOpt := verification.DefaultOptionsWithCacheSetting(o.None[string]())
tufOpt := verification.DefaultOptionsWithCacheSetting(o.None[string](), hc)
// Disable local caching, so we get up-to-date response from TUF repository
tufOpt.CacheValidity = 0
@ -151,7 +151,7 @@ func getTrustedRoot(makeTUF tufClientInstantiator, opts *Options) error {
targets: []string{defaultTR},
})
tufOpt = verification.GitHubTUFOptions(o.None[string]())
tufOpt = verification.GitHubTUFOptions(o.None[string](), hc)
tufOpt.CacheValidity = 0
tufOptions = append(tufOptions, tufConfig{
tufOptions: tufOpt,

View file

@ -28,6 +28,12 @@ func TestNewTrustedRootCmd(t *testing.T) {
Config: func() (gh.Config, error) {
return &ghmock.ConfigMock{}, nil
},
HttpClient: func() (*http.Client, error) {
reg := &httpmock.Registry{}
client := &http.Client{}
httpmock.ReplaceTripper(client, reg)
return client, nil
},
}
testcases := []struct {
@ -113,6 +119,7 @@ func TestNewTrustedRootWithTenancy(t *testing.T) {
},
}, nil
},
HttpClient: httpClientFunc,
}
cmd := NewTrustedRootCmd(f, func(_ *Options) error {
@ -171,15 +178,19 @@ func TestGetTrustedRoot(t *testing.T) {
TufRootPath: root,
}
reg := &httpmock.Registry{}
client := &http.Client{}
httpmock.ReplaceTripper(client, reg)
t.Run("failed to create TUF root", func(t *testing.T) {
err := getTrustedRoot(newTUFErrClient, opts)
err := getTrustedRoot(newTUFErrClient, opts, client)
require.Error(t, err)
require.ErrorContains(t, err, "failed to create TUF client")
})
t.Run("fails because the root cannot be found", func(t *testing.T) {
opts.TufRootPath = test.NormalizeRelativePath("./does/not/exist/root.json")
err := getTrustedRoot(tuf.New, opts)
err := getTrustedRoot(tuf.New, opts, client)
require.Error(t, err)
require.ErrorContains(t, err, "failed to read root file")
})

View file

@ -20,13 +20,6 @@ const SLSAPredicateV1 = "https://slsa.dev/provenance/v1"
var ErrUnrecognisedBundleExtension = errors.New("bundle file extension not supported, must be json or jsonl")
var ErrEmptyBundleFile = errors.New("provided bundle file is empty")
type FetchRemoteAttestationsParams struct {
Digest string
Limit int
Owner string
Repo string
}
// GetLocalAttestations returns a slice of attestations read from a local bundle file.
func GetLocalAttestations(path string) ([]*api.Attestation, error) {
var attestations []*api.Attestation
@ -89,28 +82,6 @@ func loadBundlesFromJSONLinesFile(path string) ([]*api.Attestation, error) {
return attestations, nil
}
func GetRemoteAttestations(client api.Client, params FetchRemoteAttestationsParams) ([]*api.Attestation, error) {
if client == nil {
return nil, fmt.Errorf("api client must be provided")
}
// check if Repo is set first because if Repo has been set, Owner will be set using the value of Repo.
// If Repo is not set, the field will remain empty. It will not be populated using the value of Owner.
if params.Repo != "" {
attestations, err := client.GetByRepoAndDigest(params.Repo, params.Digest, params.Limit)
if err != nil {
return nil, fmt.Errorf("failed to fetch attestations from %s: %w", params.Repo, err)
}
return attestations, nil
} else if params.Owner != "" {
attestations, err := client.GetByOwnerAndDigest(params.Owner, params.Digest, params.Limit)
if err != nil {
return nil, fmt.Errorf("failed to fetch attestations from %s: %w", params.Owner, err)
}
return attestations, nil
}
return nil, fmt.Errorf("owner or repo must be provided")
}
func GetOCIAttestations(client oci.Client, artifact artifact.DigestedArtifact) ([]*api.Attestation, error) {
attestations, err := client.GetAttestations(artifact.NameRef(), artifact.DigestWithAlg())
if err != nil {
@ -121,31 +92,3 @@ func GetOCIAttestations(client oci.Client, artifact artifact.DigestedArtifact) (
}
return attestations, nil
}
type IntotoStatement struct {
PredicateType string `json:"predicateType"`
}
func FilterAttestations(predicateType string, attestations []*api.Attestation) []*api.Attestation {
filteredAttestations := []*api.Attestation{}
for _, each := range attestations {
dsseEnvelope := each.Bundle.GetDsseEnvelope()
if dsseEnvelope != nil {
if dsseEnvelope.PayloadType != "application/vnd.in-toto+json" {
// Don't fail just because an entry isn't intoto
continue
}
var intotoStatement IntotoStatement
if err := json.Unmarshal([]byte(dsseEnvelope.Payload), &intotoStatement); err != nil {
// Don't fail just because a single entry can't be unmarshalled
continue
}
if intotoStatement.PredicateType == predicateType {
filteredAttestations = append(filteredAttestations, each)
}
}
}
return filteredAttestations
}

View file

@ -157,10 +157,11 @@ func TestFilterAttestations(t *testing.T) {
},
}
filtered := FilterAttestations("https://slsa.dev/provenance/v1", attestations)
filtered, err := api.FilterAttestations("https://slsa.dev/provenance/v1", attestations)
require.Len(t, filtered, 1)
require.NoError(t, err)
filtered = FilterAttestations("NonExistentPredicate", attestations)
require.Len(t, filtered, 0)
filtered, err = api.FilterAttestations("NonExistentPredicate", attestations)
require.Nil(t, filtered)
require.Error(t, err)
}

View file

@ -6,6 +6,7 @@ import (
"crypto/x509"
"errors"
"fmt"
"net/http"
"os"
"github.com/cli/cli/v2/pkg/cmd/attestation/api"
@ -33,6 +34,7 @@ type SigstoreConfig struct {
TrustedRoot string
Logger *io.Handler
NoPublicGood bool
HttpClient *http.Client
// If tenancy mode is not used, trust domain is empty
TrustDomain string
// TUFMetadataDir
@ -46,9 +48,9 @@ type SigstoreVerifier interface {
type LiveSigstoreVerifier struct {
Logger *io.Handler
NoPublicGood bool
PublicGood *verify.SignedEntityVerifier
GitHub *verify.SignedEntityVerifier
Custom map[string]*verify.SignedEntityVerifier
PublicGood *verify.Verifier
GitHub *verify.Verifier
Custom map[string]*verify.Verifier
}
var ErrNoAttestationsVerified = errors.New("no attestations were verified")
@ -71,13 +73,13 @@ func NewLiveSigstoreVerifier(config SigstoreConfig) (*LiveSigstoreVerifier, erro
return liveVerifier, nil
}
if !config.NoPublicGood {
publicGoodVerifier, err := newPublicGoodVerifier(config.TUFMetadataDir)
publicGoodVerifier, err := newPublicGoodVerifier(config.TUFMetadataDir, config.HttpClient)
if err != nil {
return nil, err
}
liveVerifier.PublicGood = publicGoodVerifier
}
github, err := newGitHubVerifier(config.TrustDomain, config.TUFMetadataDir)
github, err := newGitHubVerifier(config.TrustDomain, config.TUFMetadataDir, config.HttpClient)
if err != nil {
return nil, err
}
@ -86,13 +88,13 @@ func NewLiveSigstoreVerifier(config SigstoreConfig) (*LiveSigstoreVerifier, erro
return liveVerifier, nil
}
func createCustomVerifiers(trustedRoot string, noPublicGood bool) (map[string]*verify.SignedEntityVerifier, error) {
func createCustomVerifiers(trustedRoot string, noPublicGood bool) (map[string]*verify.Verifier, error) {
customTrustRoots, err := os.ReadFile(trustedRoot)
if err != nil {
return nil, fmt.Errorf("unable to read file %s: %v", trustedRoot, err)
}
verifiers := make(map[string]*verify.SignedEntityVerifier)
verifiers := make(map[string]*verify.Verifier)
reader := bufio.NewReader(bytes.NewReader(customTrustRoots))
var line []byte
var readError error
@ -189,7 +191,7 @@ func getBundleIssuer(b *bundle.Bundle) (string, error) {
return leafCert.Issuer.Organization[0], nil
}
func (v *LiveSigstoreVerifier) chooseVerifier(issuer string) (*verify.SignedEntityVerifier, error) {
func (v *LiveSigstoreVerifier) chooseVerifier(issuer string) (*verify.Verifier, error) {
// if no custom trusted root is set, return either the Public Good or GitHub verifier
// If the chosen verifier has not yet been created, create it as a LiveSigstoreVerifier field for use in future calls
if v.Custom != nil {
@ -291,7 +293,7 @@ func (v *LiveSigstoreVerifier) Verify(attestations []*api.Attestation, policy ve
return results, nil
}
func newCustomVerifier(trustedRoot *root.TrustedRoot) (*verify.SignedEntityVerifier, error) {
func newCustomVerifier(trustedRoot *root.TrustedRoot) (*verify.Verifier, error) {
// All we know about this trust root is its configuration so make some
// educated guesses as to what the policy should be.
verifierConfig := []verify.VerifierOption{}
@ -306,7 +308,7 @@ func newCustomVerifier(trustedRoot *root.TrustedRoot) (*verify.SignedEntityVerif
verifierConfig = append(verifierConfig, verify.WithTransparencyLog(1))
}
gv, err := verify.NewSignedEntityVerifier(trustedRoot, verifierConfig...)
gv, err := verify.NewVerifier(trustedRoot, verifierConfig...)
if err != nil {
return nil, fmt.Errorf("failed to create custom verifier: %v", err)
}
@ -314,10 +316,10 @@ func newCustomVerifier(trustedRoot *root.TrustedRoot) (*verify.SignedEntityVerif
return gv, nil
}
func newGitHubVerifier(trustDomain string, tufMetadataDir o.Option[string]) (*verify.SignedEntityVerifier, error) {
func newGitHubVerifier(trustDomain string, tufMetadataDir o.Option[string], hc *http.Client) (*verify.Verifier, error) {
var tr string
opts := GitHubTUFOptions(tufMetadataDir)
opts := GitHubTUFOptions(tufMetadataDir, hc)
client, err := tuf.New(opts)
if err != nil {
return nil, fmt.Errorf("failed to create TUF client: %v", err)
@ -339,8 +341,8 @@ func newGitHubVerifier(trustDomain string, tufMetadataDir o.Option[string]) (*ve
return newGitHubVerifierWithTrustedRoot(trustedRoot)
}
func newGitHubVerifierWithTrustedRoot(trustedRoot *root.TrustedRoot) (*verify.SignedEntityVerifier, error) {
gv, err := verify.NewSignedEntityVerifier(trustedRoot, verify.WithSignedTimestamps(1))
func newGitHubVerifierWithTrustedRoot(trustedRoot *root.TrustedRoot) (*verify.Verifier, error) {
gv, err := verify.NewVerifier(trustedRoot, verify.WithSignedTimestamps(1))
if err != nil {
return nil, fmt.Errorf("failed to create GitHub verifier: %v", err)
}
@ -348,8 +350,8 @@ func newGitHubVerifierWithTrustedRoot(trustedRoot *root.TrustedRoot) (*verify.Si
return gv, nil
}
func newPublicGoodVerifier(tufMetadataDir o.Option[string]) (*verify.SignedEntityVerifier, error) {
opts := DefaultOptionsWithCacheSetting(tufMetadataDir)
func newPublicGoodVerifier(tufMetadataDir o.Option[string], hc *http.Client) (*verify.Verifier, error) {
opts := DefaultOptionsWithCacheSetting(tufMetadataDir, hc)
client, err := tuf.New(opts)
if err != nil {
return nil, fmt.Errorf("failed to create TUF client: %v", err)
@ -362,8 +364,8 @@ func newPublicGoodVerifier(tufMetadataDir o.Option[string]) (*verify.SignedEntit
return newPublicGoodVerifierWithTrustedRoot(trustedRoot)
}
func newPublicGoodVerifierWithTrustedRoot(trustedRoot *root.TrustedRoot) (*verify.SignedEntityVerifier, error) {
sv, err := verify.NewSignedEntityVerifier(trustedRoot, verify.WithSignedCertificateTimestamps(1), verify.WithTransparencyLog(1), verify.WithObserverTimestamps(1))
func newPublicGoodVerifierWithTrustedRoot(trustedRoot *root.TrustedRoot) (*verify.Verifier, error) {
sv, err := verify.NewVerifier(trustedRoot, verify.WithSignedCertificateTimestamps(1), verify.WithTransparencyLog(1), verify.WithObserverTimestamps(1))
if err != nil {
return nil, fmt.Errorf("failed to create Public Good verifier: %v", err)
}

View file

@ -3,6 +3,7 @@
package verification
import (
"net/http"
"testing"
"github.com/cli/cli/v2/pkg/cmd/attestation/api"
@ -51,6 +52,7 @@ func TestLiveSigstoreVerifier(t *testing.T) {
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
HttpClient: http.DefaultClient,
Logger: io.NewTestHandler(),
TUFMetadataDir: o.Some(t.TempDir()),
})
@ -71,6 +73,7 @@ func TestLiveSigstoreVerifier(t *testing.T) {
t.Run("with 2/3 verified attestations", func(t *testing.T) {
verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
HttpClient: http.DefaultClient,
Logger: io.NewTestHandler(),
TUFMetadataDir: o.Some(t.TempDir()),
})
@ -89,6 +92,7 @@ func TestLiveSigstoreVerifier(t *testing.T) {
t.Run("fail with 0/2 verified attestations", func(t *testing.T) {
verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
HttpClient: http.DefaultClient,
Logger: io.NewTestHandler(),
TUFMetadataDir: o.Some(t.TempDir()),
})
@ -114,6 +118,7 @@ func TestLiveSigstoreVerifier(t *testing.T) {
attestations := getAttestationsFor(t, "../test/data/github_provenance_demo-0.0.12-py3-none-any-bundle.jsonl")
verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
HttpClient: http.DefaultClient,
Logger: io.NewTestHandler(),
TUFMetadataDir: o.Some(t.TempDir()),
})
@ -128,6 +133,7 @@ func TestLiveSigstoreVerifier(t *testing.T) {
attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl")
verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
HttpClient: http.DefaultClient,
Logger: io.NewTestHandler(),
TrustedRoot: test.NormalizeRelativePath("../test/data/trusted_root.json"),
TUFMetadataDir: o.Some(t.TempDir()),

View file

@ -2,12 +2,15 @@ package verification
import (
_ "embed"
"net/http"
"os"
"path/filepath"
"github.com/cenkalti/backoff/v5"
o "github.com/cli/cli/v2/pkg/option"
"github.com/cli/go-gh/v2/pkg/config"
"github.com/sigstore/sigstore-go/pkg/tuf"
"github.com/theupdateframework/go-tuf/v2/metadata/fetcher"
)
//go:embed embed/tuf-repo.github.com/root.json
@ -15,7 +18,7 @@ var githubRoot []byte
const GitHubTUFMirror = "https://tuf-repo.github.com"
func DefaultOptionsWithCacheSetting(tufMetadataDir o.Option[string]) *tuf.Options {
func DefaultOptionsWithCacheSetting(tufMetadataDir o.Option[string], hc *http.Client) *tuf.Options {
opts := tuf.DefaultOptions()
// The CODESPACES environment variable will be set to true in a Codespaces workspace
@ -32,11 +35,18 @@ func DefaultOptionsWithCacheSetting(tufMetadataDir o.Option[string]) *tuf.Option
// Allow TUF cache for 1 day
opts.CacheValidity = 1
// configure fetcher timeout and retry
f := fetcher.NewDefaultFetcher()
f.SetHTTPClient(hc)
retryOptions := []backoff.RetryOption{backoff.WithMaxTries(3)}
f.SetRetryOptions(retryOptions...)
opts.WithFetcher(f)
return opts
}
func GitHubTUFOptions(tufMetadataDir o.Option[string]) *tuf.Options {
opts := DefaultOptionsWithCacheSetting(tufMetadataDir)
func GitHubTUFOptions(tufMetadataDir o.Option[string], hc *http.Client) *tuf.Options {
opts := DefaultOptionsWithCacheSetting(tufMetadataDir, hc)
opts.Root = githubRoot
opts.RepositoryBaseURL = GitHubTUFMirror

View file

@ -12,7 +12,7 @@ import (
func TestGitHubTUFOptionsNoMetadataDir(t *testing.T) {
os.Setenv("CODESPACES", "true")
opts := GitHubTUFOptions(o.None[string]())
opts := GitHubTUFOptions(o.None[string](), nil)
require.Equal(t, GitHubTUFMirror, opts.RepositoryBaseURL)
require.NotNil(t, opts.Root)
@ -21,6 +21,6 @@ func TestGitHubTUFOptionsNoMetadataDir(t *testing.T) {
}
func TestGitHubTUFOptionsWithMetadataDir(t *testing.T) {
opts := GitHubTUFOptions(o.Some("anything"))
opts := GitHubTUFOptions(o.Some("anything"), nil)
require.Equal(t, "anything", opts.CachePath)
}

View file

@ -1,6 +1,7 @@
package verify
import (
"errors"
"fmt"
"github.com/cli/cli/v2/internal/text"
@ -10,43 +11,63 @@ import (
)
func getAttestations(o *Options, a artifact.DigestedArtifact) ([]*api.Attestation, string, error) {
// Fetch attestations from GitHub API within this if block since predicate type
// filter is done when the API is called
if o.FetchAttestationsFromGitHubAPI() {
if o.APIClient == nil {
errMsg := "✗ No APIClient provided"
return nil, errMsg, errors.New(errMsg)
}
params := api.FetchParams{
Digest: a.DigestWithAlg(),
Limit: o.Limit,
Owner: o.Owner,
PredicateType: o.PredicateType,
Repo: o.Repo,
}
attestations, err := o.APIClient.GetByDigest(params)
if err != nil {
msg := "✗ Loading attestations from GitHub API failed"
return nil, msg, err
}
pluralAttestation := text.Pluralize(len(attestations), "attestation")
msg := fmt.Sprintf("Loaded %s from GitHub API", pluralAttestation)
return attestations, msg, nil
}
// Fetch attestations from local bundle or OCI registry
// Predicate type filtering is done after the attestations are fetched
var attestations []*api.Attestation
var err error
var msg string
if o.BundlePath != "" {
attestations, err := verification.GetLocalAttestations(o.BundlePath)
attestations, err = verification.GetLocalAttestations(o.BundlePath)
if err != nil {
msg := fmt.Sprintf("✗ Loading attestations from %s failed", a.URL)
return nil, msg, err
pluralAttestation := text.Pluralize(len(attestations), "attestation")
msg = fmt.Sprintf("Loaded %s from %s", pluralAttestation, o.BundlePath)
} else {
msg = fmt.Sprintf("Loaded %d attestations from %s", len(attestations), o.BundlePath)
}
pluralAttestation := text.Pluralize(len(attestations), "attestation")
msg := fmt.Sprintf("Loaded %s from %s", pluralAttestation, o.BundlePath)
return attestations, msg, nil
}
if o.UseBundleFromRegistry {
attestations, err := verification.GetOCIAttestations(o.OCIClient, a)
} else if o.UseBundleFromRegistry {
attestations, err = verification.GetOCIAttestations(o.OCIClient, a)
if err != nil {
msg := "✗ Loading attestations from OCI registry failed"
return nil, msg, err
msg = "✗ Loading attestations from OCI registry failed"
} else {
pluralAttestation := text.Pluralize(len(attestations), "attestation")
msg = fmt.Sprintf("Loaded %s from OCI registry", pluralAttestation)
}
pluralAttestation := text.Pluralize(len(attestations), "attestation")
msg := fmt.Sprintf("Loaded %s from %s", pluralAttestation, o.ArtifactPath)
return attestations, msg, nil
}
params := verification.FetchRemoteAttestationsParams{
Digest: a.DigestWithAlg(),
Limit: o.Limit,
Owner: o.Owner,
Repo: o.Repo,
}
attestations, err := verification.GetRemoteAttestations(o.APIClient, params)
if err != nil {
msg := "✗ Loading attestations from GitHub API failed"
return nil, msg, err
}
pluralAttestation := text.Pluralize(len(attestations), "attestation")
msg := fmt.Sprintf("Loaded %s from GitHub API", pluralAttestation)
return attestations, msg, nil
filtered, err := api.FilterAttestations(o.PredicateType, attestations)
if err != nil {
return nil, err.Error(), err
}
return filtered, msg, nil
}
func verifyAttestations(art artifact.DigestedArtifact, att []*api.Attestation, sgVerifier verification.SigstoreVerifier, ec verification.EnforcementCriteria) ([]*verification.AttestationProcessingResult, string, error) {

View file

@ -3,6 +3,7 @@
package verify
import (
"net/http"
"testing"
"github.com/cli/cli/v2/pkg/cmd/attestation/api"
@ -26,6 +27,7 @@ func getAttestationsFor(t *testing.T, bundlePath string) []*api.Attestation {
func TestVerifyAttestations(t *testing.T) {
sgVerifier, err := verification.NewLiveSigstoreVerifier(verification.SigstoreConfig{
HttpClient: http.DefaultClient,
Logger: io.NewTestHandler(),
TUFMetadataDir: o.Some(t.TempDir()),
})

View file

@ -0,0 +1,71 @@
package verify
import (
"testing"
"github.com/cli/cli/v2/pkg/cmd/attestation/api"
"github.com/cli/cli/v2/pkg/cmd/attestation/artifact"
"github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci"
"github.com/cli/cli/v2/pkg/cmd/attestation/verification"
"github.com/stretchr/testify/require"
)
func TestGetAttestations_OCIRegistry_PredicateTypeFiltering(t *testing.T) {
artifact, err := artifact.NewDigestedArtifact(nil, "../test/data/gh_2.60.1_windows_arm64.zip", "sha256")
require.NoError(t, err)
o := &Options{
OCIClient: oci.MockClient{},
PredicateType: verification.SLSAPredicateV1,
Repo: "cli/cli",
UseBundleFromRegistry: true,
}
attestations, msg, err := getAttestations(o, *artifact)
require.NoError(t, err)
require.Contains(t, msg, "Loaded 2 attestations from OCI registry")
require.Len(t, attestations, 2)
o.PredicateType = "custom predicate type"
attestations, msg, err = getAttestations(o, *artifact)
require.Error(t, err)
require.Contains(t, msg, "no attestations found with predicate type")
require.Nil(t, attestations)
}
func TestGetAttestations_LocalBundle_PredicateTypeFiltering(t *testing.T) {
artifact, err := artifact.NewDigestedArtifact(nil, "../test/data/gh_2.60.1_windows_arm64.zip", "sha256")
require.NoError(t, err)
o := &Options{
BundlePath: "../test/data/sigstore-js-2.1.0-bundle.json",
PredicateType: verification.SLSAPredicateV1,
Repo: "sigstore/sigstore-js",
}
attestations, _, err := getAttestations(o, *artifact)
require.NoError(t, err)
require.Len(t, attestations, 1)
o.PredicateType = "custom predicate type"
attestations, _, err = getAttestations(o, *artifact)
require.Error(t, err)
require.Nil(t, attestations)
}
func TestGetAttestations_GhAPI_NoAttestationsFound(t *testing.T) {
artifact, err := artifact.NewDigestedArtifact(nil, "../test/data/gh_2.60.1_windows_arm64.zip", "sha256")
require.NoError(t, err)
o := &Options{
APIClient: api.NewTestClient(),
PredicateType: verification.SLSAPredicateV1,
Repo: "sigstore/sigstore-js",
}
attestations, _, err := getAttestations(o, *artifact)
require.NoError(t, err)
require.Len(t, attestations, 2)
o.PredicateType = "custom predicate type"
attestations, _, err = getAttestations(o, *artifact)
require.Error(t, err)
require.Nil(t, attestations)
}

View file

@ -53,6 +53,12 @@ func (opts *Options) Clean() {
}
}
// FetchAttestationsFromGitHubAPI returns true if the command should fetch attestations from the GitHub API
// It checks that a bundle path is not provided and that the "use bundle from registry" flag is not set
func (opts *Options) FetchAttestationsFromGitHubAPI() bool {
return opts.BundlePath == "" && !opts.UseBundleFromRegistry
}
// AreFlagsValid checks that the provided flag combination is valid
// and returns an error otherwise
func (opts *Options) AreFlagsValid() error {

View file

@ -186,9 +186,10 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
opts.APIClient = api.NewLiveClient(hc, opts.Hostname, opts.Logger)
config := verification.SigstoreConfig{
TrustedRoot: opts.TrustedRoot,
HttpClient: hc,
Logger: opts.Logger,
NoPublicGood: opts.NoPublicGood,
TrustedRoot: opts.TrustedRoot,
}
// Prepare for tenancy if detected
@ -288,14 +289,6 @@ func runVerify(opts *Options) error {
// Print the message signifying success fetching attestations
opts.Logger.Println(logMsg)
// Apply predicate type filter to returned attestations
filteredAttestations := verification.FilterAttestations(ec.PredicateType, attestations)
if len(filteredAttestations) == 0 {
opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found with predicate type: %s\n"), opts.PredicateType)
return fmt.Errorf("no matching predicate found")
}
attestations = filteredAttestations
// print information about the policy that will be enforced against attestations
opts.Logger.Println("\nThe following policy criteria will be enforced:")
opts.Logger.Println(ec.BuildPolicyInformation())

View file

@ -3,6 +3,7 @@
package verify
import (
"net/http"
"testing"
"github.com/cli/cli/v2/pkg/cmd/attestation/api"
@ -20,6 +21,7 @@ func TestVerifyIntegration(t *testing.T) {
logger := io.NewTestHandler()
sigstoreConfig := verification.SigstoreConfig{
HttpClient: http.DefaultClient,
Logger: logger,
TUFMetadataDir: o.Some(t.TempDir()),
}
@ -136,6 +138,7 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) {
logger := io.NewTestHandler()
sigstoreConfig := verification.SigstoreConfig{
HttpClient: http.DefaultClient,
Logger: logger,
TUFMetadataDir: o.Some(t.TempDir()),
}
@ -209,6 +212,7 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) {
logger := io.NewTestHandler()
sigstoreConfig := verification.SigstoreConfig{
HttpClient: http.DefaultClient,
Logger: logger,
TUFMetadataDir: o.Some(t.TempDir()),
}
@ -301,6 +305,7 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) {
logger := io.NewTestHandler()
sigstoreConfig := verification.SigstoreConfig{
HttpClient: http.DefaultClient,
Logger: logger,
TUFMetadataDir: o.Some(t.TempDir()),
}

View file

@ -510,7 +510,7 @@ func TestRunVerify(t *testing.T) {
err := runVerify(&customOpts)
require.Error(t, err)
require.ErrorContains(t, err, "no matching predicate found")
require.ErrorContains(t, err, "no attestations found with predicate type")
})
t.Run("with valid OCI artifact with UseBundleFromRegistry flag but no bundle return from registry", func(t *testing.T) {

View file

@ -16,14 +16,14 @@ import (
func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
longDoc := strings.Builder{}
longDoc.WriteString("Display or change configuration settings for gh.\n\n")
longDoc.WriteString("Current respected settings:\n\n")
longDoc.WriteString("Current respected settings:\n")
for _, co := range config.Options {
longDoc.WriteString(fmt.Sprintf("- `%s`: %s", co.Key, co.Description))
if len(co.AllowedValues) > 0 {
longDoc.WriteString(fmt.Sprintf(" {%s}", strings.Join(co.AllowedValues, "|")))
longDoc.WriteString(fmt.Sprintf(" `{%s}`", strings.Join(co.AllowedValues, " | ")))
}
if co.DefaultValue != "" {
longDoc.WriteString(fmt.Sprintf(" (default %s)", co.DefaultValue))
longDoc.WriteString(fmt.Sprintf(" (default `%s`)", co.DefaultValue))
}
longDoc.WriteRune('\n')
}

View file

@ -101,6 +101,9 @@ func Test_listRun(t *testing.T) {
http_unix_socket=
browser=brave
color_labels=disabled
accessible_colors=disabled
accessible_prompter=disabled
spinner=enabled
`),
},
}

View file

@ -23,15 +23,14 @@ import (
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewCmdExtension(t *testing.T) {
tempDir := t.TempDir()
oldWd, _ := os.Getwd()
localExtensionTempDir := filepath.Join(tempDir, "gh-hello")
assert.NoError(t, os.MkdirAll(localExtensionTempDir, 0755))
assert.NoError(t, os.Chdir(localExtensionTempDir))
t.Cleanup(func() { _ = os.Chdir(oldWd) })
require.NoError(t, os.MkdirAll(localExtensionTempDir, 0755))
t.Chdir(localExtensionTempDir)
tests := []struct {
name string

View file

@ -1251,9 +1251,10 @@ func TestManager_repo_not_found(t *testing.T) {
}
func TestManager_Create(t *testing.T) {
chdirTemp(t)
tempDir := t.TempDir()
t.Chdir(tempDir)
err := os.MkdirAll("gh-test", 0755)
assert.NoError(t, err)
require.NoError(t, err)
ios, _, stdout, stderr := iostreams.Test()
@ -1279,9 +1280,10 @@ func TestManager_Create(t *testing.T) {
}
func TestManager_Create_go_binary(t *testing.T) {
chdirTemp(t)
tempDir := t.TempDir()
t.Chdir(tempDir)
err := os.MkdirAll("gh-test", 0755)
assert.NoError(t, err)
require.NoError(t, err)
reg := httpmock.Registry{}
defer reg.Verify(t)
@ -1329,9 +1331,10 @@ func TestManager_Create_go_binary(t *testing.T) {
}
func TestManager_Create_other_binary(t *testing.T) {
chdirTemp(t)
tempDir := t.TempDir()
t.Chdir(tempDir)
err := os.MkdirAll("gh-test", 0755)
assert.NoError(t, err)
require.NoError(t, err)
ios, _, stdout, stderr := iostreams.Test()
@ -1392,18 +1395,6 @@ func Test_ensurePrefixed(t *testing.T) {
}
}
// chdirTemp changes the current working directory to a temporary directory for the duration of the test.
func chdirTemp(t *testing.T) {
oldWd, _ := os.Getwd()
tempDir := t.TempDir()
if err := os.Chdir(tempDir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = os.Chdir(oldWd)
})
}
func fileNames(files []os.DirEntry) []string {
names := make([]string, len(files))
for i, f := range files {

View file

@ -227,7 +227,7 @@ func newBrowser(f *cmdutil.Factory) browser.Browser {
func newPrompter(f *cmdutil.Factory) prompter.Prompter {
editor, _ := cmdutil.DetermineEditor(f.Config)
io := f.IOStreams
return prompter.New(editor, io.In, io.Out, io.ErrOut)
return prompter.New(editor, io)
}
func configFunc() func() (gh.Config, error) {
@ -284,9 +284,23 @@ func ioStreams(f *cmdutil.Factory) *iostreams.IOStreams {
io.SetNeverPrompt(true)
}
ghSpinnerDisabledValue, ghSpinnerDisabledIsSet := os.LookupEnv("GH_SPINNER_DISABLED")
falseyValues := []string{"false", "0", "no", ""}
if ghSpinnerDisabledIsSet && !slices.Contains(falseyValues, ghSpinnerDisabledValue) {
accessiblePrompterValue, accessiblePrompterIsSet := os.LookupEnv("GH_ACCESSIBLE_PROMPTER")
if accessiblePrompterIsSet {
if !slices.Contains(falseyValues, accessiblePrompterValue) {
io.SetAccessiblePrompterEnabled(true)
}
} else if prompt := cfg.AccessiblePrompter(""); prompt.Value == "enabled" {
io.SetAccessiblePrompterEnabled(true)
}
ghSpinnerDisabledValue, ghSpinnerDisabledIsSet := os.LookupEnv("GH_SPINNER_DISABLED")
if ghSpinnerDisabledIsSet {
if !slices.Contains(falseyValues, ghSpinnerDisabledValue) {
io.SetSpinnerDisabled(true)
}
} else if spinnerDisabled := cfg.Spinner(""); spinnerDisabled.Value == "disabled" {
io.SetSpinnerDisabled(true)
}

View file

@ -435,6 +435,7 @@ func Test_ioStreams_prompt(t *testing.T) {
func Test_ioStreams_spinnerDisabled(t *testing.T) {
tests := []struct {
name string
config gh.Config
spinnerDisabled bool
env map[string]string
}{
@ -442,6 +443,16 @@ func Test_ioStreams_spinnerDisabled(t *testing.T) {
name: "default config",
spinnerDisabled: false,
},
{
name: "config with spinner disabled",
config: disableSpinnersConfig(),
spinnerDisabled: true,
},
{
name: "config with spinner enabled",
config: enableSpinnersConfig(),
spinnerDisabled: false,
},
{
name: "spinner disabled via GH_SPINNER_DISABLED env var = 0",
env: map[string]string{"GH_SPINNER_DISABLED": "0"},
@ -467,6 +478,18 @@ func Test_ioStreams_spinnerDisabled(t *testing.T) {
env: map[string]string{"GH_SPINNER_DISABLED": "true"},
spinnerDisabled: true,
},
{
name: "config enabled but env disabled, respects env",
config: enableSpinnersConfig(),
env: map[string]string{"GH_SPINNER_DISABLED": "true"},
spinnerDisabled: true,
},
{
name: "config disabled but env enabled, respects env",
config: disableSpinnersConfig(),
env: map[string]string{"GH_SPINNER_DISABLED": "false"},
spinnerDisabled: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -474,12 +497,87 @@ func Test_ioStreams_spinnerDisabled(t *testing.T) {
t.Setenv(k, v)
}
f := New("1")
f.Config = func() (gh.Config, error) {
if tt.config == nil {
return config.NewBlankConfig(), nil
} else {
return tt.config, nil
}
}
io := ioStreams(f)
assert.Equal(t, tt.spinnerDisabled, io.GetSpinnerDisabled())
})
}
}
func Test_ioStreams_accessiblePrompterEnabled(t *testing.T) {
tests := []struct {
name string
config gh.Config
accessiblePrompterEnabled bool
env map[string]string
}{
{
name: "default config",
accessiblePrompterEnabled: false,
},
{
name: "config with accessible prompter enabled",
config: enableAccessiblePrompterConfig(),
accessiblePrompterEnabled: true,
},
{
name: "config with accessible prompter disabled",
config: disableAccessiblePrompterConfig(),
accessiblePrompterEnabled: false,
},
{
name: "accessible prompter enabled via GH_ACCESSIBLE_PROMPTER env var = 1",
env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "1"},
accessiblePrompterEnabled: true,
},
{
name: "accessible prompter enabled via GH_ACCESSIBLE_PROMPTER env var = true",
env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "true"},
accessiblePrompterEnabled: true,
},
{
name: "accessible prompter disabled via GH_ACCESSIBLE_PROMPTER env var = 0",
env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "0"},
accessiblePrompterEnabled: false,
},
{
name: "config disabled but env enabled, respects env",
config: disableAccessiblePrompterConfig(),
env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "true"},
accessiblePrompterEnabled: true,
},
{
name: "config enabled but env disabled, respects env",
config: enableAccessiblePrompterConfig(),
env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "false"},
accessiblePrompterEnabled: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for k, v := range tt.env {
t.Setenv(k, v)
}
f := New("1")
f.Config = func() (gh.Config, error) {
if tt.config == nil {
return config.NewBlankConfig(), nil
} else {
return tt.config, nil
}
}
io := ioStreams(f)
assert.Equal(t, tt.accessiblePrompterEnabled, io.AccessiblePrompterEnabled())
})
}
}
func Test_ioStreams_colorLabels(t *testing.T) {
tests := []struct {
name string
@ -664,6 +762,22 @@ func disablePromptConfig() gh.Config {
return config.NewFromString("prompt: disabled")
}
func enableAccessiblePrompterConfig() gh.Config {
return config.NewFromString("accessible_prompter: enabled")
}
func disableAccessiblePrompterConfig() gh.Config {
return config.NewFromString("accessible_prompter: disabled")
}
func disableSpinnersConfig() gh.Config {
return config.NewFromString("spinner: disabled")
}
func enableSpinnersConfig() gh.Config {
return config.NewFromString("spinner: enabled")
}
func disableColorLabelsConfig() gh.Config {
return config.NewFromString("color_labels: disabled")
}

View file

@ -18,6 +18,7 @@ import (
ghAPI "github.com/cli/go-gh/v2/pkg/api"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewCmdDelete(t *testing.T) {
@ -327,11 +328,12 @@ func Test_deleteRun(t *testing.T) {
func Test_gistDelete(t *testing.T) {
tests := []struct {
name string
httpStubs func(*httpmock.Registry)
hostname string
gistID string
wantErr error
name string
httpStubs func(*httpmock.Registry)
hostname string
gistID string
wantErr error
wantErrString string
}{
{
name: "successful delete",
@ -343,36 +345,34 @@ func Test_gistDelete(t *testing.T) {
},
hostname: "github.com",
gistID: "1234",
wantErr: nil,
},
{
name: "when an gist is not found, it returns a NotFoundError",
name: "when a gist is not found, it returns a NotFoundError",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("DELETE", "gists/1234"),
httpmock.StatusStringResponse(404, "{}"),
)
},
hostname: "github.com",
gistID: "1234",
wantErr: shared.NotFoundErr,
hostname: "github.com",
gistID: "1234",
wantErr: shared.NotFoundErr, // To make sure we return the pre-defined error instance.
wantErrString: "not found",
},
{
name: "when there is a non-404 error deleting the gist, that error is returned",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("DELETE", "gists/1234"),
httpmock.StatusJSONResponse(500, `{"message": "arbitrary error"}`),
httpmock.JSONErrorResponse(500, ghAPI.HTTPError{
StatusCode: 500,
Message: "arbitrary error",
}),
)
},
hostname: "github.com",
gistID: "1234",
wantErr: api.HTTPError{
HTTPError: &ghAPI.HTTPError{
StatusCode: 500,
Message: "arbitrary error",
},
},
hostname: "github.com",
gistID: "1234",
wantErrString: "HTTP 500: arbitrary error (https://api.github.com/gists/1234)",
},
}
@ -383,12 +383,16 @@ func Test_gistDelete(t *testing.T) {
client := api.NewClientFromHTTP(&http.Client{Transport: reg})
err := deleteGist(client, tt.hostname, tt.gistID)
if tt.wantErr != nil {
assert.ErrorAs(t, err, &tt.wantErr)
if tt.wantErrString == "" && tt.wantErr == nil {
require.NoError(t, err)
} else {
assert.NoError(t, err)
if tt.wantErrString != "" {
require.EqualError(t, err, tt.wantErrString)
}
if tt.wantErr != nil {
require.ErrorIs(t, err, tt.wantErr)
}
}
})
}
}

View file

@ -236,6 +236,8 @@ func editRun(opts *EditOptions) error {
if filename == "" {
if len(candidates) == 1 {
filename = candidates[0]
} else if len(candidates) == 0 {
return errors.New("no file in the gist")
} else {
if !opts.IO.CanPrompt() {
return errors.New("unsure what file to edit; either specify --filename or run interactively")

View file

@ -557,6 +557,30 @@ func Test_editRun(t *testing.T) {
},
wantErr: "gist ID or URL required when not running interactively",
},
{
name: "edit no-file gist (#10626)",
opts: &EditOptions{
Selector: "1234",
},
mockGist: &shared.Gist{
ID: "1234",
Files: map[string]*shared.GistFile{},
Owner: &shared.GistOwner{Login: "octocat"},
},
wantErr: "no file in the gist",
},
{
name: "edit no-file gist, nil map (#10626)",
opts: &EditOptions{
Selector: "1234",
},
mockGist: &shared.Gist{
ID: "1234",
Files: nil,
Owner: &shared.GistOwner{Login: "octocat"},
},
wantErr: "no file in the gist",
},
}
for _, tt := range tests {

View file

@ -44,6 +44,9 @@ func (g Gist) Filename() string {
for fn := range g.Files {
filenames = append(filenames, fn)
}
if len(filenames) == 0 {
return ""
}
sort.Strings(filenames)
return filenames[0]
}

View file

@ -163,6 +163,33 @@ func TestPromptGists(t *testing.T) {
response: `{ "data": { "viewer": { "gists": { "nodes": [] } } } }`,
wantOut: Gist{},
},
{
name: "prompt list contains no-file gist (#10626)",
prompterStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a gist",
[]string{" about 6 hours ago", "gistfile0.txt about 6 hours ago"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, " about 6 hours ago")
})
},
response: `{ "data": { "viewer": { "gists": { "nodes": [
{
"name": "1234",
"files": [],
"description": "",
"updatedAt": "%[1]v",
"isPublic": true
},
{
"name": "5678",
"files": [{ "name": "gistfile0.txt" }],
"description": "",
"updatedAt": "%[1]v",
"isPublic": true
}
] } } } }`,
wantOut: Gist{ID: "1234", Files: map[string]*GistFile{}, UpdatedAt: sixHoursAgo, Public: true},
},
}
ios, _, _, _ := iostreams.Test()

View file

@ -177,7 +177,7 @@ func Test_deleteRun(t *testing.T) {
opts: DeleteOptions{KeyID: "ABC123", Confirmed: true},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "user/gpg_keys"), httpmock.StatusStringResponse(200, keysResp))
reg.Register(httpmock.REST("DELETE", "user/gpg_keys/123"), httpmock.StatusJSONResponse(404, api.HTTPError{
reg.Register(httpmock.REST("DELETE", "user/gpg_keys/123"), httpmock.JSONErrorResponse(404, api.HTTPError{
StatusCode: 404,
Message: "GPG key 123 not found",
}))

View file

@ -0,0 +1,137 @@
package argparsetest
import (
"bytes"
"reflect"
"testing"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/google/shlex"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newCmdFunc represents the typical function signature we use for creating commands e.g. `NewCmdView`.
//
// It is generic over `T` as each command construction has their own Options type e.g. `ViewOptions`
type newCmdFunc[T any] func(f *cmdutil.Factory, runF func(*T) error) *cobra.Command
// TestArgParsing is a test helper that verifies that issue commands correctly parse the `{issue number | url}`
// positional arg into an issue number and base repo.
//
// Looking through the existing tests, I noticed that the coverage was pretty smattered.
// Since nearly all issue commands only accept a single positional argument, we are able to reuse this test helper.
// Commands with no further flags or args can use this solely.
// Commands with extra flags use this and further table tests.
// Commands with extra required positional arguments (like `transfer`) cannot use this. They duplicate these cases inline.
func TestArgParsing[T any](t *testing.T, fn newCmdFunc[T]) {
tests := []struct {
name string
input string
expectedissueNumber int
expectedBaseRepo ghrepo.Interface
expectErr bool
}{
{
name: "no argument",
input: "",
expectErr: true,
},
{
name: "issue number argument",
input: "23 --repo owner/repo",
expectedissueNumber: 23,
expectedBaseRepo: ghrepo.New("owner", "repo"),
},
{
name: "argument is hash prefixed number",
// Escaping is required here to avoid what I think is shellex treating it as a comment.
input: "\\#23 --repo owner/repo",
expectedissueNumber: 23,
expectedBaseRepo: ghrepo.New("owner", "repo"),
},
{
name: "argument is a URL",
input: "https://github.com/cli/cli/issues/23",
expectedissueNumber: 23,
expectedBaseRepo: ghrepo.New("cli", "cli"),
},
{
name: "argument cannot be parsed to an issue",
input: "unparseable",
expectErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := &cmdutil.Factory{}
argv, err := shlex.Split(tt.input)
assert.NoError(t, err)
var gotOpts T
cmd := fn(f, func(opts *T) error {
gotOpts = *opts
return nil
})
cmdutil.EnableRepoOverride(cmd, f)
// TODO: remember why we do this
cmd.Flags().BoolP("help", "x", false, "")
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
_, err = cmd.ExecuteC()
if tt.expectErr {
require.Error(t, err)
return
} else {
require.NoError(t, err)
}
actualIssueNumber := issueNumberFromOpts(t, gotOpts)
assert.Equal(t, tt.expectedissueNumber, actualIssueNumber)
actualBaseRepo := baseRepoFromOpts(t, gotOpts)
assert.True(
t,
ghrepo.IsSame(tt.expectedBaseRepo, actualBaseRepo),
"expected base repo %+v, got %+v", tt.expectedBaseRepo, actualBaseRepo,
)
})
}
}
func issueNumberFromOpts(t *testing.T, v any) int {
rv := reflect.ValueOf(v)
field := rv.FieldByName("IssueNumber")
if !field.IsValid() || field.Kind() != reflect.Int {
t.Fatalf("Type %T does not have IssueNumber int field", v)
}
return int(field.Int())
}
func baseRepoFromOpts(t *testing.T, v any) ghrepo.Interface {
rv := reflect.ValueOf(v)
field := rv.FieldByName("BaseRepo")
// check whether the field is valid and of type func() (ghrepo.Interface, error)
if !field.IsValid() || field.Kind() != reflect.Func {
t.Fatalf("Type %T does not have BaseRepo func field", v)
}
// call the function and check the return value
results := field.Call([]reflect.Value{})
if len(results) != 2 {
t.Fatalf("%T.BaseRepo() does not return two values", v)
}
if !results[1].IsNil() {
t.Fatalf("%T.BaseRepo() returned an error: %v", v, results[1].Interface())
}
return results[0].Interface().(ghrepo.Interface)
}

View file

@ -21,7 +21,7 @@ type CloseOptions struct {
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
SelectorArg string
IssueNumber int
Comment string
Reason string
@ -39,13 +39,23 @@ func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Comm
Short: "Close issue",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
if len(args) > 0 {
opts.SelectorArg = args[0]
issueNumber, baseRepo, err := shared.ParseIssueFromArg(args[0])
if err != nil {
return err
}
// If the args provided the base repo then use that directly.
if baseRepo, present := baseRepo.Value(); present {
opts.BaseRepo = func() (ghrepo.Interface, error) {
return baseRepo, nil
}
} else {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
}
opts.IssueNumber = issueNumber
if runF != nil {
return runF(opts)
}
@ -67,7 +77,12 @@ func closeRun(opts *CloseOptions) error {
return err
}
issue, baseRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.SelectorArg, []string{"id", "number", "title", "state"})
baseRepo, err := opts.BaseRepo()
if err != nil {
return err
}
issue, err := shared.FindIssueOrPR(httpClient, baseRepo, opts.IssueNumber, []string{"id", "number", "title", "state"})
if err != nil {
return err
}

View file

@ -7,46 +7,32 @@ import (
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"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewCmdClose(t *testing.T) {
// Test shared parsing of issue number / URL.
argparsetest.TestArgParsing(t, NewCmdClose)
tests := []struct {
name string
input string
output CloseOptions
wantErr bool
errMsg string
name string
input string
output CloseOptions
expectedBaseRepo ghrepo.Interface
wantErr bool
errMsg string
}{
{
name: "no argument",
input: "",
wantErr: true,
errMsg: "accepts 1 arg(s), received 0",
},
{
name: "issue number",
input: "123",
output: CloseOptions{
SelectorArg: "123",
},
},
{
name: "issue url",
input: "https://github.com/cli/cli/3",
output: CloseOptions{
SelectorArg: "https://github.com/cli/cli/3",
},
},
{
name: "comment",
input: "123 --comment 'closing comment'",
output: CloseOptions{
SelectorArg: "123",
IssueNumber: 123,
Comment: "closing comment",
},
},
@ -54,7 +40,7 @@ func TestNewCmdClose(t *testing.T) {
name: "reason",
input: "123 --reason 'not planned'",
output: CloseOptions{
SelectorArg: "123",
IssueNumber: 123,
Reason: "not planned",
},
},
@ -79,15 +65,24 @@ func TestNewCmdClose(t *testing.T) {
_, err = cmd.ExecuteC()
if tt.wantErr {
assert.Error(t, err)
require.Error(t, err)
assert.Equal(t, tt.errMsg, err.Error())
return
}
assert.NoError(t, err)
assert.Equal(t, tt.output.SelectorArg, gotOpts.SelectorArg)
require.NoError(t, err)
assert.Equal(t, tt.output.IssueNumber, gotOpts.IssueNumber)
assert.Equal(t, tt.output.Comment, gotOpts.Comment)
assert.Equal(t, tt.output.Reason, gotOpts.Reason)
if tt.expectedBaseRepo != nil {
baseRepo, err := gotOpts.BaseRepo()
require.NoError(t, err)
require.True(
t,
ghrepo.IsSame(tt.expectedBaseRepo, baseRepo),
"expected base repo %+v, got %+v", tt.expectedBaseRepo, baseRepo,
)
}
})
}
}
@ -104,7 +99,7 @@ func TestCloseRun(t *testing.T) {
{
name: "close issue by number",
opts: &CloseOptions{
SelectorArg: "13",
IssueNumber: 13,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
@ -128,7 +123,7 @@ func TestCloseRun(t *testing.T) {
{
name: "close issue with comment",
opts: &CloseOptions{
SelectorArg: "13",
IssueNumber: 13,
Comment: "closing comment",
},
httpStubs: func(reg *httpmock.Registry) {
@ -164,7 +159,7 @@ func TestCloseRun(t *testing.T) {
{
name: "close issue with reason",
opts: &CloseOptions{
SelectorArg: "13",
IssueNumber: 13,
Reason: "not planned",
Detector: &fd.EnabledDetectorMock{},
},
@ -192,7 +187,7 @@ func TestCloseRun(t *testing.T) {
{
name: "close issue with reason when reason is not supported",
opts: &CloseOptions{
SelectorArg: "13",
IssueNumber: 13,
Reason: "not planned",
Detector: &fd.DisabledDetectorMock{},
},
@ -219,7 +214,7 @@ func TestCloseRun(t *testing.T) {
{
name: "issue already closed",
opts: &CloseOptions{
SelectorArg: "13",
IssueNumber: 13,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
@ -236,7 +231,7 @@ func TestCloseRun(t *testing.T) {
{
name: "issues disabled",
opts: &CloseOptions{
SelectorArg: "13",
IssueNumber: 13,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(

View file

@ -3,6 +3,7 @@ package comment
import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmd/issue/shared"
issueShared "github.com/cli/cli/v2/pkg/cmd/issue/shared"
prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
@ -17,6 +18,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) e
InteractiveEditSurvey: prShared.CommentableInteractiveEditSurvey(f.Config, f.IOStreams),
ConfirmSubmitSurvey: prShared.CommentableConfirmSubmitSurvey(f.Prompter),
ConfirmCreateIfNoneSurvey: prShared.CommentableInteractiveCreateIfNoneSurvey(f.Prompter),
ConfirmDeleteLastComment: prShared.CommentableConfirmDeleteLastComment(f.Prompter),
OpenInBrowser: f.Browser.Browse,
}
@ -37,15 +39,41 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) e
Args: cobra.ExactArgs(1),
PreRunE: func(cmd *cobra.Command, args []string) error {
opts.RetrieveCommentable = func() (prShared.Commentable, ghrepo.Interface, error) {
// TODO wm: more testing
issueNumber, parsedBaseRepo, err := shared.ParseIssueFromArg(args[0])
if err != nil {
return nil, nil, err
}
// If the args provided the base repo then use that directly.
var baseRepo ghrepo.Interface
if parsedBaseRepo, present := parsedBaseRepo.Value(); present {
baseRepo = parsedBaseRepo
} else {
// support `-R, --repo` override
baseRepo, err = f.BaseRepo()
if err != nil {
return nil, nil, err
}
}
httpClient, err := f.HttpClient()
if err != nil {
return nil, nil, err
}
fields := []string{"id", "url"}
if opts.EditLast {
if opts.EditLast || opts.DeleteLast {
fields = append(fields, "comments")
}
return issueShared.IssueFromArgWithFields(httpClient, f.BaseRepo, args[0], fields)
issue, err := issueShared.FindIssueOrPR(httpClient, baseRepo, issueNumber, fields)
if err != nil {
return nil, nil, err
}
return issue, baseRepo, nil
}
return prShared.CommentablePreRun(cmd, opts)
},
@ -69,7 +97,9 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) e
cmd.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read body text from `file` (use \"-\" to read from standard input)")
cmd.Flags().BoolP("editor", "e", false, "Skip prompts and open the text editor to write the body in")
cmd.Flags().BoolP("web", "w", false, "Open the web browser to write the comment")
cmd.Flags().BoolVar(&opts.EditLast, "edit-last", false, "Edit the last comment of the same author")
cmd.Flags().BoolVar(&opts.EditLast, "edit-last", false, "Edit the last comment of the current user")
cmd.Flags().BoolVar(&opts.DeleteLast, "delete-last", false, "Delete the last comment of the current user")
cmd.Flags().BoolVar(&opts.DeleteLastConfirmed, "yes", false, "Skip the delete confirmation prompt when --delete-last is provided")
cmd.Flags().BoolVar(&opts.CreateIfNone, "create-if-none", false, "Create a new comment if no comments are found. Can be used only with --edit-last")
return cmd

View file

@ -2,6 +2,7 @@ package comment
import (
"bytes"
"errors"
"fmt"
"net/http"
"os"
@ -31,11 +32,13 @@ func TestNewCmdComment(t *testing.T) {
stdin string
output shared.CommentableOptions
wantsErr bool
isTTY bool
}{
{
name: "no arguments",
input: "",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
@ -46,6 +49,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: 0,
Body: "",
},
isTTY: true,
wantsErr: false,
},
{
@ -56,6 +60,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: 0,
Body: "",
},
isTTY: true,
wantsErr: false,
},
{
@ -66,6 +71,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: shared.InputTypeInline,
Body: "test",
},
isTTY: true,
wantsErr: false,
},
{
@ -77,6 +83,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: shared.InputTypeInline,
Body: "this is on standard input",
},
isTTY: true,
wantsErr: false,
},
{
@ -87,6 +94,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: shared.InputTypeInline,
Body: "a body from file",
},
isTTY: true,
wantsErr: false,
},
{
@ -97,6 +105,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: shared.InputTypeEditor,
Body: "",
},
isTTY: true,
wantsErr: false,
},
{
@ -107,6 +116,7 @@ func TestNewCmdComment(t *testing.T) {
InputType: shared.InputTypeWeb,
Body: "",
},
isTTY: true,
wantsErr: false,
},
{
@ -118,6 +128,7 @@ func TestNewCmdComment(t *testing.T) {
Body: "",
EditLast: true,
},
isTTY: true,
wantsErr: false,
},
{
@ -130,42 +141,110 @@ func TestNewCmdComment(t *testing.T) {
EditLast: true,
CreateIfNone: true,
},
isTTY: true,
wantsErr: false,
},
{
name: "delete last flag non-interactive",
input: "1 --delete-last",
isTTY: false,
wantsErr: true,
},
{
name: "delete last flag and pre-confirmation non-interactive",
input: "1 --delete-last --yes",
output: shared.CommentableOptions{
DeleteLast: true,
DeleteLastConfirmed: true,
},
isTTY: false,
wantsErr: false,
},
{
name: "delete last flag interactive",
input: "1 --delete-last",
output: shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
},
isTTY: true,
wantsErr: false,
},
{
name: "delete last flag and pre-confirmation interactive",
input: "1 --delete-last --yes",
output: shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
DeleteLastConfirmed: true,
},
isTTY: true,
wantsErr: false,
},
{
name: "delete last flag and pre-confirmation with web flag",
input: "1 --delete-last --yes --web",
isTTY: true,
wantsErr: true,
},
{
name: "delete last flag and pre-confirmation with editor flag",
input: "1 --delete-last --yes --editor",
isTTY: true,
wantsErr: true,
},
{
name: "delete last flag and pre-confirmation with body flag",
input: "1 --delete-last --yes --body",
isTTY: true,
wantsErr: true,
},
{
name: "delete pre-confirmation without delete last flag",
input: "1 --yes",
isTTY: true,
wantsErr: true,
},
{
name: "body and body-file flags",
input: "1 --body 'test' --body-file 'test-file.txt'",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
name: "editor and web flags",
input: "1 --editor --web",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
name: "editor and body flags",
input: "1 --editor --body test",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
name: "web and body flags",
input: "1 --web --body test",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
name: "editor, web, and body flags",
input: "1 --editor --web --body test",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
{
name: "create-if-none flag without edit-last",
input: "1 --create-if-none",
output: shared.CommentableOptions{},
isTTY: true,
wantsErr: true,
},
}
@ -173,9 +252,10 @@ func TestNewCmdComment(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, stdin, _, _ := iostreams.Test()
ios.SetStdoutTTY(true)
ios.SetStdinTTY(true)
ios.SetStderrTTY(true)
isTTY := tt.isTTY
ios.SetStdoutTTY(isTTY)
ios.SetStdinTTY(isTTY)
ios.SetStderrTTY(isTTY)
if tt.stdin != "" {
_, _ = stdin.WriteString(tt.stdin)
@ -211,6 +291,8 @@ func TestNewCmdComment(t *testing.T) {
assert.Equal(t, tt.output.Interactive, gotOpts.Interactive)
assert.Equal(t, tt.output.InputType, gotOpts.InputType)
assert.Equal(t, tt.output.Body, gotOpts.Body)
assert.Equal(t, tt.output.DeleteLast, gotOpts.DeleteLast)
assert.Equal(t, tt.output.DeleteLastConfirmed, gotOpts.DeleteLastConfirmed)
})
}
}
@ -220,6 +302,7 @@ func Test_commentRun(t *testing.T) {
name string
input *shared.CommentableOptions
emptyComments bool
comments api.Comments
httpStubs func(*testing.T, *httpmock.Registry)
stdout string
stderr string
@ -255,6 +338,7 @@ func Test_commentRun(t *testing.T) {
},
emptyComments: true,
wantsErr: true,
stdout: "no comments found for current user",
},
{
name: "updating last comment with interactive editor succeeds if there are comments",
@ -331,6 +415,7 @@ func Test_commentRun(t *testing.T) {
},
emptyComments: true,
wantsErr: true,
stdout: "no comments found for current user",
},
{
name: "creating new comment with non-interactive editor succeeds",
@ -358,6 +443,7 @@ func Test_commentRun(t *testing.T) {
},
emptyComments: true,
wantsErr: true,
stdout: "no comments found for current user",
},
{
name: "updating last comment with non-interactive editor succeeds if there are comments",
@ -433,6 +519,117 @@ func Test_commentRun(t *testing.T) {
},
stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n",
},
{
name: "deleting last comment non-interactively without any comment",
input: &shared.CommentableOptions{
Interactive: false,
DeleteLast: true,
},
emptyComments: true,
wantsErr: true,
stdout: "no comments found for current user",
},
{
name: "deleting last comment interactively without any comment",
input: &shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
},
emptyComments: true,
wantsErr: true,
stdout: "no comments found for current user",
},
{
name: "deleting last comment non-interactively and pre-confirmed",
input: &shared.CommentableOptions{
Interactive: false,
DeleteLast: true,
DeleteLastConfirmed: true,
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentDelete(t, reg)
},
stderr: "Comment deleted\n",
},
{
name: "deleting last comment interactively and pre-confirmed",
input: &shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
DeleteLastConfirmed: true,
},
comments: api.Comments{Nodes: []api.Comment{
{ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-111", ViewerDidAuthor: true, Body: "comment body"},
}},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentDelete(t, reg)
},
stderr: "Comment deleted\n",
},
{
name: "deleting last comment interactively and confirmed",
input: &shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
ConfirmDeleteLastComment: func(body string) (bool, error) {
if body != "comment body" {
return false, errors.New("unexpected comment body")
}
return true, nil
},
},
comments: api.Comments{Nodes: []api.Comment{
{ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-111", ViewerDidAuthor: true, Body: "comment body"},
}},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentDelete(t, reg)
},
stdout: "! Deleted comments cannot be recovered.\n",
stderr: "Comment deleted\n",
},
{
name: "deleting last comment interactively and confirmation declined",
input: &shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
ConfirmDeleteLastComment: func(body string) (bool, error) {
if body != "comment body" {
return false, errors.New("unexpected comment body")
}
return true, nil
},
},
comments: api.Comments{Nodes: []api.Comment{
{ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-111", ViewerDidAuthor: true, Body: "comment body"},
}},
wantsErr: true,
stdout: "deletion not confirmed",
},
{
name: "deleting last comment interactively and confirmed with long comment body",
input: &shared.CommentableOptions{
Interactive: true,
DeleteLast: true,
ConfirmDeleteLastComment: func(body string) (bool, error) {
if body != "Lorem ipsum dolor sit amet, consectet lo..." {
return false, errors.New("unexpected comment body")
}
return true, nil
},
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentDelete(t, reg)
},
comments: api.Comments{Nodes: []api.Comment{
{ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-111", ViewerDidAuthor: true, Body: "Lorem ipsum dolor sit amet, consectet lorem ipsum again"},
}},
wantsErr: false,
stdout: "! Deleted comments cannot be recovered.\n",
stderr: "Comment deleted\n",
},
}
for _, tt := range tests {
ios, _, stdout, stderr := iostreams.Test()
@ -458,6 +655,8 @@ func Test_commentRun(t *testing.T) {
if tt.emptyComments {
comments.Nodes = []api.Comment{}
} else if len(tt.comments.Nodes) > 0 {
comments = tt.comments
}
tt.input.RetrieveCommentable = func() (shared.Commentable, ghrepo.Interface, error) {
@ -472,6 +671,7 @@ func Test_commentRun(t *testing.T) {
err := shared.CommentableRun(tt.input)
if tt.wantsErr {
assert.Error(t, err)
assert.Equal(t, tt.stderr, stderr.String())
return
}
assert.NoError(t, err)
@ -508,3 +708,15 @@ func mockCommentUpdate(t *testing.T, reg *httpmock.Registry) {
}),
)
}
func mockCommentDelete(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`mutation CommentDelete\b`),
httpmock.GraphQLMutation(`
{ "data": { "deleteIssueComment": {} } }`,
func(inputs map[string]interface{}) {
assert.Equal(t, "id1", inputs["id"])
},
),
)
}

View file

@ -4,10 +4,12 @@ import (
"errors"
"fmt"
"net/http"
"time"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/browser"
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/text"
@ -24,6 +26,7 @@ type CreateOptions struct {
BaseRepo func() (ghrepo.Interface, error)
Browser browser.Browser
Prompter prShared.Prompt
Detector fd.Detector
TitledEditSurvey func(string, string) (string, string, error)
RootDirOverride string
@ -46,11 +49,12 @@ type CreateOptions struct {
func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command {
opts := &CreateOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
Config: f.Config,
Browser: f.Browser,
Prompter: f.Prompter,
IO: f.IOStreams,
HttpClient: f.HttpClient,
Config: f.Config,
Browser: f.Browser,
Prompter: f.Prompter,
TitledEditSurvey: prShared.TitledEditSurvey(&prShared.UserEditor{Config: f.Config, IO: f.IOStreams}),
}
@ -146,6 +150,15 @@ func createRun(opts *CreateOptions) (err error) {
return
}
// TODO projectsV1Deprecation
// Remove this section as we should no longer need to detect
if opts.Detector == nil {
cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24)
opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost())
}
projectsV1Support := opts.Detector.ProjectsV1()
isTerminal := opts.IO.IsStdoutTTY()
var milestones []string
@ -160,13 +173,13 @@ func createRun(opts *CreateOptions) (err error) {
}
tb := prShared.IssueMetadataState{
Type: prShared.IssueMetadata,
Assignees: assignees,
Labels: opts.Labels,
Projects: opts.Projects,
Milestones: milestones,
Title: opts.Title,
Body: opts.Body,
Type: prShared.IssueMetadata,
Assignees: assignees,
Labels: opts.Labels,
ProjectTitles: opts.Projects,
Milestones: milestones,
Title: opts.Title,
Body: opts.Body,
}
if opts.RecoverFile != "" {
@ -182,7 +195,7 @@ func createRun(opts *CreateOptions) (err error) {
if opts.WebMode {
var openURL string
if opts.Title != "" || opts.Body != "" || tb.HasMetadata() {
openURL, err = generatePreviewURL(apiClient, baseRepo, tb)
openURL, err = generatePreviewURL(apiClient, baseRepo, tb, projectsV1Support)
if err != nil {
return
}
@ -260,7 +273,7 @@ func createRun(opts *CreateOptions) (err error) {
}
}
openURL, err = generatePreviewURL(apiClient, baseRepo, tb)
openURL, err = generatePreviewURL(apiClient, baseRepo, tb, projectsV1Support)
if err != nil {
return
}
@ -279,7 +292,7 @@ func createRun(opts *CreateOptions) (err error) {
Repo: baseRepo,
State: &tb,
}
err = prShared.MetadataSurvey(opts.Prompter, opts.IO, baseRepo, fetcher, &tb)
err = prShared.MetadataSurvey(opts.Prompter, opts.IO, baseRepo, fetcher, &tb, projectsV1Support)
if err != nil {
return
}
@ -335,7 +348,7 @@ func createRun(opts *CreateOptions) (err error) {
params["issueTemplate"] = templateNameForSubmit
}
err = prShared.AddMetadataToIssueParams(apiClient, baseRepo, params, &tb)
err = prShared.AddMetadataToIssueParams(apiClient, baseRepo, params, &tb, projectsV1Support)
if err != nil {
return
}
@ -354,7 +367,7 @@ func createRun(opts *CreateOptions) (err error) {
return
}
func generatePreviewURL(apiClient *api.Client, baseRepo ghrepo.Interface, tb prShared.IssueMetadataState) (string, error) {
func generatePreviewURL(apiClient *api.Client, baseRepo ghrepo.Interface, tb prShared.IssueMetadataState, projectsV1Support gh.ProjectsV1Support) (string, error) {
openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new")
return prShared.WithPrAndIssueQueryParams(apiClient, baseRepo, openURL, tb)
return prShared.WithPrAndIssueQueryParams(apiClient, baseRepo, openURL, tb, projectsV1Support)
}

View file

@ -14,6 +14,7 @@ import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/browser"
"github.com/cli/cli/v2/internal/config"
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"
@ -473,6 +474,7 @@ func Test_createRun(t *testing.T) {
opts.BaseRepo = func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
}
opts.Detector = &fd.EnabledDetectorMock{}
browser := &browser.Stub{}
opts.Browser = browser
@ -521,6 +523,7 @@ func runCommandWithRootDirOverridden(rt http.RoundTripper, isTTY bool, cli strin
cmd := NewCmdCreate(factory, func(opts *CreateOptions) error {
opts.RootDirOverride = rootDir
opts.Detector = &fd.EnabledDetectorMock{}
return createRun(opts)
})
@ -1026,3 +1029,146 @@ func TestIssueCreate_projectsV2(t *testing.T) {
assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String())
}
// TODO projectsV1Deprecation
// Remove this test.
func TestProjectsV1Deprecation(t *testing.T) {
t.Run("non-interactive submission", func(t *testing.T) {
t.Run("when projects v1 is supported, queries for it", func(t *testing.T) {
ios, _, _, _ := iostreams.Test()
reg := &httpmock.Registry{}
reg.StubRepoInfoResponse("OWNER", "REPO", "main")
reg.Register(
// ( is required to avoid matching projectsV2
httpmock.GraphQL(`projects\(`),
// Simulate a GraphQL error to early exit the test.
httpmock.StatusStringResponse(500, ""),
)
_, cmdTeardown := run.Stub()
defer cmdTeardown(t)
// Ignore the error because we have no way to really stub it without
// fully stubbing a GQL error structure in the request body.
_ = createRun(&CreateOptions{
IO: ios,
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
},
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
},
Detector: &fd.EnabledDetectorMock{},
Title: "Test Title",
Body: "Test Body",
// Required to force a lookup of projects
Projects: []string{"Project"},
})
// Verify that our request contained projects
reg.Verify(t)
})
t.Run("when projects v1 is not supported, does not query for it", func(t *testing.T) {
ios, _, _, _ := iostreams.Test()
reg := &httpmock.Registry{}
reg.StubRepoInfoResponse("OWNER", "REPO", "main")
// ( is required to avoid matching projectsV2
reg.Exclude(t, httpmock.GraphQL(`projects\(`))
_, cmdTeardown := run.Stub()
defer cmdTeardown(t)
// Ignore the error because we're not really interested in it.
_ = createRun(&CreateOptions{
IO: ios,
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
},
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
},
Detector: &fd.DisabledDetectorMock{},
Title: "Test Title",
Body: "Test Body",
// Required to force a lookup of projects
Projects: []string{"Project"},
})
// Verify that our request contained projectCards
reg.Verify(t)
})
})
t.Run("web mode", func(t *testing.T) {
t.Run("when projects v1 is supported, queries for it", func(t *testing.T) {
ios, _, _, _ := iostreams.Test()
reg := &httpmock.Registry{}
reg.Register(
// ( is required to avoid matching projectsV2
httpmock.GraphQL(`projects\(`),
// Simulate a GraphQL error to early exit the test.
httpmock.StatusStringResponse(500, ""),
)
_, cmdTeardown := run.Stub()
defer cmdTeardown(t)
// Ignore the error because we have no way to really stub it without
// fully stubbing a GQL error structure in the request body.
_ = createRun(&CreateOptions{
IO: ios,
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
},
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
},
Detector: &fd.EnabledDetectorMock{},
WebMode: true,
// Required to force a lookup of projects
Projects: []string{"Project"},
})
// Verify that our request contained projects
reg.Verify(t)
})
t.Run("when projects v1 is not supported, does not query for it", func(t *testing.T) {
ios, _, _, _ := iostreams.Test()
reg := &httpmock.Registry{}
// ( is required to avoid matching projectsV2
reg.Exclude(t, httpmock.GraphQL(`projects\(`))
_, cmdTeardown := run.Stub()
defer cmdTeardown(t)
// Ignore the error because we're not really interested in it.
_ = createRun(&CreateOptions{
IO: ios,
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
},
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
},
Detector: &fd.DisabledDetectorMock{},
WebMode: true,
// Required to force a lookup of projects
Projects: []string{"Project"},
})
// Verify that our request contained projectCards
reg.Verify(t)
})
})
}

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