diff --git a/README.md b/README.md index fbd99a5a8..cefe1abb0 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ There are two common ways to verify a downloaded release, depending if `gh` is a $ cosign verify-blob-attestation --bundle cli-cli-attestation-3120304.sigstore.json \ --new-bundle-format \ --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \ - --certificate-identity-regexp="^https://github.com/cli/cli/.github/workflows/deployment.yml@refs/heads/trunk$" \ + --certificate-identity="https://github.com/cli/cli/.github/workflows/deployment.yml@refs/heads/trunk" \ gh_2.62.0_macOS_arm64.zip Verified OK ``` diff --git a/acceptance/testdata/pr/pr-checkout-with-url-from-fork.txtar b/acceptance/testdata/pr/pr-checkout-with-url-from-fork.txtar index 9a0494f4b..637422a5a 100644 --- a/acceptance/testdata/pr/pr-checkout-with-url-from-fork.txtar +++ b/acceptance/testdata/pr/pr-checkout-with-url-from-fork.txtar @@ -12,6 +12,7 @@ defer gh repo delete --yes ${ORG}/${REPO} # Create a fork exec gh repo fork ${ORG}/${REPO} --org ${ORG} --fork-name ${REPO}-fork +sleep 5 # Defer fork cleanup defer gh repo delete --yes ${ORG}/${REPO}-fork diff --git a/acceptance/testdata/pr/pr-create-guesses-remote-from-sha.txtar b/acceptance/testdata/pr/pr-create-guesses-remote-from-sha.txtar new file mode 100644 index 000000000..52579b501 --- /dev/null +++ b/acceptance/testdata/pr/pr-create-guesses-remote-from-sha.txtar @@ -0,0 +1,46 @@ +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' +exec git push upstream feature-branch + +# Prepare an additional commit +exec git commit --allow-empty -m 'Fork 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 + +# 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} diff --git a/acceptance/testdata/pr/pr-create-no-local-repo.txtar b/acceptance/testdata/pr/pr-create-no-local-repo.txtar new file mode 100644 index 000000000..cb42d99f8 --- /dev/null +++ b/acceptance/testdata/pr/pr-create-no-local-repo.txtar @@ -0,0 +1,27 @@ +# Use gh as a credential helper +exec gh auth setup-git + +# Setup environment variables used for testscript +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} + +# 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} + +# Clone the repo +exec gh repo clone ${ORG}/${REPO} + +# Prepare a branch to PR +cd ${REPO} +exec git checkout -b feature-branch +exec git commit --allow-empty -m 'Empty Commit' +exec git push -u origin feature-branch + +# Leave the repo so there's no local repo +cd ${WORK} + +# Create the PR +exec gh pr create --title 'Feature Title' --body 'Feature Body' --repo ${ORG}/${REPO} --head feature-branch +stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 \ No newline at end of file diff --git a/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar b/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar new file mode 100644 index 000000000..e0d0c099c --- /dev/null +++ b/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar @@ -0,0 +1,49 @@ +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 branch --set-upstream-to upstream/main +exec git config branch.feature-branch.pushRemote origin +exec git config unset remote.upstream.gh-resolved +exec git commit --allow-empty -m 'Empty Commit' +exec git push + +# 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} diff --git a/acceptance/testdata/pr/pr-create-respects-push-destination.txtar b/acceptance/testdata/pr/pr-create-respects-push-destination.txtar new file mode 100644 index 000000000..51708405d --- /dev/null +++ b/acceptance/testdata/pr/pr-create-respects-push-destination.txtar @@ -0,0 +1,53 @@ +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} + +# Configure default push behavior so local and remote branches will be the same +exec git config push.default current + +# Prepare a branch where changes are pulled from the default branch instead of remote branch of same name +exec git checkout -b feature-branch +exec git branch --set-upstream-to origin/main +exec git rev-parse --abbrev-ref feature-branch@{upstream} +stdout origin/main +exec git config unset remote.upstream.gh-resolved +exec git commit --allow-empty -m 'Empty Commit' +exec git push + +# 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} diff --git a/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar b/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar new file mode 100644 index 000000000..ff92f1e2d --- /dev/null +++ b/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar @@ -0,0 +1,49 @@ +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 branch --set-upstream-to upstream/main +exec git config remote.pushDefault origin +exec git config unset remote.upstream.gh-resolved +exec git commit --allow-empty -m 'Empty Commit' +exec git push + +# 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} diff --git a/acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar b/acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar new file mode 100644 index 000000000..63d3ae2b4 --- /dev/null +++ b/acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar @@ -0,0 +1,34 @@ +# Setup environment variables used for testscript +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} + +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create ${ORG}/${REPO} --add-readme --private + +# Defer repo cleanup of repo +defer gh repo delete --yes ${ORG}/${REPO} +exec gh repo view ${ORG}/${REPO} --json id --jq '.id' +stdout2env REPO_ID + +# Clone the repo +exec gh repo clone ${ORG}/${REPO} +cd ${REPO} + +# Configure default push behavior so local and remote branches have to be the same +exec git config push.default simple + +# Prepare a branch where changes are pulled from the default branch instead of remote branch of same name +exec git checkout -b feature-branch +exec git branch --set-upstream-to origin/main +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":"${REPO_ID}","name":"${REPO}"},"isCrossRepository":false} diff --git a/acceptance/testdata/pr/pr-create-respects-user-colon-branch-syntax.txtar b/acceptance/testdata/pr/pr-create-respects-user-colon-branch-syntax.txtar new file mode 100644 index 000000000..a59171d58 --- /dev/null +++ b/acceptance/testdata/pr/pr-create-respects-user-colon-branch-syntax.txtar @@ -0,0 +1,47 @@ +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 fork +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 branch --set-upstream-to upstream/main +exec git commit --allow-empty -m 'Empty Commit' +exec git push origin feature-branch + +# Create the PR spanning upstream and fork repositories +exec gh pr create --title 'Feature Title' --body 'Feature Body' --head ${USER}:feature-branch +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 ${USER}:feature-branch --json headRefName,headRepository,baseRefName,isCrossRepository +stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} diff --git a/acceptance/testdata/pr/pr-create-without-upstream-config.txtar b/acceptance/testdata/pr/pr-create-without-upstream-config.txtar index 00f3535a7..e5a40af72 100644 --- a/acceptance/testdata/pr/pr-create-without-upstream-config.txtar +++ b/acceptance/testdata/pr/pr-create-without-upstream-config.txtar @@ -1,20 +1,22 @@ # This test is the same as pr-create-basic, except that the git push doesn't include the -u argument # This causes a git config read to fail during gh pr create, but it should not be fatal +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} + # Use gh as a credential helper exec gh auth setup-git # Create a repository with a file so it has a default branch -exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private +exec gh repo create ${ORG}/${REPO} --add-readme --private # Defer repo cleanup -defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING +defer gh repo delete --yes ${ORG}/${REPO} # Clone the repo -exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING +exec gh repo clone ${ORG}/${REPO} # Prepare a branch to PR -cd $SCRIPT_NAME-$RANDOM_STRING +cd ${REPO} exec git checkout -b feature-branch exec git commit --allow-empty -m 'Empty Commit' exec git push origin feature-branch diff --git a/acceptance/testdata/pr/pr-status-respects-cross-org.txtar b/acceptance/testdata/pr/pr-status-respects-cross-org.txtar new file mode 100644 index 000000000..4505be923 --- /dev/null +++ b/acceptance/testdata/pr/pr-status-respects-cross-org.txtar @@ -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 status +! stdout 'There is no pull request associated with' diff --git a/acceptance/testdata/pr/pr-view-same-org-fork.txtar b/acceptance/testdata/pr/pr-view-same-org-fork.txtar index ca58918a9..eed524dec 100644 --- a/acceptance/testdata/pr/pr-view-same-org-fork.txtar +++ b/acceptance/testdata/pr/pr-view-same-org-fork.txtar @@ -15,10 +15,11 @@ stdout2env REPO_ID # Create a fork in the same org exec gh repo fork ${ORG}/${REPO} --org ${ORG} --fork-name ${FORK} +sleep 5 # Defer repo cleanup of fork defer gh repo delete --yes ${ORG}/${FORK} -sleep 1 + exec gh repo view ${ORG}/${FORK} --json id --jq '.id' stdout2env FORK_ID diff --git a/acceptance/testdata/pr/pr-view-status-respects-branch-pushremote.txtar b/acceptance/testdata/pr/pr-view-status-respects-branch-pushremote.txtar index ef80cd8ba..4e1e5e64a 100644 --- a/acceptance/testdata/pr/pr-view-status-respects-branch-pushremote.txtar +++ b/acceptance/testdata/pr/pr-view-status-respects-branch-pushremote.txtar @@ -15,10 +15,11 @@ stdout2env REPO_ID # Create a user fork of repository as opposed to private organization fork exec gh repo fork ${ORG}/${REPO} --org ${ORG} --fork-name ${FORK} +sleep 5 # Defer repo cleanup of fork defer gh repo delete --yes ${ORG}/${FORK} -sleep 5 + exec gh repo view ${ORG}/${FORK} --json id --jq '.id' stdout2env FORK_ID @@ -27,7 +28,8 @@ exec gh repo clone ${ORG}/${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 upstream/main +exec git checkout -b feature-branch +exec git branch --set-upstream-to upstream/main exec git config branch.feature-branch.pushRemote origin exec git commit --allow-empty -m 'Empty Commit' exec git push diff --git a/acceptance/testdata/pr/pr-view-status-respects-remote-pushdefault.txtar b/acceptance/testdata/pr/pr-view-status-respects-remote-pushdefault.txtar index 8bfac2837..6c0743a6f 100644 --- a/acceptance/testdata/pr/pr-view-status-respects-remote-pushdefault.txtar +++ b/acceptance/testdata/pr/pr-view-status-respects-remote-pushdefault.txtar @@ -15,10 +15,11 @@ stdout2env REPO_ID # Create a user fork of repository as opposed to private organization fork exec gh repo fork ${ORG}/${REPO} --org ${ORG} --fork-name ${FORK} +sleep 5 # Defer repo cleanup of fork defer gh repo delete --yes ${ORG}/${FORK} -sleep 5 + exec gh repo view ${ORG}/${FORK} --json id --jq '.id' stdout2env FORK_ID @@ -27,7 +28,8 @@ exec gh repo clone ${ORG}/${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 upstream/main +exec git checkout -b feature-branch +exec git branch --set-upstream-to upstream/main exec git config remote.pushDefault origin exec git commit --allow-empty -m 'Empty Commit' exec git push diff --git a/acceptance/testdata/pr/pr-view-status-respects-simple-pushdefault.txtar b/acceptance/testdata/pr/pr-view-status-respects-simple-pushdefault.txtar index 114f401ec..b9621ea72 100644 --- a/acceptance/testdata/pr/pr-view-status-respects-simple-pushdefault.txtar +++ b/acceptance/testdata/pr/pr-view-status-respects-simple-pushdefault.txtar @@ -18,7 +18,8 @@ cd ${REPO} exec git config push.default simple # Prepare a branch where changes are pulled from the default branch instead of remote branch of same name -exec git checkout -b feature-branch origin/main +exec git checkout -b feature-branch +exec git branch --set-upstream-to origin/main # Create the PR exec git commit --allow-empty -m 'Empty Commit' diff --git a/acceptance/testdata/repo/repo-fork-sync.txtar b/acceptance/testdata/repo/repo-fork-sync.txtar index 6ed7b94e1..04c4c5845 100644 --- a/acceptance/testdata/repo/repo-fork-sync.txtar +++ b/acceptance/testdata/repo/repo-fork-sync.txtar @@ -9,13 +9,11 @@ defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING # Fork and clone the repo exec gh repo fork $ORG/$SCRIPT_NAME-$RANDOM_STRING --org $ORG --fork-name $SCRIPT_NAME-$RANDOM_STRING-fork --clone +sleep 5 # Defer fork cleanup defer gh repo delete $ORG/$SCRIPT_NAME-$RANDOM_STRING-fork --yes -# Sleep so that the BE has time to sync -sleep 5 - # Check that the repo was forked exec gh repo view $ORG/$SCRIPT_NAME-$RANDOM_STRING-fork --json='isFork' --jq='.isFork' stdout 'true' diff --git a/acceptance/testdata/secret/secret-require-remote-disambiguation.txtar b/acceptance/testdata/secret/secret-require-remote-disambiguation.txtar index 02dec06a0..f3fa4a47a 100644 --- a/acceptance/testdata/secret/secret-require-remote-disambiguation.txtar +++ b/acceptance/testdata/secret/secret-require-remote-disambiguation.txtar @@ -12,13 +12,11 @@ defer gh repo delete --yes ${ORG}/${REPO} # Create a fork exec gh repo fork ${ORG}/${REPO} --org ${ORG} --fork-name ${REPO}-fork +sleep 5 # Defer fork cleanup defer gh repo delete --yes ${ORG}/${REPO}-fork -# Sleep to allow the fork to be created before cloning -sleep 2 - # Clone and move into the fork repo exec gh repo clone ${ORG}/${REPO}-fork cd ${REPO}-fork diff --git a/git/client.go b/git/client.go index 11a2e2e20..fe2819cf0 100644 --- a/git/client.go +++ b/git/client.go @@ -381,7 +381,6 @@ func (c *Client) lookupCommit(ctx context.Context, sha, format string) ([]byte, // Downstream consumers of ReadBranchConfig should consider the behavior they desire if this errors, // as an empty config is not necessarily breaking. func (c *Client) ReadBranchConfig(ctx context.Context, branch string) (BranchConfig, error) { - prefix := regexp.QuoteMeta(fmt.Sprintf("branch.%s.", branch)) args := []string{"config", "--get-regexp", fmt.Sprintf("^%s(remote|merge|pushremote|%s)$", prefix, MergeBaseConfig)} cmd, err := c.Command(ctx, args...) @@ -441,18 +440,50 @@ func (c *Client) SetBranchConfig(ctx context.Context, branch, name, value string return err } +// PushDefault defines the action git push should take if no refspec is given. +// See: https://git-scm.com/docs/git-config#Documentation/git-config.txt-pushdefault +type PushDefault string + +const ( + PushDefaultNothing PushDefault = "nothing" + PushDefaultCurrent PushDefault = "current" + PushDefaultUpstream PushDefault = "upstream" + PushDefaultTracking PushDefault = "tracking" + PushDefaultSimple PushDefault = "simple" + PushDefaultMatching PushDefault = "matching" +) + +func ParsePushDefault(s string) (PushDefault, error) { + validPushDefaults := map[string]struct{}{ + string(PushDefaultNothing): {}, + string(PushDefaultCurrent): {}, + string(PushDefaultUpstream): {}, + string(PushDefaultTracking): {}, + string(PushDefaultSimple): {}, + string(PushDefaultMatching): {}, + } + + if _, ok := validPushDefaults[s]; ok { + return PushDefault(s), nil + } + + return "", fmt.Errorf("unknown push.default value: %s", s) +} + // PushDefault returns the value of push.default in the config. If the value // is not set, it returns "simple" (the default git value). See // https://git-scm.com/docs/git-config#Documentation/git-config.txt-pushdefault -func (c *Client) PushDefault(ctx context.Context) (string, error) { +func (c *Client) PushDefault(ctx context.Context) (PushDefault, error) { pushDefault, err := c.Config(ctx, "push.default") if err == nil { - return pushDefault, nil + return ParsePushDefault(pushDefault) } + // If there is an error that the config key is not set, return the default value + // that git uses since 2.0. var gitError *GitError if ok := errors.As(err, &gitError); ok && gitError.ExitCode == 1 { - return "simple", nil + return PushDefaultSimple, nil } return "", err } @@ -473,13 +504,48 @@ func (c *Client) RemotePushDefault(ctx context.Context) (string, error) { return "", err } -// ParsePushRevision gets the value of the @{push} revision syntax +// RemoteTrackingRef is the structured form of the string "refs/remotes//". +// For example, the @{push} revision syntax could report "refs/remotes/origin/main" which would +// be parsed into RemoteTrackingRef{Remote: "origin", Branch: "main"}. +type RemoteTrackingRef struct { + Remote string + Branch string +} + +func (r RemoteTrackingRef) String() string { + return fmt.Sprintf("refs/remotes/%s/%s", r.Remote, r.Branch) +} + +// ParseRemoteTrackingRef parses a string of the form "refs/remotes//" into +// a RemoteTrackingBranch struct. If the string does not match this format, an error is returned. +func ParseRemoteTrackingRef(s string) (RemoteTrackingRef, error) { + parts := strings.Split(s, "/") + if len(parts) != 4 || parts[0] != "refs" || parts[1] != "remotes" { + return RemoteTrackingRef{}, fmt.Errorf("remote tracking branch must have format refs/remotes// but was: %s", s) + } + + return RemoteTrackingRef{ + Remote: parts[2], + Branch: parts[3], + }, nil +} + +// PushRevision gets the value of the @{push} revision syntax // An error here doesn't necessarily mean something is broken, but may mean that the @{push} // revision syntax couldn't be resolved, such as in non-centralized workflows with // push.default = simple. Downstream consumers should consider how to handle this error. -func (c *Client) ParsePushRevision(ctx context.Context, branch string) (string, error) { - revParseOut, err := c.revParse(ctx, "--abbrev-ref", branch+"@{push}") - return firstLine(revParseOut), err +func (c *Client) PushRevision(ctx context.Context, branch string) (RemoteTrackingRef, error) { + revParseOut, err := c.revParse(ctx, "--symbolic-full-name", branch+"@{push}") + if err != nil { + return RemoteTrackingRef{}, err + } + + ref, err := ParseRemoteTrackingRef(firstLine(revParseOut)) + if err != nil { + return RemoteTrackingRef{}, fmt.Errorf("could not parse push revision: %v", err) + } + + return ref, nil } func (c *Client) DeleteLocalTag(ctx context.Context, tag string) error { diff --git a/git/client_test.go b/git/client_test.go index 9fa076199..3d7560228 100644 --- a/git/client_test.go +++ b/git/client_test.go @@ -952,7 +952,7 @@ func TestClientPushDefault(t *testing.T) { tests := []struct { name string commandResult commandResult - wantPushDefault string + wantPushDefault PushDefault wantError *GitError }{ { @@ -961,7 +961,7 @@ func TestClientPushDefault(t *testing.T) { ExitStatus: 1, Stderr: "error: key does not contain a section: remote.pushDefault", }, - wantPushDefault: "simple", + wantPushDefault: PushDefaultSimple, wantError: nil, }, { @@ -970,7 +970,7 @@ func TestClientPushDefault(t *testing.T) { ExitStatus: 0, Stdout: "current", }, - wantPushDefault: "current", + wantPushDefault: PushDefaultCurrent, wantError: nil, }, { @@ -1077,17 +1077,17 @@ func TestClientParsePushRevision(t *testing.T) { name string branch string commandResult commandResult - wantParsedPushRevision string - wantError *GitError + wantParsedPushRevision RemoteTrackingRef + wantError error }{ { - name: "@{push} resolves to origin/branchName", + name: "@{push} resolves to refs/remotes/origin/branchName", branch: "branchName", commandResult: commandResult{ ExitStatus: 0, - Stdout: "origin/branchName", + Stdout: "refs/remotes/origin/branchName", }, - wantParsedPushRevision: "origin/branchName", + wantParsedPushRevision: RemoteTrackingRef{Remote: "origin", Branch: "branchName"}, }, { name: "@{push} doesn't resolve", @@ -1095,16 +1095,25 @@ func TestClientParsePushRevision(t *testing.T) { ExitStatus: 128, Stderr: "fatal: git error", }, - wantParsedPushRevision: "", + wantParsedPushRevision: RemoteTrackingRef{}, wantError: &GitError{ ExitCode: 128, Stderr: "fatal: git error", }, }, + { + name: "@{push} resolves to something surprising", + commandResult: commandResult{ + ExitStatus: 0, + Stdout: "not/a/valid/remote/ref", + }, + wantParsedPushRevision: RemoteTrackingRef{}, + wantError: fmt.Errorf("could not parse push revision: remote tracking branch must have format refs/remotes// but was: not/a/valid/remote/ref"), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cmd := fmt.Sprintf("path/to/git rev-parse --abbrev-ref %s@{push}", tt.branch) + cmd := fmt.Sprintf("path/to/git rev-parse --symbolic-full-name %s@{push}", tt.branch) cmdCtx := createMockedCommandContext(t, mockedCommands{ args(cmd): tt.commandResult, }) @@ -1112,20 +1121,91 @@ func TestClientParsePushRevision(t *testing.T) { GitPath: "path/to/git", commandContext: cmdCtx, } - pushDefault, err := client.ParsePushRevision(context.Background(), tt.branch) + trackingRef, err := client.PushRevision(context.Background(), tt.branch) if tt.wantError != nil { - var gitError *GitError - require.ErrorAs(t, err, &gitError) - assert.Equal(t, tt.wantError.ExitCode, gitError.ExitCode) - assert.Equal(t, tt.wantError.Stderr, gitError.Stderr) + var wantErrorAsGit *GitError + if errors.As(err, &wantErrorAsGit) { + var gitError *GitError + require.ErrorAs(t, err, &gitError) + assert.Equal(t, wantErrorAsGit.ExitCode, gitError.ExitCode) + assert.Equal(t, wantErrorAsGit.Stderr, gitError.Stderr) + } else { + assert.Equal(t, err, tt.wantError) + } } else { require.NoError(t, err) } - assert.Equal(t, tt.wantParsedPushRevision, pushDefault) + assert.Equal(t, tt.wantParsedPushRevision, trackingRef) }) } } +func TestRemoteTrackingRef(t *testing.T) { + t.Run("parsing", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + remoteTrackingRef string + wantRemoteTrackingRef RemoteTrackingRef + wantError error + }{ + { + name: "valid remote tracking ref", + remoteTrackingRef: "refs/remotes/origin/branchName", + wantRemoteTrackingRef: RemoteTrackingRef{ + Remote: "origin", + Branch: "branchName", + }, + }, + { + name: "incorrect parts", + remoteTrackingRef: "refs/remotes/origin", + wantRemoteTrackingRef: RemoteTrackingRef{}, + wantError: fmt.Errorf("remote tracking branch must have format refs/remotes// but was: refs/remotes/origin"), + }, + { + name: "incorrect prefix type", + remoteTrackingRef: "invalid/remotes/origin/branchName", + wantRemoteTrackingRef: RemoteTrackingRef{}, + wantError: fmt.Errorf("remote tracking branch must have format refs/remotes// but was: invalid/remotes/origin/branchName"), + }, + { + name: "incorrect ref type", + remoteTrackingRef: "refs/invalid/origin/branchName", + wantRemoteTrackingRef: RemoteTrackingRef{}, + wantError: fmt.Errorf("remote tracking branch must have format refs/remotes// but was: refs/invalid/origin/branchName"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + trackingRef, err := ParseRemoteTrackingRef(tt.remoteTrackingRef) + if tt.wantError != nil { + require.Equal(t, tt.wantError, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantRemoteTrackingRef, trackingRef) + }) + } + }) + + t.Run("stringifying", func(t *testing.T) { + t.Parallel() + + remoteTrackingRef := RemoteTrackingRef{ + Remote: "origin", + Branch: "branchName", + } + + require.Equal(t, "refs/remotes/origin/branchName", remoteTrackingRef.String()) + }) +} + func TestClientDeleteLocalTag(t *testing.T) { tests := []struct { name string @@ -1992,6 +2072,41 @@ func TestCredentialPatternFromHost(t *testing.T) { } } +func TestPushDefault(t *testing.T) { + t.Run("it parses valid values correctly", func(t *testing.T) { + t.Parallel() + + tests := []struct { + value string + expectedPushDefault PushDefault + }{ + {"nothing", PushDefaultNothing}, + {"current", PushDefaultCurrent}, + {"upstream", PushDefaultUpstream}, + {"tracking", PushDefaultTracking}, + {"simple", PushDefaultSimple}, + {"matching", PushDefaultMatching}, + } + + for _, test := range tests { + t.Run(test.value, func(t *testing.T) { + t.Parallel() + + pushDefault, err := ParsePushDefault(test.value) + require.NoError(t, err) + assert.Equal(t, test.expectedPushDefault, pushDefault) + }) + } + }) + + t.Run("it returns an error for invalid values", func(t *testing.T) { + t.Parallel() + + _, err := ParsePushDefault("invalid") + require.Error(t, err) + }) +} + func createCommandContext(t *testing.T, exitStatus int, stdout, stderr string) (*exec.Cmd, commandCtx) { cmd := exec.CommandContext(context.Background(), os.Args[0], "-test.run=TestHelperProcess", "--") cmd.Env = []string{ diff --git a/git/command.go b/git/command.go index 8065ffd86..c4614d086 100644 --- a/git/command.go +++ b/git/command.go @@ -43,10 +43,21 @@ func (gc *Command) Output() ([]byte, error) { out, err := run.PrepareCmd(gc.Cmd).Output() if err != nil { ge := GitError{err: err} + + // In real implementation, this should be an exec.ExitError, as below, + // but the tests use a different type because exec.ExitError are difficult + // to create. We want to get the exit code and stderr, but stderr + // is not a method and so tests can't access it. + // THIS MEANS THAT TESTS WILL NOT CORRECTLY HAVE STDERR SET, + // but at least tests can get the exit code. + var exitErrorWithExitCode errWithExitCode + if errors.As(err, &exitErrorWithExitCode) { + ge.ExitCode = exitErrorWithExitCode.ExitCode() + } + var exitError *exec.ExitError if errors.As(err, &exitError) { ge.Stderr = string(exitError.Stderr) - ge.ExitCode = exitError.ExitCode() } err = &ge } diff --git a/go.mod b/go.mod index bea712a2d..8fe69cc84 100644 --- a/go.mod +++ b/go.mod @@ -7,9 +7,11 @@ toolchain go1.23.5 require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/MakeNowJust/heredoc v1.0.0 + 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/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3 + github.com/charmbracelet/huh v0.6.1-0.20250409210615-c5906631cbb5 github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc github.com/cli/go-gh/v2 v2.12.0 github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24 @@ -22,13 +24,14 @@ require ( github.com/gabriel-vasile/mimetype v1.4.8 github.com/gdamore/tcell/v2 v2.5.4 github.com/golang/snappy v0.0.4 - github.com/google/go-cmp v0.6.0 + github.com/google/go-cmp v0.7.0 github.com/google/go-containerregistry v0.20.3 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/joho/godotenv v1.5.1 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 @@ -40,18 +43,18 @@ 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.3.3 - github.com/sigstore/sigstore-go v0.7.0 - github.com/spf13/cobra v1.8.1 + github.com/sigstore/protobuf-specs v0.4.1 + github.com/sigstore/sigstore-go v0.7.2 + github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 github.com/stretchr/testify v1.10.0 github.com/zalando/go-keyring v0.2.5 - golang.org/x/crypto v0.35.0 - golang.org/x/sync v0.12.0 - golang.org/x/term v0.30.0 - golang.org/x/text v0.23.0 - google.golang.org/grpc v1.69.4 - google.golang.org/protobuf v1.36.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.0 + google.golang.org/protobuf v1.36.6 gopkg.in/h2non/gock.v1 v1.1.2 gopkg.in/yaml.v3 v3.0.1 ) @@ -64,12 +67,17 @@ require ( github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/alessio/shellescape v1.4.2 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/blang/semver v3.5.1+incompatible // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.0 // indirect + github.com/charmbracelet/bubbletea v1.3.4 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/cli/browser v1.3.0 // indirect github.com/cli/shurcooL-graphql v0.0.4 // indirect @@ -82,30 +90,32 @@ require ( github.com/docker/cli v27.5.0+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.8.2 // 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 - github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect 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/stdr v1.2.2 // indirect github.com/go-openapi/analysis v0.23.0 // indirect - github.com/go-openapi/errors v0.22.0 // indirect + github.com/go-openapi/errors v0.22.1 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/loads v0.22.0 // indirect github.com/go-openapi/runtime v0.28.0 // indirect github.com/go-openapi/spec v0.21.0 // indirect github.com/go-openapi/strfmt v0.23.0 // indirect - github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/swag v0.23.1 // indirect github.com/go-openapi/validate v0.24.0 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/google/certificate-transparency-go v1.3.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/in-toto/in-toto-golang v0.9.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -116,40 +126,42 @@ require ( github.com/klauspost/compress v1.17.11 // indirect github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/magiconair/properties v1.8.7 // indirect - github.com/mailru/easyjson v0.7.7 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect 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/pelletier/go-toml/v2 v2.2.2 // 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 github.com/rivo/uniseg v0.4.7 // indirect github.com/rodaine/table v1.0.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/sagikazarmark/locafero v0.4.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sassoftware/relic v7.2.1+incompatible // indirect github.com/secure-systems-lab/go-securesystemslib v0.9.0 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect - github.com/sigstore/rekor v1.3.8 // indirect - github.com/sigstore/sigstore v1.8.12 // indirect - github.com/sigstore/timestamp-authority v1.2.4 // 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/sirupsen/logrus v1.9.3 // indirect github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.7.0 // indirect - github.com/spf13/viper v1.19.0 // indirect + github.com/spf13/afero v1.12.0 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/spf13/viper v1.20.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/theupdateframework/go-tuf v0.7.0 // indirect @@ -163,18 +175,17 @@ require ( 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.33.0 // indirect - go.opentelemetry.io/otel/metric v1.33.0 // indirect - go.opentelemetry.io/otel/trace v1.33.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.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.22.0 // indirect - golang.org/x/net v0.36.0 // indirect - golang.org/x/sys v0.31.0 // 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-20241209162323-e6fa225c2576 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d // indirect - gopkg.in/ini.v1 v1.67.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 k8s.io/klog/v2 v2.130.1 // indirect ) diff --git a/go.sum b/go.sum index 2b5a31212..63a1cdd77 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,17 @@ -cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= -cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= -cloud.google.com/go/auth v0.13.0 h1:8Fu8TZy167JkW8Tj3q7dIkr2v4cndv41ouecJx0PAHs= -cloud.google.com/go/auth v0.13.0/go.mod h1:COOjD9gwfKNKz+IIduatIhYJQIc0mG3H102r/EMxX6Q= -cloud.google.com/go/auth/oauth2adapt v0.2.6 h1:V6a6XDu2lTwPZWOawrAa9HUK+DB2zfJyTuciBG5hFkU= -cloud.google.com/go/auth/oauth2adapt v0.2.6/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8= -cloud.google.com/go/compute v1.24.0 h1:phWcR2eWzRJaL/kOiJwfFsPs4BaKq1j6vnpZrc1YlVg= +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.2.2 h1:ozUSofHUGf/F4tCNy/mu9tHLTaxZFLOUiKzjcgWHGIA= -cloud.google.com/go/iam v1.2.2/go.mod h1:0Ys8ccaZHdI1dEUilwzqng/6ps2YB6vRsjIe00/+6JY= -cloud.google.com/go/kms v1.20.4 h1:CJ0hMpOg1ANN9tx/a/GPJ+Uxudy8k6f3fvGFuTHiE5A= -cloud.google.com/go/kms v1.20.4/go.mod h1:gPLsp1r4FblUgBYPOcvI/bUPpdMg2Jm1ZVKU4tQUfcc= -cloud.google.com/go/longrunning v0.6.2 h1:xjDfh1pQcWPEvnfjZmwjKQEcHnpz6lHjfy7Fo0MK+hc= -cloud.google.com/go/longrunning v0.6.2/go.mod h1:k/vIs83RN4bE3YCswdXC5PFfWVILjm3hpEUlSko4PiI= +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= 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= @@ -21,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.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0 h1:B/dfvscEQtew9dVuoxqxrUKKv8Ih2f55PydknDamU+g= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0/go.mod h1:fiPSssYvltE08HJchL04dOy+RD4hgrjph0cwGGMntdI= +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/security/keyvault/azkeys v1.3.0 h1:7rKG7UmnrxX4N53TFhkYqjc+kVUZuw0fL8I3Fh+Ld9E= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.0/go.mod h1:Wjo+24QJVhhl/L7jy6w9yzFF2yDOf3cKECAa8ecf9vE= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.0 h1:eXnN9kaS8TiDwXjoie3hMRLuwdUBUMW9KRgOqB3mCaw= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.0/go.mod h1:XIpam8wumeZ5rVMuhdDQLMfIPDf1WO3IzrCRO3e3e3o= -github.com/AzureAD/microsoft-authentication-library-for-go v1.3.1 h1:gUDtaZk8heteyfdmv+pcfHvhR9llnh7c7GMwZ8RVG04= -github.com/AzureAD/microsoft-authentication-library-for-go v1.3.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +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/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= @@ -53,36 +52,38 @@ github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4u github.com/alessio/shellescape v1.4.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= -github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU= -github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= -github.com/aws/aws-sdk-go-v2 v1.32.8 h1:cZV+NUS/eGxKXMtmyhtYPJ7Z4YLoI/V8bkTdRZfYhGo= -github.com/aws/aws-sdk-go-v2 v1.32.8/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U= -github.com/aws/aws-sdk-go-v2/config v1.28.10 h1:fKODZHfqQu06pCzR69KJ3GuttraRJkhlC8g80RZ0Dfg= -github.com/aws/aws-sdk-go-v2/config v1.28.10/go.mod h1:PvdxRYZ5Um9QMq9PQ0zHHNdtKK+he2NHtFCUFMXWXeg= -github.com/aws/aws-sdk-go-v2/credentials v1.17.51 h1:F/9Sm6Y6k4LqDesZDPJCLxQGXNNHd/ZtJiWd0lCZKRk= -github.com/aws/aws-sdk-go-v2/credentials v1.17.51/go.mod h1:TKbzCHm43AoPyA+iLGGcruXd4AFhF8tOmLex2R9jWNQ= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.23 h1:IBAoD/1d8A8/1aA8g4MBVtTRHhXRiNAgwdbo/xRM2DI= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.23/go.mod h1:vfENuCM7dofkgKpYzuzf1VT1UKkA/YL3qanfBn7HCaA= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.27 h1:jSJjSBzw8VDIbWv+mmvBSP8ezsztMYJGH+eKqi9AmNs= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.27/go.mod h1:/DAhLbFRgwhmvJdOfSm+WwikZrCuUJiA4WgJG0fTNSw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.27 h1:l+X4K77Dui85pIj5foXDhPlnqcNRG2QUyvca300lXh8= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.27/go.mod h1:KvZXSFEXm6x84yE8qffKvT3x8J5clWnVFXphpohhzJ8= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.8 h1:cWno7lefSH6Pp+mSznagKCgfDGeZRin66UvYUqAkyeA= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.8/go.mod h1:tPD+VjU3ABTBoEJ3nctu5Nyg4P4yjqSH5bJGGkY4+XE= -github.com/aws/aws-sdk-go-v2/service/kms v1.37.8 h1:KbLZjYqhQ9hyB4HwXiheiflTlYQa0+Fz0Ms/rh5f3mk= -github.com/aws/aws-sdk-go-v2/service/kms v1.37.8/go.mod h1:ANs9kBhK4Ghj9z1W+bsr3WsNaPF71qkgd6eE6Ekol/Y= -github.com/aws/aws-sdk-go-v2/service/sso v1.24.9 h1:YqtxripbjWb2QLyzRK9pByfEDvgg95gpC2AyDq4hFE8= -github.com/aws/aws-sdk-go-v2/service/sso v1.24.9/go.mod h1:lV8iQpg6OLOfBnqbGMBKYjilBlf633qwHnBEiMSPoHY= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.8 h1:6dBT1Lz8fK11m22R+AqfRsFn8320K0T5DTGxxOQBSMw= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.8/go.mod h1:/kiBvRQXBc6xeJTYzhSdGvJ5vm1tjaDEjH+MSeRJnlY= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.6 h1:VwhTrsTuVn52an4mXx29PqRzs2Dvu921NpGk7y43tAM= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.6/go.mod h1:+8h7PZb3yY5ftmVLD7ocEoE98hdc8PoKS0H3wfx1dlc= -github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro= -github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +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/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= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= +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/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= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= @@ -95,22 +96,32 @@ github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdn github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/briandowns/spinner v1.18.1 h1:yhQmQtM1zsqFsouh09Bk/jCjd50pC3EOGsh28gLVvwY= github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU= +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/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= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= +github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 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/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/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +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/cli/browser v1.0.0/go.mod h1:IEWkHYbLjkhtjwwWlwTHW2lGxeS5gezEQBMLTwDHf5Q= @@ -131,7 +142,6 @@ 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.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 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/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= @@ -160,6 +170,10 @@ github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBi 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/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= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= @@ -167,8 +181,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +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/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= @@ -177,8 +191,6 @@ github.com/gdamore/tcell/v2 v2.5.4 h1:TGU4tSjD3sCL788vFNeJnTdzpNKIw1H5dgLnJRQVv/ github.com/gdamore/tcell/v2 v2.5.4/go.mod h1:dZgRy5v4iMobMEcWNYBtREnDZAT9DYmfqIkrgEMxLyw= github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec= github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= -github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= -github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= 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= @@ -188,8 +200,8 @@ 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= github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo= -github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w= -github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE= +github.com/go-openapi/errors v0.22.1 h1:kslMRRnK7NCb/CvR1q1VWuEQCEIsBGn5GgKD9e+HYhU= +github.com/go-openapi/errors v0.22.1/go.mod h1:+n/5UdIqdVnLIJ6Q9Se8HNGUXYaY6CN8ImWzfi/Gzp0= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= @@ -202,32 +214,34 @@ github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9Z github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c= github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +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-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= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/certificate-transparency-go v1.3.1 h1:akbcTfQg0iZlANZLn0L9xOeWtyCIdeoYhKrqi5iH3Go= github.com/google/certificate-transparency-go v1.3.1/go.mod h1:gg+UQlx6caKEDQ9EElFOujyxEQEfOiQzAt6782Bvi8k= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/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.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= -github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= +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= @@ -236,8 +250,8 @@ 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= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= -github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= +github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= +github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= @@ -265,13 +279,12 @@ github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0S github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= github.com/hashicorp/go-version v1.3.0 h1:McDWVJIU/y+u1BRV06dPaLfLCaT7fUTJLp5r04x7iNw= github.com/hashicorp/go-version v1.3.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hashicorp/vault/api v1.15.0 h1:O24FYQCWwhwKnF7CuSqP30S51rTV7vz1iACXE/pj5DA= -github.com/hashicorp/vault/api v1.15.0/go.mod h1:+5YTO09JGn0u+b6ySD/LLVf8WkJCPLAL2Vkmrn2+CM8= +github.com/hashicorp/vault/api v1.16.0 h1:nbEYGJiAPGzT9U4oWgaaB0g+Rj8E59QuHKyA5LhwQN4= +github.com/hashicorp/vault/api v1.16.0/go.mod h1:KhuUhzOD8lDSk29AtzNjgAu2kxRA9jL9NAbkFlqvkBA= github.com/henvic/httpretty v0.1.4 h1:Jo7uwIRWVFxkqOnErcoYfH90o3ddQyVrSANeS4cxYmU= github.com/henvic/httpretty v0.1.4/go.mod h1:Dn60sQTZfbt2dYsdUSNsCljyF4AfdqnuJFDLJA1I4AM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= @@ -330,16 +343,16 @@ github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec h1:2tTW6cDth2T github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec/go.mod h1:TmwEoGCwIti7BCeJ9hescZgRtatxRE+A72pCoPfmcfk= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= @@ -356,10 +369,16 @@ github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa1 github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= @@ -378,8 +397,8 @@ github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQ github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= 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.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= -github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -387,12 +406,12 @@ 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.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= -github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +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_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.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= -github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/rivo/tview v0.0.0-20221029100920-c4a7e501810d h1:jKIUJdMcIVGOSHi6LSqJqw9RqblyblE2ZrHvFbWR3S0= @@ -409,10 +428,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= -github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= -github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= -github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= -github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= +github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGqpgjJU3DYAZeI05A= github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk= github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4= @@ -429,51 +446,44 @@ 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.3.3 h1:RMZQgXTD/pF7KW6b5NaRLYxFYZ/wzx44PQFXN2PEo5g= -github.com/sigstore/protobuf-specs v0.3.3/go.mod h1:vIhZ6Uor1a38+wvRrKcqL2PtYNlgoIW9lhzYzkyy4EU= -github.com/sigstore/rekor v1.3.8 h1:B8kJI8mpSIXova4Jxa6vXdJyysRxFGsEsLKBDl0rRjA= -github.com/sigstore/rekor v1.3.8/go.mod h1:/dHFYKSuxEygfDRnEwyJ+ZD6qoVYNXQdi1mJrKvKWsI= -github.com/sigstore/sigstore v1.8.12 h1:S8xMVZbE2z9ZBuQUEG737pxdLjnbOIcFi5v9UFfkJFc= -github.com/sigstore/sigstore v1.8.12/go.mod h1:+PYQAa8rfw0QdPpBcT+Gl3egKD9c+TUgAlF12H3Nmjo= -github.com/sigstore/sigstore-go v0.7.0 h1:bIGPc2IbnbxnzlqQcKlh1o96bxVJ4yRElpP1gHrOH48= -github.com/sigstore/sigstore-go v0.7.0/go.mod h1:4RrCK+i+jhx7lyOG2Vgef0/kFLbKlDI1hrioUYvkxxA= -github.com/sigstore/sigstore/pkg/signature/kms/aws v1.8.12 h1:EC3UmIaa7nV9sCgSpVevmvgvTYTkMqyrRbj5ojPp7tE= -github.com/sigstore/sigstore/pkg/signature/kms/aws v1.8.12/go.mod h1:aw60vs3crnQdM/DYH+yF2P0MVKtItwAX34nuaMrY7Lk= -github.com/sigstore/sigstore/pkg/signature/kms/azure v1.8.12 h1:FPpliDTywSy0woLHMAdmTSZ5IS/lVBZ0dY0I+2HmnSY= -github.com/sigstore/sigstore/pkg/signature/kms/azure v1.8.12/go.mod h1:NkPiz4XA0JcBSXzJUrjMj7Xi7oSTew1Ip3Zmt56mHlw= -github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.8.12 h1:kweBChR6M9FEvmxN3BMEcl7SNnwxTwKF7THYFKLOE5U= -github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.8.12/go.mod h1:6+d+A6oYt1W5OgtzgEVb21V7tAZ/C2Ihtzc5MNJbayY= -github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.8.12 h1:jvY1B9bjP+tKzdKDyuq5K7O19CG2IKzGJNTy5tuL2Gs= -github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.8.12/go.mod h1:2uEeOb8xE2RC6OvzxKux1wkS39Zv8gA27z92m49xUTc= -github.com/sigstore/timestamp-authority v1.2.4 h1:RjXZxOWorEiem/uSr0pFHVtQpyzpcFxgugo5jVqm3mw= -github.com/sigstore/timestamp-authority v1.2.4/go.mod h1:ExrbobKdEuwuBptZIiKp1IaVBRiUeKbiuSyZTO8Okik= +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/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= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= -github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= -github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= +github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= -github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= @@ -484,6 +494,12 @@ github.com/theupdateframework/go-tuf/v2 v2.0.2 h1:PyNnjV9BJNzN1ZE6BcWK+5JbF+if37 github.com/theupdateframework/go-tuf/v2 v2.0.2/go.mod h1:baB22nBHeHBCeuGZcIlctNq4P61PcOdyARlplg5xmLA= 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/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= @@ -504,22 +520,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.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= -go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw= -go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I= -go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ= -go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M= -go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM= -go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM= -go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= -go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= -go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= -go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= -go.step.sm/crypto v0.57.0 h1:YjoRQDaJYAxHLVwjst0Bl0xcnoKzVwuHCJtEo2VSHYU= -go.step.sm/crypto v0.57.0/go.mod h1:+Lwp5gOVPaTa3H/Ul/TzGbxQPXZZcKIUGMS0lG6n9Go= +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/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.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= @@ -528,73 +544,72 @@ 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.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= -golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +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/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= -golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +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/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.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= -golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= -golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= -golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +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/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.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.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= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.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.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= -golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 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.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= -golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +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/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.216.0 h1:xnEHy+xWFrtYInWPy8OdGFsyIfWJjtVnO39g7pz2BFY= -google.golang.org/api v0.216.0/go.mod h1:K9wzQMvWi47Z9IU7OgdOofvZuw75Ge3PPITImZR/UyI= -google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk= -google.golang.org/genproto v0.0.0-20241118233622-e639e219e697/go.mod h1:JJrvXBWRZaFMxBufik1a4RpFw4HhgVtBBWQeQgUj2cc= -google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q= -google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d h1:xJJRGY7TJcvIlpSrN3K6LAWgNFUILlO+OMAqtg9aqnw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d/go.mod h1:3ENsm/5D1mzDyhpzeRi1NR784I0BcofWBoSc5QqqMK4= -google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A= -google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= -google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= -google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +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/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.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= +google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +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= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go index 476154d66..e7534dfdb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,7 @@ import ( const ( aliasesKey = "aliases" browserKey = "browser" + colorLabelsKey = "color_labels" editorKey = "editor" gitProtocolKey = "git_protocol" hostsKey = "hosts" @@ -113,6 +114,11 @@ func (c *cfg) Browser(hostname string) gh.ConfigEntry { return c.GetOrDefault(hostname, browserKey).Unwrap() } +func (c *cfg) ColorLabels(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, colorLabelsKey).Unwrap() +} + func (c *cfg) Editor(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, editorKey).Unwrap() @@ -532,6 +538,8 @@ aliases: http_unix_socket: # What web browser gh should use when opening URLs. If blank, will refer to environment. browser: +# Whether to display labels using their RGB hex color codes in terminals that support truecolor. Supported values: enabled, disabled +color_labels: disabled ` type ConfigOption struct { @@ -602,6 +610,15 @@ var Options = []ConfigOption{ return c.Browser(hostname).Value }, }, + { + Key: colorLabelsKey, + Description: "whether to display labels using their RGB hex color codes in terminals that support truecolor", + DefaultValue: "disabled", + AllowedValues: []string{"enabled", "disabled"}, + CurrentValue: func(c gh.Config, hostname string) string { + return c.ColorLabels(hostname).Value + }, + }, } func HomeDirPath(subdir string) (string, error) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index fef87ddc6..67a9a98d1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -32,6 +32,7 @@ func TestNewConfigProvidesFallback(t *testing.T) { requireKeyWithValue(t, spiedCfg, []string{aliasesKey, "co"}, "pr checkout") requireKeyWithValue(t, spiedCfg, []string{httpUnixSocketKey}, "") requireKeyWithValue(t, spiedCfg, []string{browserKey}, "") + requireKeyWithValue(t, spiedCfg, []string{colorLabelsKey}, "disabled") } func TestGetOrDefaultApplicationDefaults(t *testing.T) { @@ -137,6 +138,7 @@ func TestFallbackConfig(t *testing.T) { requireKeyWithValue(t, cfg, []string{aliasesKey, "co"}, "pr checkout") requireKeyWithValue(t, cfg, []string{httpUnixSocketKey}, "") requireKeyWithValue(t, cfg, []string{browserKey}, "") + requireKeyWithValue(t, cfg, []string{colorLabelsKey}, "disabled") requireNoKey(t, cfg, []string{"unknown"}) } diff --git a/internal/config/stub.go b/internal/config/stub.go index 71d44556d..78073da4a 100644 --- a/internal/config/stub.go +++ b/internal/config/stub.go @@ -55,6 +55,9 @@ func NewFromString(cfgStr string) *ghmock.ConfigMock { mock.BrowserFunc = func(hostname string) gh.ConfigEntry { return cfg.Browser(hostname) } + mock.ColorLabelsFunc = func(hostname string) gh.ConfigEntry { + return cfg.ColorLabels(hostname) + } mock.EditorFunc = func(hostname string) gh.ConfigEntry { return cfg.Editor(hostname) } diff --git a/internal/gh/gh.go b/internal/gh/gh.go index 8e640c41a..b17c6bd67 100644 --- a/internal/gh/gh.go +++ b/internal/gh/gh.go @@ -37,6 +37,8 @@ type Config interface { // Browser returns the configured browser, optionally scoped by host. Browser(hostname string) ConfigEntry + // ColorLabels returns the configured color_label setting, optionally scoped by host. + ColorLabels(hostname string) ConfigEntry // Editor returns the configured editor, optionally scoped by host. Editor(hostname string) ConfigEntry // GitProtocol returns the configured git protocol, optionally scoped by host. diff --git a/internal/gh/mock/config.go b/internal/gh/mock/config.go index 569af1fac..b94cb084d 100644 --- a/internal/gh/mock/config.go +++ b/internal/gh/mock/config.go @@ -31,6 +31,9 @@ var _ gh.Config = &ConfigMock{} // CacheDirFunc: func() string { // panic("mock out the CacheDir method") // }, +// ColorLabelsFunc: func(hostname string) gh.ConfigEntry { +// panic("mock out the ColorLabels method") +// }, // EditorFunc: func(hostname string) gh.ConfigEntry { // panic("mock out the Editor method") // }, @@ -83,6 +86,9 @@ type ConfigMock struct { // CacheDirFunc mocks the CacheDir method. CacheDirFunc func() string + // ColorLabelsFunc mocks the ColorLabels method. + ColorLabelsFunc func(hostname string) gh.ConfigEntry + // EditorFunc mocks the Editor method. EditorFunc func(hostname string) gh.ConfigEntry @@ -132,6 +138,11 @@ type ConfigMock struct { // CacheDir holds details about calls to the CacheDir method. CacheDir []struct { } + // ColorLabels holds details about calls to the ColorLabels method. + ColorLabels []struct { + // Hostname is the hostname argument value. + Hostname string + } // Editor holds details about calls to the Editor method. Editor []struct { // Hostname is the hostname argument value. @@ -194,6 +205,7 @@ type ConfigMock struct { lockAuthentication sync.RWMutex lockBrowser sync.RWMutex lockCacheDir sync.RWMutex + lockColorLabels sync.RWMutex lockEditor sync.RWMutex lockGetOrDefault sync.RWMutex lockGitProtocol sync.RWMutex @@ -320,6 +332,38 @@ func (mock *ConfigMock) CacheDirCalls() []struct { return calls } +// ColorLabels calls ColorLabelsFunc. +func (mock *ConfigMock) ColorLabels(hostname string) gh.ConfigEntry { + if mock.ColorLabelsFunc == nil { + panic("ConfigMock.ColorLabelsFunc: method is nil but Config.ColorLabels was just called") + } + callInfo := struct { + Hostname string + }{ + Hostname: hostname, + } + mock.lockColorLabels.Lock() + mock.calls.ColorLabels = append(mock.calls.ColorLabels, callInfo) + mock.lockColorLabels.Unlock() + return mock.ColorLabelsFunc(hostname) +} + +// ColorLabelsCalls gets all the calls that were made to ColorLabels. +// Check the length with: +// +// len(mockedConfig.ColorLabelsCalls()) +func (mock *ConfigMock) ColorLabelsCalls() []struct { + Hostname string +} { + var calls []struct { + Hostname string + } + mock.lockColorLabels.RLock() + calls = mock.calls.ColorLabels + mock.lockColorLabels.RUnlock() + return calls +} + // Editor calls EditorFunc. func (mock *ConfigMock) Editor(hostname string) gh.ConfigEntry { if mock.EditorFunc == nil { diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go new file mode 100644 index 000000000..56096972d --- /dev/null +++ b/internal/prompter/accessible_prompter_test.go @@ -0,0 +1,493 @@ +//go:build !windows + +package prompter_test + +import ( + "fmt" + "io" + "strings" + "testing" + "time" + + "github.com/Netflix/go-expect" + "github.com/cli/cli/v2/internal/prompter" + "github.com/creack/pty" + "github.com/hinshun/vt10x" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The following tests are broadly testing the accessible prompter, and NOT asserting +// on the prompter's complete and exact output strings. +// +// These tests generally operate with this logic: +// - Wait for a particular substring (a portion of the prompt) to appear +// - Send input +// - Wait for another substring to appear or for control to return to the test +// - Assert that the input value was returned from the prompter function + +// In the future, expanding these tests to assert on the exact prompt strings +// would help build confidence in `huh` upgrades, but for now these tests +// 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) { + t.Run("Select", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) + + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Choose:") + require.NoError(t, err) + + // Select option 1 + _, err = console.SendLine("1") + require.NoError(t, err) + }() + + selectValue, err := p.Select("Select a number", "", []string{"1", "2", "3"}) + require.NoError(t, err) + assert.Equal(t, 0, selectValue) + }) + + t.Run("MultiSelect", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) + + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Select a number") + require.NoError(t, err) + + // Select options 1 and 2 + _, err = console.SendLine("1") + require.NoError(t, err) + _, err = console.SendLine("2") + require.NoError(t, err) + + // This confirms selections + _, err = console.SendLine("0") + require.NoError(t, err) + }() + + multiSelectValue, err := p.MultiSelect("Select a number", []string{}, []string{"1", "2", "3"}) + require.NoError(t, err) + assert.Equal(t, []int{0, 1}, multiSelectValue) + }) + + t.Run("Input", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) + dummyText := "12345abcdefg" + + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Enter some characters") + require.NoError(t, err) + + // Enter a number + _, err = console.SendLine(dummyText) + require.NoError(t, err) + }() + + inputValue, err := p.Input("Enter some characters", "") + require.NoError(t, err) + assert.Equal(t, dummyText, inputValue) + }) + + t.Run("Input - blank input returns default value", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) + dummyDefaultValue := "12345abcdefg" + + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Enter some characters") + 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) + dummyPassword := "12345abcdefg" + + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Enter password") + require.NoError(t, err) + + // Enter a number + _, err = console.SendLine(dummyPassword) + require.NoError(t, err) + }() + + passwordValue, err := p.Password("Enter password") + require.NoError(t, err) + require.Equal(t, dummyPassword, passwordValue) + }) + + t.Run("Confirm", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) + + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Are you sure") + require.NoError(t, err) + + // Confirm + _, err = console.SendLine("y") + require.NoError(t, err) + }() + + confirmValue, err := p.Confirm("Are you sure", false) + require.NoError(t, err) + require.Equal(t, true, confirmValue) + }) + + t.Run("Confirm - blank input returns default", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) + + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Are you sure") + require.NoError(t, err) + + // Enter nothing + _, err = console.SendLine("") + require.NoError(t, err) + }() + + confirmValue, err := p.Confirm("Are you sure", false) + require.NoError(t, err) + require.Equal(t, false, confirmValue) + }) + + t.Run("AuthToken", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) + dummyAuthToken := "12345abcdefg" + + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Paste your authentication token:") + require.NoError(t, err) + + // Enter some dummy auth token + _, err = console.SendLine(dummyAuthToken) + require.NoError(t, err) + }() + + authValue, err := p.AuthToken() + require.NoError(t, err) + require.Equal(t, dummyAuthToken, authValue) + }) + + t.Run("AuthToken - blank input returns error", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) + dummyAuthTokenForAfterFailure := "12345abcdefg" + + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Paste your authentication token:") + require.NoError(t, err) + + // Enter nothing + _, err = console.SendLine("") + require.NoError(t, err) + + // Expect an error message + _, err = console.ExpectString("token is required") + require.NoError(t, err) + + // Now enter some dummy auth token to return control back to the test + _, err = console.SendLine(dummyAuthTokenForAfterFailure) + require.NoError(t, err) + }() + + authValue, err := p.AuthToken() + require.NoError(t, err) + require.Equal(t, dummyAuthTokenForAfterFailure, authValue) + }) + + t.Run("ConfirmDeletion", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) + + requiredValue := "test" + go func() { + // Wait for prompt to appear + _, err := console.ExpectString(fmt.Sprintf("Type %q to confirm deletion", requiredValue)) + require.NoError(t, err) + + // Confirm + _, err = console.SendLine(requiredValue) + require.NoError(t, err) + }() + + // An err indicates that the confirmation text sent did not match + err := p.ConfirmDeletion(requiredValue) + require.NoError(t, err) + }) + + t.Run("ConfirmDeletion - bad input", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) + requiredValue := "test" + badInputValue := "garbage" + + go func() { + // Wait for prompt to appear + _, err := console.ExpectString(fmt.Sprintf("Type %q to confirm deletion", requiredValue)) + require.NoError(t, err) + + // Confirm with bad input + _, err = console.SendLine(badInputValue) + require.NoError(t, err) + + // Expect an error message and loop back to the prompt + _, err = console.ExpectString(fmt.Sprintf("You entered: %q", badInputValue)) + require.NoError(t, err) + + // Confirm with the correct input to return control back to the test + _, err = console.SendLine(requiredValue) + require.NoError(t, err) + }() + + // An err indicates that the confirmation text sent did not match + err := p.ConfirmDeletion(requiredValue) + require.NoError(t, err) + }) + + t.Run("InputHostname", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) + hostname := "example.com" + + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Hostname:") + require.NoError(t, err) + + // Enter the hostname + _, err = console.SendLine(hostname) + require.NoError(t, err) + }() + + inputValue, err := p.InputHostname() + require.NoError(t, err) + require.Equal(t, hostname, inputValue) + }) + + t.Run("MarkdownEditor - blank allowed with blank input returns blank", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) + + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("How to edit?") + require.NoError(t, err) + + // Enter 2, to select "skip" + _, err = console.SendLine("2") + require.NoError(t, err) + }() + + inputValue, err := p.MarkdownEditor("How to edit?", "", true) + require.NoError(t, err) + require.Equal(t, "", inputValue) + }) + + t.Run("MarkdownEditor - blank disallowed with default value returns default value", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) + defaultValue := "12345abcdefg" + + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("How to edit?") + require.NoError(t, err) + + // Enter number 2 to select "skip". This shouldn't be allowed. + _, err = console.SendLine("2") + require.NoError(t, err) + + // Expect a notice to enter something valid since blank is disallowed. + _, err = console.ExpectString("invalid input. please try again") + require.NoError(t, err) + + // Send a 1 to select to open the editor. This will immediately exit + _, err = console.SendLine("1") + require.NoError(t, err) + }() + + inputValue, err := p.MarkdownEditor("How to edit?", defaultValue, false) + require.NoError(t, err) + require.Equal(t, defaultValue, inputValue) + }) + + t.Run("MarkdownEditor - blank disallowed no default value returns error", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) + + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("How to edit?") + require.NoError(t, err) + + // Enter number 2 to select "skip". This shouldn't be allowed. + _, err = console.SendLine("2") + require.NoError(t, err) + + // Expect a notice to enter something valid since blank is disallowed. + _, err = console.ExpectString("invalid input. please try again") + require.NoError(t, err) + + // Send a 1 to select to open the editor since skip is invalid and + // we need to return control back to the test. + _, err = console.SendLine("1") + require.NoError(t, err) + }() + + inputValue, err := p.MarkdownEditor("How to edit?", "", false) + require.NoError(t, err) + require.Equal(t, "", inputValue) + }) +} + +func TestSurveyPrompter(t *testing.T) { + // This not a comprehensive test of the survey prompter, but it does + // demonstrate that the survey prompter is used when the + // accessible prompter is disabled. + t.Run("Select uses survey prompter when accessible prompter is disabled", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestSurveyPrompter(t, console) + + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Select a number") + require.NoError(t, err) + + // Send a newline to select the first option + // Note: This would not work with the accessible prompter + // because it would requires sending a 1 to select the first option. + // So it proves we are seeing a survey prompter. + _, err = console.SendLine("") + require.NoError(t, err) + }() + + selectValue, err := p.Select("Select a number", "", []string{"1", "2", "3"}) + require.NoError(t, err) + assert.Equal(t, 0, selectValue) + }) +} + +func newTestVirtualTerminal(t *testing.T) *expect.Console { + t.Helper() + + // Create a PTY and hook up a virtual terminal emulator + ptm, pts, err := pty.Open() + require.NoError(t, err) + + term := vt10x.New(vt10x.WithWriter(pts)) + + // Create a console via Expect that allows scripting against the terminal + consoleOpts := []expect.ConsoleOpt{ + expect.WithStdin(ptm), + expect.WithStdout(term), + expect.WithCloser(ptm, pts), + failOnExpectError(t), + failOnSendError(t), + expect.WithDefaultTimeout(time.Second), + } + + console, err := expect.NewConsole(consoleOpts...) + require.NoError(t, err) + t.Cleanup(func() { testCloser(t, console) }) + + return console +} + +func newTestAcessiblePrompter(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()) +} + +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()) +} + +// failOnExpectError adds an observer that will fail the test in a standardised way +// if any expectation on the command output fails, without requiring an explicit +// assertion. +// +// Use WithRelaxedIO to disable this behaviour. +func failOnExpectError(t *testing.T) expect.ConsoleOpt { + t.Helper() + return expect.WithExpectObserver( + func(matchers []expect.Matcher, buf string, err error) { + t.Helper() + + if err == nil { + return + } + + if len(matchers) == 0 { + t.Fatalf("Error occurred while matching %q: %s\n", buf, err) + } + + var criteria []string + for _, matcher := range matchers { + criteria = append(criteria, fmt.Sprintf("%q", matcher.Criteria())) + } + t.Fatalf("Failed to find [%s] in %q: %s\n", strings.Join(criteria, ", "), buf, err) + }, + ) +} + +// failOnSendError adds an observer that will fail the test in a standardised way +// if any sending of input fails, without requiring an explicit assertion. +// +// Use WithRelaxedIO to disable this behaviour. +func failOnSendError(t *testing.T) expect.ConsoleOpt { + t.Helper() + return expect.WithSendObserver( + func(msg string, n int, err error) { + t.Helper() + + if err != nil { + t.Fatalf("Failed to send %q: %s\n", msg, err) + } + if len(msg) != n { + t.Fatalf("Only sent %d of %d bytes for %q\n", n, len(msg), msg) + } + }, + ) +} + +// testCloser is a helper to fail the test if a Closer fails to close. +func testCloser(t *testing.T, closer io.Closer) { + t.Helper() + if err := closer.Close(); err != nil { + t.Errorf("Close failed: %s", err) + } +} diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 1d4b11cbc..6ef61cf15 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -2,9 +2,12 @@ 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/surveyext" ghPrompter "github.com/cli/go-gh/v2/pkg/prompter" @@ -13,20 +16,46 @@ import ( //go:generate moq -rm -out prompter_mock.go . Prompter type Prompter interface { // generic prompts from go-gh - Select(string, string, []string) (int, error) + + // Select prompts the user to select an option from a list of options. + Select(prompt string, defaultValue string, options []string) (int, error) + // MultiSelect prompts the user to select one or more options from a list of options. MultiSelect(prompt string, defaults []string, options []string) ([]int, error) - Input(string, string) (string, error) - Password(string) (string, error) - Confirm(string, bool) (bool, error) + // Input prompts the user to enter a string value. + Input(prompt string, defaultValue string) (string, error) + // Password prompts the user to enter a password. + Password(prompt string) (string, error) + // Confirm prompts the user to confirm an action. + Confirm(prompt string, defaultValue bool) (bool, error) // gh specific prompts + + // AuthToken prompts the user to enter an authentication token. AuthToken() (string, error) - ConfirmDeletion(string) error + // ConfirmDeletion prompts the user to confirm deletion of a resource by + // typing the requiredValue. + ConfirmDeletion(requiredValue string) error + // InputHostname prompts the user to enter a hostname. InputHostname() (string, error) - MarkdownEditor(string, string, bool) (string, error) + // MarkdownEditor prompts the user to edit a markdown document in an editor. + // If blankAllowed is true, the user can skip the editor and an empty string + // will be returned. + 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) { + return &accessiblePrompter{ + stdin: stdin, + stdout: stdout, + stderr: stderr, + editorCmd: editorCmd, + } + } + return &surveyPrompter{ prompter: ghPrompter.New(stdin, stdout, stderr), stdin: stdin, @@ -36,6 +65,209 @@ func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWr } } +type accessiblePrompter struct { + stdin ghPrompter.FileReader + stdout ghPrompter.FileWriter + stderr ghPrompter.FileWriter + editorCmd string +} + +func (p *accessiblePrompter) newForm(groups ...*huh.Group) *huh.Form { + return huh.NewForm(groups...). + WithTheme(huh.ThemeBase16()). + WithAccessible(true). + WithInput(p.stdin). + WithOutput(p.stdout) +} + +func (p *accessiblePrompter) Select(prompt, _ string, options []string) (int, error) { + var result int + formOptions := []huh.Option[int]{} + for i, o := range options { + formOptions = append(formOptions, huh.NewOption(o, i)) + } + + form := p.newForm( + huh.NewGroup( + huh.NewSelect[int](). + Title(prompt). + Value(&result). + Options(formOptions...), + ), + ) + + err := form.Run() + return result, err +} + +func (p *accessiblePrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) { + var result []int + formOptions := make([]huh.Option[int], len(options)) + for i, o := range options { + formOptions[i] = huh.NewOption(o, i) + } + + form := p.newForm( + huh.NewGroup( + huh.NewMultiSelect[int](). + Title(prompt). + Value(&result). + Limit(len(options)). + Options(formOptions...), + ), + ) + + if err := form.Run(); err != nil { + return nil, err + } + + return result, nil +} + +func (p *accessiblePrompter) Input(prompt, defaultValue string) (string, error) { + result := defaultValue + prompt = fmt.Sprintf("%s (%s)", prompt, defaultValue) + form := p.newForm( + huh.NewGroup( + huh.NewInput(). + Title(prompt). + Value(&result), + ), + ) + + err := form.Run() + return result, err +} + +func (p *accessiblePrompter) Password(prompt string) (string, error) { + var result string + // EchoMode(huh.EchoModePassword) doesn't have any effect in accessible mode. + form := p.newForm( + huh.NewGroup( + huh.NewInput(). + Title(prompt). + Value(&result), + ), + ) + + err := form.Run() + if err != nil { + return "", err + } + + return result, nil +} + +func (p *accessiblePrompter) Confirm(prompt string, defaultValue bool) (bool, error) { + result := defaultValue + form := p.newForm( + huh.NewGroup( + huh.NewConfirm(). + Title(prompt). + Value(&result), + ), + ) + if err := form.Run(); err != nil { + return false, err + } + return result, nil +} + +func (p *accessiblePrompter) AuthToken() (string, error) { + var result string + form := p.newForm( + huh.NewGroup( + huh.NewInput(). + Title("Paste your authentication token:"). + // Note: if this validation fails, the prompt loops. + Validate(func(input string) error { + if input == "" { + return fmt.Errorf("token is required") + } + return nil + }). + Value(&result), + // This doesn't have any effect in accessible mode. + // EchoMode(huh.EchoModePassword), + ), + ) + + err := form.Run() + return result, err +} + +func (p *accessiblePrompter) ConfirmDeletion(requiredValue string) error { + form := p.newForm( + huh.NewGroup( + huh.NewInput(). + Title(fmt.Sprintf("Type %q to confirm deletion", requiredValue)). + Validate(func(input string) error { + if input != requiredValue { + return fmt.Errorf("You entered: %q", input) + } + return nil + }), + ), + ) + + return form.Run() +} + +func (p *accessiblePrompter) InputHostname() (string, error) { + var result string + form := p.newForm( + huh.NewGroup( + huh.NewInput(). + Title("Hostname:"). + Validate(ghinstance.HostnameValidator). + Value(&result), + ), + ) + + err := form.Run() + if err != nil { + return "", err + } + return result, nil +} + +func (p *accessiblePrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { + var result string + skipOption := "skip" + launchOption := "launch" + options := []huh.Option[string]{ + huh.NewOption(fmt.Sprintf("Launch %s", surveyext.EditorName(p.editorCmd)), launchOption), + } + if blankAllowed { + options = append(options, huh.NewOption("Skip", skipOption)) + } + + form := p.newForm( + huh.NewGroup( + huh.NewSelect[string](). + Title(prompt). + Options(options...). + Value(&result), + ), + ) + + if err := form.Run(); err != nil { + return "", err + } + + if result == skipOption { + return "", nil + } + + // launchOption was selected + text, err := surveyext.Edit(p.editorCmd, "*.md", defaultValue, p.stdin, p.stdout, p.stderr) + if err != nil { + return "", err + } + + return text, nil +} + type surveyPrompter struct { prompter *ghPrompter.Prompter stdin ghPrompter.FileReader diff --git a/internal/run/stub.go b/internal/run/stub.go index 5cd3c6de5..507fd61d6 100644 --- a/internal/run/stub.go +++ b/internal/run/stub.go @@ -46,7 +46,7 @@ func Stub() (*CommandStubber, func(T)) { return } t.Helper() - t.Errorf("unmatched stubs (%d): %s", len(unmatched), strings.Join(unmatched, ", ")) + t.Errorf("unmatched exec stubs (%d): %s", len(unmatched), strings.Join(unmatched, ", ")) } } diff --git a/internal/tableprinter/table_printer.go b/internal/tableprinter/table_printer.go index 69b22be12..47128afb4 100644 --- a/internal/tableprinter/table_printer.go +++ b/internal/tableprinter/table_printer.go @@ -73,7 +73,7 @@ func NewWithWriter(w io.Writer, isTTY bool, maxWidth int, cs *iostreams.ColorSch // was not padded. In tests cs.Enabled() is false which allows us to avoid having to fix up // numerous tests that verify header padding. var paddingFunc func(int, string) string - if cs.Enabled() { + if cs.Enabled { paddingFunc = text.PadRight } diff --git a/pkg/cmd/attestation/inspect/inspect.go b/pkg/cmd/attestation/inspect/inspect.go index 6fbddd6da..b571eee01 100644 --- a/pkg/cmd/attestation/inspect/inspect.go +++ b/pkg/cmd/attestation/inspect/inspect.go @@ -105,7 +105,11 @@ func NewInspectCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command config.TrustDomain = td } - opts.SigstoreVerifier = verification.NewLiveSigstoreVerifier(config) + sgVerifier, err := verification.NewLiveSigstoreVerifier(config) + if err != nil { + return fmt.Errorf("failed to create Sigstore verifier: %w", err) + } + opts.SigstoreVerifier = sgVerifier if runF != nil { return runF(opts) diff --git a/pkg/cmd/attestation/test/data/data.go b/pkg/cmd/attestation/test/data/data.go index b33efaa28..ef3c35c20 100644 --- a/pkg/cmd/attestation/test/data/data.go +++ b/pkg/cmd/attestation/test/data/data.go @@ -5,13 +5,17 @@ import ( "testing" "github.com/sigstore/sigstore-go/pkg/bundle" - sgData "github.com/sigstore/sigstore-go/pkg/testing/data" ) //go:embed sigstore-js-2.1.0-bundle.json var SigstoreBundleRaw []byte -// SigstoreBundle returns a test *sigstore.Bundle +// SigstoreBundle returns a test sigstore-go bundle.Bundle func SigstoreBundle(t *testing.T) *bundle.Bundle { - return sgData.TestBundle(t, SigstoreBundleRaw) + b := &bundle.Bundle{} + err := b.UnmarshalJSON(SigstoreBundleRaw) + if err != nil { + t.Fatalf("failed to unmarshal sigstore bundle: %v", err) + } + return b } diff --git a/pkg/cmd/attestation/verification/sigstore.go b/pkg/cmd/attestation/verification/sigstore.go index 6dd31dac0..190ea5c0f 100644 --- a/pkg/cmd/attestation/verification/sigstore.go +++ b/pkg/cmd/attestation/verification/sigstore.go @@ -44,12 +44,11 @@ type SigstoreVerifier interface { } type LiveSigstoreVerifier struct { - TrustedRoot string Logger *io.Handler NoPublicGood bool - // If tenancy mode is not used, trust domain is empty - TrustDomain string - TUFMetadataDir o.Option[string] + PublicGood *verify.SignedEntityVerifier + GitHub *verify.SignedEntityVerifier + Custom map[string]*verify.SignedEntityVerifier } var ErrNoAttestationsVerified = errors.New("no attestations were verified") @@ -57,56 +56,43 @@ var ErrNoAttestationsVerified = errors.New("no attestations were verified") // NewLiveSigstoreVerifier creates a new LiveSigstoreVerifier struct // that is used to verify artifacts and attestations against the // Public Good, GitHub, or a custom trusted root. -func NewLiveSigstoreVerifier(config SigstoreConfig) *LiveSigstoreVerifier { - return &LiveSigstoreVerifier{ - TrustedRoot: config.TrustedRoot, - Logger: config.Logger, - NoPublicGood: config.NoPublicGood, - TrustDomain: config.TrustDomain, - TUFMetadataDir: config.TUFMetadataDir, +func NewLiveSigstoreVerifier(config SigstoreConfig) (*LiveSigstoreVerifier, error) { + liveVerifier := &LiveSigstoreVerifier{ + Logger: config.Logger, + NoPublicGood: config.NoPublicGood, } -} - -func getBundleIssuer(b *bundle.Bundle) (string, error) { - if !b.MinVersion("0.2") { - return "", fmt.Errorf("unsupported bundle version: %s", b.MediaType) - } - verifyContent, err := b.VerificationContent() - if err != nil { - return "", fmt.Errorf("failed to get bundle verification content: %v", err) - } - leafCert := verifyContent.Certificate() - if leafCert == nil { - return "", fmt.Errorf("leaf cert not found") - } - if len(leafCert.Issuer.Organization) != 1 { - return "", fmt.Errorf("expected the leaf certificate issuer to only have one organization") - } - return leafCert.Issuer.Organization[0], nil -} - -func (v *LiveSigstoreVerifier) chooseVerifier(issuer string) (*verify.SignedEntityVerifier, error) { - // if no custom trusted root is set, attempt to create a Public Good or - // GitHub Sigstore verifier - if v.TrustedRoot == "" { - switch issuer { - case PublicGoodIssuerOrg: - if v.NoPublicGood { - return nil, fmt.Errorf("detected public good instance but requested verification without public good instance") - } - return newPublicGoodVerifier(v.TUFMetadataDir) - case GitHubIssuerOrg: - return newGitHubVerifier(v.TrustDomain, v.TUFMetadataDir) - default: - return nil, fmt.Errorf("leaf certificate issuer is not recognized") + // if a custom trusted root is set, configure custom verifiers + if config.TrustedRoot != "" { + customVerifiers, err := createCustomVerifiers(config.TrustedRoot, config.NoPublicGood) + if err != nil { + return nil, err } + liveVerifier.Custom = customVerifiers + return liveVerifier, nil } - - customTrustRoots, err := os.ReadFile(v.TrustedRoot) + if !config.NoPublicGood { + publicGoodVerifier, err := newPublicGoodVerifier(config.TUFMetadataDir) + if err != nil { + return nil, err + } + liveVerifier.PublicGood = publicGoodVerifier + } + github, err := newGitHubVerifier(config.TrustDomain, config.TUFMetadataDir) if err != nil { - return nil, fmt.Errorf("unable to read file %s: %v", v.TrustedRoot, err) + return nil, err + } + liveVerifier.GitHub = github + + return liveVerifier, nil +} + +func createCustomVerifiers(trustedRoot string, noPublicGood bool) (map[string]*verify.SignedEntityVerifier, 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) reader := bufio.NewReader(bytes.NewReader(customTrustRoots)) var line []byte var readError error @@ -130,10 +116,11 @@ func (v *LiveSigstoreVerifier) chooseVerifier(issuer string) (*verify.SignedEnti return nil, err } - // if the custom trusted root issuer is not set or doesn't match the given issuer, skip it - if len(lowestCert.Issuer.Organization) == 0 || lowestCert.Issuer.Organization[0] != issuer { + // if the custom trusted root issuer is not set, skip it + if len(lowestCert.Issuer.Organization) == 0 { continue } + issuer := lowestCert.Issuer.Organization[0] // Determine what policy to use with this trusted root. // @@ -141,21 +128,88 @@ func (v *LiveSigstoreVerifier) chooseVerifier(issuer string) (*verify.SignedEnti // issuer. We *must* use the trusted root provided. switch issuer { case PublicGoodIssuerOrg: - if v.NoPublicGood { + if noPublicGood { return nil, fmt.Errorf("detected public good instance but requested verification without public good instance") } - return newPublicGoodVerifierWithTrustedRoot(trustedRoot) + if _, ok := verifiers[PublicGoodIssuerOrg]; ok { + // we have already created a public good verifier with this custom trusted root + // so we skip it + continue + } + publicGood, err := newPublicGoodVerifierWithTrustedRoot(trustedRoot) + if err != nil { + return nil, err + } + verifiers[PublicGoodIssuerOrg] = publicGood case GitHubIssuerOrg: - return newGitHubVerifierWithTrustedRoot(trustedRoot) + if _, ok := verifiers[GitHubIssuerOrg]; ok { + // we have already created a github verifier with this custom trusted root + // so we skip it + continue + } + github, err := newGitHubVerifierWithTrustedRoot(trustedRoot) + if err != nil { + return nil, err + } + verifiers[GitHubIssuerOrg] = github default: + if _, ok := verifiers[issuer]; ok { + // we have already created a custom verifier with this custom trusted root + // so we skip it + continue + } // Make best guess at reasonable policy - return newCustomVerifier(trustedRoot) + custom, err := newCustomVerifier(trustedRoot) + if err != nil { + return nil, err + } + verifiers[issuer] = custom } } line, readError = reader.ReadBytes('\n') } + return verifiers, nil +} - return nil, fmt.Errorf("unable to use provided trusted roots") +func getBundleIssuer(b *bundle.Bundle) (string, error) { + if !b.MinVersion("0.2") { + return "", fmt.Errorf("unsupported bundle version: %s", b.MediaType) + } + verifyContent, err := b.VerificationContent() + if err != nil { + return "", fmt.Errorf("failed to get bundle verification content: %v", err) + } + leafCert := verifyContent.Certificate() + if leafCert == nil { + return "", fmt.Errorf("leaf cert not found") + } + if len(leafCert.Issuer.Organization) != 1 { + return "", fmt.Errorf("expected the leaf certificate issuer to only have one organization") + } + return leafCert.Issuer.Organization[0], nil +} + +func (v *LiveSigstoreVerifier) chooseVerifier(issuer string) (*verify.SignedEntityVerifier, 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 { + custom, ok := v.Custom[issuer] + if !ok { + return nil, fmt.Errorf("no custom verifier found for issuer \"%s\"", issuer) + } + return custom, nil + } + switch issuer { + case PublicGoodIssuerOrg: + if v.NoPublicGood { + return nil, fmt.Errorf("detected public good instance but requested verification without public good instance") + } + return v.PublicGood, nil + case GitHubIssuerOrg: + return v.GitHub, nil + default: + return nil, fmt.Errorf("leaf certificate issuer is not recognized") + } } func getLowestCertInChain(ca *root.FulcioCertificateAuthority) (*x509.Certificate, error) { @@ -177,7 +231,7 @@ func (v *LiveSigstoreVerifier) verify(attestation *api.Attestation, policy verif // determine which verifier should attempt verification against the bundle verifier, err := v.chooseVerifier(issuer) if err != nil { - return nil, fmt.Errorf("failed to find recognized issuer from bundle content: %v", err) + return nil, fmt.Errorf("failed to choose verifier based on provided bundle issuer: %v", err) } v.Logger.VerbosePrintf("Attempting verification against issuer \"%s\"\n", issuer) diff --git a/pkg/cmd/attestation/verification/sigstore_integration_test.go b/pkg/cmd/attestation/verification/sigstore_integration_test.go index 987fb9caa..2a2d3beea 100644 --- a/pkg/cmd/attestation/verification/sigstore_integration_test.go +++ b/pkg/cmd/attestation/verification/sigstore_integration_test.go @@ -50,10 +50,11 @@ func TestLiveSigstoreVerifier(t *testing.T) { for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { - verifier := NewLiveSigstoreVerifier(SigstoreConfig{ + verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ Logger: io.NewTestHandler(), TUFMetadataDir: o.Some(t.TempDir()), }) + require.NoError(t, err) results, err := verifier.Verify(tc.attestations, publicGoodPolicy(t)) @@ -69,10 +70,11 @@ func TestLiveSigstoreVerifier(t *testing.T) { } t.Run("with 2/3 verified attestations", func(t *testing.T) { - verifier := NewLiveSigstoreVerifier(SigstoreConfig{ + verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ Logger: io.NewTestHandler(), TUFMetadataDir: o.Some(t.TempDir()), }) + require.NoError(t, err) invalidBundle := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0-bundle-v0.1.json") attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl") @@ -86,10 +88,11 @@ func TestLiveSigstoreVerifier(t *testing.T) { }) t.Run("fail with 0/2 verified attestations", func(t *testing.T) { - verifier := NewLiveSigstoreVerifier(SigstoreConfig{ + verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ Logger: io.NewTestHandler(), TUFMetadataDir: o.Some(t.TempDir()), }) + require.NoError(t, err) invalidBundle := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0-bundle-v0.1.json") attestations := getAttestationsFor(t, "../test/data/sigstoreBundle-invalid-signature.json") @@ -110,10 +113,11 @@ func TestLiveSigstoreVerifier(t *testing.T) { attestations := getAttestationsFor(t, "../test/data/github_provenance_demo-0.0.12-py3-none-any-bundle.jsonl") - verifier := NewLiveSigstoreVerifier(SigstoreConfig{ + verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ Logger: io.NewTestHandler(), TUFMetadataDir: o.Some(t.TempDir()), }) + require.NoError(t, err) results, err := verifier.Verify(attestations, githubPolicy) require.Len(t, results, 1) @@ -123,11 +127,12 @@ func TestLiveSigstoreVerifier(t *testing.T) { t.Run("with custom trusted root", func(t *testing.T) { attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl") - verifier := NewLiveSigstoreVerifier(SigstoreConfig{ + verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ Logger: io.NewTestHandler(), TrustedRoot: test.NormalizeRelativePath("../test/data/trusted_root.json"), TUFMetadataDir: o.Some(t.TempDir()), }) + require.NoError(t, err) results, err := verifier.Verify(attestations, publicGoodPolicy(t)) require.Len(t, results, 2) diff --git a/pkg/cmd/attestation/verify/attestation_integration_test.go b/pkg/cmd/attestation/verify/attestation_integration_test.go index 9ff174141..73452c425 100644 --- a/pkg/cmd/attestation/verify/attestation_integration_test.go +++ b/pkg/cmd/attestation/verify/attestation_integration_test.go @@ -25,10 +25,11 @@ func getAttestationsFor(t *testing.T, bundlePath string) []*api.Attestation { } func TestVerifyAttestations(t *testing.T) { - sgVerifier := verification.NewLiveSigstoreVerifier(verification.SigstoreConfig{ + sgVerifier, err := verification.NewLiveSigstoreVerifier(verification.SigstoreConfig{ Logger: io.NewTestHandler(), TUFMetadataDir: o.Some(t.TempDir()), }) + require.NoError(t, err) certSummary := certificate.Summary{} certSummary.SourceRepositoryOwnerURI = "https://github.com/sigstore" diff --git a/pkg/cmd/attestation/verify/verify.go b/pkg/cmd/attestation/verify/verify.go index 3affdfabb..b3bad519a 100644 --- a/pkg/cmd/attestation/verify/verify.go +++ b/pkg/cmd/attestation/verify/verify.go @@ -211,7 +211,11 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command return runF(opts) } - opts.SigstoreVerifier = verification.NewLiveSigstoreVerifier(config) + sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) + if err != nil { + return fmt.Errorf("error creating Sigstore verifier: %w", err) + } + opts.SigstoreVerifier = sigstoreVerifier opts.Config = f.Config if err := runVerify(opts); err != nil { diff --git a/pkg/cmd/attestation/verify/verify_integration_test.go b/pkg/cmd/attestation/verify/verify_integration_test.go index 09479995c..92864f78e 100644 --- a/pkg/cmd/attestation/verify/verify_integration_test.go +++ b/pkg/cmd/attestation/verify/verify_integration_test.go @@ -33,6 +33,8 @@ func TestVerifyIntegration(t *testing.T) { host, _ := auth.DefaultHost() + sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig) + require.NoError(t, err) publicGoodOpts := Options{ APIClient: api.NewLiveClient(hc, host, logger), ArtifactPath: artifactPath, @@ -44,7 +46,7 @@ func TestVerifyIntegration(t *testing.T) { Owner: "sigstore", PredicateType: verification.SLSAPredicateV1, SANRegex: "^https://github.com/sigstore/", - SigstoreVerifier: verification.NewLiveSigstoreVerifier(sigstoreConfig), + SigstoreVerifier: sigstoreVerifier, } t.Run("with valid owner", func(t *testing.T) { @@ -106,6 +108,8 @@ func TestVerifyIntegration(t *testing.T) { }) t.Run("with bundle from OCI registry", func(t *testing.T) { + sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig) + require.NoError(t, err) opts := Options{ APIClient: api.NewLiveClient(hc, host, logger), ArtifactPath: "oci://ghcr.io/github/artifact-attestations-helm-charts/policy-controller:v0.10.0-github9", @@ -117,10 +121,10 @@ func TestVerifyIntegration(t *testing.T) { Owner: "github", PredicateType: verification.SLSAPredicateV1, SANRegex: "^https://github.com/github/", - SigstoreVerifier: verification.NewLiveSigstoreVerifier(sigstoreConfig), + SigstoreVerifier: sigstoreVerifier, } - err := runVerify(&opts) + err = runVerify(&opts) require.NoError(t, err) }) } @@ -145,6 +149,8 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) { host, _ := auth.DefaultHost() + sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig) + require.NoError(t, err) baseOpts := Options{ APIClient: api.NewLiveClient(hc, host, logger), ArtifactPath: artifactPath, @@ -154,7 +160,7 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) { OCIClient: oci.NewLiveClient(), OIDCIssuer: "https://token.actions.githubusercontent.com/hammer-time", PredicateType: verification.SLSAPredicateV1, - SigstoreVerifier: verification.NewLiveSigstoreVerifier(sigstoreConfig), + SigstoreVerifier: sigstoreVerifier, } t.Run("with owner and valid workflow SAN", func(t *testing.T) { @@ -216,6 +222,8 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) { host, _ := auth.DefaultHost() + sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig) + require.NoError(t, err) baseOpts := Options{ APIClient: api.NewLiveClient(hc, host, logger), ArtifactPath: artifactPath, @@ -225,7 +233,7 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) { OCIClient: oci.NewLiveClient(), OIDCIssuer: verification.GitHubOIDCIssuer, PredicateType: verification.SLSAPredicateV1, - SigstoreVerifier: verification.NewLiveSigstoreVerifier(sigstoreConfig), + SigstoreVerifier: sigstoreVerifier, } t.Run("with owner and valid reusable workflow SAN", func(t *testing.T) { @@ -306,6 +314,8 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) { host, _ := auth.DefaultHost() + sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig) + require.NoError(t, err) baseOpts := Options{ APIClient: api.NewLiveClient(hc, host, logger), ArtifactPath: artifactPath, @@ -318,7 +328,7 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) { Owner: "malancas", PredicateType: verification.SLSAPredicateV1, Repo: "malancas/attest-demo", - SigstoreVerifier: verification.NewLiveSigstoreVerifier(sigstoreConfig), + SigstoreVerifier: sigstoreVerifier, } type testcase struct { diff --git a/pkg/cmd/config/list/list_test.go b/pkg/cmd/config/list/list_test.go index 65f83d659..2184d0f16 100644 --- a/pkg/cmd/config/list/list_test.go +++ b/pkg/cmd/config/list/list_test.go @@ -4,6 +4,7 @@ import ( "bytes" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/pkg/cmdutil" @@ -91,14 +92,16 @@ func Test_listRun(t *testing.T) { return cfg }(), input: &ListOptions{Hostname: "HOST"}, - stdout: `git_protocol=ssh -editor=/usr/bin/vim -prompt=disabled -prefer_editor_prompt=enabled -pager=less -http_unix_socket= -browser=brave -`, + stdout: heredoc.Doc(` + git_protocol=ssh + editor=/usr/bin/vim + prompt=disabled + prefer_editor_prompt=enabled + pager=less + http_unix_socket= + browser=brave + color_labels=disabled + `), }, } diff --git a/pkg/cmd/extension/extension.go b/pkg/cmd/extension/extension.go index dc7ffa8c3..f30bf63c1 100644 --- a/pkg/cmd/extension/extension.go +++ b/pkg/cmd/extension/extension.go @@ -163,7 +163,8 @@ func (e *Extension) IsPinned() bool { isPinned = manifest.IsPinned } case GitKind: - pinPath := filepath.Join(e.Path(), fmt.Sprintf(".pin-%s", e.CurrentVersion())) + extDir := filepath.Dir(e.path) + pinPath := filepath.Join(extDir, fmt.Sprintf(".pin-%s", e.CurrentVersion())) if _, err := os.Stat(pinPath); err == nil { isPinned = true } else { diff --git a/pkg/cmd/extension/extension_test.go b/pkg/cmd/extension/extension_test.go index 6f0afc3a6..6928c6ed9 100644 --- a/pkg/cmd/extension/extension_test.go +++ b/pkg/cmd/extension/extension_test.go @@ -102,3 +102,84 @@ func TestOwnerCached(t *testing.T) { assert.Equal(t, "cli", e.Owner()) } + +func TestIsPinnedBinaryExtensionUnpinned(t *testing.T) { + tempDir := t.TempDir() + extName := "gh-bin-ext" + extDir := filepath.Join(tempDir, "extensions", extName) + extPath := filepath.Join(extDir, extName) + bm := binManifest{ + Name: "gh-bin-ext", + } + assert.NoError(t, stubBinaryExtension(extDir, bm)) + e := &Extension{ + kind: BinaryKind, + path: extPath, + } + + assert.False(t, e.IsPinned()) +} + +func TestIsPinnedBinaryExtensionPinned(t *testing.T) { + tempDir := t.TempDir() + extName := "gh-bin-ext" + extDir := filepath.Join(tempDir, "extensions", extName) + extPath := filepath.Join(extDir, extName) + bm := binManifest{ + Name: "gh-bin-ext", + IsPinned: true, + } + assert.NoError(t, stubBinaryExtension(extDir, bm)) + e := &Extension{ + kind: BinaryKind, + path: extPath, + } + + assert.True(t, e.IsPinned()) +} + +func TestIsPinnedGitExtensionUnpinned(t *testing.T) { + tempDir := t.TempDir() + extPath := filepath.Join(tempDir, "extensions", "gh-local", "gh-local") + assert.NoError(t, stubExtension(extPath)) + + gc := &mockGitClient{} + gc.On("CommandOutput", []string{"rev-parse", "HEAD"}).Return("abcd1234", nil) + e := &Extension{ + kind: GitKind, + gitClient: gc, + path: extPath, + } + + assert.False(t, e.IsPinned()) + gc.AssertExpectations(t) +} + +func TestIsPinnedGitExtensionPinned(t *testing.T) { + tempDir := t.TempDir() + extPath := filepath.Join(tempDir, "extensions", "gh-local", "gh-local") + assert.NoError(t, stubPinnedExtension(extPath, "abcd1234")) + + gc := &mockGitClient{} + gc.On("CommandOutput", []string{"rev-parse", "HEAD"}).Return("abcd1234", nil) + e := &Extension{ + kind: GitKind, + gitClient: gc, + path: extPath, + } + + assert.True(t, e.IsPinned()) + gc.AssertExpectations(t) +} + +func TestIsPinnedLocalExtension(t *testing.T) { + tempDir := t.TempDir() + extPath := filepath.Join(tempDir, "extensions", "gh-local", "gh-local") + assert.NoError(t, stubLocalExtension(tempDir, extPath)) + e := &Extension{ + kind: LocalKind, + path: extPath, + } + + assert.False(t, e.IsPinned()) +} diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index 598be0747..7a45efeb4 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "regexp" + "slices" "time" "github.com/cli/cli/v2/api" @@ -283,6 +284,12 @@ 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) { + io.SetSpinnerDisabled(true) + } + // Pager precedence // 1. GH_PAGER // 2. pager from config @@ -293,6 +300,17 @@ func ioStreams(f *cmdutil.Factory) *iostreams.IOStreams { io.SetPager(pager.Value) } + if ghColorLabels, ghColorLabelsExists := os.LookupEnv("GH_COLOR_LABELS"); ghColorLabelsExists { + switch ghColorLabels { + case "", "0", "false", "no": + io.SetColorLabels(false) + default: + io.SetColorLabels(true) + } + } else if prompt := cfg.ColorLabels(""); prompt.Value == "enabled" { + io.SetColorLabels(true) + } + io.SetAccessibleColorsEnabled(xcolor.IsAccessibleColorsEnabled()) return io diff --git a/pkg/cmd/factory/default_test.go b/pkg/cmd/factory/default_test.go index 94955bb30..5036a1dc1 100644 --- a/pkg/cmd/factory/default_test.go +++ b/pkg/cmd/factory/default_test.go @@ -432,6 +432,132 @@ func Test_ioStreams_prompt(t *testing.T) { } } +func Test_ioStreams_spinnerDisabled(t *testing.T) { + tests := []struct { + name string + spinnerDisabled bool + env map[string]string + }{ + { + name: "default config", + spinnerDisabled: false, + }, + { + name: "spinner disabled via GH_SPINNER_DISABLED env var = 0", + env: map[string]string{"GH_SPINNER_DISABLED": "0"}, + spinnerDisabled: false, + }, + { + name: "spinner disabled via GH_SPINNER_DISABLED env var = false", + env: map[string]string{"GH_SPINNER_DISABLED": "false"}, + spinnerDisabled: false, + }, + { + name: "spinner disabled via GH_SPINNER_DISABLED env var = no", + env: map[string]string{"GH_SPINNER_DISABLED": "no"}, + spinnerDisabled: false, + }, + { + name: "spinner enabled via GH_SPINNER_DISABLED env var = 1", + env: map[string]string{"GH_SPINNER_DISABLED": "1"}, + spinnerDisabled: true, + }, + { + name: "spinner enabled via GH_SPINNER_DISABLED env var = true", + env: map[string]string{"GH_SPINNER_DISABLED": "true"}, + spinnerDisabled: true, + }, + } + 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") + io := ioStreams(f) + assert.Equal(t, tt.spinnerDisabled, io.GetSpinnerDisabled()) + }) + } +} + +func Test_ioStreams_colorLabels(t *testing.T) { + tests := []struct { + name string + config gh.Config + colorLabelsEnabled bool + env map[string]string + }{ + { + name: "default config", + colorLabelsEnabled: false, + }, + { + name: "config with colorLabels enabled", + config: enableColorLabelsConfig(), + colorLabelsEnabled: true, + }, + { + name: "config with colorLabels disabled", + config: disableColorLabelsConfig(), + colorLabelsEnabled: false, + }, + { + name: "colorLabels enabled via `1` in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "1"}, + colorLabelsEnabled: true, + }, + { + name: "colorLabels enabled via `true` in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "true"}, + colorLabelsEnabled: true, + }, + { + name: "colorLabels enabled via `yes` in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "yes"}, + colorLabelsEnabled: true, + }, + { + name: "colorLabels disable via empty string in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": ""}, + colorLabelsEnabled: false, + }, + { + name: "colorLabels disabled via `0` in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "0"}, + colorLabelsEnabled: false, + }, + { + name: "colorLabels disabled via `false` in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "false"}, + colorLabelsEnabled: false, + }, + { + name: "colorLabels disabled via `no` in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "no"}, + colorLabelsEnabled: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.env != nil { + 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.colorLabelsEnabled, io.ColorLabels()) + }) + } +} + func TestSSOURL(t *testing.T) { tests := []struct { name string @@ -537,3 +663,11 @@ func pagerConfig() gh.Config { func disablePromptConfig() gh.Config { return config.NewFromString("prompt: disabled") } + +func disableColorLabelsConfig() gh.Config { + return config.NewFromString("color_labels: disabled") +} + +func enableColorLabelsConfig() gh.Config { + return config.NewFromString("color_labels: enabled") +} diff --git a/pkg/cmd/gist/create/create.go b/pkg/cmd/gist/create/create.go index 5392b997e..4f51bed25 100644 --- a/pkg/cmd/gist/create/create.go +++ b/pkg/cmd/gist/create/create.go @@ -138,7 +138,7 @@ func createRun(opts *CreateOptions) error { processMessage = fmt.Sprintf("Creating gist %s", gistName) } } - fmt.Fprintf(errOut, "%s %s\n", cs.Gray("-"), processMessage) + fmt.Fprintf(errOut, "%s %s\n", cs.Muted("-"), processMessage) httpClient, err := opts.HttpClient() if err != nil { diff --git a/pkg/cmd/gist/list/list_test.go b/pkg/cmd/gist/list/list_test.go index ad6d8cdb5..14351418f 100644 --- a/pkg/cmd/gist/list/list_test.go +++ b/pkg/cmd/gist/list/list_test.go @@ -654,50 +654,57 @@ func Test_highlightMatch(t *testing.T) { tests := []struct { name string input string - color bool + cs *iostreams.ColorScheme want string }{ { name: "single match", input: "Octo", + cs: &iostreams.ColorScheme{}, want: "Octo", }, { name: "single match (color)", input: "Octo", - color: true, - want: "\x1b[0;30;43mOcto\x1b[0m", + cs: &iostreams.ColorScheme{ + Enabled: true, + }, + want: "\x1b[0;30;43mOcto\x1b[0m", }, { name: "single match with extra", input: "Hello, Octocat!", + cs: &iostreams.ColorScheme{}, want: "Hello, Octocat!", }, { name: "single match with extra (color)", input: "Hello, Octocat!", - color: true, - want: "\x1b[0;34mHello, \x1b[0m\x1b[0;30;43mOcto\x1b[0m\x1b[0;34mcat!\x1b[0m", + cs: &iostreams.ColorScheme{ + Enabled: true, + }, + want: "\x1b[0;34mHello, \x1b[0m\x1b[0;30;43mOcto\x1b[0m\x1b[0;34mcat!\x1b[0m", }, { name: "multiple matches", input: "Octocat/octo", + cs: &iostreams.ColorScheme{}, want: "Octocat/octo", }, { name: "multiple matches (color)", input: "Octocat/octo", - color: true, - want: "\x1b[0;30;43mOcto\x1b[0m\x1b[0;34mcat/\x1b[0m\x1b[0;30;43mocto\x1b[0m", + cs: &iostreams.ColorScheme{ + Enabled: true, + }, + want: "\x1b[0;30;43mOcto\x1b[0m\x1b[0;34mcat/\x1b[0m\x1b[0;30;43mocto\x1b[0m", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cs := iostreams.NewColorScheme(tt.color, false, false, false, iostreams.NoTheme) - matched := false - got, err := highlightMatch(tt.input, regex, &matched, cs.Blue, cs.Highlight) + got, err := highlightMatch(tt.input, regex, &matched, tt.cs.Blue, tt.cs.Highlight) assert.NoError(t, err) assert.True(t, matched) assert.Equal(t, tt.want, got) diff --git a/pkg/cmd/gist/shared/shared.go b/pkg/cmd/gist/shared/shared.go index 53b577e4c..fc63f56ce 100644 --- a/pkg/cmd/gist/shared/shared.go +++ b/pkg/cmd/gist/shared/shared.go @@ -230,7 +230,7 @@ func PromptGists(prompter prompter.Prompter, client *http.Client, host string, c for i, gist := range gists { gistTime := text.FuzzyAgo(time.Now(), gist.UpdatedAt) // TODO: support dynamic maxWidth - opts[i] = fmt.Sprintf("%s %s %s", cs.Bold(gist.Filename()), gist.TruncDescription(), cs.Gray(gistTime)) + opts[i] = fmt.Sprintf("%s %s %s", cs.Bold(gist.Filename()), gist.TruncDescription(), cs.Muted(gistTime)) } result, err := prompter.Select("Select a gist", "", opts) diff --git a/pkg/cmd/gist/view/view.go b/pkg/cmd/gist/view/view.go index 705f8f703..f789c5b04 100644 --- a/pkg/cmd/gist/view/view.go +++ b/pkg/cmd/gist/view/view.go @@ -140,7 +140,7 @@ func viewRun(opts *ViewOptions) error { if len(gist.Files) == 1 || opts.Filename != "" { return fmt.Errorf("error: file is binary") } - _, err = fmt.Fprintln(opts.IO.Out, cs.Gray("(skipping rendering binary content)")) + _, err = fmt.Fprintln(opts.IO.Out, cs.Muted("(skipping rendering binary content)")) return nil } @@ -197,7 +197,7 @@ func viewRun(opts *ViewOptions) error { for i, fn := range filenames { if showFilenames { - fmt.Fprintf(opts.IO.Out, "%s\n\n", cs.Gray(fn)) + fmt.Fprintf(opts.IO.Out, "%s\n\n", cs.Muted(fn)) } if err := render(gist.Files[fn]); err != nil { return err diff --git a/pkg/cmd/issue/shared/display.go b/pkg/cmd/issue/shared/display.go index 0c56ffd2c..24a5137e3 100644 --- a/pkg/cmd/issue/shared/display.go +++ b/pkg/cmd/issue/shared/display.go @@ -38,13 +38,13 @@ func PrintIssues(io *iostreams.IOStreams, now time.Time, prefix string, totalCou } table.AddField(text.RemoveExcessiveWhitespace(issue.Title)) table.AddField(issueLabelList(&issue, cs, isTTY)) - table.AddTimeField(now, issue.UpdatedAt, cs.Gray) + table.AddTimeField(now, issue.UpdatedAt, cs.Muted) table.EndRow() } _ = table.Render() remaining := totalCount - len(issues) if remaining > 0 { - fmt.Fprintf(io.Out, cs.Gray("%sAnd %d more\n"), prefix, remaining) + fmt.Fprintf(io.Out, cs.Muted("%sAnd %d more\n"), prefix, remaining) } } @@ -56,7 +56,7 @@ func issueLabelList(issue *api.Issue, cs *iostreams.ColorScheme, colorize bool) labelNames := make([]string, 0, len(issue.Labels.Nodes)) for _, label := range issue.Labels.Nodes { if colorize { - labelNames = append(labelNames, cs.HexToRGB(label.Color, label.Name)) + labelNames = append(labelNames, cs.Label(label.Color, label.Name)) } else { labelNames = append(labelNames, label.Name) } diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index b188c6a4c..8e3aa6040 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -228,7 +228,7 @@ func printHumanIssuePreview(opts *ViewOptions, baseRepo ghrepo.Interface, issue var md string var err error if issue.Body == "" { - md = fmt.Sprintf("\n %s\n\n", cs.Gray("No description provided")) + md = fmt.Sprintf("\n %s\n\n", cs.Muted("No description provided")) } else { md, err = markdown.Render(issue.Body, markdown.WithTheme(opts.IO.TerminalTheme()), @@ -250,7 +250,7 @@ func printHumanIssuePreview(opts *ViewOptions, baseRepo ghrepo.Interface, issue } // Footer - fmt.Fprintf(out, cs.Gray("View this issue on GitHub: %s\n"), issue.URL) + fmt.Fprintf(out, cs.Muted("View this issue on GitHub: %s\n"), issue.URL) return nil } @@ -317,7 +317,7 @@ func issueLabelList(issue *api.Issue, cs *iostreams.ColorScheme) string { if cs == nil { labelNames[i] = label.Name } else { - labelNames[i] = cs.HexToRGB(label.Color, label.Name) + labelNames[i] = cs.Label(label.Color, label.Name) } } diff --git a/pkg/cmd/label/list.go b/pkg/cmd/label/list.go index fe1e9cdb7..4fe2f5ec9 100644 --- a/pkg/cmd/label/list.go +++ b/pkg/cmd/label/list.go @@ -137,7 +137,12 @@ func printLabels(io *iostreams.IOStreams, labels []label) error { table := tableprinter.New(io, tableprinter.WithHeader("NAME", "DESCRIPTION", "COLOR")) for _, label := range labels { - table.AddField(label.Name, tableprinter.WithColor(cs.ColorFromRGB(label.Color))) + // Colorize the label using tableprinter's WithColor function for it to handle non-TTY situations + labelColor := tableprinter.WithColor(func(s string) string { + return cs.Label(label.Color, s) + }) + + table.AddField(label.Name, labelColor) table.AddField(label.Description) table.AddField("#" + label.Color) diff --git a/pkg/cmd/pr/checks/output.go b/pkg/cmd/pr/checks/output.go index 5d2f080c6..24105c3e2 100644 --- a/pkg/cmd/pr/checks/output.go +++ b/pkg/cmd/pr/checks/output.go @@ -30,7 +30,7 @@ func addRow(tp *tableprinter.TablePrinter, io *iostreams.IOStreams, o check) { markColor = cs.Yellow case "skipping", "cancel": mark = "-" - markColor = cs.Gray + markColor = cs.Muted } if io.IsStdoutTTY() { diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 0066bbc6e..eda7a3ce7 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -25,6 +25,7 @@ import ( "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/markdown" + o "github.com/cli/cli/v2/pkg/option" "github.com/spf13/cobra" ) @@ -72,18 +73,107 @@ type CreateOptions struct { DryRun bool } +// creationRefs is an interface that provides the necessary information for creating a pull request in the API. +// Upcasting to concrete implementations can provide further context on other operations (forking and pushing). +type creationRefs interface { + // QualifiedHeadRef returns a stringified form of the head ref, varying depending + // on whether the head ref is in the same repository as the base ref. If they are + // the same repository, we return the branch name only. If they are different repositories, + // we return the owner and branch name in the form :. + QualifiedHeadRef() string + // UnqualifiedHeadRef returns a head ref in the form of the branch name only. + UnqualifiedHeadRef() string + //BaseRef returns the base branch name. + BaseRef() string + + // While the only thing really required from an api.Repository is the repository ID, changing that + // would require changing the API function signatures, and the refactor that introduced this refs + // type is already large enough. + BaseRepo() *api.Repository +} + +type baseRefs struct { + baseRepo *api.Repository + baseBranchName string +} + +func (r baseRefs) BaseRef() string { + return r.baseBranchName +} + +func (r baseRefs) BaseRepo() *api.Repository { + return r.baseRepo +} + +// skipPushRefs indicate to handlePush that no pushing is required. +type skipPushRefs struct { + baseRefs + + qualifiedHeadRef shared.QualifiedHeadRef +} + +func (r skipPushRefs) QualifiedHeadRef() string { + return r.qualifiedHeadRef.String() +} + +func (r skipPushRefs) UnqualifiedHeadRef() string { + return r.qualifiedHeadRef.BranchName() +} + +// pushableRefs indicate to handlePush that pushing is required, +// and provide further information (HeadRepo) on where that push +// should go. +type pushableRefs struct { + baseRefs + + headRepo ghrepo.Interface + headBranchName string +} + +func (r pushableRefs) QualifiedHeadRef() string { + if ghrepo.IsSame(r.headRepo, r.baseRepo) { + return r.headBranchName + } + return fmt.Sprintf("%s:%s", r.headRepo.RepoOwner(), r.headBranchName) +} + +func (r pushableRefs) UnqualifiedHeadRef() string { + return r.headBranchName +} + +func (r pushableRefs) HeadRepo() ghrepo.Interface { + return r.headRepo +} + +// forkableRefs indicate to handlePush that forking is required before +// pushing. The expectation is that after forking, this is converted to +// pushableRefs. We could go very OOP and have a Fork method on this +// struct that returns a pushableRefs but then we'd need to embed an API client +// and it just seems nice that it is a simple bag of data. +type forkableRefs struct { + baseRefs + + qualifiedHeadRef shared.QualifiedHeadRef +} + +func (r forkableRefs) QualifiedHeadRef() string { + return r.qualifiedHeadRef.String() +} + +func (r forkableRefs) UnqualifiedHeadRef() string { + return r.qualifiedHeadRef.BranchName() +} + +// CreateContext stores contextual data about the creation process and is for building up enough +// data to create a pull request. type CreateContext struct { - // This struct stores contextual data about the creation process and is for building up enough - // data to create a pull request - RepoContext *ghContext.ResolvedRemotes - BaseRepo *api.Repository - HeadRepo ghrepo.Interface + ResolvedRemotes *ghContext.ResolvedRemotes + PRRefs creationRefs + // BaseTrackingBranch is perhaps a slightly leaky abstraction in the presence + // of PRRefs, but a huge amount of refactoring was done to introduce that struct, + // and this is a small price to pay for the convenience of not having to do a lot + // more design. BaseTrackingBranch string - BaseBranch string - HeadBranch string - HeadBranchLabel string - HeadRemote *ghContext.Remote - IsPushEnabled bool Client *api.Client GitClient *git.Client } @@ -113,6 +203,10 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co to push the branch and offer an option to fork the base repository. Use %[1]s--head%[1]s to explicitly skip any forking or pushing behavior. + %[1]s--head%[1]s supports %[1]s:%[1]s syntax to select a head repo owned by %[1]s%[1]s. + Using an organization as the %[1]s%[1]s is currently not supported. + For more information, see + A prompt will also ask for the title and the body of the pull request. Use %[1]s--title%[1]s and %[1]s--body%[1]s to skip this, or use %[1]s--fill%[1]s to autofill these values from git commits. It's important to notice that if the %[1]s--title%[1]s and/or %[1]s--body%[1]s are also provided @@ -310,8 +404,8 @@ func createRun(opts *CreateOptions) error { } existingPR, _, err := opts.Finder.Find(shared.FindOptions{ - Selector: ctx.HeadBranchLabel, - BaseBranch: ctx.BaseBranch, + Selector: ctx.PRRefs.QualifiedHeadRef(), + BaseBranch: ctx.PRRefs.BaseRef(), States: []string{"OPEN"}, Fields: []string{"url"}, }) @@ -321,7 +415,7 @@ func createRun(opts *CreateOptions) error { } if err == nil { return fmt.Errorf("a pull request for branch %q into branch %q already exists:\n%s", - ctx.HeadBranchLabel, ctx.BaseBranch, existingPR.URL) + ctx.PRRefs.QualifiedHeadRef(), ctx.PRRefs.BaseRef(), existingPR.URL) } message := "\nCreating pull request for %s into %s in %s\n\n" @@ -336,9 +430,9 @@ func createRun(opts *CreateOptions) error { if opts.IO.CanPrompt() { fmt.Fprintf(opts.IO.ErrOut, message, - cs.Cyan(ctx.HeadBranchLabel), - cs.Cyan(ctx.BaseBranch), - ghrepo.FullName(ctx.BaseRepo)) + cs.Cyan(ctx.PRRefs.QualifiedHeadRef()), + cs.Cyan(ctx.PRRefs.BaseRef()), + ghrepo.FullName(ctx.PRRefs.BaseRepo())) } if !opts.EditorMode && (opts.FillVerbose || opts.Autofill || opts.FillFirst || (opts.TitleProvided && opts.BodyProvided)) { @@ -361,7 +455,7 @@ func createRun(opts *CreateOptions) error { action = shared.SubmitDraftAction } - tpl := shared.NewTemplateManager(client.HTTP(), ctx.BaseRepo, opts.Prompter, opts.RootDirOverride, opts.RepoOverride == "", true) + tpl := shared.NewTemplateManager(client.HTTP(), ctx.PRRefs.BaseRepo(), opts.Prompter, opts.RootDirOverride, opts.RepoOverride == "", true) if opts.EditorMode { if opts.Template != "" { @@ -429,7 +523,7 @@ func createRun(opts *CreateOptions) error { } allowPreview := !state.HasMetadata() && shared.ValidURL(openURL) && !opts.DryRun - allowMetadata := ctx.BaseRepo.ViewerCanTriage() + allowMetadata := ctx.PRRefs.BaseRepo().ViewerCanTriage() action, err = shared.ConfirmPRSubmission(opts.Prompter, allowPreview, allowMetadata, state.Draft) if err != nil { return fmt.Errorf("unable to confirm: %w", err) @@ -439,10 +533,10 @@ func createRun(opts *CreateOptions) error { fetcher := &shared.MetadataFetcher{ IO: opts.IO, APIClient: client, - Repo: ctx.BaseRepo, + Repo: ctx.PRRefs.BaseRepo(), State: state, } - err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.BaseRepo, fetcher, state) + err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.PRRefs.BaseRepo(), fetcher, state) if err != nil { return err } @@ -485,11 +579,7 @@ func createRun(opts *CreateOptions) error { var regexPattern = regexp.MustCompile(`(?m)^`) func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState, useFirstCommit bool, addBody bool) error { - baseRef := ctx.BaseTrackingBranch - headRef := ctx.HeadBranch - gitClient := ctx.GitClient - - commits, err := gitClient.Commits(context.Background(), baseRef, headRef) + commits, err := ctx.GitClient.Commits(context.Background(), ctx.BaseTrackingBranch, ctx.PRRefs.UnqualifiedHeadRef()) if err != nil { return err } @@ -498,7 +588,7 @@ func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState, u state.Title = commits[len(commits)-1].Title state.Body = commits[len(commits)-1].Body } else { - state.Title = humanize(headRef) + state.Title = humanize(ctx.PRRefs.UnqualifiedHeadRef()) var body strings.Builder for i := len(commits) - 1; i >= 0; i-- { fmt.Fprintf(&body, "- **%s**\n", commits[i].Title) @@ -518,90 +608,13 @@ func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState, u return nil } -// TODO: Replace with the finder's PullRequestRefs struct -// trackingRef represents a ref for a remote tracking branch. -type trackingRef struct { - remoteName string - branchName string -} - -func (r trackingRef) String() string { - return "refs/remotes/" + r.remoteName + "/" + r.branchName -} - -func mustParseTrackingRef(text string) trackingRef { - parts := strings.SplitN(string(text), "/", 4) - // The only place this is called is tryDetermineTrackingRef, where we are reconstructing - // the same tracking ref we passed in. If it doesn't match the expected format, this is a - // programmer error we want to know about, so it's ok to panic. - if len(parts) != 4 { - panic(fmt.Errorf("tracking ref should have four parts: %s", text)) - } - if parts[0] != "refs" || parts[1] != "remotes" { - panic(fmt.Errorf("tracking ref should start with refs/remotes/: %s", text)) - } - - return trackingRef{ - remoteName: parts[2], - branchName: parts[3], - } -} - -// tryDetermineTrackingRef is intended to try and find a remote branch on the same commit as the currently checked out -// HEAD, i.e. the local branch. If there are multiple branches that might match, the first remote is chosen, which in -// practice is determined by the sorting algorithm applied much earlier in the process, roughly "upstream", "github", "origin", -// and then everything else unstably sorted. -func tryDetermineTrackingRef(gitClient *git.Client, remotes ghContext.Remotes, localBranchName string, headBranchConfig git.BranchConfig) (trackingRef, bool) { - // To try and determine the tracking ref for a local branch, we first construct a collection of refs - // that might be tracking, given the current branch's config, and the list of known remotes. - refsForLookup := []string{"HEAD"} - if headBranchConfig.RemoteName != "" && headBranchConfig.MergeRef != "" { - tr := trackingRef{ - remoteName: headBranchConfig.RemoteName, - branchName: strings.TrimPrefix(headBranchConfig.MergeRef, "refs/heads/"), - } - refsForLookup = append(refsForLookup, tr.String()) - } - - for _, remote := range remotes { - tr := trackingRef{ - remoteName: remote.Name, - branchName: localBranchName, - } - refsForLookup = append(refsForLookup, tr.String()) - } - - // Then we ask git for details about these refs, for example, refs/remotes/origin/trunk might return a hash - // for the remote tracking branch, trunk, for the remote, origin. If there is no ref, the git client returns - // no ref information. - // - // We also first check for the HEAD ref, so that we have the hash of the currently checked out commit. - resolvedRefs, _ := gitClient.ShowRefs(context.Background(), refsForLookup) - - // If there is more than one resolved ref, that means that at least one ref was found in addition to the HEAD. - if len(resolvedRefs) > 1 { - headRef := resolvedRefs[0] - for _, r := range resolvedRefs[1:] { - // If the hash of the remote ref doesn't match the hash of HEAD then the remote branch is not in the same - // state, so it can't be used. - if r.Hash != headRef.Hash { - continue - } - // Otherwise we can parse the returned ref into a tracking ref and return that - return mustParseTrackingRef(r.Name), true - } - } - - return trackingRef{}, false -} - func NewIssueState(ctx CreateContext, opts CreateOptions) (*shared.IssueMetadataState, error) { var milestoneTitles []string if opts.Milestone != "" { milestoneTitles = []string{opts.Milestone} } - meReplacer := shared.NewMeReplacer(ctx.Client, ctx.BaseRepo.RepoHost()) + meReplacer := shared.NewMeReplacer(ctx.Client, ctx.PRRefs.BaseRepo().RepoHost()) assignees, err := meReplacer.ReplaceSlice(opts.Assignees) if err != nil { return nil, err @@ -638,13 +651,14 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { if err != nil { return nil, err } - repoContext, err := ghContext.ResolveRemotesToRepos(remotes, client, opts.RepoOverride) + + resolvedRemotes, err := ghContext.ResolveRemotesToRepos(remotes, client, opts.RepoOverride) if err != nil { return nil, err } var baseRepo *api.Repository - if br, err := repoContext.BaseRepo(opts.IO); err == nil { + if br, err := resolvedRemotes.BaseRepo(opts.IO); err == nil { if r, ok := br.(*api.Repository); ok { baseRepo = r } else { @@ -659,137 +673,284 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { return nil, err } - isPushEnabled := false - headBranch := opts.HeadBranch - headBranchLabel := opts.HeadBranch - if headBranch == "" { - headBranch, err = opts.Branch() - if err != nil { - return nil, fmt.Errorf("could not determine the current branch: %w", err) + // This closure provides an easy way to instantiate a CreateContext with everything other than + // the refs. This probably indicates that CreateContext could do with some rework, but the refactor + // to introduce PRRefs is already large enough. + var newCreateContext = func(refs creationRefs) *CreateContext { + baseTrackingBranch := refs.BaseRef() + + // The baseTrackingBranch is used later for a command like: + // `git commit upstream/main feature` in order to create a PR message showing the commits + // between these two refs. I'm not really sure what is expected to happen if we don't have a remote, + // which seems like it would be possible with a command `gh pr create --repo owner/repo-that-is-not-a-remote`. + // In that case, we might just have a mess? In any case, this is what the old code did, so I don't want to change + // it as part of an already large refactor. + baseRemote, _ := resolvedRemotes.RemoteForRepo(baseRepo) + if baseRemote != nil { + baseTrackingBranch = fmt.Sprintf("%s/%s", baseRemote.Name, baseTrackingBranch) + } + + return &CreateContext{ + ResolvedRemotes: resolvedRemotes, + Client: client, + GitClient: opts.GitClient, + PRRefs: refs, + BaseTrackingBranch: baseTrackingBranch, } - headBranchLabel = headBranch - isPushEnabled = true - } else if idx := strings.IndexRune(headBranch, ':'); idx >= 0 { - headBranch = headBranch[idx+1:] } - gitClient := opts.GitClient - if ucc, err := gitClient.UncommittedChangeCount(context.Background()); err == nil && ucc > 0 { + // If the user provided a head branch we're going to use that without any interrogation + // of git. The value can take the form of or :. In the former case, the + // PR base and head repos are the same. In the latter case we don't know the head repo + // (though we could look it up in the API) but fortunately we don't need to because the API + // will resolve this for us when we create the pull request. This is possible because + // users can only have a single fork in their namespace, and organizations don't work at all with this ref format. + // + // Note that providing the head branch in this way indicates that we shouldn't push the branch, + // and we indicate that via the returned type as well. + if opts.HeadBranch != "" { + qualifiedHeadRef, err := shared.ParseQualifiedHeadRef(opts.HeadBranch) + if err != nil { + return nil, err + } + + branchConfig, err := opts.GitClient.ReadBranchConfig(context.Background(), qualifiedHeadRef.BranchName()) + if err != nil { + return nil, err + } + + baseBranch := opts.BaseBranch + if baseBranch == "" { + baseBranch = branchConfig.MergeBase + } + if baseBranch == "" { + baseBranch = baseRepo.DefaultBranchRef.Name + } + + return newCreateContext(skipPushRefs{ + qualifiedHeadRef: qualifiedHeadRef, + baseRefs: baseRefs{ + baseRepo: baseRepo, + baseBranchName: baseBranch, + }, + }), nil + } + + if ucc, err := opts.GitClient.UncommittedChangeCount(context.Background()); err == nil && ucc > 0 { fmt.Fprintf(opts.IO.ErrOut, "Warning: %s\n", text.Pluralize(ucc, "uncommitted change")) } - var headRepo ghrepo.Interface - var headRemote *ghContext.Remote + // If the user didn't provide a head branch then we're gettin' real. We're going to interrogate git + // and try to create refs that are pushable. + currentBranch, err := opts.Branch() + if err != nil { + return nil, fmt.Errorf("could not determine the current branch: %w", err) + } - headBranchConfig, err := gitClient.ReadBranchConfig(context.Background(), headBranch) + branchConfig, err := opts.GitClient.ReadBranchConfig(context.Background(), currentBranch) if err != nil { return nil, err } - if isPushEnabled { - // TODO: This doesn't respect the @{push} revision resolution or triagular workflows assembled with - // remote.pushDefault, or branch..pushremote config settings. The finder's ParsePRRefs - // may be able to replace this function entirely. - if trackingRef, found := tryDetermineTrackingRef(gitClient, remotes, headBranch, headBranchConfig); found { - isPushEnabled = false - if r, err := remotes.FindByName(trackingRef.remoteName); err == nil { - headRepo = r - headRemote = r - headBranchLabel = trackingRef.branchName - if !ghrepo.IsSame(baseRepo, headRepo) { - headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), trackingRef.branchName) - } - } - } - } - - // otherwise, ask the user for the head repository using info obtained from the API - if headRepo == nil && isPushEnabled && opts.IO.CanPrompt() { - pushableRepos, err := repoContext.HeadRepos() - if err != nil { - return nil, err - } - - if len(pushableRepos) == 0 { - pushableRepos, err = api.RepoFindForks(client, baseRepo, 3) - if err != nil { - return nil, err - } - } - - currentLogin, err := api.CurrentLoginName(client, baseRepo.RepoHost()) - if err != nil { - return nil, err - } - - hasOwnFork := false - var pushOptions []string - for _, r := range pushableRepos { - pushOptions = append(pushOptions, ghrepo.FullName(r)) - if r.RepoOwner() == currentLogin { - hasOwnFork = true - } - } - - if !hasOwnFork { - pushOptions = append(pushOptions, "Create a fork of "+ghrepo.FullName(baseRepo)) - } - pushOptions = append(pushOptions, "Skip pushing the branch") - pushOptions = append(pushOptions, "Cancel") - - selectedOption, err := opts.Prompter.Select(fmt.Sprintf("Where should we push the '%s' branch?", headBranch), "", pushOptions) - if err != nil { - return nil, err - } - - if selectedOption < len(pushableRepos) { - headRepo = pushableRepos[selectedOption] - if !ghrepo.IsSame(baseRepo, headRepo) { - headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), headBranch) - } - } else if pushOptions[selectedOption] == "Skip pushing the branch" { - isPushEnabled = false - } else if pushOptions[selectedOption] == "Cancel" { - return nil, cmdutil.CancelError - } else { - // "Create a fork of ..." - headBranchLabel = fmt.Sprintf("%s:%s", currentLogin, headBranch) - } - } - - if headRepo == nil && isPushEnabled && !opts.IO.CanPrompt() { - fmt.Fprintf(opts.IO.ErrOut, "aborted: you must first push the current branch to a remote, or use the --head flag") - return nil, cmdutil.SilentError - } baseBranch := opts.BaseBranch if baseBranch == "" { - baseBranch = headBranchConfig.MergeBase + baseBranch = branchConfig.MergeBase } if baseBranch == "" { baseBranch = baseRepo.DefaultBranchRef.Name } - if headBranch == baseBranch && headRepo != nil && ghrepo.IsSame(baseRepo, headRepo) { - return nil, fmt.Errorf("must be on a branch named differently than %q", baseBranch) + + // First we check with the git information we have to see if we can figure out the default + // head repo and remote branch name. + defaultPRHead, err := shared.TryDetermineDefaultPRHead( + // We requested the branch config already, so let's cache that + shared.CachedBranchConfigGitConfigClient{ + CachedBranchConfig: branchConfig, + GitConfigClient: opts.GitClient, + }, + shared.NewRemoteToRepoResolver(opts.Remotes), + currentBranch, + ) + if err != nil { + return nil, err } - baseTrackingBranch := baseBranch - if baseRemote, err := remotes.FindByRepo(baseRepo.RepoOwner(), baseRepo.RepoName()); err == nil { - baseTrackingBranch = fmt.Sprintf("%s/%s", baseRemote.Name, baseBranch) + // The baseRefs are always going to be the same from now on. If I could make this immutable I would! + baseRefs := baseRefs{ + baseRepo: baseRepo, + baseBranchName: baseBranch, } - return &CreateContext{ - BaseRepo: baseRepo, - HeadRepo: headRepo, - BaseBranch: baseBranch, - BaseTrackingBranch: baseTrackingBranch, - HeadBranch: headBranch, - HeadBranchLabel: headBranchLabel, - HeadRemote: headRemote, - IsPushEnabled: isPushEnabled, - RepoContext: repoContext, - Client: client, - GitClient: gitClient, - }, nil + // If we were able to determine a head repo, then let's check that the remote tracking ref matches the SHA of + // HEAD. If it does, then we don't need to push, otherwise we'll need to ask the user to tell us where to push. + if headRepo, present := defaultPRHead.Repo.Value(); present { + // We may not find a remote because the git branch config may have a URL rather than a remote name. + // Ideally, we would return a sentinel error from RemoteForRepo that we could compare to, but the + // refactor that introduced this code was already large enough. + headRemote, _ := resolvedRemotes.RemoteForRepo(headRepo) + if headRemote != nil { + resolvedRefs, _ := opts.GitClient.ShowRefs( + context.Background(), + []string{ + "HEAD", + fmt.Sprintf("refs/remotes/%s/%s", headRemote.Name, defaultPRHead.BranchName), + }, + ) + + // Two refs returned means we can compare HEAD to the remote tracking branch. + // If we had a matching ref, then we can skip pushing. + refsMatch := len(resolvedRefs) == 2 && resolvedRefs[0].Hash == resolvedRefs[1].Hash + if refsMatch { + qualifiedHeadRef := shared.NewQualifiedHeadRefWithoutOwner(defaultPRHead.BranchName) + if headRepo.RepoOwner() != baseRepo.RepoOwner() { + qualifiedHeadRef = shared.NewQualifiedHeadRef(headRepo.RepoOwner(), defaultPRHead.BranchName) + } + + return newCreateContext(skipPushRefs{ + qualifiedHeadRef: qualifiedHeadRef, + baseRefs: baseRefs, + }), nil + } + } + } + + // If we didn't determine that the git indicated repo had the correct ref, we'll take a look at the other + // remotes and see whether any of them have the same SHA as HEAD. Now, at this point, you might be asking yourself: + // "Why didn't we collect all the SHAs with a single ShowRefs command above, for use in both cases?" + // ... + // That's because the code below has a bug that I've ported from the old code, in order to preserve the existing + // behaviour, and to limit the scope of an already large refactor. The intention of the original code was to loop + // over all the returned refs. However, as it turns out, our implementation of ShowRefs doesn't do that correctly. + // Since it provides the --verify flag, git will return the SHAs for refs up until it hits a ref that doesn't exist, + // at which point it bails out. + // + // Imagine you have a remotes "upstream" and "origin", and you have pushed your branch "feature" to "origin". Since + // the order of remotes is always guaranteed "upstream", "github", "origin", and then everything else unstably sorted, + // we will never get a SHA for origin, as refs/remotes/upstream/feature doesn't exist. + // + // Furthermore, when you really think about it, this code is a bit eager. What happens if you have the same SHA on + // remotes "origin" and "colleague", this will always offer origin. If it were "colleague-a" and "colleague-b", no + // order would be guaranteed between different invocations of pr create, because the order of remotes after "origin" + // is unstable sorted. + // + // All that said, this has been the behaviour for a long, long time, and I do not want to make other behavioural changes + // in what is mostly a refactor. + refsToLookup := []string{"HEAD"} + for _, remote := range remotes { + refsToLookup = append(refsToLookup, fmt.Sprintf("refs/remotes/%s/%s", remote.Name, currentBranch)) + } + + // Ignoring the error in this case is allowed because we may get refs and an error (see: --verify flag above). + // Ideally there would be a typed error to allow us to distinguish between an execution error and some refs + // not existing. However, this is too much to take on in an already large refactor. + refs, _ := opts.GitClient.ShowRefs(context.Background(), refsToLookup) + if len(refs) > 1 { + headRef := refs[0] + var firstMatchingRef o.Option[git.RemoteTrackingRef] + // Loop over all the refs, trying to find one that matches the SHA of HEAD. + for _, r := range refs[1:] { + if r.Hash == headRef.Hash { + remoteTrackingRef, err := git.ParseRemoteTrackingRef(r.Name) + if err != nil { + return nil, err + } + + firstMatchingRef = o.Some(remoteTrackingRef) + break + } + } + + // If we found a matching ref, then we don't need to push. + if ref, present := firstMatchingRef.Value(); present { + remote, err := remotes.FindByName(ref.Remote) + if err != nil { + return nil, err + } + + qualifiedHeadRef := shared.NewQualifiedHeadRefWithoutOwner(ref.Branch) + if baseRepo.RepoOwner() != remote.RepoOwner() { + qualifiedHeadRef = shared.NewQualifiedHeadRef(remote.RepoOwner(), ref.Branch) + } + + return newCreateContext(skipPushRefs{ + qualifiedHeadRef: qualifiedHeadRef, + baseRefs: baseRefs, + }), nil + } + } + + // If we haven't got a repo by now, and we can't prompt then it's game over. + if !opts.IO.CanPrompt() { + fmt.Fprintln(opts.IO.ErrOut, "aborted: you must first push the current branch to a remote, or use the --head flag") + return nil, cmdutil.SilentError + } + + // Otherwise, hooray, prompting! + + // First, we're going to look at our remotes and decide whether there are any repos we can push to. + pushableRepos, err := resolvedRemotes.HeadRepos() + if err != nil { + return nil, err + } + + // If we couldn't find any pushable repos, then find forks of the base repo. + if len(pushableRepos) == 0 { + pushableRepos, err = api.RepoFindForks(client, baseRepo, 3) + if err != nil { + return nil, err + } + } + + currentLogin, err := api.CurrentLoginName(client, baseRepo.RepoHost()) + if err != nil { + return nil, err + } + + hasOwnFork := false + var pushOptions []string + for _, r := range pushableRepos { + pushOptions = append(pushOptions, ghrepo.FullName(r)) + if r.RepoOwner() == currentLogin { + hasOwnFork = true + } + } + + if !hasOwnFork { + pushOptions = append(pushOptions, fmt.Sprintf("Create a fork of %s", ghrepo.FullName(baseRepo))) + } + pushOptions = append(pushOptions, "Skip pushing the branch") + pushOptions = append(pushOptions, "Cancel") + + selectedOption, err := opts.Prompter.Select(fmt.Sprintf("Where should we push the '%s' branch?", currentBranch), "", pushOptions) + if err != nil { + return nil, err + } + + if selectedOption < len(pushableRepos) { + // A repository has been selected to push to. + return newCreateContext(pushableRefs{ + headRepo: pushableRepos[selectedOption], + headBranchName: currentBranch, + baseRefs: baseRefs, + }), nil + } else if pushOptions[selectedOption] == "Skip pushing the branch" { + // We're going to skip pushing the branch altogether, meaning, use whatever SHA is already pushed. + // It's not exactly clear what repo the user expects to use here for the HEAD, and maybe we should + // make that clear in the UX somehow, but in the old implementation as far as I can tell, this + // always meant "use the base repo". + return newCreateContext(skipPushRefs{ + qualifiedHeadRef: shared.NewQualifiedHeadRefWithoutOwner(currentBranch), + baseRefs: baseRefs, + }), nil + } else if pushOptions[selectedOption] == "Cancel" { + return nil, cmdutil.CancelError + } else { + // A fork should be created. + return newCreateContext(forkableRefs{ + qualifiedHeadRef: shared.NewQualifiedHeadRef(currentLogin, currentBranch), + baseRefs: baseRefs, + }), nil + } } func getRemotes(opts *CreateOptions) (ghContext.Remotes, error) { @@ -812,8 +973,8 @@ func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataS "title": state.Title, "body": state.Body, "draft": state.Draft, - "baseRefName": ctx.BaseBranch, - "headRefName": ctx.HeadBranchLabel, + "baseRefName": ctx.PRRefs.BaseRef(), + "headRefName": ctx.PRRefs.QualifiedHeadRef(), "maintainerCanModify": opts.MaintainerCanModify, } @@ -821,7 +982,7 @@ func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataS return errors.New("pull request title must not be blank") } - err := shared.AddMetadataToIssueParams(client, ctx.BaseRepo, params, &state) + err := shared.AddMetadataToIssueParams(client, ctx.PRRefs.BaseRepo(), params, &state) if err != nil { return err } @@ -835,7 +996,7 @@ func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataS } opts.IO.StartProgressIndicator() - pr, err := api.CreatePullRequest(client, ctx.BaseRepo, params) + pr, err := api.CreatePullRequest(client, ctx.PRRefs.BaseRepo(), params) opts.IO.StopProgressIndicator() if pr != nil { fmt.Fprintln(opts.IO.Out, pr.URL) @@ -879,37 +1040,37 @@ func renderPullRequestPlain(w io.Writer, params map[string]interface{}, state *s } func renderPullRequestTTY(io *iostreams.IOStreams, params map[string]interface{}, state *shared.IssueMetadataState) error { - iofmt := io.ColorScheme() + cs := io.ColorScheme() out := io.Out fmt.Fprint(out, "Would have created a Pull Request with:\n") - fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Title"), params["title"].(string)) - fmt.Fprintf(out, "%s: %t\n", iofmt.Bold("Draft"), params["draft"]) - fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Base"), params["baseRefName"]) - fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Head"), params["headRefName"]) + fmt.Fprintf(out, "%s: %s\n", cs.Bold("Title"), params["title"].(string)) + fmt.Fprintf(out, "%s: %t\n", cs.Bold("Draft"), params["draft"]) + fmt.Fprintf(out, "%s: %s\n", cs.Bold("Base"), params["baseRefName"]) + fmt.Fprintf(out, "%s: %s\n", cs.Bold("Head"), params["headRefName"]) if len(state.Labels) != 0 { - fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Labels"), strings.Join(state.Labels, ", ")) + fmt.Fprintf(out, "%s: %s\n", cs.Bold("Labels"), strings.Join(state.Labels, ", ")) } if len(state.Reviewers) != 0 { - fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Reviewers"), strings.Join(state.Reviewers, ", ")) + fmt.Fprintf(out, "%s: %s\n", cs.Bold("Reviewers"), strings.Join(state.Reviewers, ", ")) } if len(state.Assignees) != 0 { - fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Assignees"), strings.Join(state.Assignees, ", ")) + fmt.Fprintf(out, "%s: %s\n", cs.Bold("Assignees"), strings.Join(state.Assignees, ", ")) } if len(state.Milestones) != 0 { - fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Milestones"), strings.Join(state.Milestones, ", ")) + fmt.Fprintf(out, "%s: %s\n", cs.Bold("Milestones"), strings.Join(state.Milestones, ", ")) } if len(state.Projects) != 0 { - fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Projects"), strings.Join(state.Projects, ", ")) + fmt.Fprintf(out, "%s: %s\n", cs.Bold("Projects"), strings.Join(state.Projects, ", ")) } - fmt.Fprintf(out, "%s: %t\n", iofmt.Bold("MaintainerCanModify"), params["maintainerCanModify"]) + fmt.Fprintf(out, "%s: %t\n", cs.Bold("MaintainerCanModify"), params["maintainerCanModify"]) - fmt.Fprintf(out, "%s\n", iofmt.Bold("Body:")) + fmt.Fprintf(out, "%s\n", cs.Bold("Body:")) // Body var md string var err error if len(params["body"].(string)) == 0 { - md = fmt.Sprintf("%s\n", iofmt.Gray("No description provided")) + md = fmt.Sprintf("%s\n", cs.Muted("No description provided")) } else { md, err = markdown.Render(params["body"].(string), markdown.WithTheme(io.TerminalTheme()), @@ -931,38 +1092,43 @@ func previewPR(opts CreateOptions, openURL string) error { } func handlePush(opts CreateOptions, ctx CreateContext) error { - didForkRepo := false - headRepo := ctx.HeadRepo - headRemote := ctx.HeadRemote - client := ctx.Client - gitClient := ctx.GitClient - - var err error - // if a head repository could not be determined so far, automatically create - // one by forking the base repository - if headRepo == nil && ctx.IsPushEnabled { + refs := ctx.PRRefs + forkableRefs, requiresFork := refs.(forkableRefs) + if requiresFork { opts.IO.StartProgressIndicator() - headRepo, err = api.ForkRepo(client, ctx.BaseRepo, "", "", false) + forkedRepo, err := api.ForkRepo(ctx.Client, forkableRefs.BaseRepo(), "", "", false) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("error forking repo: %w", err) } - didForkRepo = true + + refs = pushableRefs{ + headRepo: forkedRepo, + headBranchName: forkableRefs.qualifiedHeadRef.BranchName(), + baseRefs: baseRefs{ + baseRepo: forkableRefs.baseRepo, + baseBranchName: forkableRefs.baseBranchName, + }, + } } - if headRemote == nil && headRepo != nil { - headRemote, _ = ctx.RepoContext.RemoteForRepo(headRepo) + // We may have upcast to pushableRefs on fork, or we may have been passed an instance + // already. But if we haven't, then there's nothing more to do. + pushableRefs, ok := refs.(pushableRefs) + if !ok { + return nil } // There are two cases when an existing remote for the head repo will be - // missing: + // missing (and an error will be returned): // 1. the head repo was just created by auto-forking; // 2. an existing fork was discovered by querying the API. // In either case, we want to add the head repo as a new git remote so we // can push to it. We will try to add the head repo as the "origin" remote // and fallback to the "fork" remote if it is unavailable. Also, if the // base repo is the "origin" remote we will rename it "upstream". - if headRemote == nil && ctx.IsPushEnabled { + headRemote, _ := ctx.ResolvedRemotes.RemoteForRepo(pushableRefs.HeadRepo()) + if headRemote == nil { cfg, err := opts.Config() if err != nil { return err @@ -973,8 +1139,8 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { return err } - cloneProtocol := cfg.GitProtocol(headRepo.RepoHost()).Value - headRepoURL := ghrepo.FormatRemoteURL(headRepo, cloneProtocol) + cloneProtocol := cfg.GitProtocol(pushableRefs.HeadRepo().RepoHost()).Value + headRepoURL := ghrepo.FormatRemoteURL(pushableRefs.HeadRepo(), cloneProtocol) gitClient := ctx.GitClient origin, _ := remotes.FindByName("origin") upstreamName := "upstream" @@ -985,7 +1151,7 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { remoteName = "fork" } - if origin != nil && upstream == nil && ghrepo.IsSame(origin, ctx.BaseRepo) { + if origin != nil && upstream == nil && ghrepo.IsSame(origin, pushableRefs.BaseRepo()) { renameCmd, err := gitClient.Command(context.Background(), "remote", "rename", "origin", upstreamName) if err != nil { return err @@ -994,7 +1160,7 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { return fmt.Errorf("error renaming origin remote: %w", err) } remoteName = "origin" - fmt.Fprintf(opts.IO.ErrOut, "Changed %s remote to %q\n", ghrepo.FullName(ctx.BaseRepo), upstreamName) + fmt.Fprintf(opts.IO.ErrOut, "Changed %s remote to %q\n", ghrepo.FullName(pushableRefs.BaseRepo()), upstreamName) } gitRemote, err := gitClient.AddRemote(context.Background(), remoteName, headRepoURL, []string{}) @@ -1002,10 +1168,10 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { return fmt.Errorf("error adding remote: %w", err) } - fmt.Fprintf(opts.IO.ErrOut, "Added %s as remote %q\n", ghrepo.FullName(headRepo), remoteName) + fmt.Fprintf(opts.IO.ErrOut, "Added %s as remote %q\n", ghrepo.FullName(pushableRefs.HeadRepo()), remoteName) // Only mark `upstream` remote as default if `gh pr create` created the remote. - if didForkRepo { + if requiresFork { err := gitClient.SetRemoteResolution(context.Background(), upstreamName, "base") if err != nil { return fmt.Errorf("error setting upstream as default: %w", err) @@ -1013,52 +1179,45 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { if opts.IO.IsStdoutTTY() { cs := opts.IO.ColorScheme() - fmt.Fprintf(opts.IO.ErrOut, "%s Repository %s set as the default repository. To learn more about the default repository, run: gh repo set-default --help\n", cs.WarningIcon(), cs.Bold(ghrepo.FullName(headRepo))) + fmt.Fprintf(opts.IO.ErrOut, "%s Repository %s set as the default repository. To learn more about the default repository, run: gh repo set-default --help\n", cs.WarningIcon(), cs.Bold(ghrepo.FullName(pushableRefs.HeadRepo()))) } } headRemote = &ghContext.Remote{ Remote: gitRemote, - Repo: headRepo, + Repo: pushableRefs.HeadRepo(), } } // automatically push the branch if it hasn't been pushed anywhere yet - if ctx.IsPushEnabled { - pushBranch := func() error { - w := NewRegexpWriter(opts.IO.ErrOut, gitPushRegexp, "") - defer w.Flush() - ref := fmt.Sprintf("HEAD:refs/heads/%s", ctx.HeadBranch) - bo := backoff.NewConstantBackOff(2 * time.Second) - ctx := context.Background() - return backoff.Retry(func() error { - if err := gitClient.Push(ctx, headRemote.Name, ref, git.WithStderr(w)); err != nil { - // Only retry if we have forked the repo else the push should succeed the first time. - if didForkRepo { - fmt.Fprintf(opts.IO.ErrOut, "waiting 2 seconds before retrying...\n") - return err - } - return backoff.Permanent(err) + pushBranch := func() error { + w := NewRegexpWriter(opts.IO.ErrOut, gitPushRegexp, "") + defer w.Flush() + ref := fmt.Sprintf("HEAD:refs/heads/%s", ctx.PRRefs.UnqualifiedHeadRef()) + bo := backoff.NewConstantBackOff(2 * time.Second) + root := context.Background() + return backoff.Retry(func() error { + if err := ctx.GitClient.Push(root, headRemote.Name, ref, git.WithStderr(w)); err != nil { + // Only retry if we have forked the repo else the push should succeed the first time. + if requiresFork { + fmt.Fprintf(opts.IO.ErrOut, "waiting 2 seconds before retrying...\n") + return err } - return nil - }, backoff.WithContext(backoff.WithMaxRetries(bo, 3), ctx)) - } - - err := pushBranch() - if err != nil { - return err - } + return backoff.Permanent(err) + } + return nil + }, backoff.WithContext(backoff.WithMaxRetries(bo, 3), root)) } - return nil + return pushBranch() } func generateCompareURL(ctx CreateContext, state shared.IssueMetadataState) (string, error) { u := ghrepo.GenerateRepoURL( - ctx.BaseRepo, + ctx.PRRefs.BaseRepo(), "compare/%s...%s?expand=1", - url.PathEscape(ctx.BaseBranch), url.PathEscape(ctx.HeadBranchLabel)) - url, err := shared.WithPrAndIssueQueryParams(ctx.Client, ctx.BaseRepo, u, state) + url.PathEscape(ctx.PRRefs.BaseRef()), url.PathEscape(ctx.PRRefs.QualifiedHeadRef())) + url, err := shared.WithPrAndIssueQueryParams(ctx.Client, ctx.PRRefs.BaseRepo(), u, state) if err != nil { return "", err } diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 55012d7dd..2a88b5eee 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -2,7 +2,6 @@ package create import ( "encoding/json" - "errors" "fmt" "net/http" "os" @@ -607,7 +606,7 @@ func Test_createRun(t *testing.T) { `), }, { - name: "survey", + name: "select a specific branch to push to on prompt", tty: true, setup: func(opts *CreateOptions, t *testing.T) func() { opts.TitleProvided = true @@ -636,7 +635,9 @@ func Test_createRun(t *testing.T) { })) }, cmdStubs: func(cs *run.CommandStubber) { - cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "") + cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature") + cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") + cs.Register("git show-ref --verify -- HEAD refs/remotes/origin/feature", 1, "") cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { @@ -651,6 +652,52 @@ func Test_createRun(t *testing.T) { expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n", }, + { + name: "skip pushing to branch on prompt", + tty: true, + setup: func(opts *CreateOptions, t *testing.T) func() { + opts.TitleProvided = true + opts.BodyProvided = true + opts.Title = "my title" + opts.Body = "my body" + return func() {} + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.StubRepoResponse("OWNER", "REPO") + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`)) + reg.Register( + httpmock.GraphQL(`mutation PullRequestCreate\b`), + httpmock.GraphQLMutation(` + { "data": { "createPullRequest": { "pullRequest": { + "URL": "https://github.com/OWNER/REPO/pull/12" + } } } }`, func(input map[string]interface{}) { + assert.Equal(t, "REPOID", input["repositoryId"].(string)) + assert.Equal(t, "my title", input["title"].(string)) + assert.Equal(t, "my body", input["body"].(string)) + assert.Equal(t, "master", input["baseRefName"].(string)) + assert.Equal(t, "feature", input["headRefName"].(string)) + assert.Equal(t, false, input["draft"].(bool)) + })) + }, + cmdStubs: func(cs *run.CommandStubber) { + cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature") + cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") + cs.Register("git show-ref --verify -- HEAD refs/remotes/origin/feature", 1, "") + }, + promptStubs: func(pm *prompter.PrompterMock) { + pm.SelectFunc = func(p, _ string, opts []string) (int, error) { + if p == "Where should we push the 'feature' branch?" { + return prompter.IndexFor(opts, "Skip pushing the branch") + } else { + return -1, prompter.NoSuchPromptErr(p) + } + } + }, + expectedOut: "https://github.com/OWNER/REPO/pull/12\n", + expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n", + }, { name: "project v2", tty: true, @@ -699,7 +746,9 @@ func Test_createRun(t *testing.T) { })) }, cmdStubs: func(cs *run.CommandStubber) { - cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "") + cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature") + cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") + cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { @@ -745,7 +794,9 @@ func Test_createRun(t *testing.T) { })) }, cmdStubs: func(cs *run.CommandStubber) { - cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "") + cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature") + cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") + cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { @@ -794,7 +845,10 @@ func Test_createRun(t *testing.T) { })) }, cmdStubs: func(cs *run.CommandStubber) { - cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "") + cs.Register("git rev-parse --symbolic-full-name feature@{push}", 1, "") + cs.Register("git config remote.pushDefault", 1, "") + cs.Register("git config push.default", 1, "") + cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") cs.Register("git remote rename origin upstream", 0, "") cs.Register(`git remote add origin https://github.com/monalisa/REPO.git`, 0, "") cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "") @@ -853,10 +907,10 @@ func Test_createRun(t *testing.T) { })) }, cmdStubs: func(cs *run.CommandStubber) { - cs.Register("git show-ref --verify", 0, heredoc.Doc(` - deadbeef HEAD - deadb00f refs/remotes/upstream/feature - deadbeef refs/remotes/origin/feature`)) // determineTrackingBranch + cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature") + cs.Register("git show-ref --verify -- HEAD refs/remotes/origin/feature", 0, heredoc.Doc(` + deadbeef HEAD + deadbeef refs/remotes/origin/feature`)) }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "\nCreating pull request for monalisa:feature into master in OWNER/REPO\n\n", @@ -889,11 +943,12 @@ func Test_createRun(t *testing.T) { cs.Register(`git config --get-regexp \^branch\\\.feature\\\.`, 0, heredoc.Doc(` branch.feature.remote origin branch.feature.merge refs/heads/my-feat2 - `)) // determineTrackingBranch - cs.Register("git show-ref --verify", 0, heredoc.Doc(` + `)) + cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/my-feat2") + cs.Register("git show-ref --verify -- HEAD refs/remotes/origin/my-feat2", 0, heredoc.Doc(` deadbeef HEAD deadbeef refs/remotes/origin/my-feat2 - `)) // determineTrackingBranch + `)) }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "\nCreating pull request for my-feat2 into master in OWNER/REPO\n\n", @@ -1073,8 +1128,10 @@ func Test_createRun(t *testing.T) { httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`)) }, cmdStubs: func(cs *run.CommandStubber) { - cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "") cs.Register(`git( .+)? log( .+)? origin/master\.\.\.feature`, 0, "") + cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature") + cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") + cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { @@ -1105,8 +1162,10 @@ func Test_createRun(t *testing.T) { mockRetrieveProjects(t, reg) }, cmdStubs: func(cs *run.CommandStubber) { - cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "") cs.Register(`git( .+)? log( .+)? origin/master\.\.\.feature`, 0, "") + cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature") + cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") + cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { @@ -1271,31 +1330,6 @@ func Test_createRun(t *testing.T) { }, wantErr: "cannot open in browser: maximum URL length exceeded", }, - { - name: "no local git repo", - setup: func(opts *CreateOptions, t *testing.T) func() { - opts.Title = "My PR" - opts.TitleProvided = true - opts.Body = "" - opts.BodyProvided = true - opts.HeadBranch = "feature" - opts.RepoOverride = "OWNER/REPO" - opts.Remotes = func() (context.Remotes, error) { - return nil, errors.New("not a git repository") - } - return func() {} - }, - httpStubs: func(reg *httpmock.Registry, t *testing.T) { - reg.Register( - httpmock.GraphQL(`mutation PullRequestCreate\b`), - httpmock.StringResponse(` - { "data": { "createPullRequest": { "pullRequest": { - "URL": "https://github.com/OWNER/REPO/pull/12" - } } } } - `)) - }, - expectedOut: "https://github.com/OWNER/REPO/pull/12\n", - }, { name: "single commit title and body are used", tty: true, @@ -1520,19 +1554,45 @@ func Test_createRun(t *testing.T) { branch.task1.remote origin branch.task1.merge refs/heads/task1 branch.task1.gh-merge-base feature/feat2`)) // ReadBranchConfig - cs.Register(`git show-ref --verify`, 0, heredoc.Doc(` + cs.Register("git rev-parse --symbolic-full-name task1@{push}", 0, "refs/remotes/origin/task1") + cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/task1`, 0, heredoc.Doc(` deadbeef HEAD - deadb00f refs/remotes/upstream/feature/feat2 - deadbeef refs/remotes/origin/task1`)) // determineTrackingBranch + deadbeef refs/remotes/origin/task1`)) }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "\nCreating pull request for monalisa:task1 into feature/feat2 in OWNER/REPO\n\n", }, + { + name: "--head contains : syntax", + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`mutation PullRequestCreate\b`), + httpmock.GraphQLMutation(` + { "data": { "createPullRequest": { "pullRequest": { + "URL": "https://github.com/OWNER/REPO/pull/12" + } } } }`, + func(input map[string]interface{}) { + assert.Equal(t, "REPOID", input["repositoryId"]) + assert.Equal(t, "my title", input["title"]) + assert.Equal(t, "my body", input["body"]) + assert.Equal(t, "master", input["baseRefName"]) + assert.Equal(t, "otherowner:feature", input["headRefName"]) + })) + }, + setup: func(opts *CreateOptions, t *testing.T) func() { + opts.TitleProvided = true + opts.BodyProvided = true + opts.Title = "my title" + opts.Body = "my body" + opts.HeadBranch = "otherowner:feature" + return func() {} + }, + expectedOut: "https://github.com/OWNER/REPO/pull/12\n", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { branch := "feature" - reg := &httpmock.Registry{} reg.StubRepoInfoResponse("OWNER", "REPO", "master") defer reg.Verify(t) @@ -1548,7 +1608,7 @@ func Test_createRun(t *testing.T) { cs, cmdTeardown := run.Stub() defer cmdTeardown(t) - cs.Register(`git status --porcelain`, 0, "") + if !tt.customBranchConfig { cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "") } @@ -1599,6 +1659,10 @@ func Test_createRun(t *testing.T) { } defer cleanSetup() + if opts.HeadBranch == "" { + cs.Register(`git status --porcelain`, 0, "") + } + err := createRun(&opts) output := &test.CmdOut{ OutBuf: stdout, @@ -1622,109 +1686,166 @@ func Test_createRun(t *testing.T) { } } -func Test_tryDetermineTrackingRef(t *testing.T) { - tests := []struct { - name string - cmdStubs func(*run.CommandStubber) - headBranchConfig git.BranchConfig - remotes context.Remotes - expectedTrackingRef trackingRef - expectedFound bool - }{ - { - name: "empty", - cmdStubs: func(cs *run.CommandStubber) { - cs.Register(`git show-ref --verify -- HEAD`, 0, "abc HEAD") - }, - headBranchConfig: git.BranchConfig{}, - expectedTrackingRef: trackingRef{}, - expectedFound: false, +func TestRemoteGuessing(t *testing.T) { + // Given git config does not provide the necessary info to determine a remote + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + cs.Register(`git status --porcelain`, 0, "") + cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "") + cs.Register(`git rev-parse --symbolic-full-name feature@{push}`, 1, "") + cs.Register("git config remote.pushDefault", 1, "") + cs.Register("git config push.default", 1, "") + + // And Given there is a remote on a SHA that matches the current HEAD + cs.Register(`git show-ref --verify -- HEAD refs/remotes/upstream/feature refs/remotes/origin/feature`, 0, heredoc.Doc(` + deadbeef HEAD + deadb00f refs/remotes/upstream/feature + deadbeef refs/remotes/origin/feature`)) + + // When the command is run + reg := &httpmock.Registry{} + reg.StubRepoInfoResponse("OWNER", "REPO", "master") + defer reg.Verify(t) + + reg.Register( + httpmock.GraphQL(`mutation PullRequestCreate\b`), + httpmock.GraphQLMutation(` + { "data": { "createPullRequest": { "pullRequest": { + "URL": "https://github.com/OWNER/REPO/pull/12" + } } } }`, func(input map[string]interface{}) { + assert.Equal(t, "REPOID", input["repositoryId"].(string)) + assert.Equal(t, "master", input["baseRefName"].(string)) + assert.Equal(t, "OTHEROWNER:feature", input["headRefName"].(string)) + })) + + ios, _, _, _ := iostreams.Test() + + opts := CreateOptions{ + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil }, - { - name: "no match", - cmdStubs: func(cs *run.CommandStubber) { - cs.Register("git show-ref --verify -- HEAD refs/remotes/upstream/feature refs/remotes/origin/feature", 0, "abc HEAD\nbca refs/remotes/upstream/feature") - }, - headBranchConfig: git.BranchConfig{}, - remotes: context.Remotes{ - &context.Remote{ - Remote: &git.Remote{Name: "upstream"}, - Repo: ghrepo.New("octocat", "Spoon-Knife"), - }, - &context.Remote{ - Remote: &git.Remote{Name: "origin"}, - Repo: ghrepo.New("hubot", "Spoon-Knife"), - }, - }, - expectedTrackingRef: trackingRef{}, - expectedFound: false, + Config: func() (gh.Config, error) { + return config.NewBlankConfig(), nil }, - { - name: "match", - cmdStubs: func(cs *run.CommandStubber) { - cs.Register(`git show-ref --verify -- HEAD refs/remotes/upstream/feature refs/remotes/origin/feature$`, 0, heredoc.Doc(` - deadbeef HEAD - deadb00f refs/remotes/upstream/feature - deadbeef refs/remotes/origin/feature - `)) - }, - headBranchConfig: git.BranchConfig{}, - remotes: context.Remotes{ - &context.Remote{ - Remote: &git.Remote{Name: "upstream"}, - Repo: ghrepo.New("octocat", "Spoon-Knife"), - }, - &context.Remote{ - Remote: &git.Remote{Name: "origin"}, - Repo: ghrepo.New("hubot", "Spoon-Knife"), - }, - }, - expectedTrackingRef: trackingRef{ - remoteName: "origin", - branchName: "feature", - }, - expectedFound: true, + Browser: &browser.Stub{}, + IO: ios, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{ + GhPath: "some/path/gh", + GitPath: "some/path/git", }, - { - name: "respect tracking config", - cmdStubs: func(cs *run.CommandStubber) { - cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/great-feat refs/remotes/origin/feature$`, 0, heredoc.Doc(` - deadbeef HEAD - deadb00f refs/remotes/origin/feature - `)) - }, - headBranchConfig: git.BranchConfig{ - RemoteName: "origin", - MergeRef: "refs/heads/great-feat", - }, - remotes: context.Remotes{ - &context.Remote{ - Remote: &git.Remote{Name: "origin"}, - Repo: ghrepo.New("hubot", "Spoon-Knife"), + Finder: shared.NewMockFinder("feature", nil, nil), + Remotes: func() (context.Remotes, error) { + return context.Remotes{ + { + Remote: &git.Remote{ + Name: "upstream", + Resolved: "base", + }, + Repo: ghrepo.New("OWNER", "REPO"), }, - }, - expectedTrackingRef: trackingRef{}, - expectedFound: false, + { + Remote: &git.Remote{ + Name: "origin", + }, + Repo: ghrepo.New("OTHEROWNER", "REPO-FORK"), + }, + }, nil }, + Branch: func() (string, error) { + return "feature", nil + }, + + TitleProvided: true, + BodyProvided: true, + Title: "my title", + Body: "my body", } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cs, cmdTeardown := run.Stub() - defer cmdTeardown(t) - tt.cmdStubs(cs) + require.NoError(t, createRun(&opts)) - gitClient := &git.Client{ - GhPath: "some/path/gh", - GitPath: "some/path/git", - } + // Then guessed remote is used for the PR head, + // which annoyingly, is asserted above on the line: + // assert.Equal(t, "OTHEROWNER:feature", input["headRefName"].(string)) + // + // This is because OTHEROWNER relates to the "origin" remote, which has a + // SHA that matches the HEAD ref in the `git show-ref` output. +} - ref, found := tryDetermineTrackingRef(gitClient, tt.remotes, "feature", tt.headBranchConfig) +func TestNoRepoCanBeDetermined(t *testing.T) { + // Given no head repo can be determined from git config + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) - assert.Equal(t, tt.expectedTrackingRef, ref) - assert.Equal(t, tt.expectedFound, found) - }) + cs.Register(`git status --porcelain`, 0, "") + cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "") + cs.Register(`git rev-parse --symbolic-full-name feature@{push}`, 1, "") + cs.Register("git config remote.pushDefault", 1, "") + cs.Register("git config push.default", 1, "") + + // And Given there is no remote on the correct SHA + cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, heredoc.Doc(` + deadbeef HEAD + deadb00f refs/remotes/origin/feature`)) + + // When the command is run with no TTY + reg := &httpmock.Registry{} + reg.StubRepoInfoResponse("OWNER", "REPO", "master") + defer reg.Verify(t) + + ios, _, _, stderr := iostreams.Test() + + opts := CreateOptions{ + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Config: func() (gh.Config, error) { + return config.NewBlankConfig(), nil + }, + Browser: &browser.Stub{}, + IO: ios, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{ + GhPath: "some/path/gh", + GitPath: "some/path/git", + }, + Finder: shared.NewMockFinder("feature", nil, nil), + Remotes: func() (context.Remotes, error) { + return context.Remotes{ + { + Remote: &git.Remote{ + Name: "origin", + Resolved: "base", + }, + Repo: ghrepo.New("OWNER", "REPO"), + }, + }, nil + }, + Branch: func() (string, error) { + return "feature", nil + }, + + TitleProvided: true, + BodyProvided: true, + Title: "my title", + Body: "my body", } + + // When we run the command + err := createRun(&opts) + + // Then create fails + require.Equal(t, cmdutil.SilentError, err) + assert.Equal(t, "aborted: you must first push the current branch to a remote, or use the --head flag\n", stderr.String()) +} + +func mustParseQualifiedHeadRef(ref string) shared.QualifiedHeadRef { + parsed, err := shared.ParseQualifiedHeadRef(ref) + if err != nil { + panic(err) + } + return parsed } func Test_generateCompareURL(t *testing.T) { @@ -1738,9 +1859,13 @@ func Test_generateCompareURL(t *testing.T) { { name: "basic", ctx: CreateContext{ - BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), - BaseBranch: "main", - HeadBranchLabel: "feature", + PRRefs: &skipPushRefs{ + qualifiedHeadRef: shared.NewQualifiedHeadRefWithoutOwner("feature"), + baseRefs: baseRefs{ + baseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + baseBranchName: "main", + }, + }, }, want: "https://github.com/OWNER/REPO/compare/main...feature?body=&expand=1", wantErr: false, @@ -1748,9 +1873,13 @@ func Test_generateCompareURL(t *testing.T) { { name: "with labels", ctx: CreateContext{ - BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), - BaseBranch: "a", - HeadBranchLabel: "b", + PRRefs: &skipPushRefs{ + qualifiedHeadRef: shared.NewQualifiedHeadRefWithoutOwner("b"), + baseRefs: baseRefs{ + baseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + baseBranchName: "a", + }, + }, }, state: shared.IssueMetadataState{ Labels: []string{"one", "two three"}, @@ -1761,35 +1890,47 @@ func Test_generateCompareURL(t *testing.T) { { name: "'/'s in branch names/labels are percent-encoded", ctx: CreateContext{ - BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), - BaseBranch: "main/trunk", - HeadBranchLabel: "owner:feature", + PRRefs: &skipPushRefs{ + qualifiedHeadRef: mustParseQualifiedHeadRef("ORIGINOWNER:feature"), + baseRefs: baseRefs{ + baseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "UPSTREAMOWNER"}}, "github.com"), + baseBranchName: "main/trunk", + }, + }, }, - want: "https://github.com/OWNER/REPO/compare/main%2Ftrunk...owner:feature?body=&expand=1", + want: "https://github.com/UPSTREAMOWNER/REPO/compare/main%2Ftrunk...ORIGINOWNER:feature?body=&expand=1", wantErr: false, }, { name: "Any of !'(),; but none of $&+=@ and : in branch names/labels are percent-encoded ", /* - - Technically, per section 3.3 of RFC 3986, none of !$&'()*+,;= (sub-delims) and :[]@ (part of gen-delims) in path segments are optionally percent-encoded, but url.PathEscape percent-encodes !'(),; anyway - - !$&'()+,;=@ is a valid Git branch name—essentially RFC 3986 sub-delims without * and gen-delims without :/?#[] - - : is GitHub separator between a fork name and a branch name - - See https://github.com/golang/go/issues/27559. + - Technically, per section 3.3 of RFC 3986, none of !$&'()*+,;= (sub-delims) and :[]@ (part of gen-delims) in path segments are optionally percent-encoded, but url.PathEscape percent-encodes !'(),; anyway + - !$&'()+,;=@ is a valid Git branch name—essentially RFC 3986 sub-delims without * and gen-delims without :/?#[] + - : is GitHub separator between a fork name and a branch name + - See https://github.com/golang/go/issues/27559. */ ctx: CreateContext{ - BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), - BaseBranch: "main/trunk", - HeadBranchLabel: "owner:!$&'()+,;=@", + PRRefs: &skipPushRefs{ + qualifiedHeadRef: mustParseQualifiedHeadRef("ORIGINOWNER:!$&'()+,;=@"), + baseRefs: baseRefs{ + baseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "UPSTREAMOWNER"}}, "github.com"), + baseBranchName: "main/trunk", + }, + }, }, - want: "https://github.com/OWNER/REPO/compare/main%2Ftrunk...owner:%21$&%27%28%29+%2C%3B=@?body=&expand=1", + want: "https://github.com/UPSTREAMOWNER/REPO/compare/main%2Ftrunk...ORIGINOWNER:%21$&%27%28%29+%2C%3B=@?body=&expand=1", wantErr: false, }, { name: "with template", ctx: CreateContext{ - BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), - BaseBranch: "main", - HeadBranchLabel: "feature", + PRRefs: &skipPushRefs{ + qualifiedHeadRef: shared.NewQualifiedHeadRefWithoutOwner("feature"), + baseRefs: baseRefs{ + baseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + baseBranchName: "main", + }, + }, }, state: shared.IssueMetadataState{ Template: "story.md", diff --git a/pkg/cmd/pr/review/review.go b/pkg/cmd/pr/review/review.go index 25f81d973..cafa6ce8f 100644 --- a/pkg/cmd/pr/review/review.go +++ b/pkg/cmd/pr/review/review.go @@ -191,7 +191,7 @@ func reviewRun(opts *ReviewOptions) error { switch reviewData.State { case api.ReviewComment: - fmt.Fprintf(opts.IO.ErrOut, "%s Reviewed pull request %s#%d\n", cs.Gray("-"), ghrepo.FullName(baseRepo), pr.Number) + fmt.Fprintf(opts.IO.ErrOut, "%s Reviewed pull request %s#%d\n", cs.Muted("-"), ghrepo.FullName(baseRepo), pr.Number) case api.ReviewApprove: fmt.Fprintf(opts.IO.ErrOut, "%s Approved pull request %s#%d\n", cs.SuccessIcon(), ghrepo.FullName(baseRepo), pr.Number) case api.ReviewRequestChanges: diff --git a/pkg/cmd/pr/shared/comments.go b/pkg/cmd/pr/shared/comments.go index a05108d7b..7c6e9154c 100644 --- a/pkg/cmd/pr/shared/comments.go +++ b/pkg/cmd/pr/shared/comments.go @@ -62,7 +62,7 @@ func CommentList(io *iostreams.IOStreams, comments api.Comments, reviews api.Pul hiddenCount := totalCount - retrievedCount if preview && hiddenCount > 0 { - fmt.Fprint(&b, cs.Gray(fmt.Sprintf("———————— Not showing %s ————————", text.Pluralize(hiddenCount, "comment")))) + fmt.Fprint(&b, cs.Muted(fmt.Sprintf("———————— Not showing %s ————————", text.Pluralize(hiddenCount, "comment")))) fmt.Fprintf(&b, "\n\n\n") } @@ -79,7 +79,7 @@ func CommentList(io *iostreams.IOStreams, comments api.Comments, reviews api.Pul } if preview && hiddenCount > 0 { - fmt.Fprint(&b, cs.Gray("Use --comments to view the full conversation")) + fmt.Fprint(&b, cs.Muted("Use --comments to view the full conversation")) fmt.Fprintln(&b) } @@ -122,7 +122,7 @@ func formatComment(io *iostreams.IOStreams, comment Comment, newest bool) (strin var md string var err error if comment.Content() == "" { - md = fmt.Sprintf("\n %s\n\n", cs.Gray("No body provided")) + md = fmt.Sprintf("\n %s\n\n", cs.Muted("No body provided")) } else { md, err = markdown.Render(comment.Content(), markdown.WithTheme(io.TerminalTheme()), @@ -135,7 +135,7 @@ func formatComment(io *iostreams.IOStreams, comment Comment, newest bool) (strin // Footer if comment.Link() != "" { - fmt.Fprintf(&b, cs.Gray("View the full review: %s\n\n"), comment.Link()) + fmt.Fprintf(&b, cs.Muted("View the full review: %s\n\n"), comment.Link()) } return b.String(), nil diff --git a/pkg/cmd/pr/shared/display.go b/pkg/cmd/pr/shared/display.go index 02482951c..b4d83c719 100644 --- a/pkg/cmd/pr/shared/display.go +++ b/pkg/cmd/pr/shared/display.go @@ -60,7 +60,7 @@ func PrintHeader(io *iostreams.IOStreams, s string) { } func PrintMessage(io *iostreams.IOStreams, s string) { - fmt.Fprintln(io.Out, io.ColorScheme().Gray(s)) + fmt.Fprintln(io.Out, io.ColorScheme().Muted(s)) } func ListNoResults(repoName string, itemName string, hasFilters bool) error { @@ -83,7 +83,7 @@ func ListHeader(repoName string, itemName string, matchCount int, totalMatchCoun } func PrCheckStatusSummaryWithColor(cs *iostreams.ColorScheme, checks api.PullRequestChecksStatus) string { - var summary = cs.Gray("No checks") + var summary = cs.Muted("No checks") if checks.Total > 0 { if checks.Failing > 0 { if checks.Failing == checks.Total { diff --git a/pkg/cmd/pr/shared/find_refs_resolution.go b/pkg/cmd/pr/shared/find_refs_resolution.go new file mode 100644 index 000000000..833075af8 --- /dev/null +++ b/pkg/cmd/pr/shared/find_refs_resolution.go @@ -0,0 +1,394 @@ +package shared + +import ( + "context" + "fmt" + "net/url" + "strings" + + ghContext "github.com/cli/cli/v2/context" + "github.com/cli/cli/v2/git" + + "github.com/cli/cli/v2/internal/ghrepo" + o "github.com/cli/cli/v2/pkg/option" +) + +// QualifiedHeadRef represents a git branch with an optional owner, used +// for the head of a pull request. For example, within a single repository, +// we would expect a PR to have a head ref of no owner, and a branch name. +// However, for cross-repository pull requests, we would expect a head ref +// with an owner and a branch name. In string form this is represented as +// :. The GitHub API is able to interpret this format in order +// to discover the correct fork repository. +// +// In other parts of the code, you may see this refered to as a HeadLabel. +type QualifiedHeadRef struct { + owner o.Option[string] + branchName string +} + +// NewQualifiedHeadRef creates a QualifiedHeadRef. If the empty string is provided +// for the owner, it will be treated as None. +func NewQualifiedHeadRef(owner string, branchName string) QualifiedHeadRef { + return QualifiedHeadRef{ + owner: o.SomeIfNonZero(owner), + branchName: branchName, + } +} + +func NewQualifiedHeadRefWithoutOwner(branchName string) QualifiedHeadRef { + return QualifiedHeadRef{ + owner: o.None[string](), + branchName: branchName, + } +} + +// ParseQualifiedHeadRef takes strings of the form : or +// and returns a QualifiedHeadRef. If the form : is used, +// the owner is set to the value of , and the branch name is set to +// the value of . If the form is used, the owner is set to +// None, and the branch name is set to the value of . +// +// This does no further error checking about the validity of a ref, so +// it is not safe to assume the ref is truly a valid ref, e.g. "my~bad:ref?" +// is going to result in a nonsense result. +func ParseQualifiedHeadRef(ref string) (QualifiedHeadRef, error) { + if !strings.Contains(ref, ":") { + return NewQualifiedHeadRefWithoutOwner(ref), nil + } + + parts := strings.Split(ref, ":") + if len(parts) != 2 { + return QualifiedHeadRef{}, fmt.Errorf("invalid qualified head ref format '%s'", ref) + } + + return NewQualifiedHeadRef(parts[0], parts[1]), nil +} + +// A QualifiedHeadRef without an owner returns , while a QualifiedHeadRef +// with an owner returns :. +func (r QualifiedHeadRef) String() string { + if owner, present := r.owner.Value(); present { + return fmt.Sprintf("%s:%s", owner, r.branchName) + } + return r.branchName +} + +func (r QualifiedHeadRef) BranchName() string { + return r.branchName +} + +// PRFindRefs represents the necessary data to find a pull request from the API. +type PRFindRefs struct { + qualifiedHeadRef QualifiedHeadRef + + baseRepo ghrepo.Interface + // baseBranchName is an optional branch name, because it is not required for + // finding a pull request, only for disambiguation if multiple pull requests + // contain the same head ref. + baseBranchName o.Option[string] +} + +// QualifiedHeadRef returns a stringified form of the head ref, varying depending +// on whether the head ref is in the same repository as the base ref. If they are +// the same repository, we return the branch name only. If they are different repositories, +// we return the owner and branch name in the form :. +func (r PRFindRefs) QualifiedHeadRef() string { + return r.qualifiedHeadRef.String() +} + +func (r PRFindRefs) UnqualifiedHeadRef() string { + return r.qualifiedHeadRef.BranchName() +} + +// Matches checks whether the provided baseBranchName and headRef match the refs. +// It is used to determine whether Pull Requests returned from the API +func (r PRFindRefs) Matches(baseBranchName, qualifiedHeadRef string) bool { + headMatches := qualifiedHeadRef == r.QualifiedHeadRef() + baseMatches := r.baseBranchName.IsNone() || baseBranchName == r.baseBranchName.Unwrap() + return headMatches && baseMatches +} + +func (r PRFindRefs) BaseRepo() ghrepo.Interface { + return r.baseRepo +} + +type RemoteNameToRepoFn func(remoteName string) (ghrepo.Interface, error) + +// PullRequestFindRefsResolver interrogates git configuration to try and determine +// a head repository and a remote branch name, from a local branch name. +type PullRequestFindRefsResolver struct { + GitConfigClient GitConfigClient + RemoteNameToRepoFn RemoteNameToRepoFn +} + +func NewPullRequestFindRefsResolver(gitConfigClient GitConfigClient, remotesFn func() (ghContext.Remotes, error)) PullRequestFindRefsResolver { + return PullRequestFindRefsResolver{ + GitConfigClient: gitConfigClient, + RemoteNameToRepoFn: newRemoteNameToRepoFn(remotesFn), + } +} + +// ResolvePullRequests takes a base repository, a base branch name and a local branch name and uses the git configuration to +// determine the head repository and remote branch name. If we were unable to determine this from git, we default the head +// repository to the base repository. +func (r *PullRequestFindRefsResolver) ResolvePullRequestRefs(baseRepo ghrepo.Interface, baseBranchName, localBranchName string) (PRFindRefs, error) { + if baseRepo == nil { + return PRFindRefs{}, fmt.Errorf("find pull request ref resolution cannot be performed without a base repository") + } + + if localBranchName == "" { + return PRFindRefs{}, fmt.Errorf("find pull request ref resolution cannot be performed without a local branch name") + } + + headPRRef, err := TryDetermineDefaultPRHead(r.GitConfigClient, remoteToRepoResolver{r.RemoteNameToRepoFn}, localBranchName) + if err != nil { + return PRFindRefs{}, err + } + + // If the headRepo was resolved, we can just convert the response + // to refs and return it. + if headRepo, present := headPRRef.Repo.Value(); present { + qualifiedHeadRef := NewQualifiedHeadRefWithoutOwner(headPRRef.BranchName) + if !ghrepo.IsSame(headRepo, baseRepo) { + qualifiedHeadRef = NewQualifiedHeadRef(headRepo.RepoOwner(), headPRRef.BranchName) + } + + return PRFindRefs{ + qualifiedHeadRef: qualifiedHeadRef, + baseRepo: baseRepo, + baseBranchName: o.SomeIfNonZero(baseBranchName), + }, nil + } + + // If we didn't find a head repo, default to the base repo + return PRFindRefs{ + qualifiedHeadRef: NewQualifiedHeadRefWithoutOwner(headPRRef.BranchName), + baseRepo: baseRepo, + baseBranchName: o.SomeIfNonZero(baseBranchName), + }, nil +} + +// DefaultPRHead is a neighbour to defaultPushTarget, but instead of holding +// basic git remote information, it holds a resolved repository in `gh` terms. +// +// Since we may not be able to determine a default remote for a branch, this +// is also true of the resolved repository. +type DefaultPRHead struct { + Repo o.Option[ghrepo.Interface] + BranchName string +} + +// TryDetermineDefaultPRHead is a thin wrapper around determineDefaultPushTarget, which attempts to convert +// a present remote into a resolved repository. If the remote is not present, we indicate that to the caller +// by returning a None value for the repo. +func TryDetermineDefaultPRHead(gitClient GitConfigClient, remoteToRepo remoteToRepoResolver, branch string) (DefaultPRHead, error) { + pushTarget, err := tryDetermineDefaultPushTarget(gitClient, branch) + if err != nil { + return DefaultPRHead{}, err + } + + // If we have no remote, let the caller decide what to do by indicating that with a None. + if pushTarget.remote.IsNone() { + return DefaultPRHead{ + Repo: o.None[ghrepo.Interface](), + BranchName: pushTarget.branchName, + }, nil + } + + repo, err := remoteToRepo.resolve(pushTarget.remote.Unwrap()) + if err != nil { + return DefaultPRHead{}, err + } + + return DefaultPRHead{ + Repo: o.Some(repo), + BranchName: pushTarget.branchName, + }, nil +} + +// remote represents the value of the remote key in a branch's git configuration. +// This value may be a name or a URL, both of which are strings, but are unfortunately +// parsed by ReadBranchConfig into separate fields, allowing for illegal states to be +// created by accident. This is an attempt to indicate that they are mutally exclusive. +type remote interface{ sealedRemote() } + +type remoteName struct{ name string } + +func (rn remoteName) sealedRemote() {} + +type remoteURL struct{ url *url.URL } + +func (ru remoteURL) sealedRemote() {} + +// newRemoteNameToRepoFn takes a function that returns a list of remotes and +// returns a function that takes a remote name and returns the corresponding +// repository. It is a convenience function to call sites having to duplicate +// the same logic. +func newRemoteNameToRepoFn(remotesFn func() (ghContext.Remotes, error)) RemoteNameToRepoFn { + return func(remoteName string) (ghrepo.Interface, error) { + remotes, err := remotesFn() + if err != nil { + return nil, err + } + repo, err := remotes.FindByName(remoteName) + if err != nil { + return nil, err + } + return repo, nil + } +} + +// remoteToRepoResolver provides a utility method to resolve a remote (either name or URL) +// to a repo (ghrepo.Interface). +type remoteToRepoResolver struct { + remoteNameToRepo RemoteNameToRepoFn +} + +func NewRemoteToRepoResolver(remotesFn func() (ghContext.Remotes, error)) remoteToRepoResolver { + return remoteToRepoResolver{ + remoteNameToRepo: newRemoteNameToRepoFn(remotesFn), + } +} + +// resolve takes a remote and returns a repository representing it. +func (r remoteToRepoResolver) resolve(remote remote) (ghrepo.Interface, error) { + switch v := remote.(type) { + case remoteName: + repo, err := r.remoteNameToRepo(v.name) + if err != nil { + return nil, fmt.Errorf("could not resolve remote %q: %w", v.name, err) + } + return repo, nil + case remoteURL: + repo, err := ghrepo.FromURL(v.url) + if err != nil { + return nil, fmt.Errorf("could not parse remote URL %q: %w", v.url, err) + } + return repo, nil + default: + return nil, fmt.Errorf("unsupported remote type %T, value: %v", v, remote) + } +} + +// A defaultPushTarget represents the remote name or URL and a branch name +// that we would expect a branch to be pushed to if `git push` were run with +// no further arguments. This is the most likely place for the head of the PR +// to be, but it's not guaranteed. The user may have pushed to another branch +// directly via `git push :` and not set up tracking information. +// A branch name is always present. +// +// It's possible that we're unable to determine a remote, if the user had pushed directly +// to a URL for example `git push `, which is why it is optional. When present, +// the remote may either be a name or a URL. +type defaultPushTarget struct { + remote o.Option[remote] + branchName string +} + +// newDefaultPushTarget is a thin wrapper over defaultPushTarget to help with +// generic type inference, to reduce verbosity in repeating the parametric type. +func newDefaultPushTarget(remote remote, branchName string) defaultPushTarget { + return defaultPushTarget{ + remote: o.Some(remote), + branchName: branchName, + } +} + +// tryDetermineDefaultPushTarget uses git configuration to make a best guess about where a branch +// is pushed to, and where it would be pushed to if the user ran `git push` with no additional +// arguments. +// +// Firstly, it attempts to resolve the @{push} ref, which is the most reliable method, as this +// is what git uses to determine the remote tracking branch +// +// If this fails, we go through a series of steps to determine the remote: +// +// 1. check branch configuration for `branch..pushRemote = | ` +// 2. check remote configuration for `remote.pushDefault = ` +// 3. check branch configuration for `branch..remote = | ` +// +// If none of these are set, we indicate that we were unable to determine the +// remote by returning a None value for the remote. +// +// The branch name is always set. The default configuration for push.default (current) indicates +// that a git push should use the same remote branch name as the local branch name. If push.default +// is set to upstream or tracking (deprecated form of upstream), then we use the branch name from the merge ref. +func tryDetermineDefaultPushTarget(gitClient GitConfigClient, localBranchName string) (defaultPushTarget, error) { + // If @{push} resolves, then we have the remote tracking branch already, no problem. + if pushRevisionRef, err := gitClient.PushRevision(context.Background(), localBranchName); err == nil { + return newDefaultPushTarget(remoteName{pushRevisionRef.Remote}, pushRevisionRef.Branch), nil + } + + // But it doesn't always resolve, so we can suppress the error and move on to other means + // of determination. We'll first look at branch and remote configuration to make a determination. + branchConfig, err := gitClient.ReadBranchConfig(context.Background(), localBranchName) + if err != nil { + return defaultPushTarget{}, err + } + + pushDefault, err := gitClient.PushDefault(context.Background()) + if err != nil { + return defaultPushTarget{}, err + } + + // We assume the PR's branch name is the same as whatever was provided, unless the user has specified + // push.default = upstream or tracking, then we use the branch name from the merge ref. + remoteBranch := localBranchName + if pushDefault == git.PushDefaultUpstream || pushDefault == git.PushDefaultTracking { + remoteBranch = strings.TrimPrefix(branchConfig.MergeRef, "refs/heads/") + if remoteBranch == "" { + return defaultPushTarget{}, fmt.Errorf("could not determine remote branch name") + } + } + + // To get the remote, we look to the git config. It comes from one of the following, in order of precedence: + // 1. branch..pushRemote (which may be a name or a URL) + // 2. remote.pushDefault (which is a remote name) + // 3. branch..remote (which may be a name or a URL) + if branchConfig.PushRemoteName != "" { + return newDefaultPushTarget( + remoteName{branchConfig.PushRemoteName}, + remoteBranch, + ), nil + } + + if branchConfig.PushRemoteURL != nil { + return newDefaultPushTarget( + remoteURL{branchConfig.PushRemoteURL}, + remoteBranch, + ), nil + } + + remotePushDefault, err := gitClient.RemotePushDefault(context.Background()) + if err != nil { + return defaultPushTarget{}, err + } + + if remotePushDefault != "" { + return newDefaultPushTarget( + remoteName{remotePushDefault}, + remoteBranch, + ), nil + } + + if branchConfig.RemoteName != "" { + return newDefaultPushTarget( + remoteName{branchConfig.RemoteName}, + remoteBranch, + ), nil + } + + if branchConfig.RemoteURL != nil { + return newDefaultPushTarget( + remoteURL{branchConfig.RemoteURL}, + remoteBranch, + ), nil + } + + // If we couldn't find the remote, we'll indicate that to the caller via None. + return defaultPushTarget{ + remote: o.None[remote](), + branchName: remoteBranch, + }, nil +} diff --git a/pkg/cmd/pr/shared/find_refs_resolution_test.go b/pkg/cmd/pr/shared/find_refs_resolution_test.go new file mode 100644 index 000000000..8cbb62146 --- /dev/null +++ b/pkg/cmd/pr/shared/find_refs_resolution_test.go @@ -0,0 +1,508 @@ +package shared + +import ( + "errors" + "net/url" + "testing" + + ghContext "github.com/cli/cli/v2/context" + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/ghrepo" + o "github.com/cli/cli/v2/pkg/option" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestQualifiedHeadRef(t *testing.T) { + t.Parallel() + + testCases := []struct { + behavior string + ref string + expectedString string + expectedBranchName string + expectedError error + }{ + { + behavior: "when a branch is provided, the parsed qualified head ref only has a branch", + ref: "feature-branch", + expectedString: "feature-branch", + expectedBranchName: "feature-branch", + }, + { + behavior: "when an owner and branch are provided, the parsed qualified head ref has both", + ref: "owner:feature-branch", + expectedString: "owner:feature-branch", + expectedBranchName: "feature-branch", + }, + { + behavior: "when the structure cannot be interpreted correctly, an error is returned", + ref: "owner:feature-branch:extra", + expectedError: errors.New("invalid qualified head ref format 'owner:feature-branch:extra'"), + }, + } + + for _, tc := range testCases { + t.Run(tc.behavior, func(t *testing.T) { + t.Parallel() + + qualifiedHeadRef, err := ParseQualifiedHeadRef(tc.ref) + if tc.expectedError != nil { + require.Equal(t, tc.expectedError, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.expectedString, qualifiedHeadRef.String()) + assert.Equal(t, tc.expectedBranchName, qualifiedHeadRef.BranchName()) + }) + } +} + +func TestPRFindRefs(t *testing.T) { + t.Parallel() + + t.Run("qualified head ref with owner", func(t *testing.T) { + t.Parallel() + + refs := PRFindRefs{ + qualifiedHeadRef: mustParseQualifiedHeadRef("forkowner:feature-branch"), + } + + require.Equal(t, "forkowner:feature-branch", refs.QualifiedHeadRef()) + require.Equal(t, "feature-branch", refs.UnqualifiedHeadRef()) + }) + + t.Run("qualified head ref without owner", func(t *testing.T) { + t.Parallel() + + refs := PRFindRefs{ + qualifiedHeadRef: mustParseQualifiedHeadRef("feature-branch"), + } + + require.Equal(t, "feature-branch", refs.QualifiedHeadRef()) + require.Equal(t, "feature-branch", refs.UnqualifiedHeadRef()) + }) + + t.Run("base repo", func(t *testing.T) { + t.Parallel() + + refs := PRFindRefs{ + baseRepo: ghrepo.New("owner", "repo"), + } + + require.True(t, ghrepo.IsSame(refs.BaseRepo(), ghrepo.New("owner", "repo")), "expected repos to be the same") + }) + + t.Run("matches", func(t *testing.T) { + t.Parallel() + + testCases := []struct { + behavior string + refs PRFindRefs + baseBranchName string + qualifiedHeadRef string + expectedMatch bool + }{ + { + behavior: "when qualified head refs don't match, returns false", + refs: PRFindRefs{ + qualifiedHeadRef: mustParseQualifiedHeadRef("owner:feature-branch"), + }, + baseBranchName: "feature-branch", + qualifiedHeadRef: "feature-branch", + expectedMatch: false, + }, + { + behavior: "when base branches don't match, returns false", + refs: PRFindRefs{ + qualifiedHeadRef: mustParseQualifiedHeadRef("feature-branch"), + baseBranchName: o.Some("not-main"), + }, + baseBranchName: "main", + qualifiedHeadRef: "feature-branch", + expectedMatch: false, + }, + { + behavior: "when head refs match and there is no base branch, returns true", + refs: PRFindRefs{ + qualifiedHeadRef: mustParseQualifiedHeadRef("feature-branch"), + baseBranchName: o.None[string](), + }, + baseBranchName: "main", + qualifiedHeadRef: "feature-branch", + expectedMatch: true, + }, + { + behavior: "when head refs match and base branches match, returns true", + refs: PRFindRefs{ + qualifiedHeadRef: mustParseQualifiedHeadRef("feature-branch"), + baseBranchName: o.Some("main"), + }, + baseBranchName: "main", + qualifiedHeadRef: "feature-branch", + expectedMatch: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.behavior, func(t *testing.T) { + t.Parallel() + + require.Equal(t, tc.expectedMatch, tc.refs.Matches(tc.baseBranchName, tc.qualifiedHeadRef)) + }) + } + }) +} + +func TestPullRequestResolution(t *testing.T) { + t.Parallel() + + baseRepo := ghrepo.New("owner", "repo") + baseRemote := ghContext.Remote{ + Remote: &git.Remote{ + Name: "upstream", + }, + Repo: ghrepo.New("owner", "repo"), + } + + forkRemote := ghContext.Remote{ + Remote: &git.Remote{ + Name: "origin", + }, + Repo: ghrepo.New("otherowner", "repo-fork"), + } + + t.Run("when the base repo is nil, returns an error", func(t *testing.T) { + t.Parallel() + + resolver := NewPullRequestFindRefsResolver(stubGitConfigClient{}, dummyRemotesFn) + _, err := resolver.ResolvePullRequestRefs(nil, "", "") + require.Error(t, err) + }) + + t.Run("when the local branch name is empty, returns an error", func(t *testing.T) { + t.Parallel() + + resolver := NewPullRequestFindRefsResolver(stubGitConfigClient{}, dummyRemotesFn) + _, err := resolver.ResolvePullRequestRefs(baseRepo, "", "") + require.Error(t, err) + }) + + t.Run("when the default pr head has a repo, it is used for the refs", func(t *testing.T) { + t.Parallel() + + // Push revision is the first thing checked for resolution, + // so nothing else needs to be stubbed. + repoResolvedFromPushRevisionClient := stubGitConfigClient{ + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{ + Remote: "origin", + Branch: "feature-branch", + }, nil), + } + + resolver := NewPullRequestFindRefsResolver( + repoResolvedFromPushRevisionClient, + stubRemotes(ghContext.Remotes{&baseRemote, &forkRemote}, nil), + ) + + refs, err := resolver.ResolvePullRequestRefs(baseRepo, "main", "feature-branch") + require.NoError(t, err) + + expectedRefs := PRFindRefs{ + qualifiedHeadRef: QualifiedHeadRef{ + owner: o.Some("otherowner"), + branchName: "feature-branch", + }, + baseRepo: baseRepo, + baseBranchName: o.Some("main"), + } + + require.Equal(t, expectedRefs, refs) + }) + + t.Run("when the default pr head does not have a repo, we use the base repo for the head", func(t *testing.T) { + t.Parallel() + + // All the values stubbed here result in being unable to resolve a default repo. + noRepoResolutionStubClient := stubGitConfigClient{ + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("test error")), + readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil), + pushDefaultFn: stubPushDefault("", nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + } + + resolver := NewPullRequestFindRefsResolver( + noRepoResolutionStubClient, + stubRemotes(ghContext.Remotes{&baseRemote, &forkRemote}, nil), + ) + + refs, err := resolver.ResolvePullRequestRefs(baseRepo, "main", "feature-branch") + require.NoError(t, err) + + expectedRefs := PRFindRefs{ + qualifiedHeadRef: QualifiedHeadRef{ + owner: o.None[string](), + branchName: "feature-branch", + }, + baseRepo: baseRepo, + baseBranchName: o.Some("main"), + } + require.Equal(t, expectedRefs, refs) + }) +} + +func TestTryDetermineDefaultPRHead(t *testing.T) { + t.Parallel() + + baseRepo := ghrepo.New("owner", "repo") + baseRemote := ghContext.Remote{ + Remote: &git.Remote{ + Name: "upstream", + }, + Repo: baseRepo, + } + + forkRepo := ghrepo.New("otherowner", "repo-fork") + forkRemote := ghContext.Remote{ + Remote: &git.Remote{ + Name: "origin", + }, + Repo: forkRepo, + } + forkRepoURL, err := url.Parse("https://github.com/otherowner/repo-fork.git") + require.NoError(t, err) + + t.Run("when the push revision is set, use that", func(t *testing.T) { + t.Parallel() + + repoResolvedFromPushRevisionClient := stubGitConfigClient{ + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{ + Remote: "origin", + Branch: "remote-feature-branch", + }, nil), + } + + defaultPRHead, err := TryDetermineDefaultPRHead( + repoResolvedFromPushRevisionClient, + stubRemoteToRepoResolver(ghContext.Remotes{&baseRemote, &forkRemote}, nil), + "feature-branch", + ) + require.NoError(t, err) + + require.True(t, ghrepo.IsSame(defaultPRHead.Repo.Unwrap(), forkRepo), "expected repos to be the same") + require.Equal(t, "remote-feature-branch", defaultPRHead.BranchName) + }) + + t.Run("when the branch config push remote is set to a name, use that", func(t *testing.T) { + t.Parallel() + + repoResolvedFromPushRemoteClient := stubGitConfigClient{ + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("no push revision")), + readBranchConfigFn: stubBranchConfig(git.BranchConfig{ + PushRemoteName: "origin", + }, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultCurrent, nil), + } + + defaultPRHead, err := TryDetermineDefaultPRHead( + repoResolvedFromPushRemoteClient, + stubRemoteToRepoResolver(ghContext.Remotes{&baseRemote, &forkRemote}, nil), + "feature-branch", + ) + require.NoError(t, err) + + require.True(t, ghrepo.IsSame(defaultPRHead.Repo.Unwrap(), forkRepo), "expected repos to be the same") + require.Equal(t, "feature-branch", defaultPRHead.BranchName) + }) + + t.Run("when the branch config push remote is set to a URL, use that", func(t *testing.T) { + t.Parallel() + + repoResolvedFromPushRemoteClient := stubGitConfigClient{ + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("no push revision")), + readBranchConfigFn: stubBranchConfig(git.BranchConfig{ + PushRemoteURL: forkRepoURL, + }, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultCurrent, nil), + } + + defaultPRHead, err := TryDetermineDefaultPRHead( + repoResolvedFromPushRemoteClient, + dummyRemoteToRepoResolver(), + "feature-branch", + ) + require.NoError(t, err) + + require.True(t, ghrepo.IsSame(defaultPRHead.Repo.Unwrap(), forkRepo), "expected repos to be the same") + require.Equal(t, "feature-branch", defaultPRHead.BranchName) + }) + + t.Run("when a remote push default is set, use that", func(t *testing.T) { + t.Parallel() + + repoResolvedFromPushRemoteClient := stubGitConfigClient{ + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("no push revision")), + readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultCurrent, nil), + remotePushDefaultFn: stubRemotePushDefault("origin", nil), + } + + defaultPRHead, err := TryDetermineDefaultPRHead( + repoResolvedFromPushRemoteClient, + stubRemoteToRepoResolver(ghContext.Remotes{&baseRemote, &forkRemote}, nil), + "feature-branch", + ) + require.NoError(t, err) + + require.True(t, ghrepo.IsSame(defaultPRHead.Repo.Unwrap(), forkRepo), "expected repos to be the same") + require.Equal(t, "feature-branch", defaultPRHead.BranchName) + }) + + t.Run("when the branch config remote is set to a name, use that", func(t *testing.T) { + t.Parallel() + + repoResolvedFromPushRemoteClient := stubGitConfigClient{ + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("no push revision")), + readBranchConfigFn: stubBranchConfig(git.BranchConfig{ + RemoteName: "origin", + }, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultCurrent, nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + } + + defaultPRHead, err := TryDetermineDefaultPRHead( + repoResolvedFromPushRemoteClient, + stubRemoteToRepoResolver(ghContext.Remotes{&baseRemote, &forkRemote}, nil), + "feature-branch", + ) + require.NoError(t, err) + + require.True(t, ghrepo.IsSame(defaultPRHead.Repo.Unwrap(), forkRepo), "expected repos to be the same") + require.Equal(t, "feature-branch", defaultPRHead.BranchName) + }) + + t.Run("when the branch config remote is set to a URL, use that", func(t *testing.T) { + t.Parallel() + + repoResolvedFromPushRemoteClient := stubGitConfigClient{ + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("no push revision")), + readBranchConfigFn: stubBranchConfig(git.BranchConfig{ + RemoteURL: forkRepoURL, + }, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultCurrent, nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + } + + defaultPRHead, err := TryDetermineDefaultPRHead( + repoResolvedFromPushRemoteClient, + dummyRemoteToRepoResolver(), + "feature-branch", + ) + require.NoError(t, err) + + require.True(t, ghrepo.IsSame(defaultPRHead.Repo.Unwrap(), forkRepo), "expected repos to be the same") + require.Equal(t, "feature-branch", defaultPRHead.BranchName) + }) + + t.Run("when git didn't provide the necessary information, return none for the remote", func(t *testing.T) { + t.Parallel() + + // All the values stubbed here result in being unable to resolve a default repo. + noRepoResolutionStubClient := stubGitConfigClient{ + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("test error")), + readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil), + pushDefaultFn: stubPushDefault("", nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + } + + defaultPRHead, err := TryDetermineDefaultPRHead( + noRepoResolutionStubClient, + stubRemoteToRepoResolver(ghContext.Remotes{&baseRemote, &forkRemote}, nil), + "feature-branch", + ) + require.NoError(t, err) + + require.True(t, defaultPRHead.Repo.IsNone(), "expected repo to be none") + require.Equal(t, "feature-branch", defaultPRHead.BranchName) + }) + + t.Run("when the push default is tracking or upstream, use the merge ref", func(t *testing.T) { + t.Parallel() + + testCases := []struct { + pushDefault git.PushDefault + }{ + {pushDefault: git.PushDefaultTracking}, + {pushDefault: git.PushDefaultUpstream}, + } + + for _, tc := range testCases { + t.Run(string(tc.pushDefault), func(t *testing.T) { + t.Parallel() + + repoResolvedFromPushRemoteClient := stubGitConfigClient{ + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("test error")), + readBranchConfigFn: stubBranchConfig(git.BranchConfig{ + PushRemoteName: "origin", + MergeRef: "main", + }, nil), + pushDefaultFn: stubPushDefault(tc.pushDefault, nil), + } + + defaultPRHead, err := TryDetermineDefaultPRHead( + repoResolvedFromPushRemoteClient, + stubRemoteToRepoResolver(ghContext.Remotes{&baseRemote, &forkRemote}, nil), + "feature-branch", + ) + require.NoError(t, err) + + require.True(t, ghrepo.IsSame(defaultPRHead.Repo.Unwrap(), forkRepo), "expected repos to be the same") + require.Equal(t, "main", defaultPRHead.BranchName) + }) + } + + t.Run("but if the merge ref is empty, error", func(t *testing.T) { + t.Parallel() + + repoResolvedFromPushRemoteClient := stubGitConfigClient{ + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("test error")), + readBranchConfigFn: stubBranchConfig(git.BranchConfig{ + PushRemoteName: "origin", + MergeRef: "", // intentionally empty + }, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultUpstream, nil), + } + + _, err := TryDetermineDefaultPRHead( + repoResolvedFromPushRemoteClient, + stubRemoteToRepoResolver(ghContext.Remotes{&baseRemote, &forkRemote}, nil), + "feature-branch", + ) + require.Error(t, err) + }) + }) + +} + +func dummyRemotesFn() (ghContext.Remotes, error) { + panic("remotes fn not implemented") +} + +func dummyRemoteToRepoResolver() remoteToRepoResolver { + return NewRemoteToRepoResolver(dummyRemotesFn) +} + +func stubRemoteToRepoResolver(remotes ghContext.Remotes, err error) remoteToRepoResolver { + return NewRemoteToRepoResolver(func() (ghContext.Remotes, error) { + return remotes, err + }) +} + +func mustParseQualifiedHeadRef(ref string) QualifiedHeadRef { + parsed, err := ParseQualifiedHeadRef(ref) + if err != nil { + panic(err) + } + return parsed +} diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index 6e0ea0401..9e92c0692 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -13,11 +13,12 @@ import ( "time" "github.com/cli/cli/v2/api" - remotes "github.com/cli/cli/v2/context" + ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmdutil" + o "github.com/cli/cli/v2/pkg/option" "github.com/cli/cli/v2/pkg/set" "github.com/shurcooL/githubv4" "golang.org/x/sync/errgroup" @@ -32,16 +33,20 @@ type progressIndicator interface { StopProgressIndicator() } +type GitConfigClient interface { + ReadBranchConfig(ctx context.Context, branchName string) (git.BranchConfig, error) + PushDefault(ctx context.Context) (git.PushDefault, error) + RemotePushDefault(ctx context.Context) (string, error) + PushRevision(ctx context.Context, branchName string) (git.RemoteTrackingRef, error) +} + type finder struct { - baseRepoFn func() (ghrepo.Interface, error) - branchFn func() (string, error) - remotesFn func() (remotes.Remotes, error) - httpClient func() (*http.Client, error) - pushDefault func() (string, error) - remotePushDefault func() (string, error) - parsePushRevision func(string) (string, error) - branchConfig func(string) (git.BranchConfig, error) - progress progressIndicator + baseRepoFn func() (ghrepo.Interface, error) + branchFn func() (string, error) + httpClient func() (*http.Client, error) + remotesFn func() (ghContext.Remotes, error) + gitConfigClient GitConfigClient + progress progressIndicator baseRefRepo ghrepo.Interface prNumber int @@ -56,23 +61,12 @@ func NewFinder(factory *cmdutil.Factory) PRFinder { } return &finder{ - baseRepoFn: factory.BaseRepo, - branchFn: factory.Branch, - remotesFn: factory.Remotes, - httpClient: factory.HttpClient, - pushDefault: func() (string, error) { - return factory.GitClient.PushDefault(context.Background()) - }, - remotePushDefault: func() (string, error) { - return factory.GitClient.RemotePushDefault(context.Background()) - }, - parsePushRevision: func(branch string) (string, error) { - return factory.GitClient.ParsePushRevision(context.Background(), branch) - }, - progress: factory.IOStreams, - branchConfig: func(s string) (git.BranchConfig, error) { - return factory.GitClient.ReadBranchConfig(context.Background(), s) - }, + baseRepoFn: factory.BaseRepo, + branchFn: factory.Branch, + httpClient: factory.HttpClient, + gitConfigClient: factory.GitClient, + remotesFn: factory.Remotes, + progress: factory.IOStreams, } } @@ -97,28 +91,6 @@ type FindOptions struct { States []string } -// TODO: Does this also need the BaseBranchName? -// PR's are represented by the following: -// baseRef -----PR-----> headRef -// -// A ref is described as "remoteName/branchName", so -// baseRepoName/baseBranchName -----PR-----> headRepoName/headBranchName -type PullRequestRefs struct { - BranchName string - HeadRepo ghrepo.Interface - BaseRepo ghrepo.Interface -} - -// GetPRHeadLabel returns the string that the GitHub API uses to identify the PR. This is -// either just the branch name or, if the PR is originating from a fork, the fork owner -// and the branch name, like :. -func (s *PullRequestRefs) GetPRHeadLabel() string { - if ghrepo.IsSame(s.HeadRepo, s.BaseRepo) { - return s.BranchName - } - return fmt.Sprintf("%s:%s", s.HeadRepo.RepoOwner(), s.BranchName) -} - func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, error) { // If we have a URL, we don't need git stuff if len(opts.Fields) == 0 { @@ -138,7 +110,7 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err f.baseRefRepo = repo } - var prRefs PullRequestRefs + var prRefs PRFindRefs if opts.Selector == "" { // You must be in a git repo for this case to work currentBranchName, err := f.branchFn() @@ -148,7 +120,7 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err f.branchName = currentBranchName // Get the branch config for the current branchName - branchConfig, err := f.branchConfig(f.branchName) + branchConfig, err := f.gitConfigClient.ReadBranchConfig(context.Background(), f.branchName) if err != nil { return nil, nil, err } @@ -162,30 +134,19 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err // Determine the PullRequestRefs from config if f.prNumber == 0 { - rems, err := f.remotesFn() - if err != nil { - return nil, nil, err - } - - // Suppressing these errors as we have other means of computing the PullRequestRefs when these fail. - parsedPushRevision, _ := f.parsePushRevision(f.branchName) - - pushDefault, err := f.pushDefault() - if err != nil { - return nil, nil, err - } - - remotePushDefault, err := f.remotePushDefault() - if err != nil { - return nil, nil, err - } - - prRefs, err = ParsePRRefs(f.branchName, branchConfig, parsedPushRevision, pushDefault, remotePushDefault, f.baseRefRepo, rems) + prRefsResolver := NewPullRequestFindRefsResolver( + // We requested the branch config already, so let's cache that + CachedBranchConfigGitConfigClient{ + CachedBranchConfig: branchConfig, + GitConfigClient: f.gitConfigClient, + }, + f.remotesFn, + ) + prRefs, err = prRefsResolver.ResolvePullRequestRefs(f.baseRefRepo, opts.BaseBranch, f.branchName) if err != nil { return nil, nil, err } } - } else if f.prNumber == 0 { // You gave me a selector but I couldn't find a PR number (it wasn't a URL) @@ -200,11 +161,17 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err f.prNumber = prNumber } else { f.branchName = opts.Selector - // We don't expect an error here because parsedPushRevision is empty - prRefs, err = ParsePRRefs(f.branchName, git.BranchConfig{}, "", "", "", f.baseRefRepo, remotes.Remotes{}) + + qualifiedHeadRef, err := ParseQualifiedHeadRef(f.branchName) if err != nil { return nil, nil, err } + + prRefs = PRFindRefs{ + qualifiedHeadRef: qualifiedHeadRef, + baseRepo: f.baseRefRepo, + baseBranchName: o.SomeIfNonZero(opts.BaseBranch), + } } } @@ -255,7 +222,7 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err return pr, f.baseRefRepo, err } } else { - pr, err = findForBranch(httpClient, f.baseRefRepo, opts.BaseBranch, prRefs.GetPRHeadLabel(), opts.States, fields.ToSlice()) + pr, err = findForRefs(httpClient, prRefs, opts.States, fields.ToSlice()) if err != nil { return pr, f.baseRefRepo, err } @@ -317,72 +284,6 @@ func (f *finder) parseURL(prURL string) (ghrepo.Interface, int, error) { return repo, prNumber, nil } -func ParsePRRefs(currentBranchName string, branchConfig git.BranchConfig, parsedPushRevision string, pushDefault string, remotePushDefault string, baseRefRepo ghrepo.Interface, rems remotes.Remotes) (PullRequestRefs, error) { - prRefs := PullRequestRefs{ - BaseRepo: baseRefRepo, - } - - // If @{push} resolves, then we have all the information we need to determine the head repo - // and branch name. It is of the form /. - if parsedPushRevision != "" { - for _, r := range rems { - // Find the remote who's name matches the push prefix - if strings.HasPrefix(parsedPushRevision, r.Name+"/") { - prRefs.BranchName = strings.TrimPrefix(parsedPushRevision, r.Name+"/") - prRefs.HeadRepo = r.Repo - return prRefs, nil - } - } - - remoteNames := make([]string, len(rems)) - for i, r := range rems { - remoteNames[i] = r.Name - } - return PullRequestRefs{}, fmt.Errorf("no remote for %q found in %q", parsedPushRevision, strings.Join(remoteNames, ", ")) - } - - // We assume the PR's branch name is the same as whatever f.BranchFn() returned earlier - // unless the user has specified push.default = upstream or tracking, then we use the - // branch name from the merge ref. - prRefs.BranchName = currentBranchName - if pushDefault == "upstream" || pushDefault == "tracking" { - prRefs.BranchName = strings.TrimPrefix(branchConfig.MergeRef, "refs/heads/") - } - - // To get the HeadRepo, we look to the git config. The HeadRepo comes from one of the following, in order of precedence: - // 1. branch..pushRemote - // 2. remote.pushDefault - // 3. branch..remote - if branchConfig.PushRemoteName != "" { - if r, err := rems.FindByName(branchConfig.PushRemoteName); err == nil { - prRefs.HeadRepo = r.Repo - } - } else if branchConfig.PushRemoteURL != nil { - if r, err := ghrepo.FromURL(branchConfig.PushRemoteURL); err == nil { - prRefs.HeadRepo = r - } - } else if remotePushDefault != "" { - if r, err := rems.FindByName(remotePushDefault); err == nil { - prRefs.HeadRepo = r.Repo - } - } else if branchConfig.RemoteName != "" { - if r, err := rems.FindByName(branchConfig.RemoteName); err == nil { - prRefs.HeadRepo = r.Repo - } - } else if branchConfig.RemoteURL != nil { - if r, err := ghrepo.FromURL(branchConfig.RemoteURL); err == nil { - prRefs.HeadRepo = r - } - } - - // The PR merges from a branch in the same repo as the base branch (usually the default branch) - if prRefs.HeadRepo == nil { - prRefs.HeadRepo = baseRefRepo - } - - return prRefs, nil -} - func findByNumber(httpClient *http.Client, repo ghrepo.Interface, number int, fields []string) (*api.PullRequest, error) { type response struct { Repository struct { @@ -413,7 +314,7 @@ func findByNumber(httpClient *http.Client, repo ghrepo.Interface, number int, fi return &resp.Repository.PullRequest, nil } -func findForBranch(httpClient *http.Client, repo ghrepo.Interface, baseBranch, headBranchWithOwnerIfFork string, stateFilters, fields []string) (*api.PullRequest, error) { +func findForRefs(httpClient *http.Client, prRefs PRFindRefs, stateFilters, fields []string) (*api.PullRequest, error) { type response struct { Repository struct { PullRequests struct { @@ -440,21 +341,16 @@ func findForBranch(httpClient *http.Client, repo ghrepo.Interface, baseBranch, h } }`, api.PullRequestGraphQL(fieldSet.ToSlice())) - branchWithoutOwner := headBranchWithOwnerIfFork - if idx := strings.Index(headBranchWithOwnerIfFork, ":"); idx >= 0 { - branchWithoutOwner = headBranchWithOwnerIfFork[idx+1:] - } - variables := map[string]interface{}{ - "owner": repo.RepoOwner(), - "repo": repo.RepoName(), - "headRefName": branchWithoutOwner, + "owner": prRefs.BaseRepo().RepoOwner(), + "repo": prRefs.BaseRepo().RepoName(), + "headRefName": prRefs.UnqualifiedHeadRef(), "states": stateFilters, } var resp response client := api.NewClientFromHTTP(httpClient) - err := client.GraphQL(repo.RepoHost(), query, variables, &resp) + err := client.GraphQL(prRefs.BaseRepo().RepoHost(), query, variables, &resp) if err != nil { return nil, err } @@ -465,17 +361,15 @@ func findForBranch(httpClient *http.Client, repo ghrepo.Interface, baseBranch, h }) for _, pr := range prs { - headBranchMatches := pr.HeadLabel() == headBranchWithOwnerIfFork - baseBranchEmptyOrMatches := baseBranch == "" || pr.BaseRefName == baseBranch // When the head is the default branch, it doesn't really make sense to show merged or closed PRs. // https://github.com/cli/cli/issues/4263 - isNotClosedOrMergedWhenHeadIsDefault := pr.State == "OPEN" || resp.Repository.DefaultBranchRef.Name != headBranchWithOwnerIfFork - if headBranchMatches && baseBranchEmptyOrMatches && isNotClosedOrMergedWhenHeadIsDefault { + isNotClosedOrMergedWhenHeadIsDefault := pr.State == "OPEN" || resp.Repository.DefaultBranchRef.Name != prRefs.QualifiedHeadRef() + if prRefs.Matches(pr.BaseRefName, pr.HeadLabel()) && isNotClosedOrMergedWhenHeadIsDefault { return &pr, nil } } - return nil, &NotFoundError{fmt.Errorf("no pull requests found for branch %q", headBranchWithOwnerIfFork)} + return nil, &NotFoundError{fmt.Errorf("no pull requests found for branch %q", prRefs.QualifiedHeadRef())} } func preloadPrReviews(httpClient *http.Client, repo ghrepo.Interface, pr *api.PullRequest) error { diff --git a/pkg/cmd/pr/shared/finder_test.go b/pkg/cmd/pr/shared/finder_test.go index 25a948416..09c2bf7a7 100644 --- a/pkg/cmd/pr/shared/finder_test.go +++ b/pkg/cmd/pr/shared/finder_test.go @@ -1,46 +1,41 @@ package shared import ( + "context" "errors" - "fmt" "net/http" "net/url" "testing" - "github.com/cli/cli/v2/context" + ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/httpmock" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) type args struct { - baseRepoFn func() (ghrepo.Interface, error) - branchFn func() (string, error) - branchConfig func(string) (git.BranchConfig, error) - pushDefault func() (string, error) - remotePushDefault func() (string, error) - parsePushRevision func(string) (string, error) - selector string - fields []string - baseBranch string + baseRepoFn func() (ghrepo.Interface, error) + branchFn func() (string, error) + gitConfigClient stubGitConfigClient + selector string + fields []string + baseBranch string } func TestFind(t *testing.T) { - // TODO: Abstract these out meaningfully for reuse in parsePRRefs tests originOwnerUrl, err := url.Parse("https://github.com/ORIGINOWNER/REPO.git") if err != nil { t.Fatal(err) } - remoteOrigin := context.Remote{ + remoteOrigin := ghContext.Remote{ Remote: &git.Remote{ Name: "origin", FetchURL: originOwnerUrl, }, Repo: ghrepo.New("ORIGINOWNER", "REPO"), } - remoteOther := context.Remote{ + remoteOther := ghContext.Remote{ Remote: &git.Remote{ Name: "other", FetchURL: originOwnerUrl, @@ -52,7 +47,7 @@ func TestFind(t *testing.T) { if err != nil { t.Fatal(err) } - remoteUpstream := context.Remote{ + remoteUpstream := ghContext.Remote{ Remote: &git.Remote{ Name: "upstream", FetchURL: upstreamOwnerUrl, @@ -77,7 +72,6 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{}, nil), }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -112,12 +106,14 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{ - PushRemoteName: remoteOrigin.Remote.Name, - }, nil), - pushDefault: stubPushDefault("simple", nil), - remotePushDefault: stubRemotePushDefault("", nil), - parsePushRevision: stubParsedPushRevision("", nil), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{ + PushRemoteName: remoteOrigin.Remote.Name, + }, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("testErr")), + }, }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -147,9 +143,11 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{}, nil), - pushDefault: stubPushDefault("simple", nil), - remotePushDefault: stubRemotePushDefault("", nil), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + }, }, wantErr: true, }, @@ -170,9 +168,11 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{}, nil), - pushDefault: stubPushDefault("simple", nil), - remotePushDefault: stubRemotePushDefault("", nil), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + }, }, httpStub: nil, wantPR: 13, @@ -187,9 +187,11 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{}, nil), - pushDefault: stubPushDefault("simple", nil), - remotePushDefault: stubRemotePushDefault("", nil), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + }, }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -210,9 +212,11 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{}, nil), - pushDefault: stubPushDefault("simple", nil), - remotePushDefault: stubRemotePushDefault("", nil), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + }, }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -236,15 +240,17 @@ func TestFind(t *testing.T) { ExitCode: 128, } }, - branchConfig: stubBranchConfig(git.BranchConfig{}, &git.GitError{ - Stderr: "fatal: branchConfig error", - ExitCode: 128, - }), - pushDefault: stubPushDefault("simple", nil), - remotePushDefault: stubRemotePushDefault("", &git.GitError{ - Stderr: "fatal: remotePushDefault error", - ExitCode: 128, - }), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, &git.GitError{ + Stderr: "fatal: branchConfig error", + ExitCode: 128, + }), + pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil), + remotePushDefaultFn: stubRemotePushDefault("", &git.GitError{ + Stderr: "fatal: remotePushDefault error", + ExitCode: 128, + }), + }, }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -265,10 +271,12 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{}, nil), - pushDefault: stubPushDefault("simple", nil), - parsePushRevision: stubParsedPushRevision("", nil), - remotePushDefault: stubRemotePushDefault("", nil), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil), + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("testErr")), + remotePushDefaultFn: stubRemotePushDefault("", nil), + }, }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -309,10 +317,12 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{}, nil), - pushDefault: stubPushDefault("simple", nil), - remotePushDefault: stubRemotePushDefault("", nil), - parsePushRevision: stubParsedPushRevision("", nil), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("testErr")), + }, }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -352,10 +362,12 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{}, nil), - pushDefault: stubPushDefault("simple", nil), - remotePushDefault: stubRemotePushDefault("", nil), - parsePushRevision: stubParsedPushRevision("", nil), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("testErr")), + }, }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -387,10 +399,12 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{}, nil), - pushDefault: stubPushDefault("simple", nil), - remotePushDefault: stubRemotePushDefault("", nil), - parsePushRevision: stubParsedPushRevision("", nil), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("testErr")), + }, }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -436,13 +450,15 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{ - MergeRef: "refs/heads/blue-upstream-berries", - PushRemoteName: "upstream", - }, nil), - pushDefault: stubPushDefault("upstream", nil), - remotePushDefault: stubRemotePushDefault("", nil), - parsePushRevision: stubParsedPushRevision("", nil), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{ + MergeRef: "refs/heads/blue-upstream-berries", + PushRemoteName: "upstream", + }, nil), + pushDefaultFn: stubPushDefault("upstream", nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("testErr")), + }, }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -476,13 +492,15 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{ - MergeRef: "refs/heads/blue-upstream-berries", - PushRemoteURL: remoteUpstream.Remote.FetchURL, - }, nil), - pushDefault: stubPushDefault("upstream", nil), - remotePushDefault: stubRemotePushDefault("", nil), - parsePushRevision: stubParsedPushRevision("", nil), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{ + MergeRef: "refs/heads/blue-upstream-berries", + PushRemoteURL: remoteUpstream.Remote.FetchURL, + }, nil), + pushDefaultFn: stubPushDefault("upstream", nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("testErr")), + }, }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -512,10 +530,12 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{}, nil), - pushDefault: stubPushDefault("simple", nil), - remotePushDefault: stubRemotePushDefault("", nil), - parsePushRevision: stubParsedPushRevision("other/blueberries", nil), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{Remote: "other", Branch: "blueberries"}, nil), + }, }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -547,9 +567,11 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{ - MergeRef: "refs/pull/13/head", - }, nil), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{ + MergeRef: "refs/pull/13/head", + }, nil), + }, }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -572,11 +594,13 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{ - MergeRef: "refs/pull/13/head", - }, nil), - pushDefault: stubPushDefault("simple", nil), - remotePushDefault: stubRemotePushDefault("", nil), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{ + MergeRef: "refs/pull/13/head", + }, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + }, }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -588,32 +612,32 @@ func TestFind(t *testing.T) { r.Register( httpmock.GraphQL(`query PullRequestProjectItems\b`), httpmock.GraphQLQuery(`{ - "data": { - "repository": { - "pullRequest": { - "projectItems": { - "nodes": [ - { - "id": "PVTI_lADOB-vozM4AVk16zgK6U50", - "project": { - "id": "PVT_kwDOB-vozM4AVk16", - "title": "Test Project" - }, - "status": { - "optionId": "47fc9ee4", - "name": "In Progress" - } - } - ], - "pageInfo": { - "hasNextPage": false, - "endCursor": "MQ" - } - } - } - } - } - }`, + "data": { + "repository": { + "pullRequest": { + "projectItems": { + "nodes": [ + { + "id": "PVTI_lADOB-vozM4AVk16zgK6U50", + "project": { + "id": "PVT_kwDOB-vozM4AVk16", + "title": "Test Project" + }, + "status": { + "optionId": "47fc9ee4", + "name": "In Progress" + } + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": "MQ" + } + } + } + } + } + }`, func(query string, inputs map[string]interface{}) { require.Equal(t, float64(13), inputs["number"]) require.Equal(t, "OWNER", inputs["owner"]) @@ -637,13 +661,10 @@ func TestFind(t *testing.T) { httpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - baseRepoFn: tt.args.baseRepoFn, - branchFn: tt.args.branchFn, - branchConfig: tt.args.branchConfig, - pushDefault: tt.args.pushDefault, - remotePushDefault: tt.args.remotePushDefault, - parsePushRevision: tt.args.parsePushRevision, - remotesFn: stubRemotes(context.Remotes{ + baseRepoFn: tt.args.baseRepoFn, + branchFn: tt.args.branchFn, + gitConfigClient: tt.args.gitConfigClient, + remotesFn: stubRemotes(ghContext.Remotes{ &remoteOrigin, &remoteOther, &remoteUpstream, @@ -680,319 +701,14 @@ func TestFind(t *testing.T) { } } -func TestParsePRRefs(t *testing.T) { - originOwnerUrl, err := url.Parse("https://github.com/ORIGINOWNER/REPO.git") - if err != nil { - t.Fatal(err) - } - remoteOrigin := context.Remote{ - Remote: &git.Remote{ - Name: "origin", - FetchURL: originOwnerUrl, - }, - Repo: ghrepo.New("ORIGINOWNER", "REPO"), - } - remoteOther := context.Remote{ - Remote: &git.Remote{ - Name: "other", - FetchURL: originOwnerUrl, - }, - Repo: ghrepo.New("ORIGINOWNER", "REPO"), - } - - upstreamOwnerUrl, err := url.Parse("https://github.com/UPSTREAMOWNER/REPO.git") - if err != nil { - t.Fatal(err) - } - remoteUpstream := context.Remote{ - Remote: &git.Remote{ - Name: "upstream", - FetchURL: upstreamOwnerUrl, - }, - Repo: ghrepo.New("UPSTREAMOWNER", "REPO"), - } - - tests := []struct { - name string - branchConfig git.BranchConfig - pushDefault string - parsedPushRevision string - remotePushDefault string - currentBranchName string - baseRefRepo ghrepo.Interface - rems context.Remotes - wantPRRefs PullRequestRefs - wantErr error - }{ - { - name: "When the branch is called 'blueberries' with an empty branch config, it returns the correct PullRequestRefs", - branchConfig: git.BranchConfig{}, - currentBranchName: "blueberries", - baseRefRepo: remoteOrigin.Repo, - wantPRRefs: PullRequestRefs{ - BranchName: "blueberries", - HeadRepo: remoteOrigin.Repo, - BaseRepo: remoteOrigin.Repo, - }, - wantErr: nil, - }, - { - name: "When the branch is called 'otherBranch' with an empty branch config, it returns the correct PullRequestRefs", - branchConfig: git.BranchConfig{}, - currentBranchName: "otherBranch", - baseRefRepo: remoteOrigin.Repo, - wantPRRefs: PullRequestRefs{ - BranchName: "otherBranch", - HeadRepo: remoteOrigin.Repo, - BaseRepo: remoteOrigin.Repo, - }, - wantErr: nil, - }, - { - name: "When the branch name doesn't match the branch name in BranchConfig.Push, it returns the BranchConfig.Push branch name", - parsedPushRevision: "origin/pushBranch", - currentBranchName: "blueberries", - baseRefRepo: remoteOrigin.Repo, - rems: context.Remotes{ - &remoteOrigin, - }, - wantPRRefs: PullRequestRefs{ - BranchName: "pushBranch", - HeadRepo: remoteOrigin.Repo, - BaseRepo: remoteOrigin.Repo, - }, - wantErr: nil, - }, - { - name: "When the push revision doesn't match a remote, it returns an error", - parsedPushRevision: "origin/differentPushBranch", - currentBranchName: "blueberries", - baseRefRepo: remoteOrigin.Repo, - rems: context.Remotes{ - &remoteUpstream, - &remoteOther, - }, - wantPRRefs: PullRequestRefs{}, - wantErr: fmt.Errorf("no remote for %q found in %q", "origin/differentPushBranch", "upstream, other"), - }, - { - name: "When the branch name doesn't match a different branch name in BranchConfig.Push and the remote isn't 'origin', it returns the BranchConfig.Push branch name", - parsedPushRevision: "other/pushBranch", - currentBranchName: "blueberries", - baseRefRepo: remoteOrigin.Repo, - rems: context.Remotes{ - &remoteOther, - }, - wantPRRefs: PullRequestRefs{ - BranchName: "pushBranch", - HeadRepo: remoteOther.Repo, - BaseRepo: remoteOrigin.Repo, - }, - wantErr: nil, - }, - { - name: "When the push remote is the same as the baseRepo, it returns the baseRepo as the PullRequestRefs HeadRepo", - branchConfig: git.BranchConfig{ - PushRemoteName: remoteOrigin.Remote.Name, - }, - currentBranchName: "blueberries", - baseRefRepo: remoteOrigin.Repo, - rems: context.Remotes{ - &remoteOrigin, - &remoteUpstream, - }, - wantPRRefs: PullRequestRefs{ - BranchName: "blueberries", - HeadRepo: remoteOrigin.Repo, - BaseRepo: remoteOrigin.Repo, - }, - wantErr: nil, - }, - { - name: "When the push remote is different from the baseRepo, it returns the push remote repo as the PullRequestRefs HeadRepo", - branchConfig: git.BranchConfig{ - PushRemoteName: remoteOrigin.Remote.Name, - }, - currentBranchName: "blueberries", - baseRefRepo: remoteUpstream.Repo, - rems: context.Remotes{ - &remoteOrigin, - &remoteUpstream, - }, - wantPRRefs: PullRequestRefs{ - BranchName: "blueberries", - HeadRepo: remoteOrigin.Repo, - BaseRepo: remoteUpstream.Repo, - }, - wantErr: nil, - }, - { - name: "When the push remote defined by a URL and the baseRepo is different from the push remote, it returns the push remote repo as the PullRequestRefs HeadRepo", - branchConfig: git.BranchConfig{ - PushRemoteURL: remoteOrigin.Remote.FetchURL, - }, - currentBranchName: "blueberries", - baseRefRepo: remoteUpstream.Repo, - rems: context.Remotes{ - &remoteOrigin, - &remoteUpstream, - }, - wantPRRefs: PullRequestRefs{ - BranchName: "blueberries", - HeadRepo: remoteOrigin.Repo, - BaseRepo: remoteUpstream.Repo, - }, - wantErr: nil, - }, - { - name: "When the push remote and merge ref are configured to a different repo and push.default = upstream, it should return the branch name from the other repo", - branchConfig: git.BranchConfig{ - PushRemoteName: remoteUpstream.Remote.Name, - MergeRef: "refs/heads/blue-upstream-berries", - }, - pushDefault: "upstream", - currentBranchName: "blueberries", - baseRefRepo: remoteOrigin.Repo, - rems: context.Remotes{ - &remoteOrigin, - &remoteUpstream, - }, - wantPRRefs: PullRequestRefs{ - BranchName: "blue-upstream-berries", - HeadRepo: remoteUpstream.Repo, - BaseRepo: remoteOrigin.Repo, - }, - wantErr: nil, - }, - { - name: "When the push remote and merge ref are configured to a different repo and push.default = tracking, it should return the branch name from the other repo", - branchConfig: git.BranchConfig{ - PushRemoteName: remoteUpstream.Remote.Name, - MergeRef: "refs/heads/blue-upstream-berries", - }, - pushDefault: "tracking", - currentBranchName: "blueberries", - baseRefRepo: remoteOrigin.Repo, - rems: context.Remotes{ - &remoteOrigin, - &remoteUpstream, - }, - wantPRRefs: PullRequestRefs{ - BranchName: "blue-upstream-berries", - HeadRepo: remoteUpstream.Repo, - BaseRepo: remoteOrigin.Repo, - }, - wantErr: nil, - }, - { - name: "When remote.pushDefault is set, it returns the correct PullRequestRefs", - branchConfig: git.BranchConfig{}, - remotePushDefault: remoteUpstream.Remote.Name, - currentBranchName: "blueberries", - baseRefRepo: remoteOrigin.Repo, - rems: context.Remotes{ - &remoteOrigin, - &remoteUpstream, - }, - wantPRRefs: PullRequestRefs{ - BranchName: "blueberries", - HeadRepo: remoteUpstream.Repo, - BaseRepo: remoteOrigin.Repo, - }, - wantErr: nil, - }, - { - name: "When the remote name is set on the branch, it returns the correct PullRequestRefs", - branchConfig: git.BranchConfig{ - RemoteName: remoteUpstream.Remote.Name, - }, - currentBranchName: "blueberries", - baseRefRepo: remoteOrigin.Repo, - rems: context.Remotes{ - &remoteOrigin, - &remoteUpstream, - }, - wantPRRefs: PullRequestRefs{ - BranchName: "blueberries", - HeadRepo: remoteUpstream.Repo, - BaseRepo: remoteOrigin.Repo, - }, - wantErr: nil, - }, - { - name: "When the remote URL is set on the branch, it returns the correct PullRequestRefs", - branchConfig: git.BranchConfig{ - RemoteURL: remoteUpstream.Remote.FetchURL, - }, - currentBranchName: "blueberries", - baseRefRepo: remoteOrigin.Repo, - rems: context.Remotes{ - &remoteOrigin, - &remoteUpstream, - }, - wantPRRefs: PullRequestRefs{ - BranchName: "blueberries", - HeadRepo: remoteUpstream.Repo, - BaseRepo: remoteOrigin.Repo, - }, - wantErr: nil, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - prRefs, err := ParsePRRefs(tt.currentBranchName, tt.branchConfig, tt.parsedPushRevision, tt.pushDefault, tt.remotePushDefault, tt.baseRefRepo, tt.rems) - if tt.wantErr != nil { - require.Equal(t, tt.wantErr, err) - } else { - require.NoError(t, err) - } - require.Equal(t, tt.wantPRRefs, prRefs) - }) - } -} - -func TestPRRefs_GetPRHeadLabel(t *testing.T) { - originRepo := ghrepo.New("ORIGINOWNER", "REPO") - upstreamRepo := ghrepo.New("UPSTREAMOWNER", "REPO") - tests := []struct { - name string - prRefs PullRequestRefs - want string - }{ - { - name: "When the HeadRepo and BaseRepo match, it returns the branch name", - prRefs: PullRequestRefs{ - BranchName: "blueberries", - HeadRepo: originRepo, - BaseRepo: originRepo, - }, - want: "blueberries", - }, - { - name: "When the HeadRepo and BaseRepo do not match, it returns the prepended HeadRepo owner to the branch name", - prRefs: PullRequestRefs{ - BranchName: "blueberries", - HeadRepo: originRepo, - BaseRepo: upstreamRepo, - }, - want: "ORIGINOWNER:blueberries", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, tt.prRefs.GetPRHeadLabel()) - }) - } -} - -func stubBranchConfig(branchConfig git.BranchConfig, err error) func(string) (git.BranchConfig, error) { - return func(branch string) (git.BranchConfig, error) { +func stubBranchConfig(branchConfig git.BranchConfig, err error) func(context.Context, string) (git.BranchConfig, error) { + return func(_ context.Context, branch string) (git.BranchConfig, error) { return branchConfig, err } } -func stubRemotes(remotes context.Remotes, err error) func() (context.Remotes, error) { - return func() (context.Remotes, error) { +func stubRemotes(remotes ghContext.Remotes, err error) func() (ghContext.Remotes, error) { + return func() (ghContext.Remotes, error) { return remotes, err } } @@ -1003,20 +719,55 @@ func stubBaseRepoFn(baseRepo ghrepo.Interface, err error) func() (ghrepo.Interfa } } -func stubPushDefault(pushDefault string, err error) func() (string, error) { - return func() (string, error) { +func stubPushDefault(pushDefault git.PushDefault, err error) func(context.Context) (git.PushDefault, error) { + return func(_ context.Context) (git.PushDefault, error) { return pushDefault, err } } -func stubRemotePushDefault(remotePushDefault string, err error) func() (string, error) { - return func() (string, error) { +func stubRemotePushDefault(remotePushDefault string, err error) func(context.Context) (string, error) { + return func(_ context.Context) (string, error) { return remotePushDefault, err } } -func stubParsedPushRevision(parsedPushRevision string, err error) func(string) (string, error) { - return func(_ string) (string, error) { +func stubPushRevision(parsedPushRevision git.RemoteTrackingRef, err error) func(context.Context, string) (git.RemoteTrackingRef, error) { + return func(_ context.Context, _ string) (git.RemoteTrackingRef, error) { return parsedPushRevision, err } } + +type stubGitConfigClient struct { + readBranchConfigFn func(ctx context.Context, branchName string) (git.BranchConfig, error) + pushDefaultFn func(ctx context.Context) (git.PushDefault, error) + remotePushDefaultFn func(ctx context.Context) (string, error) + pushRevisionFn func(ctx context.Context, branchName string) (git.RemoteTrackingRef, error) +} + +func (s stubGitConfigClient) ReadBranchConfig(ctx context.Context, branchName string) (git.BranchConfig, error) { + if s.readBranchConfigFn == nil { + panic("unexpected call to ReadBranchConfig") + } + return s.readBranchConfigFn(ctx, branchName) +} + +func (s stubGitConfigClient) PushDefault(ctx context.Context) (git.PushDefault, error) { + if s.pushDefaultFn == nil { + panic("unexpected call to PushDefault") + } + return s.pushDefaultFn(ctx) +} + +func (s stubGitConfigClient) RemotePushDefault(ctx context.Context) (string, error) { + if s.remotePushDefaultFn == nil { + panic("unexpected call to RemotePushDefault") + } + return s.remotePushDefaultFn(ctx) +} + +func (s stubGitConfigClient) PushRevision(ctx context.Context, branchName string) (git.RemoteTrackingRef, error) { + if s.pushRevisionFn == nil { + panic("unexpected call to PushRevision") + } + return s.pushRevisionFn(ctx, branchName) +} diff --git a/pkg/cmd/pr/shared/git_cached_config_client.go b/pkg/cmd/pr/shared/git_cached_config_client.go new file mode 100644 index 000000000..aea25abee --- /dev/null +++ b/pkg/cmd/pr/shared/git_cached_config_client.go @@ -0,0 +1,18 @@ +package shared + +import ( + "context" + + "github.com/cli/cli/v2/git" +) + +var _ GitConfigClient = &CachedBranchConfigGitConfigClient{} + +type CachedBranchConfigGitConfigClient struct { + CachedBranchConfig git.BranchConfig + GitConfigClient +} + +func (c CachedBranchConfigGitConfigClient) ReadBranchConfig(ctx context.Context, branchName string) (git.BranchConfig, error) { + return c.CachedBranchConfig, nil +} diff --git a/pkg/cmd/pr/status/status.go b/pkg/cmd/pr/status/status.go index b7b390bf2..60202594f 100644 --- a/pkg/cmd/pr/status/status.go +++ b/pkg/cmd/pr/status/status.go @@ -102,43 +102,34 @@ func statusRun(opts *StatusOptions) error { return fmt.Errorf("could not query for pull request for current branch: %w", err) } - branchConfig, err := opts.GitClient.ReadBranchConfig(ctx, currentBranchName) - if err != nil { - return err - } - // Determine if the branch is configured to merge to a special PR ref - prHeadRE := regexp.MustCompile(`^refs/pull/(\d+)/head$`) - if m := prHeadRE.FindStringSubmatch(branchConfig.MergeRef); m != nil { - currentPRNumber, _ = strconv.Atoi(m[1]) - } - - if currentPRNumber == 0 { - remotes, err := opts.Remotes() + if !errors.Is(err, git.ErrNotOnAnyBranch) { + branchConfig, err := opts.GitClient.ReadBranchConfig(ctx, currentBranchName) if err != nil { return err } - // Suppressing these errors as we have other means of computing the PullRequestRefs when these fail. - parsedPushRevision, _ := opts.GitClient.ParsePushRevision(ctx, currentBranchName) - - remotePushDefault, err := opts.GitClient.RemotePushDefault(ctx) - if err != nil { - return err + // Determine if the branch is configured to merge to a special PR ref + prHeadRE := regexp.MustCompile(`^refs/pull/(\d+)/head$`) + if m := prHeadRE.FindStringSubmatch(branchConfig.MergeRef); m != nil { + currentPRNumber, _ = strconv.Atoi(m[1]) } - pushDefault, err := opts.GitClient.PushDefault(ctx) - if err != nil { - return err - } + if currentPRNumber == 0 { + prRefsResolver := shared.NewPullRequestFindRefsResolver( + // We requested the branch config already, so let's cache that + shared.CachedBranchConfigGitConfigClient{ + CachedBranchConfig: branchConfig, + GitConfigClient: opts.GitClient, + }, + opts.Remotes, + ) - prRefs, err := shared.ParsePRRefs(currentBranchName, branchConfig, parsedPushRevision, pushDefault, remotePushDefault, baseRefRepo, remotes) - if err != nil { - return err - } - currentHeadRefBranchName = prRefs.BranchName - } + prRefs, err := prRefsResolver.ResolvePullRequestRefs(baseRefRepo, "", currentBranchName) + if err != nil { + return err + } - if err != nil { - return fmt.Errorf("could not query for pull request for current branch: %w", err) + currentHeadRefBranchName = prRefs.QualifiedHeadRef() + } } } @@ -316,6 +307,6 @@ func printPrs(io *iostreams.IOStreams, totalCount int, prs ...api.PullRequest) { } remaining := totalCount - len(prs) if remaining > 0 { - fmt.Fprintf(w, cs.Gray(" And %d more\n"), remaining) + fmt.Fprintln(w, cs.Mutedf(" And %d more", remaining)) } } diff --git a/pkg/cmd/pr/status/status_test.go b/pkg/cmd/pr/status/status_test.go index c55604c28..41c01e915 100644 --- a/pkg/cmd/pr/status/status_test.go +++ b/pkg/cmd/pr/status/status_test.go @@ -98,10 +98,10 @@ func TestPRStatus(t *testing.T) { // stub successful git commands rs, cleanup := run.Stub() defer cleanup(t) + rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "") rs.Register(`git config --get-regexp \^branch\\.`, 0, "") rs.Register(`git config remote.pushDefault`, 0, "") - rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "") - rs.Register(`git config push.default`, 0, "") + rs.Register(`git config push.default`, 1, "") output, err := runCommand(http, "blueberries", true, "") if err != nil { @@ -133,8 +133,8 @@ func TestPRStatus_reviewsAndChecks(t *testing.T) { defer cleanup(t) rs.Register(`git config --get-regexp \^branch\\.`, 0, "") rs.Register(`git config remote.pushDefault`, 0, "") - rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "") - rs.Register(`git config push.default`, 0, "") + rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "") + rs.Register(`git config push.default`, 1, "") output, err := runCommand(http, "blueberries", true, "") if err != nil { @@ -166,8 +166,8 @@ func TestPRStatus_reviewsAndChecksWithStatesByCount(t *testing.T) { defer cleanup(t) rs.Register(`git config --get-regexp \^branch\\.`, 0, "") rs.Register(`git config remote.pushDefault`, 0, "") - rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "") - rs.Register(`git config push.default`, 0, "") + rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "") + rs.Register(`git config push.default`, 1, "") output, err := runCommandWithDetector(http, "blueberries", true, "", &fd.EnabledDetectorMock{}) if err != nil { @@ -198,8 +198,8 @@ func TestPRStatus_currentBranch_showTheMostRecentPR(t *testing.T) { defer cleanup(t) rs.Register(`git config --get-regexp \^branch\\.`, 0, "") rs.Register(`git config remote.pushDefault`, 0, "") - rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "") - rs.Register(`git config push.default`, 0, "") + rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "") + rs.Register(`git config push.default`, 1, "") output, err := runCommand(http, "blueberries", true, "") if err != nil { @@ -234,8 +234,8 @@ func TestPRStatus_currentBranch_defaultBranch(t *testing.T) { defer cleanup(t) rs.Register(`git config --get-regexp \^branch\\.`, 0, "") rs.Register(`git config remote.pushDefault`, 0, "") - rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "") - rs.Register(`git config push.default`, 0, "") + rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "") + rs.Register(`git config push.default`, 1, "") output, err := runCommand(http, "blueberries", true, "") if err != nil { @@ -276,8 +276,8 @@ func TestPRStatus_currentBranch_Closed(t *testing.T) { defer cleanup(t) rs.Register(`git config --get-regexp \^branch\\.`, 0, "") rs.Register(`git config remote.pushDefault`, 0, "") - rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "") - rs.Register(`git config push.default`, 0, "") + rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "") + rs.Register(`git config push.default`, 1, "") output, err := runCommand(http, "blueberries", true, "") if err != nil { @@ -301,8 +301,8 @@ func TestPRStatus_currentBranch_Closed_defaultBranch(t *testing.T) { defer cleanup(t) rs.Register(`git config --get-regexp \^branch\\.`, 0, "") rs.Register(`git config remote.pushDefault`, 0, "") - rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "") - rs.Register(`git config push.default`, 0, "") + rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "") + rs.Register(`git config push.default`, 1, "") output, err := runCommand(http, "blueberries", true, "") if err != nil { @@ -326,8 +326,8 @@ func TestPRStatus_currentBranch_Merged(t *testing.T) { defer cleanup(t) rs.Register(`git config --get-regexp \^branch\\.`, 0, "") rs.Register(`git config remote.pushDefault`, 0, "") - rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "") - rs.Register(`git config push.default`, 0, "") + rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "") + rs.Register(`git config push.default`, 1, "") output, err := runCommand(http, "blueberries", true, "") if err != nil { @@ -351,8 +351,8 @@ func TestPRStatus_currentBranch_Merged_defaultBranch(t *testing.T) { defer cleanup(t) rs.Register(`git config --get-regexp \^branch\\.`, 0, "") rs.Register(`git config remote.pushDefault`, 0, "") - rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "") - rs.Register(`git config push.default`, 0, "") + rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "") + rs.Register(`git config push.default`, 1, "") output, err := runCommand(http, "blueberries", true, "") if err != nil { @@ -376,8 +376,8 @@ func TestPRStatus_blankSlate(t *testing.T) { defer cleanup(t) rs.Register(`git config --get-regexp \^branch\\.`, 0, "") rs.Register(`git config remote.pushDefault`, 0, "") - rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "") - rs.Register(`git config push.default`, 0, "") + rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "") + rs.Register(`git config push.default`, 1, "") output, err := runCommand(http, "blueberries", true, "") if err != nil { @@ -432,14 +432,6 @@ func TestPRStatus_detachedHead(t *testing.T) { defer http.Verify(t) http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.StringResponse(`{"data": {}}`)) - // stub successful git command - rs, cleanup := run.Stub() - defer cleanup(t) - rs.Register(`git config --get-regexp \^branch\\.`, 0, "") - rs.Register(`git config remote.pushDefault`, 0, "") - rs.Register(`git rev-parse --abbrev-ref @{push}`, 0, "") - rs.Register(`git config push.default`, 0, "") - output, err := runCommand(http, "", true, "") if err != nil { t.Errorf("error running command `pr status`: %v", err) diff --git a/pkg/cmd/pr/view/view.go b/pkg/cmd/pr/view/view.go index ccad4fa77..997f74d87 100644 --- a/pkg/cmd/pr/view/view.go +++ b/pkg/cmd/pr/view/view.go @@ -260,7 +260,7 @@ func printHumanPrPreview(opts *ViewOptions, baseRepo ghrepo.Interface, pr *api.P var md string var err error if pr.Body == "" { - md = fmt.Sprintf("\n %s\n\n", cs.Gray("No description provided")) + md = fmt.Sprintf("\n %s\n\n", cs.Muted("No description provided")) } else { md, err = markdown.Render(pr.Body, markdown.WithTheme(opts.IO.TerminalTheme()), @@ -282,7 +282,7 @@ func printHumanPrPreview(opts *ViewOptions, baseRepo ghrepo.Interface, pr *api.P } // Footer - fmt.Fprintf(out, cs.Gray("View this pull request on GitHub: %s\n"), pr.URL) + fmt.Fprintf(out, cs.Muted("View this pull request on GitHub: %s\n"), pr.URL) return nil } @@ -423,7 +423,7 @@ func prLabelList(pr api.PullRequest, cs *iostreams.ColorScheme) string { labelNames := make([]string, 0, len(pr.Labels.Nodes)) for _, label := range pr.Labels.Nodes { - labelNames = append(labelNames, cs.HexToRGB(label.Color, label.Name)) + labelNames = append(labelNames, cs.Label(label.Color, label.Name)) } list := strings.Join(labelNames, ", ") diff --git a/pkg/cmd/release/view/view.go b/pkg/cmd/release/view/view.go index a32482e65..c9030f299 100644 --- a/pkg/cmd/release/view/view.go +++ b/pkg/cmd/release/view/view.go @@ -129,19 +129,19 @@ func viewRun(opts *ViewOptions) error { } func renderReleaseTTY(io *iostreams.IOStreams, release *shared.Release) error { - iofmt := io.ColorScheme() + cs := io.ColorScheme() w := io.Out - fmt.Fprintf(w, "%s\n", iofmt.Bold(release.TagName)) + fmt.Fprintf(w, "%s\n", cs.Bold(release.TagName)) if release.IsDraft { - fmt.Fprintf(w, "%s • ", iofmt.Red("Draft")) + fmt.Fprintf(w, "%s • ", cs.Red("Draft")) } else if release.IsPrerelease { - fmt.Fprintf(w, "%s • ", iofmt.Yellow("Pre-release")) + fmt.Fprintf(w, "%s • ", cs.Yellow("Pre-release")) } if release.IsDraft { - fmt.Fprintf(w, "%s\n", iofmt.Gray(fmt.Sprintf("%s created this %s", release.Author.Login, text.FuzzyAgo(time.Now(), release.CreatedAt)))) + fmt.Fprintln(w, cs.Mutedf("%s created this %s", release.Author.Login, text.FuzzyAgo(time.Now(), release.CreatedAt))) } else { - fmt.Fprintf(w, "%s\n", iofmt.Gray(fmt.Sprintf("%s released this %s", release.Author.Login, text.FuzzyAgo(time.Now(), *release.PublishedAt)))) + fmt.Fprintln(w, cs.Mutedf("%s released this %s", release.Author.Login, text.FuzzyAgo(time.Now(), *release.PublishedAt))) } renderedDescription, err := markdown.Render(release.Body, @@ -153,7 +153,7 @@ func renderReleaseTTY(io *iostreams.IOStreams, release *shared.Release) error { fmt.Fprintln(w, renderedDescription) if len(release.Assets) > 0 { - fmt.Fprintf(w, "%s\n", iofmt.Bold("Assets")) + fmt.Fprintln(w, cs.Bold("Assets")) //nolint:staticcheck // SA1019: Showing NAME|SIZE headers adds nothing to table. table := tableprinter.New(io, tableprinter.NoHeader) for _, a := range release.Assets { @@ -168,7 +168,7 @@ func renderReleaseTTY(io *iostreams.IOStreams, release *shared.Release) error { fmt.Fprint(w, "\n") } - fmt.Fprintf(w, "%s\n", iofmt.Gray(fmt.Sprintf("View on GitHub: %s", release.URL))) + fmt.Fprintln(w, cs.Mutedf("View on GitHub: %s", release.URL)) return nil } diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index cd7c56ea8..be0be2ddd 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -262,10 +262,15 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co func createRun(opts *CreateOptions) error { if opts.Interactive { + cfg, err := opts.Config() + if err != nil { + return err + } + host, _ := cfg.Authentication().DefaultHost() answer, err := opts.Prompter.Select("What would you like to do?", "", []string{ - "Create a new repository on GitHub from scratch", - "Create a new repository on GitHub from a template repository", - "Push an existing local repository to GitHub", + fmt.Sprintf("Create a new repository on %s from scratch", host), + fmt.Sprintf("Create a new repository on %s from a template repository", host), + fmt.Sprintf("Push an existing local repository to %s", host), }) if err != nil { return err @@ -323,7 +328,9 @@ func createFromScratch(opts *CreateOptions) error { if idx := strings.IndexRune(opts.Name, '/'); idx > 0 { targetRepo = opts.Name[0:idx+1] + shared.NormalizeRepoName(opts.Name[idx+1:]) } - confirmed, err := opts.Prompter.Confirm(fmt.Sprintf(`This will create "%s" as a %s repository on GitHub. Continue?`, targetRepo, strings.ToLower(opts.Visibility)), true) + confirmed, err := opts.Prompter.Confirm( + fmt.Sprintf(`This will create "%s" as a %s repository on %s. Continue?`, targetRepo, strings.ToLower(opts.Visibility), host), + true) if err != nil { return err } else if !confirmed { @@ -392,9 +399,10 @@ func createFromScratch(opts *CreateOptions) error { isTTY := opts.IO.IsStdoutTTY() if isTTY { fmt.Fprintf(opts.IO.Out, - "%s Created repository %s on GitHub\n %s\n", + "%s Created repository %s on %s\n %s\n", cs.SuccessIconWithColor(cs.Green), ghrepo.FullName(repo), + host, repo.URL) } else { fmt.Fprintln(opts.IO.Out, repo.URL) @@ -482,7 +490,9 @@ func createFromTemplate(opts *CreateOptions) error { if idx := strings.IndexRune(opts.Name, '/'); idx > 0 { targetRepo = opts.Name[0:idx+1] + shared.NormalizeRepoName(opts.Name[idx+1:]) } - confirmed, err := opts.Prompter.Confirm(fmt.Sprintf(`This will create "%s" as a %s repository on GitHub. Continue?`, targetRepo, strings.ToLower(opts.Visibility)), true) + confirmed, err := opts.Prompter.Confirm( + fmt.Sprintf(`This will create "%s" as a %s repository on %s. Continue?`, targetRepo, strings.ToLower(opts.Visibility), host), + true) if err != nil { return err } else if !confirmed { @@ -496,9 +506,10 @@ func createFromTemplate(opts *CreateOptions) error { cs := opts.IO.ColorScheme() fmt.Fprintf(opts.IO.Out, - "%s Created repository %s on GitHub\n %s\n", + "%s Created repository %s on %s\n %s\n", cs.SuccessIconWithColor(cs.Green), ghrepo.FullName(repo), + host, repo.URL) opts.Clone, err = opts.Prompter.Confirm("Clone the new repository locally?", true) @@ -622,9 +633,10 @@ func createFromLocal(opts *CreateOptions) error { if isTTY { fmt.Fprintf(stdout, - "%s Created repository %s on GitHub\n %s\n", + "%s Created repository %s on %s\n %s\n", cs.SuccessIconWithColor(cs.Green), ghrepo.FullName(repo), + host, repo.URL) } else { fmt.Fprintln(stdout, repo.URL) diff --git a/pkg/cmd/repo/create/create_test.go b/pkg/cmd/repo/create/create_test.go index c33cfdad6..5f1f17e60 100644 --- a/pkg/cmd/repo/create/create_test.go +++ b/pkg/cmd/repo/create/create_test.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" + ghmock "github.com/cli/cli/v2/internal/gh/mock" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmdutil" @@ -201,7 +202,7 @@ func Test_createRun(t *testing.T) { name: "interactive create from scratch with gitignore and license", opts: &CreateOptions{Interactive: true}, tty: true, - wantStdout: "✓ Created repository OWNER/REPO on GitHub\n https://github.com/OWNER/REPO\n", + wantStdout: "✓ Created repository OWNER/REPO on github.com\n https://github.com/OWNER/REPO\n", promptStubs: func(p *prompter.PrompterMock) { p.ConfirmFunc = func(message string, defaultValue bool) (bool, error) { switch message { @@ -211,7 +212,7 @@ func Test_createRun(t *testing.T) { return true, nil case "Would you like to add a license?": return true, nil - case `This will create "REPO" as a private repository on GitHub. Continue?`: + case `This will create "REPO" as a private repository on github.com. Continue?`: return defaultValue, nil case "Clone the new repository locally?": return defaultValue, nil @@ -232,7 +233,7 @@ func Test_createRun(t *testing.T) { p.SelectFunc = func(message, defaultValue string, options []string) (int, error) { switch message { case "What would you like to do?": - return prompter.IndexFor(options, "Create a new repository on GitHub from scratch") + return prompter.IndexFor(options, "Create a new repository on github.com from scratch") case "Visibility": return prompter.IndexFor(options, "Private") case "Choose a license": @@ -267,7 +268,7 @@ func Test_createRun(t *testing.T) { name: "interactive create from scratch but with prompted owner", opts: &CreateOptions{Interactive: true}, tty: true, - wantStdout: "✓ Created repository org1/REPO on GitHub\n https://github.com/org1/REPO\n", + wantStdout: "✓ Created repository org1/REPO on github.com\n https://github.com/org1/REPO\n", promptStubs: func(p *prompter.PrompterMock) { p.ConfirmFunc = func(message string, defaultValue bool) (bool, error) { switch message { @@ -277,7 +278,7 @@ func Test_createRun(t *testing.T) { return false, nil case "Would you like to add a license?": return false, nil - case `This will create "org1/REPO" as a private repository on GitHub. Continue?`: + case `This will create "org1/REPO" as a private repository on github.com. Continue?`: return true, nil case "Clone the new repository locally?": return false, nil @@ -300,7 +301,7 @@ func Test_createRun(t *testing.T) { case "Repository owner": return prompter.IndexFor(options, "org1") case "What would you like to do?": - return prompter.IndexFor(options, "Create a new repository on GitHub from scratch") + return prompter.IndexFor(options, "Create a new repository on github.com from scratch") case "Visibility": return prompter.IndexFor(options, "Private") default: @@ -345,7 +346,7 @@ func Test_createRun(t *testing.T) { return false, nil case "Would you like to add a license?": return false, nil - case `This will create "REPO" as a private repository on GitHub. Continue?`: + case `This will create "REPO" as a private repository on github.com. Continue?`: return false, nil default: return false, fmt.Errorf("unexpected confirm prompt: %s", message) @@ -364,7 +365,7 @@ func Test_createRun(t *testing.T) { p.SelectFunc = func(message, defaultValue string, options []string) (int, error) { switch message { case "What would you like to do?": - return prompter.IndexFor(options, "Create a new repository on GitHub from scratch") + return prompter.IndexFor(options, "Create a new repository on github.com from scratch") case "Visibility": return prompter.IndexFor(options, "Private") default: @@ -409,7 +410,7 @@ func Test_createRun(t *testing.T) { p.SelectFunc = func(message, defaultValue string, options []string) (int, error) { switch message { case "What would you like to do?": - return prompter.IndexFor(options, "Push an existing local repository to GitHub") + return prompter.IndexFor(options, "Push an existing local repository to github.com") case "Visibility": return prompter.IndexFor(options, "Private") default: @@ -441,7 +442,7 @@ func Test_createRun(t *testing.T) { cs.Register(`git -C . rev-parse --git-dir`, 0, ".git") cs.Register(`git -C . rev-parse HEAD`, 0, "commithash") }, - wantStdout: "✓ Created repository OWNER/REPO on GitHub\n https://github.com/OWNER/REPO\n", + wantStdout: "✓ Created repository OWNER/REPO on github.com\n https://github.com/OWNER/REPO\n", }, { name: "interactive with existing bare repository public and push", @@ -475,7 +476,7 @@ func Test_createRun(t *testing.T) { p.SelectFunc = func(message, defaultValue string, options []string) (int, error) { switch message { case "What would you like to do?": - return prompter.IndexFor(options, "Push an existing local repository to GitHub") + return prompter.IndexFor(options, "Push an existing local repository to github.com") case "Visibility": return prompter.IndexFor(options, "Private") default: @@ -509,7 +510,7 @@ func Test_createRun(t *testing.T) { cs.Register(`git -C . remote add origin https://github.com/OWNER/REPO`, 0, "") cs.Register(`git -C . push origin --mirror`, 0, "") }, - wantStdout: "✓ Created repository OWNER/REPO on GitHub\n https://github.com/OWNER/REPO\n✓ Added remote https://github.com/OWNER/REPO.git\n✓ Mirrored all refs to https://github.com/OWNER/REPO.git\n", + wantStdout: "✓ Created repository OWNER/REPO on github.com\n https://github.com/OWNER/REPO\n✓ Added remote https://github.com/OWNER/REPO.git\n✓ Mirrored all refs to https://github.com/OWNER/REPO.git\n", }, { name: "interactive with existing repository public add remote and push", @@ -543,7 +544,7 @@ func Test_createRun(t *testing.T) { p.SelectFunc = func(message, defaultValue string, options []string) (int, error) { switch message { case "What would you like to do?": - return prompter.IndexFor(options, "Push an existing local repository to GitHub") + return prompter.IndexFor(options, "Push an existing local repository to github.com") case "Visibility": return prompter.IndexFor(options, "Private") default: @@ -577,7 +578,7 @@ func Test_createRun(t *testing.T) { cs.Register(`git -C . remote add origin https://github.com/OWNER/REPO`, 0, "") cs.Register(`git -C . push --set-upstream origin HEAD`, 0, "") }, - wantStdout: "✓ Created repository OWNER/REPO on GitHub\n https://github.com/OWNER/REPO\n✓ Added remote https://github.com/OWNER/REPO.git\n✓ Pushed commits to https://github.com/OWNER/REPO.git\n", + wantStdout: "✓ Created repository OWNER/REPO on github.com\n https://github.com/OWNER/REPO\n✓ Added remote https://github.com/OWNER/REPO.git\n✓ Pushed commits to https://github.com/OWNER/REPO.git\n", }, { name: "interactive create from a template repository", @@ -586,7 +587,7 @@ func Test_createRun(t *testing.T) { promptStubs: func(p *prompter.PrompterMock) { p.ConfirmFunc = func(message string, defaultValue bool) (bool, error) { switch message { - case `This will create "OWNER/REPO" as a private repository on GitHub. Continue?`: + case `This will create "OWNER/REPO" as a private repository on github.com. Continue?`: return defaultValue, nil case "Clone the new repository locally?": return defaultValue, nil @@ -611,7 +612,7 @@ func Test_createRun(t *testing.T) { case "Choose a template repository": return prompter.IndexFor(options, "REPO") case "What would you like to do?": - return prompter.IndexFor(options, "Create a new repository on GitHub from a template repository") + return prompter.IndexFor(options, "Create a new repository on github.com from a template repository") case "Visibility": return prompter.IndexFor(options, "Private") default: @@ -654,7 +655,7 @@ func Test_createRun(t *testing.T) { execStubs: func(cs *run.CommandStubber) { cs.Register(`git clone --branch main https://github.com/OWNER/REPO`, 0, "") }, - wantStdout: "✓ Created repository OWNER/REPO on GitHub\n https://github.com/OWNER/REPO\n", + wantStdout: "✓ Created repository OWNER/REPO on github.com\n https://github.com/OWNER/REPO\n", }, { name: "interactive create from template repo but there are no template repos", @@ -680,7 +681,7 @@ func Test_createRun(t *testing.T) { p.SelectFunc = func(message, defaultValue string, options []string) (int, error) { switch message { case "What would you like to do?": - return prompter.IndexFor(options, "Create a new repository on GitHub from a template repository") + return prompter.IndexFor(options, "Create a new repository on github.com from a template repository") case "Visibility": return prompter.IndexFor(options, "Private") default: @@ -950,6 +951,88 @@ func Test_createRun(t *testing.T) { }, wantStdout: "https://github.com/OWNER/REPO\n", }, + { + name: "interactive create from scratch with host override", + opts: &CreateOptions{ + Interactive: true, + Config: func() (gh.Config, error) { + cfg := &ghmock.ConfigMock{ + AuthenticationFunc: func() gh.AuthConfig { + authCfg := &config.AuthConfig{} + authCfg.SetHosts([]string{"example.com"}) + authCfg.SetDefaultHost("example.com", "GH_HOST") + return authCfg + }, + } + return cfg, nil + }, + }, + tty: true, + promptStubs: func(p *prompter.PrompterMock) { + p.ConfirmFunc = func(message string, defaultValue bool) (bool, error) { + switch message { + case "Would you like to add a README file?": + return false, nil + case "Would you like to add a .gitignore?": + return false, nil + case "Would you like to add a license?": + return false, nil + case `This will create "REPO" as a private repository on example.com. Continue?`: + return defaultValue, nil + case "Clone the new repository locally?": + return false, nil + default: + return false, fmt.Errorf("unexpected confirm prompt: %s", message) + } + } + p.InputFunc = func(message, defaultValue string) (string, error) { + switch message { + case "Repository name": + return "REPO", nil + case "Description": + return "my new repo", nil + default: + return "", fmt.Errorf("unexpected input prompt: %s", message) + } + } + p.SelectFunc = func(message, defaultValue string, options []string) (int, error) { + switch message { + case "What would you like to do?": + return prompter.IndexFor(options, "Create a new repository on example.com from scratch") + case "Visibility": + return prompter.IndexFor(options, "Private") + case "Choose a license": + return prompter.IndexFor(options, "GNU Lesser General Public License v3.0") + case "Choose a .gitignore template": + return prompter.IndexFor(options, "Go") + default: + return 0, fmt.Errorf("unexpected select prompt: %s", message) + } + } + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"someuser","organizations":{"nodes": []}}}}`)) + reg.Register( + httpmock.GraphQL(`mutation RepositoryCreate\b`), + httpmock.StringResponse(` + { + "data": { + "createRepository": { + "repository": { + "id": "REPOID", + "name": "REPO", + "owner": {"login":"OWNER"}, + "url": "https://example.com/OWNER/REPO" + } + } + } + }`), + ) + }, + wantStdout: "✓ Created repository OWNER/REPO on example.com\n https://example.com/OWNER/REPO\n", + }, } for _, tt := range tests { prompterMock := &prompter.PrompterMock{} @@ -965,8 +1048,11 @@ func Test_createRun(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } - tt.opts.Config = func() (gh.Config, error) { - return config.NewBlankConfig(), nil + + if tt.opts.Config == nil { + tt.opts.Config = func() (gh.Config, error) { + return config.NewBlankConfig(), nil + } } tt.opts.GitClient = &git.Client{ diff --git a/pkg/cmd/repo/license/view/view.go b/pkg/cmd/repo/license/view/view.go index d3e63c241..14228ba36 100644 --- a/pkg/cmd/repo/license/view/view.go +++ b/pkg/cmd/repo/license/view/view.go @@ -119,9 +119,9 @@ func renderLicense(license *api.License, opts *ViewOptions) error { cs := opts.IO.ColorScheme() var out strings.Builder if opts.IO.IsStdoutTTY() { - out.WriteString(fmt.Sprintf("\n%s\n", cs.Gray(license.Description))) - out.WriteString(fmt.Sprintf("\n%s\n", cs.Grayf("To implement: %s", license.Implementation))) - out.WriteString(fmt.Sprintf("\n%s\n\n", cs.Grayf("For more information, see: %s", license.HTMLURL))) + out.WriteString(fmt.Sprintf("\n%s\n", cs.Muted(license.Description))) + out.WriteString(fmt.Sprintf("\n%s\n", cs.Mutedf("To implement: %s", license.Implementation))) + out.WriteString(fmt.Sprintf("\n%s\n\n", cs.Mutedf("For more information, see: %s", license.HTMLURL))) } out.WriteString(license.Body) _, err := opts.IO.Out.Write([]byte(out.String())) diff --git a/pkg/cmd/repo/view/view.go b/pkg/cmd/repo/view/view.go index 06f85d048..b13276c80 100644 --- a/pkg/cmd/repo/view/view.go +++ b/pkg/cmd/repo/view/view.go @@ -181,7 +181,7 @@ func viewRun(opts *ViewOptions) error { var readmeContent string if readme == nil { - readmeContent = cs.Gray("This repository does not have a README") + readmeContent = cs.Muted("This repository does not have a README") } else if isMarkdownFile(readme.Filename) { var err error readmeContent, err = markdown.Render(readme.Content, @@ -197,7 +197,7 @@ func viewRun(opts *ViewOptions) error { description := repo.Description if description == "" { - description = cs.Gray("No description provided") + description = cs.Muted("No description provided") } repoData := struct { @@ -209,7 +209,7 @@ func viewRun(opts *ViewOptions) error { FullName: cs.Bold(fullName), Description: description, Readme: readmeContent, - View: cs.Gray(fmt.Sprintf("View this repository on GitHub: %s", openURL)), + View: cs.Mutedf("View this repository on GitHub: %s", openURL), } return tmpl.Execute(stdout, repoData) diff --git a/pkg/cmd/root/help.go b/pkg/cmd/root/help.go index a7daa7b84..7f8fb1c2e 100644 --- a/pkg/cmd/root/help.go +++ b/pkg/cmd/root/help.go @@ -87,7 +87,7 @@ func isRootCmd(command *cobra.Command) bool { return command != nil && !command.HasParent() } -func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, args []string) { +func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, _ []string) { flags := command.Flags() if isRootCmd(command) { diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go index db0ef098d..4b692777c 100644 --- a/pkg/cmd/root/help_topic.go +++ b/pkg/cmd/root/help_topic.go @@ -81,6 +81,9 @@ var HelpTopics = []helpTopic{ %[1]sCLICOLOR_FORCE%[1]s: set to a value other than %[1]s0%[1]s to keep ANSI colors in output even when the output is piped. + %[1]sGH_COLOR_LABELS%[1]s: set to any value to display labels using their RGB hex color codes in terminals that + support truecolor. + %[1]sGH_FORCE_TTY%[1]s: set to any value to force terminal-style output even when the output is redirected. When the value is a number, it is interpreted as the number of columns available in the viewport. When the value is a percentage, it will be applied against @@ -108,6 +111,12 @@ var HelpTopics = []helpTopic{ %[1]sGH_MDWIDTH%[1]s: default maximum width for markdown render wrapping. The max width of lines wrapped on the terminal will be taken as the lesser of the terminal width, this value, or 120 if not specified. This value is used, for example, with %[1]spr view%[1]s subcommand. + + %[1]sGH_ACCESSIBLE_PROMPTER%[1]s (preview): set to a truthy value to enable prompts that are + more compatible with speech synthesis and braille screen readers. + + %[1]sGH_SPINNER_DISABLED%[1]s: set to a truthy value to replace the spinner animation with + a textual progress indicator. `, "`"), }, { diff --git a/pkg/cmd/run/shared/presentation.go b/pkg/cmd/run/shared/presentation.go index 2ec149729..a3556d743 100644 --- a/pkg/cmd/run/shared/presentation.go +++ b/pkg/cmd/run/shared/presentation.go @@ -52,7 +52,8 @@ func RenderAnnotations(cs *iostreams.ColorScheme, annotations []Annotation) stri for _, a := range annotations { lines = append(lines, fmt.Sprintf("%s %s", AnnotationSymbol(cs, a), a.Message)) - lines = append(lines, cs.Grayf("%s: %s#%d\n", a.JobName, a.Path, a.StartLine)) + // Following newline is essential for spacing between annotations + lines = append(lines, cs.Mutedf("%s: %s#%d\n", a.JobName, a.Path, a.StartLine)) } return strings.Join(lines, "\n") diff --git a/pkg/cmd/run/shared/shared.go b/pkg/cmd/run/shared/shared.go index ce909fd77..b58f6b0f7 100644 --- a/pkg/cmd/run/shared/shared.go +++ b/pkg/cmd/run/shared/shared.go @@ -230,6 +230,8 @@ type Job struct { CompletedAt time.Time `json:"completed_at"` URL string `json:"html_url"` RunID int64 `json:"run_id"` + + Log *zip.File } type Step struct { @@ -239,7 +241,8 @@ type Step struct { Number int StartedAt time.Time `json:"started_at"` CompletedAt time.Time `json:"completed_at"` - Log *zip.File + + Log *zip.File } type Steps []Step @@ -575,7 +578,7 @@ func Symbol(cs *iostreams.ColorScheme, status Status, conclusion Conclusion) (st case Success: return cs.SuccessIconWithColor(noColor), cs.Green case Skipped, Neutral: - return "-", cs.Gray + return "-", cs.Muted default: return cs.FailureIconWithColor(noColor), cs.Red } diff --git a/pkg/cmd/run/shared/test.go b/pkg/cmd/run/shared/test.go index 0619541a4..5a8a4584e 100644 --- a/pkg/cmd/run/shared/test.go +++ b/pkg/cmd/run/shared/test.go @@ -104,6 +104,60 @@ var SuccessfulJob Job = Job{ }, } +// Note that this run *has* steps, but in the ZIP archive the step logs are not +// included. +var SuccessfulJobWithoutStepLogs Job = Job{ + ID: 11, + Status: Completed, + Conclusion: Success, + Name: "cool job with no step logs", + StartedAt: TestRunStartTime, + CompletedAt: TestRunStartTime.Add(time.Minute*4 + time.Second*34), + URL: "https://github.com/jobs/11", + RunID: 3, + Steps: []Step{ + { + Name: "fob the barz", + Status: Completed, + Conclusion: Success, + Number: 1, + }, + { + Name: "barz the fob", + Status: Completed, + Conclusion: Success, + Number: 2, + }, + }, +} + +// Note that this run *has* steps, but in the ZIP archive the step logs are not +// included. +var LegacySuccessfulJobWithoutStepLogs Job = Job{ + ID: 12, + Status: Completed, + Conclusion: Success, + Name: "legacy cool job with no step logs", + StartedAt: TestRunStartTime, + CompletedAt: TestRunStartTime.Add(time.Minute*4 + time.Second*34), + URL: "https://github.com/jobs/12", + RunID: 3, + Steps: []Step{ + { + Name: "fob the barz", + Status: Completed, + Conclusion: Success, + Number: 1, + }, + { + Name: "barz the fob", + Status: Completed, + Conclusion: Success, + Number: 2, + }, + }, +} + var FailedJob Job = Job{ ID: 20, Status: Completed, @@ -129,6 +183,60 @@ var FailedJob Job = Job{ }, } +// Note that this run *has* steps, but in the ZIP archive the step logs are not +// included. +var FailedJobWithoutStepLogs Job = Job{ + ID: 21, + Status: Completed, + Conclusion: Failure, + Name: "sad job with no step logs", + StartedAt: TestRunStartTime, + CompletedAt: TestRunStartTime.Add(time.Minute*4 + time.Second*34), + URL: "https://github.com/jobs/21", + RunID: 1234, + Steps: []Step{ + { + Name: "barf the quux", + Status: Completed, + Conclusion: Success, + Number: 1, + }, + { + Name: "quux the barf", + Status: Completed, + Conclusion: Failure, + Number: 2, + }, + }, +} + +// Note that this run *has* steps, but in the ZIP archive the step logs are not +// included. +var LegacyFailedJobWithoutStepLogs Job = Job{ + ID: 22, + Status: Completed, + Conclusion: Failure, + Name: "legacy sad job with no step logs", + StartedAt: TestRunStartTime, + CompletedAt: TestRunStartTime.Add(time.Minute*4 + time.Second*34), + URL: "https://github.com/jobs/22", + RunID: 1234, + Steps: []Step{ + { + Name: "barf the quux", + Status: Completed, + Conclusion: Success, + Number: 1, + }, + { + Name: "quux the barf", + Status: Completed, + Conclusion: Failure, + Number: 2, + }, + }, +} + var SuccessfulJobAnnotations []Annotation = []Annotation{ { JobName: "cool job", diff --git a/pkg/cmd/run/view/fixtures/run_log.zip b/pkg/cmd/run/view/fixtures/run_log.zip index 757a63983..425ba09dd 100644 Binary files a/pkg/cmd/run/view/fixtures/run_log.zip and b/pkg/cmd/run/view/fixtures/run_log.zip differ diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index c794cff9a..0dafbcc09 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -118,6 +118,10 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman This command does not support authenticating via fine grained PATs as it is not currently possible to create a PAT with the %[1]schecks:read%[1]s permission. + + Due to platform limitations, %[1]sgh%[1]s may not always be able to associate log lines with a + particular step in a job. In this case, the step name in the log output will be replaced with + %[1]sUNKNOWN STEP%[1]s. `, "`"), Args: cobra.MaximumNArgs(1), Example: heredoc.Doc(` @@ -397,7 +401,7 @@ func runView(opts *ViewOptions) error { for _, a := range artifacts { expiredBadge := "" if a.Expired { - expiredBadge = cs.Gray(" (expired)") + expiredBadge = cs.Muted(" (expired)") } fmt.Fprintf(out, "%s%s\n", a.Name, expiredBadge) } @@ -411,7 +415,7 @@ func runView(opts *ViewOptions) error { } else { fmt.Fprintf(out, "For more information about a job, try: gh run view --job=\n") } - fmt.Fprintf(out, cs.Gray("View this run on GitHub: %s\n"), run.URL) + fmt.Fprintln(out, cs.Mutedf("View this run on GitHub: %s", run.URL)) if opts.ExitStatus && shared.IsFailureState(run.Conclusion) { return cmdutil.SilentError @@ -423,7 +427,7 @@ func runView(opts *ViewOptions) error { } else { fmt.Fprintf(out, "To see the full job log, try: gh run view --log --job=%d\n", selectedJob.ID) } - fmt.Fprintf(out, cs.Gray("View this run on GitHub: %s\n"), run.URL) + fmt.Fprintln(out, cs.Mutedf("View this run on GitHub: %s", run.URL)) if opts.ExitStatus && shared.IsFailureState(selectedJob.Conclusion) { return cmdutil.SilentError @@ -533,7 +537,7 @@ func promptForJob(prompter shared.Prompter, cs *iostreams.ColorScheme, jobs []sh const JOB_NAME_MAX_LENGTH = 90 -func logFilenameRegexp(job shared.Job, step shared.Step) *regexp.Regexp { +func getJobNameForLogFilename(name string) string { // As described in https://github.com/cli/cli/issues/5011#issuecomment-1570713070, there are a number of steps // the server can take when producing the downloaded zip file that can result in a mismatch between the job name // and the filename in the zip including: @@ -545,10 +549,31 @@ func logFilenameRegexp(job shared.Job, step shared.Step) *regexp.Regexp { // * Strip `/` which occur when composite action job names are constructed of the form ` / ` // * Truncate long job names // - sanitizedJobName := strings.ReplaceAll(job.Name, "/", "") + sanitizedJobName := strings.ReplaceAll(name, "/", "") sanitizedJobName = strings.ReplaceAll(sanitizedJobName, ":", "") sanitizedJobName = truncateAsUTF16(sanitizedJobName, JOB_NAME_MAX_LENGTH) - re := fmt.Sprintf(`^%s\/%d_.*\.txt`, regexp.QuoteMeta(sanitizedJobName), step.Number) + return sanitizedJobName +} + +// A job run log file is a top-level .txt file whose name starts with an ordinal +// number; e.g., "0_jobname.txt". +func jobLogFilenameRegexp(job shared.Job) *regexp.Regexp { + sanitizedJobName := getJobNameForLogFilename(job.Name) + re := fmt.Sprintf(`^\d+_%s\.txt$`, regexp.QuoteMeta(sanitizedJobName)) + return regexp.MustCompile(re) +} + +// A legacy job run log file is a top-level .txt file whose name starts with a +// negative number which is the ID of the run; e.g., "-2147483648_jobname.txt". +func legacyJobLogFilenameRegexp(job shared.Job) *regexp.Regexp { + sanitizedJobName := getJobNameForLogFilename(job.Name) + re := fmt.Sprintf(`^-\d+_%s\.txt$`, regexp.QuoteMeta(sanitizedJobName)) + return regexp.MustCompile(re) +} + +func stepLogFilenameRegexp(job shared.Job, step shared.Step) *regexp.Regexp { + sanitizedJobName := getJobNameForLogFilename(job.Name) + re := fmt.Sprintf(`^%s\/%d_.*\.txt$`, regexp.QuoteMeta(sanitizedJobName), step.Number) return regexp.MustCompile(re) } @@ -627,29 +652,60 @@ func truncateAsUTF16(str string, max int) string { // │ ├── 2_anotherstepname.txt // │ ├── 3_stepstepname.txt // │ └── 4_laststepname.txt -// └── jobname2/ -// ├── 1_stepname.txt -// └── 2_somestepname.txt +// ├── jobname2/ +// | ├── 1_stepname.txt +// | └── 2_somestepname.txt +// ├── 0_jobname1.txt +// ├── 1_jobname2.txt +// └── -9999999999_jobname3.txt // // It iterates through the list of jobs and tries to find the matching // log in the zip file. If the matching log is found it is attached // to the job. +// +// The top-level .txt files include the logs for an entire job run. Note that +// the prefixed number is either: +// - An ordinal and cannot be mapped to the corresponding job's ID. +// - A negative integer which is the ID of the job in the old Actions service. +// The service right now tries to get logs and use an ordinal in a loop. +// However, if it doesn't get the logs, it falls back to an old service +// where the ID can apparently be negative. func attachRunLog(rlz *zip.Reader, jobs []shared.Job) { for i, job := range jobs { + // As a highest priority, we try to use the step logs first. We have seen zips that surprisingly contain + // step logs, normal job logs and legacy job logs. In this case, both job logs would be ignored. We have + // never seen a zip containing both job logs and no step logs, however, it may be possible. In that case + // let's prioritise the normal log over the legacy one. + jobLog := matchFileInZIPArchive(rlz, jobLogFilenameRegexp(job)) + if jobLog == nil { + jobLog = matchFileInZIPArchive(rlz, legacyJobLogFilenameRegexp(job)) + } + jobs[i].Log = jobLog + for j, step := range job.Steps { - re := logFilenameRegexp(job, step) - for _, file := range rlz.File { - if re.MatchString(file.Name) { - jobs[i].Steps[j].Log = file - break - } - } + jobs[i].Steps[j].Log = matchFileInZIPArchive(rlz, stepLogFilenameRegexp(job, step)) } } } +func matchFileInZIPArchive(zr *zip.Reader, re *regexp.Regexp) *zip.File { + for _, file := range zr.File { + if re.MatchString(file.Name) { + return file + } + } + return nil +} + func displayRunLog(w io.Writer, jobs []shared.Job, failed bool) error { for _, job := range jobs { + // To display a run log, we first try to compile it from individual step + // logs, because this way we can prepend lines with the corresponding + // step name. However, at the time of writing, logs are sometimes being + // served by a service that doesn’t include the step logs (none of them), + // in which case we fall back to print the entire job run log. + var hasStepLogs bool + steps := job.Steps sort.Sort(steps) for _, step := range steps { @@ -659,18 +715,49 @@ func displayRunLog(w io.Writer, jobs []shared.Job, failed bool) error { if step.Log == nil { continue } + hasStepLogs = true prefix := fmt.Sprintf("%s\t%s\t", job.Name, step.Name) - f, err := step.Log.Open() - if err != nil { + if err := printZIPFile(w, step.Log, prefix); err != nil { return err } - scanner := bufio.NewScanner(f) - for scanner.Scan() { - fmt.Fprintf(w, "%s%s\n", prefix, scanner.Text()) - } - f.Close() + } + + if hasStepLogs { + continue + } + + if failed && !shared.IsFailureState(job.Conclusion) { + continue + } + + if job.Log == nil { + continue + } + + // Here, we fall back to the job run log, which means we do not know + // the step name of lines. However, we want to keep the same line + // formatting to avoid breaking any code or script that rely on the + // tab-delimited formatting. So, an unknown-step placeholder is used + // instead of the actual step name. + prefix := fmt.Sprintf("%s\tUNKNOWN STEP\t", job.Name) + if err := printZIPFile(w, job.Log, prefix); err != nil { + return err } } return nil } + +func printZIPFile(w io.Writer, file *zip.File, prefix string) error { + f, err := file.Open() + if err != nil { + return err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + fmt.Fprintf(w, "%s%s\n", prefix, scanner.Text()) + } + return nil +} diff --git a/pkg/cmd/run/view/view_test.go b/pkg/cmd/run/view/view_test.go index 3a04fb186..2d150934f 100644 --- a/pkg/cmd/run/view/view_test.go +++ b/pkg/cmd/run/view/view_test.go @@ -990,6 +990,619 @@ func TestViewRun(t *testing.T) { }, wantOut: quuxTheBarfLogOutput, }, + { + name: "interactive with log, with no step logs available (#10551)", + tty: true, + opts: &ViewOptions{ + Prompt: true, + Log: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: shared.TestRuns, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "runs/3/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.SuccessfulJobWithoutStepLogs, + shared.FailedJobWithoutStepLogs, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{ + shared.TestWorkflow, + }, + })) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow run", + []string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021") + }) + pm.RegisterSelect("View a specific job in this run?", + []string{"View all jobs in this run", "✓ cool job with no step logs", "X sad job with no step logs"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "✓ cool job with no step logs") + }) + }, + wantOut: coolJobRunWithNoStepLogsLogOutput, + }, + { + name: "noninteractive with log, with no step logs available (#10551)", + opts: &ViewOptions{ + JobID: "11", + Log: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/11"), + httpmock.JSONResponse(shared.SuccessfulJobWithoutStepLogs)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + }, + wantOut: coolJobRunWithNoStepLogsLogOutput, + }, + { + name: "interactive with log-failed, with no step logs available (#10551)", + tty: true, + opts: &ViewOptions{ + Prompt: true, + LogFailed: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: shared.TestRuns, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.FailedRun)) + reg.Register( + httpmock.REST("GET", "runs/1234/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.SuccessfulJobWithoutStepLogs, + shared.FailedJobWithoutStepLogs, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{ + shared.TestWorkflow, + }, + })) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow run", + []string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"}, + func(_, _ string, opts []string) (int, error) { + return 4, nil + }) + pm.RegisterSelect("View a specific job in this run?", + []string{"View all jobs in this run", "✓ cool job with no step logs", "X sad job with no step logs"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "X sad job with no step logs") + }) + }, + wantOut: sadJobRunWithNoStepLogsLogOutput, + }, + { + name: "noninteractive with log-failed, with no step logs available (#10551)", + opts: &ViewOptions{ + JobID: "21", + LogFailed: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/21"), + httpmock.JSONResponse(shared.FailedJobWithoutStepLogs)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.FailedRun)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + }, + wantOut: sadJobRunWithNoStepLogsLogOutput, + }, + { + name: "interactive with run log, with no step logs available (#10551)", + tty: true, + opts: &ViewOptions{ + Prompt: true, + Log: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: shared.TestRuns, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "runs/3/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.SuccessfulJobWithoutStepLogs, + shared.FailedJobWithoutStepLogs, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{ + shared.TestWorkflow, + }, + })) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow run", + []string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021") + }) + pm.RegisterSelect("View a specific job in this run?", + []string{"View all jobs in this run", "✓ cool job with no step logs", "X sad job with no step logs"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "View all jobs in this run") + }) + }, + wantOut: expectedRunLogOutputWithNoSteps, + }, + { + name: "noninteractive with run log, with no step logs available (#10551)", + opts: &ViewOptions{ + RunID: "3", + Log: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "runs/3/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.SuccessfulJobWithoutStepLogs, + shared.FailedJobWithoutStepLogs, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + }, + wantOut: expectedRunLogOutputWithNoSteps, + }, + { + name: "interactive with run log-failed, with no step logs available (#10551)", + tty: true, + opts: &ViewOptions{ + Prompt: true, + LogFailed: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: shared.TestRuns, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.FailedRun)) + reg.Register( + httpmock.REST("GET", "runs/1234/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.SuccessfulJobWithoutStepLogs, + shared.FailedJobWithoutStepLogs, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{ + shared.TestWorkflow, + }, + })) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow run", + []string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"}, + func(_, _ string, opts []string) (int, error) { + return 4, nil + }) + pm.RegisterSelect("View a specific job in this run?", + []string{"View all jobs in this run", "✓ cool job with no step logs", "X sad job with no step logs"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "View all jobs in this run") + }) + }, + wantOut: sadJobRunWithNoStepLogsLogOutput, + }, + { + name: "noninteractive with run log-failed, with no step logs available (#10551)", + opts: &ViewOptions{ + RunID: "1234", + LogFailed: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.FailedRun)) + reg.Register( + httpmock.REST("GET", "runs/1234/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.SuccessfulJobWithoutStepLogs, + shared.FailedJobWithoutStepLogs, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + }, + wantOut: sadJobRunWithNoStepLogsLogOutput, + }, + { + name: "interactive with log, legacy service data, with no step logs available", + tty: true, + opts: &ViewOptions{ + Prompt: true, + Log: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: shared.TestRuns, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "runs/3/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.LegacySuccessfulJobWithoutStepLogs, + shared.LegacyFailedJobWithoutStepLogs, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{ + shared.TestWorkflow, + }, + })) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow run", + []string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021") + }) + pm.RegisterSelect("View a specific job in this run?", + []string{"View all jobs in this run", "✓ legacy cool job with no step logs", "X legacy sad job with no step logs"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "✓ legacy cool job with no step logs") + }) + }, + wantOut: legacyCoolJobRunWithNoStepLogsLogOutput, + }, + { + name: "noninteractive with log, legacy service data, with no step logs available", + opts: &ViewOptions{ + JobID: "12", + Log: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/12"), + httpmock.JSONResponse(shared.LegacySuccessfulJobWithoutStepLogs)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + }, + wantOut: legacyCoolJobRunWithNoStepLogsLogOutput, + }, + + { + name: "interactive with log-failed, legacy service data, with no step logs available (#10551)", + tty: true, + opts: &ViewOptions{ + Prompt: true, + LogFailed: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: shared.TestRuns, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.FailedRun)) + reg.Register( + httpmock.REST("GET", "runs/1234/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.LegacySuccessfulJobWithoutStepLogs, + shared.LegacyFailedJobWithoutStepLogs, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{ + shared.TestWorkflow, + }, + })) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow run", + []string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"}, + func(_, _ string, opts []string) (int, error) { + return 4, nil + }) + pm.RegisterSelect("View a specific job in this run?", + []string{"View all jobs in this run", "✓ legacy cool job with no step logs", "X legacy sad job with no step logs"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "X legacy sad job with no step logs") + }) + }, + wantOut: legacySadJobRunWithNoStepLogsLogOutput, + }, + { + name: "noninteractive with log-failed, legacy service data, with no step logs available (#10551)", + opts: &ViewOptions{ + JobID: "22", + LogFailed: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/22"), + httpmock.JSONResponse(shared.LegacyFailedJobWithoutStepLogs)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.FailedRun)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + }, + wantOut: legacySadJobRunWithNoStepLogsLogOutput, + }, + { + name: "interactive with run log, legacy service data, with no step logs available (#10551)", + tty: true, + opts: &ViewOptions{ + Prompt: true, + Log: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: shared.TestRuns, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "runs/3/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.LegacySuccessfulJobWithoutStepLogs, + shared.LegacyFailedJobWithoutStepLogs, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{ + shared.TestWorkflow, + }, + })) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow run", + []string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021") + }) + pm.RegisterSelect("View a specific job in this run?", + []string{"View all jobs in this run", "✓ legacy cool job with no step logs", "X legacy sad job with no step logs"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "View all jobs in this run") + }) + }, + wantOut: expectedLegacyRunLogOutputWithNoSteps, + }, + { + name: "noninteractive with run log, legacy service data, with no step logs available (#10551)", + opts: &ViewOptions{ + RunID: "3", + Log: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "runs/3/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.LegacySuccessfulJobWithoutStepLogs, + shared.LegacyFailedJobWithoutStepLogs, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + }, + wantOut: expectedLegacyRunLogOutputWithNoSteps, + }, + { + name: "interactive with run log-failed, legacy service data, with no step logs available (#10551)", + tty: true, + opts: &ViewOptions{ + Prompt: true, + LogFailed: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: shared.TestRuns, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.FailedRun)) + reg.Register( + httpmock.REST("GET", "runs/1234/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.LegacySuccessfulJobWithoutStepLogs, + shared.LegacyFailedJobWithoutStepLogs, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{ + shared.TestWorkflow, + }, + })) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow run", + []string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"}, + func(_, _ string, opts []string) (int, error) { + return 4, nil + }) + pm.RegisterSelect("View a specific job in this run?", + []string{"View all jobs in this run", "✓ legacy cool job with no step logs", "X legacy sad job with no step logs"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "View all jobs in this run") + }) + }, + wantOut: legacySadJobRunWithNoStepLogsLogOutput, + }, + { + name: "noninteractive with run log-failed, legacy service data, with no step logs available (#10551)", + opts: &ViewOptions{ + RunID: "1234", + LogFailed: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.FailedRun)) + reg.Register( + httpmock.REST("GET", "runs/1234/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.LegacySuccessfulJobWithoutStepLogs, + shared.LegacyFailedJobWithoutStepLogs, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + }, + wantOut: legacySadJobRunWithNoStepLogsLogOutput, + }, { name: "run log but run is not done", tty: true, @@ -1419,14 +2032,24 @@ func TestViewRun(t *testing.T) { // ├── sad job/ // │ ├── 1_barf the quux.txt // │ └── 2_quux the barf.txt -// └── ad job/ -// └── 1_barf the quux.txt +// ├── ad job/ +// | └── 1_barf the quux.txt +// ├── 0_cool job.txt +// ├── 1_sad job.txt +// ├── 2_cool job with no step logs.txt +// ├── 3_sad job with no step logs.txt +// ├── -9999999999_legacy cool job with no step logs.txt +// ├── -9999999999_legacy sad job with no step logs.txt +// ├── 4_cool job with both legacy and new logs.txt +// └── -9999999999_cool job with both legacy and new logs.txt func Test_attachRunLog(t *testing.T) { tests := []struct { - name string - job shared.Job - wantMatch bool - wantFilename string + name string + job shared.Job + wantJobMatch bool + wantJobFilename string + wantStepMatch bool + wantStepFilename string }{ { name: "matching job name and step number 1", @@ -1437,8 +2060,10 @@ func Test_attachRunLog(t *testing.T) { Number: 1, }}, }, - wantMatch: true, - wantFilename: "cool job/1_fob the barz.txt", + wantJobMatch: true, + wantJobFilename: "0_cool job.txt", + wantStepMatch: true, + wantStepFilename: "cool job/1_fob the barz.txt", }, { name: "matching job name and step number 2", @@ -1449,8 +2074,10 @@ func Test_attachRunLog(t *testing.T) { Number: 2, }}, }, - wantMatch: true, - wantFilename: "cool job/2_barz the fob.txt", + wantJobMatch: true, + wantJobFilename: "0_cool job.txt", + wantStepMatch: true, + wantStepFilename: "cool job/2_barz the fob.txt", }, { name: "matching job name and step number and mismatch step name", @@ -1461,8 +2088,10 @@ func Test_attachRunLog(t *testing.T) { Number: 1, }}, }, - wantMatch: true, - wantFilename: "cool job/1_fob the barz.txt", + wantJobMatch: true, + wantJobFilename: "0_cool job.txt", + wantStepMatch: true, + wantStepFilename: "cool job/1_fob the barz.txt", }, { name: "matching job name and mismatch step number", @@ -1473,7 +2102,62 @@ func Test_attachRunLog(t *testing.T) { Number: 3, }}, }, - wantMatch: false, + wantJobMatch: true, + wantJobFilename: "0_cool job.txt", + wantStepMatch: false, + }, + { + name: "matching job name with no step logs", + job: shared.Job{ + Name: "cool job with no step logs", + Steps: []shared.Step{{ + Name: "fob the barz", + Number: 1, + }}, + }, + wantJobMatch: true, + wantJobFilename: "2_cool job with no step logs.txt", + wantStepMatch: false, + }, + { + name: "matching job name with no step data", + job: shared.Job{ + Name: "cool job with no step logs", + }, + wantJobMatch: true, + wantJobFilename: "2_cool job with no step logs.txt", + wantStepMatch: false, + }, + { + name: "matching job name with legacy filename and no step logs", + job: shared.Job{ + Name: "legacy cool job with no step logs", + Steps: []shared.Step{{ + Name: "fob the barz", + Number: 1, + }}, + }, + wantJobMatch: true, + wantJobFilename: "-9999999999_legacy cool job with no step logs.txt", + wantStepMatch: false, + }, + { + name: "matching job name with legacy filename and no step data", + job: shared.Job{ + Name: "legacy cool job with no step logs", + }, + wantJobMatch: true, + wantJobFilename: "-9999999999_legacy cool job with no step logs.txt", + wantStepMatch: false, + }, + { + name: "matching job name with both normal and legacy filename", + job: shared.Job{ + Name: "cool job with both legacy and new logs", + }, + wantJobMatch: true, + wantJobFilename: "4_cool job with both legacy and new logs.txt", + wantStepMatch: false, }, { name: "one job name is a suffix of another", @@ -1484,8 +2168,8 @@ func Test_attachRunLog(t *testing.T) { Number: 1, }}, }, - wantMatch: true, - wantFilename: "ad job/1_barf the quux.txt", + wantStepMatch: true, + wantStepFilename: "ad job/1_barf the quux.txt", }, { name: "escape metacharacters in job name", @@ -1496,7 +2180,8 @@ func Test_attachRunLog(t *testing.T) { Number: 0, }}, }, - wantMatch: false, + wantJobMatch: false, + wantStepMatch: false, }, { name: "mismatching job name", @@ -1507,7 +2192,8 @@ func Test_attachRunLog(t *testing.T) { Number: 1, }}, }, - wantMatch: false, + wantJobMatch: false, + wantStepMatch: false, }, { name: "job name with forward slash matches dir with slash removed", @@ -1518,9 +2204,10 @@ func Test_attachRunLog(t *testing.T) { Number: 1, }}, }, - wantMatch: true, + wantJobMatch: false, + wantStepMatch: true, // not the double space in the dir name, as the slash has been removed - wantFilename: "cool job with slash/1_fob the barz.txt", + wantStepFilename: "cool job with slash/1_fob the barz.txt", }, { name: "job name with colon matches dir with colon removed", @@ -1531,8 +2218,9 @@ func Test_attachRunLog(t *testing.T) { Number: 1, }}, }, - wantMatch: true, - wantFilename: "cool job with colon/1_fob the barz.txt", + wantJobMatch: false, + wantStepMatch: true, + wantStepFilename: "cool job with colon/1_fob the barz.txt", }, { name: "Job name with really long name (over the ZIP limit)", @@ -1543,8 +2231,9 @@ func Test_attachRunLog(t *testing.T) { Number: 1, }}, }, - wantMatch: true, - wantFilename: "thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnine/1_Long Name Job.txt", + wantJobMatch: false, + wantStepMatch: true, + wantStepFilename: "thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnine/1_Long Name Job.txt", }, { name: "Job name that would be truncated by the C# server to split a grapheme", @@ -1555,8 +2244,9 @@ func Test_attachRunLog(t *testing.T) { Number: 1, }}, }, - wantMatch: true, - wantFilename: "Emoji Test 😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅�/1_Emoji Job.txt", + wantJobMatch: false, + wantStepMatch: true, + wantStepFilename: "Emoji Test 😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅�/1_Emoji Job.txt", }, } @@ -1566,17 +2256,27 @@ func Test_attachRunLog(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + jobs := []shared.Job{tt.job} - attachRunLog(&run_log_zip_reader.Reader, []shared.Job{tt.job}) + attachRunLog(&run_log_zip_reader.Reader, jobs) t.Logf("Job details: ") - for _, step := range tt.job.Steps { - log := step.Log - logPresent := log != nil - require.Equal(t, tt.wantMatch, logPresent, "log not present") - if logPresent { - require.Equal(t, tt.wantFilename, log.Name, "Filename mismatch") + job := jobs[0] + + jobLog := job.Log + jobLogPresent := jobLog != nil + require.Equal(t, tt.wantJobMatch, jobLogPresent, "job log not present") + if jobLogPresent { + require.Equal(t, tt.wantJobFilename, jobLog.Name, "job log filename mismatch") + } + + for _, step := range job.Steps { + stepLog := step.Log + stepLogPresent := stepLog != nil + require.Equal(t, tt.wantStepMatch, stepLogPresent, "step log not present") + if stepLogPresent { + require.Equal(t, tt.wantStepFilename, stepLog.Name, "step log filename mismatch") } } }) @@ -1607,9 +2307,35 @@ sad job quux the barf log line 2 sad job quux the barf log line 3 `) +var coolJobRunWithNoStepLogsLogOutput = heredoc.Doc(` +cool job with no step logs UNKNOWN STEP log line 1 +cool job with no step logs UNKNOWN STEP log line 2 +cool job with no step logs UNKNOWN STEP log line 3 +`) + +var legacyCoolJobRunWithNoStepLogsLogOutput = heredoc.Doc(` +legacy cool job with no step logs UNKNOWN STEP log line 1 +legacy cool job with no step logs UNKNOWN STEP log line 2 +legacy cool job with no step logs UNKNOWN STEP log line 3 +`) + +var sadJobRunWithNoStepLogsLogOutput = heredoc.Doc(` +sad job with no step logs UNKNOWN STEP log line 1 +sad job with no step logs UNKNOWN STEP log line 2 +sad job with no step logs UNKNOWN STEP log line 3 +`) + +var legacySadJobRunWithNoStepLogsLogOutput = heredoc.Doc(` +legacy sad job with no step logs UNKNOWN STEP log line 1 +legacy sad job with no step logs UNKNOWN STEP log line 2 +legacy sad job with no step logs UNKNOWN STEP log line 3 +`) + var coolJobRunLogOutput = fmt.Sprintf("%s%s", fobTheBarzLogOutput, barfTheFobLogOutput) var sadJobRunLogOutput = fmt.Sprintf("%s%s", barfTheQuuxLogOutput, quuxTheBarfLogOutput) var expectedRunLogOutput = fmt.Sprintf("%s%s", coolJobRunLogOutput, sadJobRunLogOutput) +var expectedRunLogOutputWithNoSteps = fmt.Sprintf("%s%s", coolJobRunWithNoStepLogsLogOutput, sadJobRunWithNoStepLogsLogOutput) +var expectedLegacyRunLogOutputWithNoSteps = fmt.Sprintf("%s%s", legacyCoolJobRunWithNoStepLogsLogOutput, legacySadJobRunWithNoStepLogsLogOutput) func TestRunLog(t *testing.T) { t.Run("when the cache dir doesn't exist, exists return false", func(t *testing.T) { diff --git a/pkg/cmd/search/commits/commits.go b/pkg/cmd/search/commits/commits.go index bc37684a2..fb1742dc9 100644 --- a/pkg/cmd/search/commits/commits.go +++ b/pkg/cmd/search/commits/commits.go @@ -161,7 +161,7 @@ func displayResults(io *iostreams.IOStreams, now time.Time, results search.Commi tp.AddField(commit.Sha) tp.AddField(text.RemoveExcessiveWhitespace(commit.Info.Message)) tp.AddField(commit.Author.Login) - tp.AddTimeField(now, commit.Info.Author.Date, cs.Gray) + tp.AddTimeField(now, commit.Info.Author.Date, cs.Muted) tp.EndRow() } if io.IsStdoutTTY() { diff --git a/pkg/cmd/search/repos/repos.go b/pkg/cmd/search/repos/repos.go index 0bad650d3..2815ee6dc 100644 --- a/pkg/cmd/search/repos/repos.go +++ b/pkg/cmd/search/repos/repos.go @@ -171,14 +171,14 @@ func displayResults(io *iostreams.IOStreams, now time.Time, results search.Repos tags = append(tags, "archived") } info := strings.Join(tags, ", ") - infoColor := cs.Gray + infoColor := cs.Muted if repo.IsPrivate { infoColor = cs.Yellow } tp.AddField(repo.FullName, tableprinter.WithColor(cs.Bold)) tp.AddField(text.RemoveExcessiveWhitespace(repo.Description)) tp.AddField(info, tableprinter.WithColor(infoColor)) - tp.AddTimeField(now, repo.UpdatedAt, cs.Gray) + tp.AddTimeField(now, repo.UpdatedAt, cs.Muted) tp.EndRow() } if io.IsStdoutTTY() { diff --git a/pkg/cmd/search/shared/shared.go b/pkg/cmd/search/shared/shared.go index f0a346fc8..3282599cf 100644 --- a/pkg/cmd/search/shared/shared.go +++ b/pkg/cmd/search/shared/shared.go @@ -132,7 +132,7 @@ func displayIssueResults(io *iostreams.IOStreams, now time.Time, et EntityType, } tp.AddField(text.RemoveExcessiveWhitespace(issue.Title)) tp.AddField(listIssueLabels(&issue, cs, tp.IsTTY())) - tp.AddTimeField(now, issue.UpdatedAt, cs.Gray) + tp.AddTimeField(now, issue.UpdatedAt, cs.Muted) tp.EndRow() } @@ -158,7 +158,7 @@ func listIssueLabels(issue *search.Issue, cs *iostreams.ColorScheme, colorize bo labelNames := make([]string, 0, len(issue.Labels)) for _, label := range issue.Labels { if colorize { - labelNames = append(labelNames, cs.HexToRGB(label.Color, label.Name)) + labelNames = append(labelNames, cs.Label(label.Color, label.Name)) } else { labelNames = append(labelNames, label.Name) } diff --git a/pkg/cmd/status/status.go b/pkg/cmd/status/status.go index 0ed7dd3a7..c9acce8bd 100644 --- a/pkg/cmd/status/status.go +++ b/pkg/cmd/status/status.go @@ -740,7 +740,7 @@ func statusRun(opts *StatusOptions) error { errs := sg.authErrors.ToSlice() sort.Strings(errs) for _, msg := range errs { - fmt.Fprintln(out, cs.Gray(fmt.Sprintf("warning: %s", msg))) + fmt.Fprintln(out, cs.Mutedf("warning: %s", msg)) } } diff --git a/pkg/cmd/workflow/view/view.go b/pkg/cmd/workflow/view/view.go index 188d79e22..2e550f496 100644 --- a/pkg/cmd/workflow/view/view.go +++ b/pkg/cmd/workflow/view/view.go @@ -175,7 +175,7 @@ func viewWorkflowContent(opts *ViewOptions, client *api.Client, repo ghrepo.Inte out := opts.IO.Out fileName := workflow.Base() - fmt.Fprintf(out, "%s - %s\n", cs.Bold(workflow.Name), cs.Gray(fileName)) + fmt.Fprintf(out, "%s - %s\n", cs.Bold(workflow.Name), cs.Muted(fileName)) fmt.Fprintf(out, "ID: %s", cs.Cyanf("%d", workflow.ID)) codeBlock := fmt.Sprintf("```yaml\n%s\n```", yaml) diff --git a/pkg/httpmock/registry.go b/pkg/httpmock/registry.go index 387d0fc95..51aa5a898 100644 --- a/pkg/httpmock/registry.go +++ b/pkg/httpmock/registry.go @@ -3,6 +3,8 @@ package httpmock import ( "fmt" "net/http" + "runtime/debug" + "strings" "sync" "testing" @@ -23,6 +25,7 @@ type Registry struct { func (r *Registry) Register(m Matcher, resp Responder) { r.stubs = append(r.stubs, &Stub{ + Stack: string(debug.Stack()), Matcher: m, Responder: resp, }) @@ -46,17 +49,24 @@ type Testing interface { } func (r *Registry) Verify(t Testing) { - n := 0 + var unmatchedStubStacks []string for _, s := range r.stubs { if !s.matched && !s.exclude { - n++ + unmatchedStubStacks = append(unmatchedStubStacks, s.Stack) } } - if n > 0 { + if len(unmatchedStubStacks) > 0 { t.Helper() - // NOTE: stubs offer no useful reflection, so we can't print details + stacks := strings.Builder{} + for i, stack := range unmatchedStubStacks { + stacks.WriteString(fmt.Sprintf("Stub %d:\n", i+1)) + stacks.WriteString(fmt.Sprintf("\t%s", stack)) + if stack != unmatchedStubStacks[len(unmatchedStubStacks)-1] { + stacks.WriteString("\n") + } + } // about dead stubs and what they were trying to match - t.Errorf("%d unmatched HTTP stubs", n) + t.Errorf("%d HTTP stubs unmatched, stacks:\n%s", len(unmatchedStubStacks), stacks.String()) } } @@ -84,7 +94,7 @@ func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) { if stub == nil { r.mu.Unlock() - return nil, fmt.Errorf("no registered stubs matched %v", req) + return nil, fmt.Errorf("no registered HTTP stubs matched %v", req) } r.Requests = append(r.Requests, req) diff --git a/pkg/httpmock/stub.go b/pkg/httpmock/stub.go index 4e61d12f4..745c12417 100644 --- a/pkg/httpmock/stub.go +++ b/pkg/httpmock/stub.go @@ -15,6 +15,7 @@ type Matcher func(req *http.Request) bool type Responder func(req *http.Request) (*http.Response, error) type Stub struct { + Stack string matched bool Matcher Matcher Responder Responder diff --git a/pkg/iostreams/color.go b/pkg/iostreams/color.go index 58d13e6ef..f786e19cd 100644 --- a/pkg/iostreams/color.go +++ b/pkg/iostreams/color.go @@ -41,33 +41,24 @@ var ( } ) -// NewColorScheme initializes color logic based on provided terminal capabilities. -// Logic dealing with terminal theme detected, such as whether color is enabled, 8-bit color supported, true color supported, -// and terminal theme detected. -func NewColorScheme(enabled, is256enabled, trueColor, accessibleColors bool, theme string) *ColorScheme { - return &ColorScheme{ - enabled: enabled, - is256enabled: is256enabled, - hasTrueColor: trueColor, - accessibleColors: accessibleColors, - theme: theme, - } -} - +// ColorScheme controls how text is colored based upon terminal capabilities and user preferences. type ColorScheme struct { - enabled bool - is256enabled bool - hasTrueColor bool - accessibleColors bool - theme string -} - -func (c *ColorScheme) Enabled() bool { - return c.enabled + // Enabled is whether color is used at all. + Enabled bool + // EightBitColor is whether the terminal supports 8-bit, 256 colors. + EightBitColor bool + // TrueColor is whether the terminal supports 24-bit, 16 million colors. + TrueColor bool + // Accessible is whether colors must be base 16 colors that users can customize in terminal preferences. + Accessible bool + // ColorLabels is whether labels are colored based on their truecolor RGB hex color. + ColorLabels bool + // Theme is the terminal background color theme used to contextually color text for light, dark, or none at all. + Theme string } func (c *ColorScheme) Bold(t string) string { - if !c.enabled { + if !c.Enabled { return t } return bold(t) @@ -79,16 +70,16 @@ func (c *ColorScheme) Boldf(t string, args ...interface{}) string { func (c *ColorScheme) Muted(t string) string { // Fallback to previous logic if accessible colors preview is disabled. - if !c.accessibleColors { + if !c.Accessible { return c.Gray(t) } // Muted text is only stylized if color is enabled. - if !c.enabled { + if !c.Enabled { return t } - switch c.theme { + switch c.Theme { case LightTheme: return lightThemeMuted(t) case DarkTheme: @@ -103,7 +94,7 @@ func (c *ColorScheme) Mutedf(t string, args ...interface{}) string { } func (c *ColorScheme) Red(t string) string { - if !c.enabled { + if !c.Enabled { return t } return red(t) @@ -114,7 +105,7 @@ func (c *ColorScheme) Redf(t string, args ...interface{}) string { } func (c *ColorScheme) Yellow(t string) string { - if !c.enabled { + if !c.Enabled { return t } return yellow(t) @@ -125,7 +116,7 @@ func (c *ColorScheme) Yellowf(t string, args ...interface{}) string { } func (c *ColorScheme) Green(t string) string { - if !c.enabled { + if !c.Enabled { return t } return green(t) @@ -136,30 +127,30 @@ func (c *ColorScheme) Greenf(t string, args ...interface{}) string { } func (c *ColorScheme) GreenBold(t string) string { - if !c.enabled { + if !c.Enabled { return t } return greenBold(t) } -// Use Muted instead for thematically contrasting color. +// Deprecated: Use Muted instead for thematically contrasting color. func (c *ColorScheme) Gray(t string) string { - if !c.enabled { + if !c.Enabled { return t } - if c.is256enabled { + if c.EightBitColor { return gray256(t) } return gray(t) } -// Use Mutedf instead for thematically contrasting color. +// Deprecated: Use Mutedf instead for thematically contrasting color. func (c *ColorScheme) Grayf(t string, args ...interface{}) string { return c.Gray(fmt.Sprintf(t, args...)) } func (c *ColorScheme) Magenta(t string) string { - if !c.enabled { + if !c.Enabled { return t } return magenta(t) @@ -170,7 +161,7 @@ func (c *ColorScheme) Magentaf(t string, args ...interface{}) string { } func (c *ColorScheme) Cyan(t string) string { - if !c.enabled { + if !c.Enabled { return t } return cyan(t) @@ -181,14 +172,14 @@ func (c *ColorScheme) Cyanf(t string, args ...interface{}) string { } func (c *ColorScheme) CyanBold(t string) string { - if !c.enabled { + if !c.Enabled { return t } return cyanBold(t) } func (c *ColorScheme) Blue(t string) string { - if !c.enabled { + if !c.Enabled { return t } return blue(t) @@ -219,7 +210,7 @@ func (c *ColorScheme) FailureIconWithColor(colo func(string) string) string { } func (c *ColorScheme) HighlightStart() string { - if !c.enabled { + if !c.Enabled { return "" } @@ -227,7 +218,7 @@ func (c *ColorScheme) HighlightStart() string { } func (c *ColorScheme) Highlight(t string) string { - if !c.enabled { + if !c.Enabled { return t } @@ -235,7 +226,7 @@ func (c *ColorScheme) Highlight(t string) string { } func (c *ColorScheme) Reset() string { - if !c.enabled { + if !c.Enabled { return "" } @@ -255,7 +246,7 @@ func (c *ColorScheme) ColorFromString(s string) func(string) string { case "green": fn = c.Green case "gray": - fn = c.Gray + fn = c.Muted case "magenta": fn = c.Magenta case "cyan": @@ -271,17 +262,9 @@ func (c *ColorScheme) ColorFromString(s string) func(string) string { return fn } -// ColorFromRGB returns a function suitable for TablePrinter.AddField -// that calls HexToRGB, coloring text if supported by the terminal. -func (c *ColorScheme) ColorFromRGB(hex string) func(string) string { - return func(s string) string { - return c.HexToRGB(hex, s) - } -} - -// HexToRGB uses the given hex to color x if supported by the terminal. -func (c *ColorScheme) HexToRGB(hex string, x string) string { - if !c.enabled || !c.hasTrueColor || len(hex) != 6 { +// Label stylizes text based on label's RGB hex color. +func (c *ColorScheme) Label(hex string, x string) string { + if !c.Enabled || !c.TrueColor || !c.ColorLabels || len(hex) != 6 { return x } @@ -293,11 +276,11 @@ func (c *ColorScheme) HexToRGB(hex string, x string) string { func (c *ColorScheme) TableHeader(t string) string { // Table headers are only stylized if color is enabled including underline modifier. - if !c.enabled { + if !c.Enabled { return t } - switch c.theme { + switch c.Theme { case DarkTheme: return darkThemeTableHeader(t) case LightTheme: diff --git a/pkg/iostreams/color_test.go b/pkg/iostreams/color_test.go index b0cf994e3..f6a72e2a7 100644 --- a/pkg/iostreams/color_test.go +++ b/pkg/iostreams/color_test.go @@ -7,7 +7,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestColorFromRGB(t *testing.T) { +func TestLabel(t *testing.T) { tests := []struct { name string hex string @@ -20,77 +20,57 @@ func TestColorFromRGB(t *testing.T) { hex: "fc0303", text: "red", wants: "\033[38;2;252;3;3mred\033[0m", - cs: NewColorScheme(true, true, true, false, NoTheme), + cs: &ColorScheme{ + Enabled: true, + EightBitColor: true, + TrueColor: true, + ColorLabels: true, + }, }, { name: "no truecolor", hex: "fc0303", text: "red", wants: "red", - cs: NewColorScheme(true, true, false, false, NoTheme), + cs: &ColorScheme{ + Enabled: true, + EightBitColor: true, + ColorLabels: true, + }, }, { name: "no color", hex: "fc0303", text: "red", wants: "red", - cs: NewColorScheme(false, false, false, false, NoTheme), + cs: &ColorScheme{ + ColorLabels: true, + }, }, { name: "invalid hex", hex: "fc0", text: "red", wants: "red", - cs: NewColorScheme(false, false, false, false, NoTheme), + cs: &ColorScheme{ + ColorLabels: true, + }, + }, + { + name: "no color labels", + hex: "fc0303", + text: "red", + wants: "red", + cs: &ColorScheme{ + Enabled: true, + EightBitColor: true, + ColorLabels: true, + }, }, } for _, tt := range tests { - fn := tt.cs.ColorFromRGB(tt.hex) - assert.Equal(t, tt.wants, fn(tt.text)) - } -} - -func TestHexToRGB(t *testing.T) { - tests := []struct { - name string - hex string - text string - wants string - cs *ColorScheme - }{ - { - name: "truecolor", - hex: "fc0303", - text: "red", - wants: "\033[38;2;252;3;3mred\033[0m", - cs: NewColorScheme(true, true, true, false, NoTheme), - }, - { - name: "no truecolor", - hex: "fc0303", - text: "red", - wants: "red", - cs: NewColorScheme(true, true, false, false, NoTheme), - }, - { - name: "no color", - hex: "fc0303", - text: "red", - wants: "red", - cs: NewColorScheme(false, false, false, false, NoTheme), - }, - { - name: "invalid hex", - hex: "fc0", - text: "red", - wants: "red", - cs: NewColorScheme(false, false, false, false, NoTheme), - }, - } - - for _, tt := range tests { - output := tt.cs.HexToRGB(tt.hex, tt.text) + output := tt.cs.Label(tt.hex, tt.text) assert.Equal(t, tt.wants, output) } } @@ -108,62 +88,110 @@ func TestTableHeader(t *testing.T) { expected string }{ { - name: "when color is disabled, text is not stylized", - cs: NewColorScheme(false, false, false, true, NoTheme), + name: "when color is disabled, text is not stylized", + cs: &ColorScheme{ + Accessible: true, + Theme: NoTheme, + }, input: "this should not be stylized", expected: "this should not be stylized", }, { - name: "when 4-bit color is enabled but no theme, 4-bit default color and underline are used", - cs: NewColorScheme(true, false, false, true, NoTheme), + name: "when 4-bit color is enabled but no theme, 4-bit default color and underline are used", + cs: &ColorScheme{ + Enabled: true, + Accessible: true, + Theme: NoTheme, + }, input: "this should have no explicit color but underlined", expected: fmt.Sprintf("%sthis should have no explicit color but underlined%s", defaultUnderline, reset), }, { - name: "when 4-bit color is enabled and theme is light, 4-bit dark color and underline are used", - cs: NewColorScheme(true, false, false, true, LightTheme), + name: "when 4-bit color is enabled and theme is light, 4-bit dark color and underline are used", + cs: &ColorScheme{ + Enabled: true, + Accessible: true, + Theme: LightTheme, + }, input: "this should have dark foreground color and underlined", expected: fmt.Sprintf("%sthis should have dark foreground color and underlined%s", brightBlackUnderline, reset), }, { - name: "when 4-bit color is enabled and theme is dark, 4-bit light color and underline are used", - cs: NewColorScheme(true, false, false, true, DarkTheme), + name: "when 4-bit color is enabled and theme is dark, 4-bit light color and underline are used", + cs: &ColorScheme{ + Enabled: true, + Accessible: true, + Theme: DarkTheme, + }, input: "this should have light foreground color and underlined", expected: fmt.Sprintf("%sthis should have light foreground color and underlined%s", dimBlackUnderline, reset), }, { - name: "when 8-bit color is enabled but no theme, 4-bit default color and underline are used", - cs: NewColorScheme(true, true, false, true, NoTheme), + name: "when 8-bit color is enabled but no theme, 4-bit default color and underline are used", + cs: &ColorScheme{ + Enabled: true, + EightBitColor: true, + Accessible: true, + Theme: NoTheme, + }, input: "this should have no explicit color but underlined", expected: fmt.Sprintf("%sthis should have no explicit color but underlined%s", defaultUnderline, reset), }, { - name: "when 8-bit color is enabled and theme is light, 4-bit dark color and underline are used", - cs: NewColorScheme(true, true, false, true, LightTheme), + name: "when 8-bit color is enabled and theme is light, 4-bit dark color and underline are used", + cs: &ColorScheme{ + Enabled: true, + EightBitColor: true, + Accessible: true, + Theme: LightTheme, + }, input: "this should have dark foreground color and underlined", expected: fmt.Sprintf("%sthis should have dark foreground color and underlined%s", brightBlackUnderline, reset), }, { - name: "when 8-bit color is true and theme is dark, 4-bit light color and underline are used", - cs: NewColorScheme(true, true, false, true, DarkTheme), + name: "when 8-bit color is true and theme is dark, 4-bit light color and underline are used", + cs: &ColorScheme{ + Enabled: true, + EightBitColor: true, + Accessible: true, + Theme: DarkTheme, + }, input: "this should have light foreground color and underlined", expected: fmt.Sprintf("%sthis should have light foreground color and underlined%s", dimBlackUnderline, reset), }, { - name: "when 24-bit color is enabled but no theme, 4-bit default color and underline are used", - cs: NewColorScheme(true, true, true, true, NoTheme), + name: "when 24-bit color is enabled but no theme, 4-bit default color and underline are used", + cs: &ColorScheme{ + Enabled: true, + EightBitColor: true, + TrueColor: true, + Accessible: true, + Theme: NoTheme, + }, input: "this should have no explicit color but underlined", expected: fmt.Sprintf("%sthis should have no explicit color but underlined%s", defaultUnderline, reset), }, { - name: "when 24-bit color is enabled and theme is light, 4-bit dark color and underline are used", - cs: NewColorScheme(true, true, true, true, LightTheme), + name: "when 24-bit color is enabled and theme is light, 4-bit dark color and underline are used", + cs: &ColorScheme{ + Enabled: true, + EightBitColor: true, + TrueColor: true, + Accessible: true, + Theme: LightTheme, + }, input: "this should have dark foreground color and underlined", expected: fmt.Sprintf("%sthis should have dark foreground color and underlined%s", brightBlackUnderline, reset), }, { - name: "when 24-bit color is true and theme is dark, 4-bit light color and underline are used", - cs: NewColorScheme(true, true, true, true, DarkTheme), + name: "when 24-bit color is true and theme is dark, 4-bit light color and underline are used", + cs: &ColorScheme{ + Enabled: true, + EightBitColor: true, + TrueColor: true, + Accessible: true, + Theme: DarkTheme, + }, input: "this should have light foreground color and underlined", expected: fmt.Sprintf("%sthis should have light foreground color and underlined%s", dimBlackUnderline, reset), }, @@ -191,43 +219,70 @@ func TestMuted(t *testing.T) { }{ { name: "when color is disabled but accessible colors are disabled, text is not stylized", - cs: NewColorScheme(false, false, false, false, NoTheme), + cs: &ColorScheme{}, input: "this should not be stylized", expected: "this should not be stylized", }, { - name: "when 4-bit color is enabled but accessible colors are disabled, legacy 4-bit gray color is used", - cs: NewColorScheme(true, false, false, false, NoTheme), + name: "when 4-bit color is enabled but accessible colors are disabled, legacy 4-bit gray color is used", + cs: &ColorScheme{ + Enabled: true, + }, input: "this should be 4-bit gray", expected: fmt.Sprintf("%sthis should be 4-bit gray%s", gray4bit, reset), }, { - name: "when 8-bit color is enabled but accessible colors are disabled, legacy 8-bit gray color is used", - cs: NewColorScheme(true, true, false, false, NoTheme), + name: "when 8-bit color is enabled but accessible colors are disabled, legacy 8-bit gray color is used", + cs: &ColorScheme{ + Enabled: true, + EightBitColor: true, + }, input: "this should be 8-bit gray", expected: fmt.Sprintf("%sthis should be 8-bit gray%s", gray8bit, reset), }, { - name: "when 24-bit color is enabled but accessible colors are disabled, legacy 8-bit gray color is used", - cs: NewColorScheme(true, true, true, false, NoTheme), + name: "when 24-bit color is enabled but accessible colors are disabled, legacy 8-bit gray color is used", + cs: &ColorScheme{ + Enabled: true, + EightBitColor: true, + TrueColor: true, + }, input: "this should be 8-bit gray", expected: fmt.Sprintf("%sthis should be 8-bit gray%s", gray8bit, reset), }, { - name: "when 4-bit color is enabled and theme is dark, 4-bit light color is used", - cs: NewColorScheme(true, true, true, true, DarkTheme), + name: "when 4-bit color is enabled and theme is dark, 4-bit light color is used", + cs: &ColorScheme{ + Enabled: true, + EightBitColor: true, + TrueColor: true, + Accessible: true, + Theme: DarkTheme, + }, input: "this should be 4-bit dim black", expected: fmt.Sprintf("%sthis should be 4-bit dim black%s", dimBlack4bit, reset), }, { - name: "when 4-bit color is enabled and theme is light, 4-bit dark color is used", - cs: NewColorScheme(true, true, true, true, LightTheme), + name: "when 4-bit color is enabled and theme is light, 4-bit dark color is used", + cs: &ColorScheme{ + Enabled: true, + EightBitColor: true, + TrueColor: true, + Accessible: true, + Theme: LightTheme, + }, input: "this should be 4-bit bright black", expected: fmt.Sprintf("%sthis should be 4-bit bright black%s", brightBlack4bit, reset), }, { - name: "when 4-bit color is enabled but no theme, 4-bit default color is used", - cs: NewColorScheme(true, true, true, true, NoTheme), + name: "when 4-bit color is enabled but no theme, 4-bit default color is used", + cs: &ColorScheme{ + Enabled: true, + EightBitColor: true, + TrueColor: true, + Accessible: true, + Theme: NoTheme, + }, input: "this should have no explicit color", expected: "this should have no explicit color", }, diff --git a/pkg/iostreams/console.go b/pkg/iostreams/console.go index 89bdd1daa..72d070396 100644 --- a/pkg/iostreams/console.go +++ b/pkg/iostreams/console.go @@ -5,7 +5,7 @@ package iostreams import "os" -func hasAlternateScreenBuffer(hasTrueColor bool) bool { +func hasAlternateScreenBuffer(_ bool) bool { // on non-Windows, we just assume that alternate screen buffer is supported in most cases return os.Getenv("TERM") != "dumb" } diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index 30981386b..ba2cc6b50 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -72,12 +72,14 @@ type IOStreams struct { colorOverride bool colorEnabled bool + colorLabels bool accessibleColorsEnabled bool pagerCommand string pagerProcess *os.Process - neverPrompt bool + neverPrompt bool + spinnerDisabled bool TempFileOverride *os.File } @@ -103,6 +105,10 @@ func (s *IOStreams) HasTrueColor() bool { return s.term.IsTrueColorSupported() } +func (s *IOStreams) ColorLabels() bool { + return s.colorLabels +} + // DetectTerminalTheme is a utility to call before starting the output pager so that the terminal background // can be reliably detected. func (s *IOStreams) DetectTerminalTheme() { @@ -135,6 +141,10 @@ func (s *IOStreams) SetColorEnabled(colorEnabled bool) { s.colorEnabled = colorEnabled } +func (s *IOStreams) SetColorLabels(colorLabels bool) { + s.colorLabels = colorLabels +} + func (s *IOStreams) SetStdinTTY(isTTY bool) { s.stdinTTYOverride = true s.stdinIsTTY = isTTY @@ -264,6 +274,14 @@ func (s *IOStreams) SetNeverPrompt(v bool) { s.neverPrompt = v } +func (s *IOStreams) GetSpinnerDisabled() bool { + return s.spinnerDisabled +} + +func (s *IOStreams) SetSpinnerDisabled(v bool) { + s.spinnerDisabled = v +} + func (s *IOStreams) StartProgressIndicator() { s.StartProgressIndicatorWithLabel("") } @@ -273,6 +291,15 @@ func (s *IOStreams) StartProgressIndicatorWithLabel(label string) { return } + if s.spinnerDisabled { + // If the spinner is disabled, simply print a + // textual progress indicator and return. + // This means that s.ProgressIndicator will be nil. + // See also: the comment on StopProgressIndicator() + s.startTextualProgressIndicator(label) + return + } + s.progressIndicatorMu.Lock() defer s.progressIndicatorMu.Unlock() @@ -286,8 +313,10 @@ func (s *IOStreams) StartProgressIndicatorWithLabel(label string) { } // https://github.com/briandowns/spinner#available-character-sets - dotStyle := spinner.CharSets[11] - sp := spinner.New(dotStyle, 120*time.Millisecond, spinner.WithWriter(s.ErrOut), spinner.WithColor("fgCyan")) + // ⣾ ⣷ ⣽ ⣻ ⡿ + spinnerStyle := spinner.CharSets[11] + + sp := spinner.New(spinnerStyle, 120*time.Millisecond, spinner.WithWriter(s.ErrOut), spinner.WithColor("fgCyan")) if label != "" { sp.Prefix = label + " " } @@ -296,6 +325,27 @@ func (s *IOStreams) StartProgressIndicatorWithLabel(label string) { s.progressIndicator = sp } +func (s *IOStreams) startTextualProgressIndicator(label string) { + s.progressIndicatorMu.Lock() + defer s.progressIndicatorMu.Unlock() + + // Default label when spinner disabled is "Working..." + if label == "" { + label = "Working..." + } + + // Add an ellipsis to the label if it doesn't already have one. + ellipsis := "..." + if !strings.HasSuffix(label, ellipsis) { + label = label + ellipsis + } + + fmt.Fprintf(s.ErrOut, "%s%s", s.ColorScheme().Cyan(label), "\n") +} + +// StopProgressIndicator stops the progress indicator if it is running. +// Note that a textual progess indicator does not create a progress indicator, +// so this method is a no-op in that case. func (s *IOStreams) StopProgressIndicator() { s.progressIndicatorMu.Lock() defer s.progressIndicatorMu.Unlock() @@ -367,7 +417,14 @@ func (s *IOStreams) TerminalWidth() int { } func (s *IOStreams) ColorScheme() *ColorScheme { - return NewColorScheme(s.ColorEnabled(), s.ColorSupport256(), s.HasTrueColor(), s.AccessibleColorsEnabled(), s.TerminalTheme()) + return &ColorScheme{ + Enabled: s.ColorEnabled(), + EightBitColor: s.ColorSupport256(), + TrueColor: s.HasTrueColor(), + Accessible: s.AccessibleColorsEnabled(), + ColorLabels: s.ColorLabels(), + Theme: s.TerminalTheme(), + } } func (s *IOStreams) ReadUserFile(fn string) ([]byte, error) { diff --git a/pkg/iostreams/iostreams_progress_indicator_test.go b/pkg/iostreams/iostreams_progress_indicator_test.go new file mode 100644 index 000000000..60d0ece91 --- /dev/null +++ b/pkg/iostreams/iostreams_progress_indicator_test.go @@ -0,0 +1,254 @@ +//go:build !windows + +package iostreams + +import ( + "fmt" + "io" + "os" + "strings" + "testing" + "time" + + "github.com/Netflix/go-expect" + "github.com/creack/pty" + "github.com/hinshun/vt10x" + "github.com/stretchr/testify/require" +) + +func TestStartProgressIndicatorWithLabel(t *testing.T) { + osOut := os.Stdout + defer func() { os.Stdout = osOut }() + // Why do we need a channel in these tests to implement a timeout instead of + // relying on expect's timeout? + // + // Well, expect's timeout is based on the maximum time of a single read + // from the console. This works in cases like prompting where we block + // waiting for input because the console is not ready to be read. + // But in this case, we are not blocking waiting for input and stdout + // can be constantly read. This means the timeout will never be reached + // in the event of a expectation failure. + // To fix this, we need to implement our own timeout that is based + // specifically on the total time spent reading the console and waiting + // for the target string instead of the max time for a single read + // from the console. + t.Run("progress indicator respects GH_SPINNER_DISABLED is true", func(t *testing.T) { + console := newTestVirtualTerminal(t) + io := newTestIOStreams(t, console, true) + + done := make(chan error) + + go func() { + _, err := console.ExpectString("Working...") + done <- err + }() + + io.StartProgressIndicatorWithLabel("") + defer io.StopProgressIndicator() + + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("Test timed out waiting for progress indicator") + } + }) + + t.Run("progress indicator respects GH_SPINNER_DISABLED is false", func(t *testing.T) { + console := newTestVirtualTerminal(t) + io := newTestIOStreams(t, console, false) + + done := make(chan error) + + go func() { + _, err := console.ExpectString("⣾") + done <- err + }() + + io.StartProgressIndicatorWithLabel("") + defer io.StopProgressIndicator() + + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("Test timed out waiting for progress indicator") + } + }) + + t.Run("progress indicator with GH_SPINNER_DISABLED shows label", func(t *testing.T) { + console := newTestVirtualTerminal(t) + io := newTestIOStreams(t, console, true) + progressIndicatorLabel := "downloading happiness" + + done := make(chan error) + + go func() { + _, err := console.ExpectString(progressIndicatorLabel + "...") + done <- err + }() + + io.StartProgressIndicatorWithLabel(progressIndicatorLabel) + defer io.StopProgressIndicator() + + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("Test timed out waiting for progress indicator") + } + }) + + t.Run("progress indicator shows label and spinner", func(t *testing.T) { + console := newTestVirtualTerminal(t) + io := newTestIOStreams(t, console, false) + progressIndicatorLabel := "downloading happiness" + + done := make(chan error) + + go func() { + _, err := console.ExpectString(progressIndicatorLabel) + require.NoError(t, err) + _, err = console.ExpectString("⣾") + done <- err + }() + + io.StartProgressIndicatorWithLabel(progressIndicatorLabel) + defer io.StopProgressIndicator() + + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("Test timed out waiting for progress indicator") + } + }) + + t.Run("multiple calls to start progress indicator with GH_SPINNER_DISABLED prints additional labels", func(t *testing.T) { + console := newTestVirtualTerminal(t) + io := newTestIOStreams(t, console, true) + progressIndicatorLabel1 := "downloading happiness" + progressIndicatorLabel2 := "downloading sadness" + done := make(chan error) + go func() { + _, err := console.ExpectString(progressIndicatorLabel1 + "...") + require.NoError(t, err) + _, err = console.ExpectString(progressIndicatorLabel2 + "...") + done <- err + }() + io.StartProgressIndicatorWithLabel(progressIndicatorLabel1) + defer io.StopProgressIndicator() + io.StartProgressIndicatorWithLabel(progressIndicatorLabel2) + + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("Test timed out waiting for progress indicator") + } + }) +} + +func newTestVirtualTerminal(t *testing.T) *expect.Console { + t.Helper() + + // Create a PTY and hook up a virtual terminal emulator + ptm, pts, err := pty.Open() + require.NoError(t, err) + + term := vt10x.New(vt10x.WithWriter(pts)) + + // Create a console via Expect that allows scripting against the terminal + consoleOpts := []expect.ConsoleOpt{ + expect.WithStdin(ptm), + expect.WithStdout(term), + expect.WithCloser(ptm, pts), + failOnExpectError(t), + failOnSendError(t), + expect.WithDefaultTimeout(time.Second), + } + + console, err := expect.NewConsole(consoleOpts...) + require.NoError(t, err) + t.Cleanup(func() { testCloser(t, console) }) + + return console +} + +func newTestIOStreams(t *testing.T, console *expect.Console, spinnerDisabled bool) *IOStreams { + t.Helper() + + in := console.Tty() + out := console.Tty() + errOut := console.Tty() + + // Because the briandowns/spinner checks os.Stdout directly, + // we need this hack to trick it into allowing the spinner to print... + os.Stdout = out + + io := &IOStreams{ + In: in, + Out: out, + ErrOut: errOut, + term: fakeTerm{}, + } + io.progressIndicatorEnabled = true + io.SetSpinnerDisabled(spinnerDisabled) + return io +} + +// failOnExpectError adds an observer that will fail the test in a standardised way +// if any expectation on the command output fails, without requiring an explicit +// assertion. +// +// Use WithRelaxedIO to disable this behaviour. +func failOnExpectError(t *testing.T) expect.ConsoleOpt { + t.Helper() + return expect.WithExpectObserver( + func(matchers []expect.Matcher, buf string, err error) { + t.Helper() + + if err == nil { + return + } + + if len(matchers) == 0 { + t.Fatalf("Error occurred while matching %q: %s\n", buf, err) + } + + var criteria []string + for _, matcher := range matchers { + criteria = append(criteria, fmt.Sprintf("%q", matcher.Criteria())) + } + t.Fatalf("Failed to find [%s] in %q: %s\n", strings.Join(criteria, ", "), buf, err) + }, + ) +} + +// failOnSendError adds an observer that will fail the test in a standardised way +// if any sending of input fails, without requiring an explicit assertion. +// +// Use WithRelaxedIO to disable this behaviour. +func failOnSendError(t *testing.T) expect.ConsoleOpt { + t.Helper() + return expect.WithSendObserver( + func(msg string, n int, err error) { + t.Helper() + + if err != nil { + t.Fatalf("Failed to send %q: %s\n", msg, err) + } + if len(msg) != n { + t.Fatalf("Only sent %d of %d bytes for %q\n", n, len(msg), msg) + } + }, + ) +} + +// testCloser is a helper to fail the test if a Closer fails to close. +func testCloser(t *testing.T, closer io.Closer) { + t.Helper() + if err := closer.Close(); err != nil { + t.Errorf("Close failed: %s", err) + } +} diff --git a/pkg/option/option.go b/pkg/option/option.go index 8d3b70f3f..caf26dd0b 100644 --- a/pkg/option/option.go +++ b/pkg/option/option.go @@ -46,6 +46,15 @@ func None[T any]() Option[T] { return Option[T]{} } +func SomeIfNonZero[T comparable](value T) Option[T] { + // value is a zero value then return a None + var zero T + if value == zero { + return None[T]() + } + return Some(value) +} + // String implements the [fmt.Stringer] interface. func (o Option[T]) String() string { if o.present { diff --git a/pkg/search/result.go b/pkg/search/result.go index 0c7c43cd7..0b9d1ab16 100644 --- a/pkg/search/result.go +++ b/pkg/search/result.go @@ -93,25 +93,29 @@ var PullRequestFields = append(IssueFields, type CodeResult struct { IncompleteResults bool `json:"incomplete_results"` Items []Code `json:"items"` - Total int `json:"total_count"` + // Number of code search results matching the query on the server. Ignoring limit. + Total int `json:"total_count"` } type CommitsResult struct { IncompleteResults bool `json:"incomplete_results"` Items []Commit `json:"items"` - Total int `json:"total_count"` + // Number of commits matching the query on the server. Ignoring limit. + Total int `json:"total_count"` } type RepositoriesResult struct { IncompleteResults bool `json:"incomplete_results"` Items []Repository `json:"items"` - Total int `json:"total_count"` + // Number of repositories matching the query on the server. Ignoring limit. + Total int `json:"total_count"` } type IssuesResult struct { IncompleteResults bool `json:"incomplete_results"` Items []Issue `json:"items"` - Total int `json:"total_count"` + // Number of isssues matching the query on the server. Ignoring limit. + Total int `json:"total_count"` } type Code struct { diff --git a/pkg/search/searcher.go b/pkg/search/searcher.go index 4168dc7f3..7cbd35562 100644 --- a/pkg/search/searcher.go +++ b/pkg/search/searcher.go @@ -14,6 +14,7 @@ import ( ) const ( + // GitHub API has a limit of 100 per page maxPerPage = 100 orderKey = "order" sortKey = "sort" @@ -60,100 +61,145 @@ func NewSearcher(client *http.Client, host string) Searcher { func (s searcher) Code(query Query) (CodeResult, error) { result := CodeResult{} - toRetrieve := query.Limit + var resp *http.Response var err error - for toRetrieve > 0 { - query.Limit = min(toRetrieve, maxPerPage) + + // We will request either the query limit if it's less than 1 page, or our max page size. + // This number doesn't change to keep a valid offset. + // + // For example, say we want 150 items out of 500. + // We request page #1 for 100 items and get items 0 to 99. + // Then we request page #2 for 100 items, we get items 100 to 199 and only keep 100 to 149. + // If we were to request page #2 for 50 items, we would instead get items 50 to 99. + numItemsToRetrieve := query.Limit + query.Limit = min(numItemsToRetrieve, maxPerPage) + + for numItemsToRetrieve > 0 { query.Page = nextPage(resp) if query.Page == 0 { break } + page := CodeResult{} resp, err = s.search(query, &page) if err != nil { return result, err } + + // If we're going to reach the requested limit, only add that many items, + // otherwise add all the results. + numItemsToAdd := min(len(page.Items), numItemsToRetrieve) result.IncompleteResults = page.IncompleteResults + // The API returns how many items match the query in every response. + // With the example above, this would be 500. result.Total = page.Total - result.Items = append(result.Items, page.Items...) - toRetrieve = toRetrieve - len(page.Items) + result.Items = append(result.Items, page.Items[:numItemsToAdd]...) + numItemsToRetrieve = numItemsToRetrieve - numItemsToAdd } + return result, nil } func (s searcher) Commits(query Query) (CommitsResult, error) { result := CommitsResult{} - toRetrieve := query.Limit + var resp *http.Response var err error - for toRetrieve > 0 { - query.Limit = min(toRetrieve, maxPerPage) + + numItemsToRetrieve := query.Limit + query.Limit = min(numItemsToRetrieve, maxPerPage) + + for numItemsToRetrieve > 0 { query.Page = nextPage(resp) if query.Page == 0 { break } + page := CommitsResult{} resp, err = s.search(query, &page) if err != nil { return result, err } + + numItemsToAdd := min(len(page.Items), numItemsToRetrieve) result.IncompleteResults = page.IncompleteResults result.Total = page.Total - result.Items = append(result.Items, page.Items...) - toRetrieve = toRetrieve - len(page.Items) + result.Items = append(result.Items, page.Items[:numItemsToAdd]...) + numItemsToRetrieve = numItemsToRetrieve - numItemsToAdd } return result, nil } func (s searcher) Repositories(query Query) (RepositoriesResult, error) { result := RepositoriesResult{} - toRetrieve := query.Limit + var resp *http.Response var err error - for toRetrieve > 0 { - query.Limit = min(toRetrieve, maxPerPage) + + numItemsToRetrieve := query.Limit + query.Limit = min(numItemsToRetrieve, maxPerPage) + + for numItemsToRetrieve > 0 { query.Page = nextPage(resp) if query.Page == 0 { break } + page := RepositoriesResult{} resp, err = s.search(query, &page) if err != nil { return result, err } + + numItemsToAdd := min(len(page.Items), numItemsToRetrieve) result.IncompleteResults = page.IncompleteResults result.Total = page.Total - result.Items = append(result.Items, page.Items...) - toRetrieve = toRetrieve - len(page.Items) + result.Items = append(result.Items, page.Items[:numItemsToAdd]...) + numItemsToRetrieve = numItemsToRetrieve - numItemsToAdd } return result, nil } func (s searcher) Issues(query Query) (IssuesResult, error) { result := IssuesResult{} - toRetrieve := query.Limit + var resp *http.Response var err error - for toRetrieve > 0 { - query.Limit = min(toRetrieve, maxPerPage) + + numItemsToRetrieve := query.Limit + query.Limit = min(numItemsToRetrieve, maxPerPage) + + for numItemsToRetrieve > 0 { query.Page = nextPage(resp) if query.Page == 0 { break } + page := IssuesResult{} resp, err = s.search(query, &page) if err != nil { return result, err } + + numItemsToAdd := min(len(page.Items), numItemsToRetrieve) result.IncompleteResults = page.IncompleteResults result.Total = page.Total - result.Items = append(result.Items, page.Items...) - toRetrieve = toRetrieve - len(page.Items) + result.Items = append(result.Items, page.Items[:numItemsToAdd]...) + numItemsToRetrieve = numItemsToRetrieve - numItemsToAdd } return result, nil } +// search makes a single-page REST search request for code, commits, issues, prs, or repos. +// +// The result argument is populated with the following information: +// +// - Total: the number of search results matching the query, which may exceed the number of items returned +// - IncompleteResults: whether the search request exceeded search time limit, potentially being incomplete +// - Items: the actual matching search results, up to 100 max items per page +// +// For more information, see https://docs.github.com/en/rest/search/search?apiVersion=2022-11-28. func (s searcher) search(query Query, result interface{}) (*http.Response, error) { path := fmt.Sprintf("%ssearch/%s", ghinstance.RESTPrefix(s.host), query.Kind) qs := url.Values{} @@ -236,10 +282,15 @@ func handleHTTPError(resp *http.Response) error { return httpError } +// https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api func nextPage(resp *http.Response) (page int) { if resp == nil { return 1 } + + // When using pagination, responses get a "Link" field in their header. + // When a next page is available, "Link" contains a link to the next page + // tagged with rel="next". for _, m := range linkRE.FindAllStringSubmatch(resp.Header.Get("Link"), -1) { if !(len(m) > 2 && m[2] == "next") { continue diff --git a/pkg/search/searcher_test.go b/pkg/search/searcher_test.go index 8642feed0..e893c9a3b 100644 --- a/pkg/search/searcher_test.go +++ b/pkg/search/searcher_test.go @@ -1,8 +1,10 @@ package search import ( + "fmt" "net/http" "net/url" + "strconv" "testing" "github.com/MakeNowJust/heredoc" @@ -46,10 +48,14 @@ func TestSearcherCode(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.QueryMatcher("GET", "search/code", values), - httpmock.JSONResponse(CodeResult{ - IncompleteResults: false, - Items: []Code{{Name: "file.go"}}, - Total: 1, + httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 1, + "items": []interface{}{ + map[string]interface{}{ + "name": "file.go", + }, + }, }), ) }, @@ -66,10 +72,14 @@ func TestSearcherCode(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.QueryMatcher("GET", "api/v3/search/code", values), - httpmock.JSONResponse(CodeResult{ - IncompleteResults: false, - Items: []Code{{Name: "file.go"}}, - Total: 1, + httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 1, + "items": []interface{}{ + map[string]interface{}{ + "name": "file.go", + }, + }, }), ) }, @@ -84,25 +94,83 @@ func TestSearcherCode(t *testing.T) { }, httpStubs: func(reg *httpmock.Registry) { firstReq := httpmock.QueryMatcher("GET", "search/code", values) - firstRes := httpmock.JSONResponse(CodeResult{ - IncompleteResults: false, - Items: []Code{{Name: "file.go"}}, - Total: 2, + firstRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 2, + "items": []interface{}{ + map[string]interface{}{ + "name": "file.go", + }, + }, + }) + firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) + secondReq := httpmock.QueryMatcher("GET", "search/code", url.Values{ + "page": []string{"2"}, + "per_page": []string{"30"}, + "q": []string{"keyword language:go"}, + }) + secondRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 2, + "items": []interface{}{ + map[string]interface{}{ + "name": "file2.go", + }, + }, + }) + reg.Register(firstReq, firstRes) + reg.Register(secondReq, secondRes) + }, + }, + { + name: "collect full and partial pages under total number of matching search results", + query: Query{ + Keywords: []string{"keyword"}, + Kind: "code", + Limit: 110, + Qualifiers: Qualifiers{ + Language: "go", }, - ) + }, + result: CodeResult{ + IncompleteResults: false, + Items: initialize(0, 110, func(i int) Code { + return Code{ + Name: fmt.Sprintf("name%d.go", i), + } + }), + Total: 287, + }, + httpStubs: func(reg *httpmock.Registry) { + firstReq := httpmock.QueryMatcher("GET", "search/code", url.Values{ + "page": []string{"1"}, + "per_page": []string{"100"}, + "q": []string{"keyword language:go"}, + }) + firstRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 287, + "items": initialize(0, 100, func(i int) interface{} { + return map[string]interface{}{ + "name": fmt.Sprintf("name%d.go", i), + } + }), + }) firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) secondReq := httpmock.QueryMatcher("GET", "search/code", url.Values{ "page": []string{"2"}, - "per_page": []string{"29"}, + "per_page": []string{"100"}, "q": []string{"keyword language:go"}, - }, - ) - secondRes := httpmock.JSONResponse(CodeResult{ - IncompleteResults: false, - Items: []Code{{Name: "file2.go"}}, - Total: 2, - }, - ) + }) + secondRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 287, + "items": initialize(100, 200, func(i int) interface{} { + return map[string]interface{}{ + "name": fmt.Sprintf("name%d.go", i), + } + }), + }) reg.Register(firstReq, firstRes) reg.Register(secondReq, secondRes) }, @@ -201,10 +269,14 @@ func TestSearcherCommits(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.QueryMatcher("GET", "search/commits", values), - httpmock.JSONResponse(CommitsResult{ - IncompleteResults: false, - Items: []Commit{{Sha: "abc"}}, - Total: 1, + httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 1, + "items": []interface{}{ + map[string]interface{}{ + "sha": "abc", + }, + }, }), ) }, @@ -221,10 +293,14 @@ func TestSearcherCommits(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.QueryMatcher("GET", "api/v3/search/commits", values), - httpmock.JSONResponse(CommitsResult{ - IncompleteResults: false, - Items: []Commit{{Sha: "abc"}}, - Total: 1, + httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 1, + "items": []interface{}{ + map[string]interface{}{ + "sha": "abc", + }, + }, }), ) }, @@ -239,27 +315,92 @@ func TestSearcherCommits(t *testing.T) { }, httpStubs: func(reg *httpmock.Registry) { firstReq := httpmock.QueryMatcher("GET", "search/commits", values) - firstRes := httpmock.JSONResponse(CommitsResult{ - IncompleteResults: false, - Items: []Commit{{Sha: "abc"}}, - Total: 2, - }, - ) - firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) + firstRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 2, + "items": []interface{}{ + map[string]interface{}{ + "sha": "abc", + }, + }, + }) + firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) secondReq := httpmock.QueryMatcher("GET", "search/commits", url.Values{ "page": []string{"2"}, - "per_page": []string{"29"}, + "per_page": []string{"30"}, "order": []string{"desc"}, "sort": []string{"committer-date"}, "q": []string{"keyword author:foobar committer-date:>2021-02-28"}, + }) + secondRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 2, + "items": []interface{}{ + map[string]interface{}{ + "sha": "def", + }, + }, + }) + reg.Register(firstReq, firstRes) + reg.Register(secondReq, secondRes) + }, + }, + { + name: "collect full and partial pages under total number of matching search results", + query: Query{ + Keywords: []string{"keyword"}, + Kind: "commits", + Limit: 110, + Order: "desc", + Sort: "committer-date", + Qualifiers: Qualifiers{ + Author: "foobar", + CommitterDate: ">2021-02-28", }, - ) - secondRes := httpmock.JSONResponse(CommitsResult{ - IncompleteResults: false, - Items: []Commit{{Sha: "def"}}, - Total: 2, - }, - ) + }, + result: CommitsResult{ + IncompleteResults: false, + Items: initialize(0, 110, func(i int) Commit { + return Commit{ + Sha: strconv.Itoa(i), + } + }), + Total: 287, + }, + httpStubs: func(reg *httpmock.Registry) { + firstReq := httpmock.QueryMatcher("GET", "search/commits", url.Values{ + "page": []string{"1"}, + "per_page": []string{"100"}, + "order": []string{"desc"}, + "sort": []string{"committer-date"}, + "q": []string{"keyword author:foobar committer-date:>2021-02-28"}, + }) + firstRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 287, + "items": initialize(0, 100, func(i int) map[string]interface{} { + return map[string]interface{}{ + "sha": strconv.Itoa(i), + } + }), + }) + firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) + secondReq := httpmock.QueryMatcher("GET", "search/commits", url.Values{ + "page": []string{"2"}, + "per_page": []string{"100"}, + "order": []string{"desc"}, + "sort": []string{"committer-date"}, + "q": []string{"keyword author:foobar committer-date:>2021-02-28"}, + }) + secondRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 287, + "items": initialize(100, 200, func(i int) map[string]interface{} { + return map[string]interface{}{ + "sha": strconv.Itoa(i), + } + }), + }) reg.Register(firstReq, firstRes) reg.Register(secondReq, secondRes) }, @@ -269,8 +410,8 @@ func TestSearcherCommits(t *testing.T) { query: query, wantErr: true, errMsg: heredoc.Doc(` - Invalid search query "keyword author:foobar committer-date:>2021-02-28". - "blah" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.`), + Invalid search query "keyword author:foobar committer-date:>2021-02-28". + "blah" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.`), httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.QueryMatcher("GET", "search/commits", values), @@ -413,15 +554,14 @@ func TestSearcherRepositories(t *testing.T) { }, }, }) - firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) + firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) secondReq := httpmock.QueryMatcher("GET", "search/repositories", url.Values{ "page": []string{"2"}, - "per_page": []string{"29"}, + "per_page": []string{"30"}, "order": []string{"desc"}, "sort": []string{"stars"}, "q": []string{"keyword stars:>=5 topic:topic"}, - }, - ) + }) secondRes := httpmock.JSONResponse(map[string]interface{}{ "incomplete_results": false, "total_count": 2, @@ -435,13 +575,73 @@ func TestSearcherRepositories(t *testing.T) { reg.Register(secondReq, secondRes) }, }, + { + name: "collect full and partial pages under total number of matching search results", + query: Query{ + Keywords: []string{"keyword"}, + Kind: "repositories", + Limit: 110, + Order: "desc", + Sort: "stars", + Qualifiers: Qualifiers{ + Stars: ">=5", + Topic: []string{"topic"}, + }, + }, + result: RepositoriesResult{ + IncompleteResults: false, + Items: initialize(0, 110, func(i int) Repository { + return Repository{ + Name: fmt.Sprintf("name%d", i), + } + }), + Total: 287, + }, + httpStubs: func(reg *httpmock.Registry) { + firstReq := httpmock.QueryMatcher("GET", "search/repositories", url.Values{ + "page": []string{"1"}, + "per_page": []string{"100"}, + "order": []string{"desc"}, + "sort": []string{"stars"}, + "q": []string{"keyword stars:>=5 topic:topic"}, + }) + firstRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 287, + "items": initialize(0, 100, func(i int) interface{} { + return map[string]interface{}{ + "name": fmt.Sprintf("name%d", i), + } + }), + }) + firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) + secondReq := httpmock.QueryMatcher("GET", "search/repositories", url.Values{ + "page": []string{"2"}, + "per_page": []string{"100"}, + "order": []string{"desc"}, + "sort": []string{"stars"}, + "q": []string{"keyword stars:>=5 topic:topic"}, + }) + secondRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 287, + "items": initialize(100, 200, func(i int) interface{} { + return map[string]interface{}{ + "name": fmt.Sprintf("name%d", i), + } + }), + }) + reg.Register(firstReq, firstRes) + reg.Register(secondReq, secondRes) + }, + }, { name: "handles search errors", query: query, wantErr: true, errMsg: heredoc.Doc(` - Invalid search query "keyword stars:>=5 topic:topic". - "blah" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.`), + Invalid search query "keyword stars:>=5 topic:topic". + "blah" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.`), httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.QueryMatcher("GET", "search/repositories", values), @@ -529,10 +729,14 @@ func TestSearcherIssues(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.QueryMatcher("GET", "search/issues", values), - httpmock.JSONResponse(IssuesResult{ - IncompleteResults: false, - Items: []Issue{{Number: 1234}}, - Total: 1, + httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 1, + "items": []interface{}{ + map[string]interface{}{ + "number": 1234, + }, + }, }), ) }, @@ -549,10 +753,14 @@ func TestSearcherIssues(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.QueryMatcher("GET", "api/v3/search/issues", values), - httpmock.JSONResponse(IssuesResult{ - IncompleteResults: false, - Items: []Issue{{Number: 1234}}, - Total: 1, + httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 1, + "items": []interface{}{ + map[string]interface{}{ + "number": 1234, + }, + }, }), ) }, @@ -567,27 +775,92 @@ func TestSearcherIssues(t *testing.T) { }, httpStubs: func(reg *httpmock.Registry) { firstReq := httpmock.QueryMatcher("GET", "search/issues", values) - firstRes := httpmock.JSONResponse(IssuesResult{ - IncompleteResults: false, - Items: []Issue{{Number: 1234}}, - Total: 2, - }, - ) - firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) + firstRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 2, + "items": []interface{}{ + map[string]interface{}{ + "number": 1234, + }, + }, + }) + firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) secondReq := httpmock.QueryMatcher("GET", "search/issues", url.Values{ "page": []string{"2"}, - "per_page": []string{"29"}, + "per_page": []string{"30"}, "order": []string{"desc"}, "sort": []string{"comments"}, "q": []string{"keyword is:locked is:public language:go"}, + }) + secondRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 2, + "items": []interface{}{ + map[string]interface{}{ + "number": 5678, + }, + }, + }) + reg.Register(firstReq, firstRes) + reg.Register(secondReq, secondRes) + }, + }, + { + name: "collect full and partial pages under total number of matching search results", + query: Query{ + Keywords: []string{"keyword"}, + Kind: "issues", + Limit: 110, + Order: "desc", + Sort: "comments", + Qualifiers: Qualifiers{ + Language: "go", + Is: []string{"public", "locked"}, }, - ) - secondRes := httpmock.JSONResponse(IssuesResult{ - IncompleteResults: false, - Items: []Issue{{Number: 5678}}, - Total: 2, - }, - ) + }, + result: IssuesResult{ + IncompleteResults: false, + Items: initialize(0, 110, func(i int) Issue { + return Issue{ + Number: i, + } + }), + Total: 287, + }, + httpStubs: func(reg *httpmock.Registry) { + firstReq := httpmock.QueryMatcher("GET", "search/issues", url.Values{ + "page": []string{"1"}, + "per_page": []string{"100"}, + "order": []string{"desc"}, + "sort": []string{"comments"}, + "q": []string{"keyword is:locked is:public language:go"}, + }) + firstRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 287, + "items": initialize(0, 100, func(i int) interface{} { + return map[string]interface{}{ + "number": i, + } + }), + }) + firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) + secondReq := httpmock.QueryMatcher("GET", "search/issues", url.Values{ + "page": []string{"2"}, + "per_page": []string{"100"}, + "order": []string{"desc"}, + "sort": []string{"comments"}, + "q": []string{"keyword is:locked is:public language:go"}, + }) + secondRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 287, + "items": initialize(100, 200, func(i int) interface{} { + return map[string]interface{}{ + "number": i, + } + }), + }) reg.Register(firstReq, firstRes) reg.Register(secondReq, secondRes) }, @@ -597,8 +870,8 @@ func TestSearcherIssues(t *testing.T) { query: query, wantErr: true, errMsg: heredoc.Doc(` - Invalid search query "keyword is:locked is:public language:go". - "blah" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.`), + Invalid search query "keyword is:locked is:public language:go". + "blah" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.`), httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.QueryMatcher("GET", "search/issues", values), @@ -686,3 +959,12 @@ func TestSearcherURL(t *testing.T) { }) } } + +// initialize generate slices over a range for test scenarios using the provided initializer. +func initialize[T any](start int, stop int, initializer func(i int) T) []T { + results := make([]T, 0, (stop - start)) + for i := start; i < stop; i++ { + results = append(results, initializer(i)) + } + return results +}