Merge branch 'trunk' into phillmv/improve-gh-at-inspect

This commit is contained in:
Phill MV 2024-12-11 16:50:01 -05:00 committed by GitHub
commit ac93417c29
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1271 additions and 372 deletions

View file

@ -0,0 +1,37 @@
# Set up env vars
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
defer gh repo delete --yes ${ORG}/${REPO}
# Clone the repo
exec gh repo clone ${ORG}/${REPO}
# Create a branch to act as the merge base branch
cd ${REPO}
exec git checkout -b long-lived-feature-branch
exec git push -u origin long-lived-feature-branch
# Create an issue to develop against
exec gh issue create --title 'Feature Request' --body 'Request Body'
stdout2env ISSUE_URL
# Create a new branch using issue develop with the long lived branch as the base
exec gh issue develop --name 'feature-branch' --base 'long-lived-feature-branch' --checkout ${ISSUE_URL}
# Prepare a PR on the develop branch
exec git commit --allow-empty -m 'Empty Commit'
exec git push -u origin feature-branch
# Create the PR
exec gh pr create --title 'Feature Title' --body 'Feature Body'
# Check the PR is created against the base branch we specified
exec gh pr view --json 'baseRefName' --jq '.baseRefName'
stdout 'long-lived-feature-branch'

View file

@ -0,0 +1,34 @@
# Set up env vars
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
defer gh repo delete --yes ${ORG}/${REPO}
# Clone the repo
exec gh repo clone ${ORG}/${REPO}
# Create a branch to act as the merge base branch
cd ${REPO}
exec git checkout -b long-lived-feature-branch
exec git push -u origin long-lived-feature-branch
# Prepare a branch from the merge base to PR
exec git checkout -b feature-branch
exec git commit --allow-empty -m 'Empty Commit'
exec git push -u origin feature-branch
# Set the merge-base branch config
exec git config 'branch.feature-branch.gh-merge-base' 'long-lived-feature-branch'
# Create the PR
exec gh pr create --title 'Feature Title' --body 'Feature Body'
# Check the PR is created against the merge base branch
exec gh pr view --json 'baseRefName' --jq '.baseRefName'
stdout 'long-lived-feature-branch'

View file

@ -0,0 +1,71 @@
# Set up env
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
defer gh repo delete --yes ${ORG}/${REPO}
# Clone the repo
exec gh repo clone ${ORG}/${REPO}
# commit the workflow file
cd ${REPO}
mkdir .github/workflows
mv ../workflow.yml .github/workflows/workflow.yml
exec git add .github/workflows/workflow.yml
exec git commit -m 'Create workflow file'
exec git push -u origin main
# Sleep because it takes a second for the workflow to register
sleep 1
# Check the workflow is indeed created
exec gh workflow list
stdout 'Test Workflow Name'
# Run the workflow
exec gh workflow run 'Test Workflow Name'
# It takes some time for a workflow run to register
sleep 10
# Get the run ID we want to watch
exec gh run list --json databaseId --jq '.[0].databaseId'
stdout2env RUN_ID
# Wait for workflow to complete
exec gh run watch ${RUN_ID} --exit-status
# Download the artifact and see there is an error
! exec gh run download ${RUN_ID}
stderr 'would result in path traversal'
-- workflow.yml --
# This is a basic workflow to help you get started with Actions
name: Test Workflow Name
# Controls when the workflow will run
on:
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "build"
build:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
- run: echo hello > world.txt
- uses: actions/upload-artifact@v4
with:
name: ..
path: world.txt

View file

@ -1,17 +1,20 @@
# Set up env
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}
# commit the workflow file
cd $SCRIPT_NAME-$RANDOM_STRING
cd ${REPO}
mkdir .github/workflows
mv ../workflow.yml .github/workflows/workflow.yml
exec git add .github/workflows/workflow.yml
@ -36,13 +39,28 @@ exec gh run list --json databaseId --jq '.[0].databaseId'
stdout2env RUN_ID
# Wait for workflow to complete
exec gh run watch $RUN_ID --exit-status
exec gh run watch ${RUN_ID} --exit-status
# Download the artifact
exec gh run download $RUN_ID
# Download the artifact to current dir
exec gh run download ${RUN_ID}
# Check if we downloaded the artifact
exists ./my-artifact/world.txt
# Check that we downloaded the artifact and extracted into a dir with the name of the artifact
exists ./my-artifact/child/world.txt
# Remove the artifact
rm ./my-artifact
# Download the artifact via name to current dir
exec gh run download -n 'my-artifact' ${RUN_ID}
# Check that we downloaded the artifact and extracted into the current dir
exists ./child/world.txt
# Download the artifact via name to a specific dir
exec gh run download -n 'my-artifact' ${RUN_ID} --dir '..'
# Check that we downloaded the artifact and extracted into the specified dir
exists ../child/world.txt
-- workflow.yml --
# This is a basic workflow to help you get started with Actions
@ -63,8 +81,10 @@ jobs:
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
- run: echo hello > world.txt
- run: |
mkdir -p ./parent/child
echo hello > ./parent/child/world.txt
- uses: actions/upload-artifact@v4
with:
name: my-artifact
path: world.txt
path: ./parent

View file

@ -20,6 +20,9 @@ import (
"github.com/cli/safeexec"
)
// MergeBaseConfig is the configuration setting to keep track of the PR target branch.
const MergeBaseConfig = "gh-merge-base"
var remoteRE = regexp.MustCompile(`(.+)\s+(.+)\s+\((push|fetch)\)`)
// This regexp exists to match lines of the following form:
@ -373,10 +376,10 @@ func (c *Client) lookupCommit(ctx context.Context, sha, format string) ([]byte,
return out, nil
}
// ReadBranchConfig parses the `branch.BRANCH.(remote|merge)` part of git config.
// ReadBranchConfig parses the `branch.BRANCH.(remote|merge|gh-merge-base)` part of git config.
func (c *Client) ReadBranchConfig(ctx context.Context, branch string) (cfg BranchConfig) {
prefix := regexp.QuoteMeta(fmt.Sprintf("branch.%s.", branch))
args := []string{"config", "--get-regexp", fmt.Sprintf("^%s(remote|merge)$", prefix)}
args := []string{"config", "--get-regexp", fmt.Sprintf("^%s(remote|merge|%s)$", prefix, MergeBaseConfig)}
cmd, err := c.Command(ctx, args...)
if err != nil {
return
@ -385,6 +388,8 @@ func (c *Client) ReadBranchConfig(ctx context.Context, branch string) (cfg Branc
if err != nil {
return
}
cfg.LocalName = branch
for _, line := range outputLines(out) {
parts := strings.SplitN(line, " ", 2)
if len(parts) < 2 {
@ -404,11 +409,26 @@ func (c *Client) ReadBranchConfig(ctx context.Context, branch string) (cfg Branc
}
case "merge":
cfg.MergeRef = parts[1]
case MergeBaseConfig:
cfg.MergeBase = parts[1]
}
}
return
}
// SetBranchConfig sets the named value on the given branch.
func (c *Client) SetBranchConfig(ctx context.Context, branch, name, value string) error {
name = fmt.Sprintf("branch.%s.%s", branch, name)
args := []string{"config", name, value}
cmd, err := c.Command(ctx, args...)
if err != nil {
return err
}
// No output expected but check for any printed git error.
_, err = cmd.Output()
return err
}
func (c *Client) DeleteLocalTag(ctx context.Context, tag string) error {
args := []string{"tag", "-d", tag}
cmd, err := c.Command(ctx, args...)

View file

@ -735,9 +735,9 @@ func TestClientReadBranchConfig(t *testing.T) {
}{
{
name: "read branch config",
cmdStdout: "branch.trunk.remote origin\nbranch.trunk.merge refs/heads/trunk",
wantCmdArgs: `path/to/git config --get-regexp ^branch\.trunk\.(remote|merge)$`,
wantBranchConfig: BranchConfig{RemoteName: "origin", MergeRef: "refs/heads/trunk"},
cmdStdout: "branch.trunk.remote origin\nbranch.trunk.merge refs/heads/trunk\nbranch.trunk.gh-merge-base trunk",
wantCmdArgs: `path/to/git config --get-regexp ^branch\.trunk\.(remote|merge|gh-merge-base)$`,
wantBranchConfig: BranchConfig{LocalName: "trunk", RemoteName: "origin", MergeRef: "refs/heads/trunk", MergeBase: "trunk"},
},
}
for _, tt := range tests {

View file

@ -71,7 +71,11 @@ type Commit struct {
}
type BranchConfig struct {
// LocalName of the branch.
LocalName string
RemoteName string
RemoteURL *url.URL
MergeRef string
// MergeBase is the optional base branch to target in a new PR if `--base` is not specified.
MergeBase string
MergeRef string
}

View file

@ -0,0 +1,74 @@
package safepaths
import (
"fmt"
"path/filepath"
"strings"
)
// Absolute must be constructed via ParseAbsolute, or other methods in this package.
// The zero value of Absolute will panic when String is called.
type Absolute struct {
path string
}
// ParseAbsolute takes a string path that may be relative and returns
// an Absolute that is guaranteed to be absolute, or an error.
func ParseAbsolute(path string) (Absolute, error) {
path, err := filepath.Abs(path)
if err != nil {
return Absolute{}, fmt.Errorf("failed to get absolute path: %w", err)
}
return Absolute{path: path}, nil
}
// String returns a string representation of the absolute path, or panics
// if the absolute path is empty. This guards against programmer error.
func (a Absolute) String() string {
if a.path == "" {
panic("empty absolute path")
}
return a.path
}
// Join an absolute path with elements to create a new Absolute path, or error.
// A PathTraversalError will be returned if the joined path would traverse outside of
// the base Absolute path. Note that this does not handle symlinks.
func (a Absolute) Join(elem ...string) (Absolute, error) {
joinedAbsolutePath, err := ParseAbsolute(filepath.Join(append([]string{a.path}, elem...)...))
if err != nil {
return Absolute{}, fmt.Errorf("failed to parse joined path: %w", err)
}
isSubpath, err := joinedAbsolutePath.isSubpathOf(a)
if err != nil {
return Absolute{}, err
}
if !isSubpath {
return Absolute{}, PathTraversalError{
Base: a,
Elems: elem,
}
}
return joinedAbsolutePath, nil
}
func (a Absolute) isSubpathOf(dir Absolute) (bool, error) {
relativePath, err := filepath.Rel(dir.path, a.path)
if err != nil {
return false, err
}
return !strings.HasPrefix(relativePath, ".."), nil
}
type PathTraversalError struct {
Base Absolute
Elems []string
}
func (e PathTraversalError) Error() string {
return fmt.Sprintf("joining %s and %s would be a traversal", e.Base, filepath.Join(e.Elems...))
}

View file

@ -0,0 +1,137 @@
package safepaths_test
import (
"fmt"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/cli/cli/v2/internal/safepaths"
"github.com/stretchr/testify/require"
)
func TestParseAbsolutePath(t *testing.T) {
t.Parallel()
absolutePath, err := safepaths.ParseAbsolute("/base")
require.NoError(t, err)
require.Equal(t, filepath.Join(rootDir(), "base"), absolutePath.String())
}
func TestAbsoluteEmptyPathStringPanic(t *testing.T) {
t.Parallel()
absolutePath := safepaths.Absolute{}
require.Panics(t, func() {
_ = absolutePath.String()
})
}
func TestJoin(t *testing.T) {
t.Parallel()
tests := []struct {
name string
base safepaths.Absolute
elems []string
want safepaths.Absolute
wantPathTraversalError bool
}{
{
name: "child of base",
base: mustParseAbsolute("/base"),
elems: []string{"child"},
want: mustParseAbsolute("/base/child"),
},
{
name: "grandchild of base",
base: mustParseAbsolute("/base"),
elems: []string{"child", "grandchild"},
want: mustParseAbsolute("/base/child/grandchild"),
},
{
name: "relative parent of base",
base: mustParseAbsolute("/base"),
elems: []string{".."},
wantPathTraversalError: true,
},
{
name: "relative grandparent of base",
base: mustParseAbsolute("/base"),
elems: []string{"..", ".."},
wantPathTraversalError: true,
},
{
name: "relative current dir",
base: mustParseAbsolute("/base"),
elems: []string{"."},
want: mustParseAbsolute("/base"),
},
{
name: "subpath via relative parent",
base: mustParseAbsolute("/child"),
elems: []string{"..", "child"},
want: mustParseAbsolute("/child"),
},
{
name: "empty string",
base: mustParseAbsolute("/base"),
elems: []string{""},
want: mustParseAbsolute("/base"),
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
joinedPath, err := tt.base.Join(tt.elems...)
if tt.wantPathTraversalError {
var pathTraversalError safepaths.PathTraversalError
require.ErrorAs(t, err, &pathTraversalError)
require.Equal(t, tt.base, pathTraversalError.Base)
require.Equal(t, tt.elems, pathTraversalError.Elems)
return
}
require.NoError(t, err)
require.Equal(t, tt.want, joinedPath)
})
}
}
func TestPathTraversalErrorMessage(t *testing.T) {
t.Parallel()
pathTraversalError := safepaths.PathTraversalError{
Base: mustParseAbsolute("/base"),
Elems: []string{".."},
}
expectedMsg := fmt.Sprintf("joining %s and %s would be a traversal", filepath.Join(rootDir(), "base"), "..")
require.EqualError(t, pathTraversalError, expectedMsg)
}
func mustParseAbsolute(s string) safepaths.Absolute {
t, err := safepaths.ParseAbsolute(s)
if err != nil {
panic(err)
}
return t
}
func rootDir() string {
// Get the current working directory
cwd, err := os.Getwd()
if err != nil {
panic(err)
}
// For Windows, extract the volume and add back the root
if runtime.GOOS == "windows" {
volume := filepath.VolumeName(cwd)
return volume + "\\"
}
// For Unix-based systems, the root is always "/"
return "/"
}

View file

@ -118,7 +118,7 @@ func GetRemoteAttestations(client api.Client, params FetchRemoteAttestationsPara
}
func GetOCIAttestations(client oci.Client, artifact artifact.DigestedArtifact) ([]*api.Attestation, error) {
attestations, err := client.GetAttestations(artifact.NameRef(), artifact.Digest())
attestations, err := client.GetAttestations(artifact.NameRef(), artifact.DigestWithAlg())
if err != nil {
return nil, fmt.Errorf("failed to fetch OCI attestations: %w", err)
}

View file

@ -111,6 +111,25 @@ func TestVerifyIntegration(t *testing.T) {
require.Error(t, err)
require.ErrorContains(t, err, "verifying with issuer \"sigstore.dev\"")
})
t.Run("with bundle from OCI registry", func(t *testing.T) {
opts := Options{
APIClient: api.NewLiveClient(hc, host, logger),
ArtifactPath: "oci://ghcr.io/github/artifact-attestations-helm-charts/policy-controller:v0.10.0-github9",
UseBundleFromRegistry: true,
DigestAlgorithm: "sha256",
Logger: logger,
OCIClient: oci.NewLiveClient(),
OIDCIssuer: verification.GitHubOIDCIssuer,
Owner: "github",
PredicateType: verification.SLSAPredicateV1,
SANRegex: "^https://github.com/github/",
SigstoreVerifier: verification.NewLiveSigstoreVerifier(sigstoreConfig),
}
err := runVerify(&opts)
require.NoError(t, err)
})
}
func TestVerifyIntegrationCustomIssuer(t *testing.T) {

View file

@ -59,10 +59,18 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co
}
cmd := &cobra.Command{
Long: "Open the GitHub repository in the web browser.",
Short: "Open the repository in the browser",
Use: "browse [<number> | <path> | <commit-SHA>]",
Args: cobra.MaximumNArgs(1),
Short: "Open repositories, issues, pull requests, and more in the browser",
Long: heredoc.Doc(`
Transition from the terminal to the web browser to view and interact with:
- Issues
- Pull requests
- Repository content
- Repository home page
- Repository settings
`),
Use: "browse [<number> | <path> | <commit-SHA>]",
Args: cobra.MaximumNArgs(1),
Example: heredoc.Doc(`
$ gh browse
#=> Open the home page of the current repository

View file

@ -198,7 +198,7 @@ func (c codespace) displayName(includeOwner bool) string {
displayName = c.Name
}
description := fmt.Sprintf("%s (%s): %s", c.Repository.FullName, branch, displayName)
description := fmt.Sprintf("%s [%s]: %s", c.Repository.FullName, branch, displayName)
if includeOwner {
description = fmt.Sprintf("%-15s %s", c.Owner.Login, description)

View file

@ -34,7 +34,7 @@ func Test_codespace_displayName(t *testing.T) {
DisplayName: "scuba steve",
},
},
want: "cli/cli (trunk): scuba steve",
want: "cli/cli [trunk]: scuba steve",
},
{
name: "No included name - included gitstatus - no unsaved changes",
@ -50,7 +50,7 @@ func Test_codespace_displayName(t *testing.T) {
DisplayName: "scuba steve",
},
},
want: "cli/cli (trunk): scuba steve",
want: "cli/cli [trunk]: scuba steve",
},
{
name: "No included name - included gitstatus - unsaved changes",
@ -67,7 +67,7 @@ func Test_codespace_displayName(t *testing.T) {
DisplayName: "scuba steve",
},
},
want: "cli/cli (trunk*): scuba steve",
want: "cli/cli [trunk*]: scuba steve",
},
{
name: "Included name - included gitstatus - unsaved changes",
@ -84,7 +84,7 @@ func Test_codespace_displayName(t *testing.T) {
DisplayName: "scuba steve",
},
},
want: "cli/cli (trunk*): scuba steve",
want: "cli/cli [trunk*]: scuba steve",
},
{
name: "Included name - included gitstatus - no unsaved changes",
@ -101,7 +101,7 @@ func Test_codespace_displayName(t *testing.T) {
DisplayName: "scuba steve",
},
},
want: "cli/cli (trunk): scuba steve",
want: "cli/cli [trunk]: scuba steve",
},
{
name: "with includeOwner true, prefixes the codespace owner",
@ -123,7 +123,7 @@ func Test_codespace_displayName(t *testing.T) {
DisplayName: "scuba steve",
},
},
want: "jimmy cli/cli (trunk): scuba steve",
want: "jimmy cli/cli [trunk]: scuba steve",
},
}
for _, tt := range tests {
@ -163,7 +163,7 @@ func Test_formatCodespacesForSelect(t *testing.T) {
},
},
wantCodespacesNames: []string{
"cli/cli (trunk): scuba steve",
"cli/cli [trunk]: scuba steve",
},
},
{
@ -191,8 +191,8 @@ func Test_formatCodespacesForSelect(t *testing.T) {
},
},
wantCodespacesNames: []string{
"cli/cli (trunk): scuba steve",
"cli/cli (trunk): flappy bird",
"cli/cli [trunk]: scuba steve",
"cli/cli [trunk]: flappy bird",
},
},
{
@ -220,8 +220,8 @@ func Test_formatCodespacesForSelect(t *testing.T) {
},
},
wantCodespacesNames: []string{
"cli/cli (trunk): scuba steve",
"cli/cli (feature): flappy bird",
"cli/cli [trunk]: scuba steve",
"cli/cli [feature]: flappy bird",
},
},
{
@ -249,8 +249,8 @@ func Test_formatCodespacesForSelect(t *testing.T) {
},
},
wantCodespacesNames: []string{
"github/cli (trunk): scuba steve",
"cli/cli (trunk): flappy bird",
"github/cli [trunk]: scuba steve",
"cli/cli [trunk]: flappy bird",
},
},
{
@ -279,8 +279,8 @@ func Test_formatCodespacesForSelect(t *testing.T) {
},
},
wantCodespacesNames: []string{
"cli/cli (trunk): scuba steve",
"cli/cli (trunk*): flappy bird",
"cli/cli [trunk]: scuba steve",
"cli/cli [trunk*]: flappy bird",
},
},
}

View file

@ -44,6 +44,13 @@ func NewCmdDevelop(f *cmdutil.Factory, runF func(*DevelopOptions) error) *cobra.
cmd := &cobra.Command{
Use: "develop {<number> | <url>}",
Short: "Manage linked branches for an issue",
Long: heredoc.Docf(`
Manage linked branches for an issue.
When using the %[1]s--base%[1]s flag, the new development branch will be created from the specified
remote branch. The new branch will be configured as the base branch for pull requests created using
%[1]sgh pr create%[1]s.
`, "`"),
Example: heredoc.Doc(`
# List branches for issue 123
$ gh issue develop --list 123
@ -171,6 +178,14 @@ func developRunCreate(opts *DevelopOptions, apiClient *api.Client, issueRepo ghr
return err
}
// Remember which branch to target when creating a PR.
if opts.BaseBranch != "" {
err = opts.GitClient.SetBranchConfig(ctx.Background(), branchName, git.MergeBaseConfig, opts.BaseBranch)
if err != nil {
return err
}
}
fmt.Fprintf(opts.IO.Out, "%s/%s/tree/%s\n", branchRepo.RepoHost(), ghrepo.FullName(branchRepo), branchName)
return checkoutBranch(opts, branchRepo, branchName)

View file

@ -399,6 +399,7 @@ func TestDevelopRun(t *testing.T) {
},
runStubs: func(cs *run.CommandStubber) {
cs.Register(`git fetch origin \+refs/heads/my-branch:refs/remotes/origin/my-branch`, 0, "")
cs.Register(`git config branch\.my-branch\.gh-merge-base main`, 0, "")
},
expectedOut: "github.com/OWNER/REPO/tree/my-branch\n",
},

View file

@ -77,6 +77,10 @@ func NewCmdChecks(f *cmdutil.Factory, runF func(*ChecksOptions) error) *cobra.Co
RunE: func(cmd *cobra.Command, args []string) error {
opts.Finder = shared.NewFinder(f)
if opts.Exporter != nil && opts.Watch {
return cmdutil.FlagErrorf("cannot use `--watch` with `--json` flag")
}
if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 {
return cmdutil.FlagErrorf("argument required when using the `--repo` flag")
}

View file

@ -78,6 +78,11 @@ func TestNewCmdChecks(t *testing.T) {
cli: "--fail-fast",
wantsError: "cannot use `--fail-fast` flag without `--watch` flag",
},
{
name: "watch with json flag",
cli: "--watch --json workflow",
wantsError: "cannot use `--watch` with `--json` flag",
},
{
name: "required flag",
cli: "--required",
@ -171,7 +176,7 @@ func Test_checksRun(t *testing.T) {
wantOut: heredoc.Doc(`
Some checks were not successful
0 cancelled, 1 failing, 1 successful, 0 skipped, and 1 pending checks
NAME DESCRIPTION ELAPSED URL
X sad tests 1m26s sweet link
cool tests 1m26s sweet link
@ -191,7 +196,7 @@ func Test_checksRun(t *testing.T) {
wantOut: heredoc.Doc(`
Some checks were cancelled
1 cancelled, 0 failing, 2 successful, 0 skipped, and 0 pending checks
NAME DESCRIPTION ELAPSED URL
cool tests 1m26s sweet link
- sad tests 1m26s sweet link
@ -211,7 +216,7 @@ func Test_checksRun(t *testing.T) {
wantOut: heredoc.Doc(`
Some checks are still pending
1 cancelled, 0 failing, 2 successful, 0 skipped, and 1 pending checks
NAME DESCRIPTION ELAPSED URL
cool tests 1m26s sweet link
rad tests 1m26s sweet link
@ -232,7 +237,7 @@ func Test_checksRun(t *testing.T) {
wantOut: heredoc.Doc(`
All checks were successful
0 cancelled, 0 failing, 3 successful, 0 skipped, and 0 pending checks
NAME DESCRIPTION ELAPSED URL
awesome tests 1m26s sweet link
cool tests 1m26s sweet link
@ -253,7 +258,7 @@ func Test_checksRun(t *testing.T) {
wantOut: heredoc.Docf(`
%[1]s[?1049hAll checks were successful
0 cancelled, 0 failing, 3 successful, 0 skipped, and 0 pending checks
NAME DESCRIPTION ELAPSED URL
awesome tests 1m26s sweet link
cool tests 1m26s sweet link
@ -281,17 +286,17 @@ func Test_checksRun(t *testing.T) {
},
wantOut: heredoc.Docf(`
%[1]s[?1049h%[1]s[0;0H%[1]s[JRefreshing checks status every 0 seconds. Press Ctrl+C to quit.
Some checks were not successful
0 cancelled, 1 failing, 1 successful, 0 skipped, and 1 pending checks
NAME DESCRIPTION ELAPSED URL
X sad tests 1m26s sweet link
cool tests 1m26s sweet link
* slow tests 1m26s sweet link
%[1]s[?1049lSome checks were not successful
0 cancelled, 1 failing, 1 successful, 0 skipped, and 1 pending checks
NAME DESCRIPTION ELAPSED URL
X sad tests 1m26s sweet link
cool tests 1m26s sweet link
@ -311,7 +316,7 @@ func Test_checksRun(t *testing.T) {
wantOut: heredoc.Doc(`
Some checks were not successful
0 cancelled, 1 failing, 2 successful, 0 skipped, and 0 pending checks
NAME DESCRIPTION ELAPSED URL
X a status sweet link
cool tests 1m26s sweet link
@ -397,7 +402,7 @@ func Test_checksRun(t *testing.T) {
wantOut: heredoc.Doc(`
All checks were successful
0 cancelled, 0 failing, 1 successful, 2 skipped, and 0 pending checks
NAME DESCRIPTION ELAPSED URL
cool tests 1m26s sweet link
- rad tests 1m26s sweet link
@ -429,7 +434,7 @@ func Test_checksRun(t *testing.T) {
wantOut: heredoc.Doc(`
All checks were successful
0 cancelled, 0 failing, 1 successful, 0 skipped, and 0 pending checks
NAME DESCRIPTION ELAPSED URL
cool tests 1m26s sweet link
`),
@ -484,7 +489,7 @@ func Test_checksRun(t *testing.T) {
wantOut: heredoc.Doc(`
All checks were successful
0 cancelled, 0 failing, 3 successful, 0 skipped, and 0 pending checks
NAME DESCRIPTION ELAPSED URL
awesome tests awesome description 1m26s sweet link
cool tests cool description 1m26s sweet link
@ -515,7 +520,7 @@ func Test_checksRun(t *testing.T) {
wantOut: heredoc.Doc(`
All checks were successful
0 cancelled, 0 failing, 2 successful, 0 skipped, and 0 pending checks
NAME DESCRIPTION ELAPSED URL
tests/cool tests (pull_request) cool description 1m26s sweet link
tests/cool tests (push) cool description 1m26s sweet link
@ -535,7 +540,7 @@ func Test_checksRun(t *testing.T) {
wantOut: heredoc.Doc(`
All checks were successful
0 cancelled, 0 failing, 1 successful, 0 skipped, and 0 pending checks
NAME DESCRIPTION ELAPSED URL
tests/cool tests cool description 1m26s sweet link
`),

View file

@ -119,6 +119,10 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
alongside %[1]s--fill%[1]s, the values specified by %[1]s--title%[1]s and/or %[1]s--body%[1]s will
take precedence and overwrite any autofilled content.
The base branch for the created PR can be specified using the %[1]s--base%[1]s flag. If not provided,
the value of %[1]sgh-merge-base%[1]s git branch config will be used. If not configured, the repository's
default branch will be used.
Link an issue to the pull request by referencing the issue in the body of the pull
request. If the body text mentions %[1]sFixes #123%[1]s or %[1]sCloses #123%[1]s, the referenced issue
will automatically get closed when the pull request gets merged.
@ -513,11 +517,10 @@ func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState, u
return nil
}
func determineTrackingBranch(gitClient *git.Client, remotes ghContext.Remotes, headBranch string) *git.TrackingRef {
func determineTrackingBranch(gitClient *git.Client, remotes ghContext.Remotes, headBranchConfig *git.BranchConfig) *git.TrackingRef {
refsForLookup := []string{"HEAD"}
var trackingRefs []git.TrackingRef
headBranchConfig := gitClient.ReadBranchConfig(context.Background(), headBranch)
if headBranchConfig.RemoteName != "" {
tr := git.TrackingRef{
RemoteName: headBranchConfig.RemoteName,
@ -530,7 +533,7 @@ func determineTrackingBranch(gitClient *git.Client, remotes ghContext.Remotes, h
for _, remote := range remotes {
tr := git.TrackingRef{
RemoteName: remote.Name,
BranchName: headBranch,
BranchName: headBranchConfig.LocalName,
}
trackingRefs = append(trackingRefs, tr)
refsForLookup = append(refsForLookup, tr.String())
@ -640,9 +643,10 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) {
var headRepo ghrepo.Interface
var headRemote *ghContext.Remote
headBranchConfig := gitClient.ReadBranchConfig(context.Background(), headBranch)
if isPushEnabled {
// determine whether the head branch is already pushed to a remote
if pushedTo := determineTrackingBranch(gitClient, remotes, headBranch); pushedTo != nil {
if pushedTo := determineTrackingBranch(gitClient, remotes, &headBranchConfig); pushedTo != nil {
isPushEnabled = false
if r, err := remotes.FindByName(pushedTo.RemoteName); err == nil {
headRepo = r
@ -715,6 +719,9 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) {
}
baseBranch := opts.BaseBranch
if baseBranch == "" {
baseBranch = headBranchConfig.MergeBase
}
if baseBranch == "" {
baseBranch = baseRepo.DefaultBranchRef.Name
}

View file

@ -1,6 +1,7 @@
package create
import (
ctx "context"
"encoding/json"
"errors"
"fmt"
@ -261,6 +262,15 @@ func TestNewCmdCreate(t *testing.T) {
cli: "--editor",
wantsErr: true,
},
{
name: "fill and base",
cli: "--fill --base trunk",
wantsOpts: CreateOptions{
Autofill: true,
BaseBranch: "trunk",
MaintainerCanModify: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -323,17 +333,18 @@ func TestNewCmdCreate(t *testing.T) {
func Test_createRun(t *testing.T) {
tests := []struct {
name string
setup func(*CreateOptions, *testing.T) func()
cmdStubs func(*run.CommandStubber)
promptStubs func(*prompter.PrompterMock)
httpStubs func(*httpmock.Registry, *testing.T)
expectedOutputs []string
expectedOut string
expectedErrOut string
expectedBrowse string
wantErr string
tty bool
name string
setup func(*CreateOptions, *testing.T) func()
cmdStubs func(*run.CommandStubber)
promptStubs func(*prompter.PrompterMock)
httpStubs func(*httpmock.Registry, *testing.T)
expectedOutputs []string
expectedOut string
expectedErrOut string
expectedBrowse string
wantErr string
tty bool
customBranchConfig bool
}{
{
name: "nontty web",
@ -626,7 +637,6 @@ func Test_createRun(t *testing.T) {
}))
},
cmdStubs: func(cs *run.CommandStubber) {
cs.Register(`git config --get-regexp.+branch\\\.feature\\\.`, 0, "")
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "")
},
@ -690,7 +700,6 @@ func Test_createRun(t *testing.T) {
}))
},
cmdStubs: func(cs *run.CommandStubber) {
cs.Register(`git config --get-regexp.+branch\\\.feature\\\.`, 0, "")
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "")
},
@ -737,7 +746,6 @@ func Test_createRun(t *testing.T) {
}))
},
cmdStubs: func(cs *run.CommandStubber) {
cs.Register(`git config --get-regexp.+branch\\\.feature\\\.`, 0, "")
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "")
},
@ -787,7 +795,6 @@ func Test_createRun(t *testing.T) {
}))
},
cmdStubs: func(cs *run.CommandStubber) {
cs.Register(`git config --get-regexp.+branch\\\.feature\\\.`, 0, "")
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
cs.Register("git remote rename origin upstream", 0, "")
cs.Register(`git remote add origin https://github.com/monalisa/REPO.git`, 0, "")
@ -846,7 +853,6 @@ func Test_createRun(t *testing.T) {
}))
},
cmdStubs: func(cs *run.CommandStubber) {
cs.Register(`git config --get-regexp \^branch\\\.feature\\\.`, 1, "") // determineTrackingBranch
cs.Register("git show-ref --verify", 0, heredoc.Doc(`
deadbeef HEAD
deadb00f refs/remotes/upstream/feature
@ -878,6 +884,7 @@ func Test_createRun(t *testing.T) {
assert.Equal(t, "my-feat2", input["headRefName"].(string))
}))
},
customBranchConfig: true,
cmdStubs: func(cs *run.CommandStubber) {
cs.Register(`git config --get-regexp \^branch\\\.feature\\\.`, 0, heredoc.Doc(`
branch.feature.remote origin
@ -1066,7 +1073,6 @@ func Test_createRun(t *testing.T) {
httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`))
},
cmdStubs: func(cs *run.CommandStubber) {
cs.Register(`git config --get-regexp.+branch\\\.feature\\\.`, 0, "")
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
cs.Register(`git( .+)? log( .+)? origin/master\.\.\.feature`, 0, "")
cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "")
@ -1099,7 +1105,6 @@ func Test_createRun(t *testing.T) {
mockRetrieveProjects(t, reg)
},
cmdStubs: func(cs *run.CommandStubber) {
cs.Register(`git config --get-regexp.+branch\\\.feature\\\.`, 0, "")
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
cs.Register(`git( .+)? log( .+)? origin/master\.\.\.feature`, 0, "")
cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "")
@ -1464,6 +1469,65 @@ func Test_createRun(t *testing.T) {
},
expectedOut: "https://github.com/OWNER/REPO/pull/12\n",
},
{
name: "gh-merge-base",
tty: true,
setup: func(opts *CreateOptions, t *testing.T) func() {
opts.TitleProvided = true
opts.BodyProvided = true
opts.Title = "my title"
opts.Body = "my body"
opts.Branch = func() (string, error) {
return "task1", nil
}
opts.Remotes = func() (context.Remotes, error) {
return context.Remotes{
{
Remote: &git.Remote{
Name: "upstream",
Resolved: "base",
},
Repo: ghrepo.New("OWNER", "REPO"),
},
{
Remote: &git.Remote{
Name: "origin",
},
Repo: ghrepo.New("monalisa", "REPO"),
},
}, nil
}
return func() {}
},
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
reg.Register(
httpmock.GraphQL(`mutation PullRequestCreate\b`),
httpmock.GraphQLMutation(`
{ "data": { "createPullRequest": { "pullRequest": {
"URL": "https://github.com/OWNER/REPO/pull/12"
} } } }
`, 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, "feature/feat2", input["baseRefName"].(string))
assert.Equal(t, "monalisa:task1", input["headRefName"].(string))
}))
},
customBranchConfig: true,
cmdStubs: func(cs *run.CommandStubber) {
cs.Register(`git config --get-regexp \^branch\\\.task1\\\.\(remote\|merge\|gh-merge-base\)\$`, 0, heredoc.Doc(`
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(`
deadbeef HEAD
deadb00f refs/remotes/upstream/feature/feat2
deadbeef refs/remotes/origin/task1`)) // determineTrackingBranch
},
expectedOut: "https://github.com/OWNER/REPO/pull/12\n",
expectedErrOut: "\nCreating pull request for monalisa:task1 into feature/feat2 in OWNER/REPO\n\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -1485,6 +1549,9 @@ 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\|gh-merge-base\)\$`, 0, "")
}
if tt.cmdStubs != nil {
tt.cmdStubs(cs)
@ -1651,7 +1718,8 @@ func Test_determineTrackingBranch(t *testing.T) {
GhPath: "some/path/gh",
GitPath: "some/path/git",
}
ref := determineTrackingBranch(gitClient, tt.remotes, "feature")
headBranchConfig := gitClient.ReadBranchConfig(ctx.Background(), "feature")
ref := determineTrackingBranch(gitClient, tt.remotes, &headBranchConfig)
tt.assert(ref, t)
})
}

View file

@ -49,9 +49,27 @@ func NewCmdRename(f *cmdutil.Factory, runf func(*RenameOptions) error) *cobra.Co
cmd := &cobra.Command{
Use: "rename [<new-name>]",
Short: "Rename a repository",
Long: heredoc.Doc(`Rename a GitHub repository.
Long: heredoc.Docf(`
Rename a GitHub repository.
%[1]s<new-name>%[1]s is the desired repository name without the owner.
By default, the current repository is renamed. Otherwise, the repository specified
with %[1]s--repo%[1]s is renamed.
To transfer repository ownership to another user account or organization,
you must follow additional steps on GitHub.com
By default, this renames the current repository; otherwise renames the specified repository.`),
For more information on transferring repository ownership, see:
<https://docs.github.com/en/repositories/creating-and-managing-repositories/transferring-a-repository>
`, "`"),
Example: heredoc.Doc(`
# Rename the current repository (foo/bar -> foo/baz)
$ gh repo rename baz
# Rename the specified repository (qux/quux -> qux/baz)
$ gh repo rename -R qux/quux baz
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
opts.BaseRepo = f.BaseRepo

View file

@ -196,9 +196,9 @@ func TestRunCancel(t *testing.T) {
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"* cool commit, CI (trunk) Feb 23, 2021"},
[]string{"* cool commit, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "* cool commit, CI (trunk) Feb 23, 2021")
return prompter.IndexFor(opts, "* cool commit, CI [trunk] Feb 23, 2021")
})
},
wantOut: "✓ Request to cancel workflow 1234 submitted.\n",

View file

@ -132,7 +132,7 @@ func TestRunDelete(t *testing.T) {
},
prompterStubs: func(pm *prompter.PrompterMock) {
pm.SelectFunc = func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "✓ cool commit, CI (trunk) Feb 23, 2021")
return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021")
}
},
httpStubs: func(reg *httpmock.Registry) {

View file

@ -6,6 +6,7 @@ import (
"path/filepath"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/safepaths"
"github.com/cli/cli/v2/pkg/cmd/run/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
@ -27,8 +28,9 @@ type DownloadOptions struct {
type platform interface {
List(runID string) ([]shared.Artifact, error)
Download(url string, dir string) error
Download(url string, dir safepaths.Absolute) error
}
type iprompter interface {
MultiSelect(string, []string, []string) ([]int, error)
}
@ -151,8 +153,15 @@ func runDownload(opts *DownloadOptions) error {
opts.IO.StartProgressIndicator()
defer opts.IO.StopProgressIndicator()
// track downloaded artifacts and avoid re-downloading any of the same name
// track downloaded artifacts and avoid re-downloading any of the same name, isolate if multiple artifacts
downloaded := set.NewStringSet()
isolateArtifacts := isolateArtifacts(wantNames, wantPatterns)
absoluteDestinationDir, err := safepaths.ParseAbsolute(opts.DestinationDir)
if err != nil {
return fmt.Errorf("error parsing destination directory: %w", err)
}
for _, a := range artifacts {
if a.Expired {
continue
@ -165,10 +174,19 @@ func runDownload(opts *DownloadOptions) error {
continue
}
}
destDir := opts.DestinationDir
if len(wantPatterns) != 0 || len(wantNames) != 1 {
destDir = filepath.Join(destDir, a.Name)
destDir := absoluteDestinationDir
if isolateArtifacts {
destDir, err = absoluteDestinationDir.Join(a.Name)
if err != nil {
var pathTraversalError safepaths.PathTraversalError
if errors.As(err, &pathTraversalError) {
return fmt.Errorf("error downloading %s: would result in path traversal", a.Name)
}
return err
}
}
err := opts.Platform.Download(a.DownloadURL, destDir)
if err != nil {
return fmt.Errorf("error downloading %s: %w", a.Name, err)
@ -183,6 +201,25 @@ func runDownload(opts *DownloadOptions) error {
return nil
}
func isolateArtifacts(wantNames []string, wantPatterns []string) bool {
if len(wantPatterns) > 0 {
// Patterns can match multiple artifacts
return true
}
if len(wantNames) == 0 {
// All artifacts wanted regardless what they are named
return true
}
if len(wantNames) > 1 {
// Multiple, specific artifacts wanted
return true
}
return false
}
func matchAnyName(names []string, name string) bool {
for _, n := range names {
if name == n {

View file

@ -2,19 +2,22 @@ package download
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"testing"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/prompter"
"github.com/cli/cli/v2/internal/safepaths"
"github.com/cli/cli/v2/pkg/cmd/run/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
@ -143,159 +146,584 @@ func Test_NewCmdDownload(t *testing.T) {
}
}
type run struct {
id string
testArtifacts []testArtifact
}
type testArtifact struct {
artifact shared.Artifact
files []string
}
type fakePlatform struct {
runs []run
}
func (f *fakePlatform) List(runID string) ([]shared.Artifact, error) {
runIds := map[string]struct{}{}
if runID != "" {
runIds[runID] = struct{}{}
} else {
for _, run := range f.runs {
runIds[run.id] = struct{}{}
}
}
var artifacts []shared.Artifact
for _, run := range f.runs {
// Skip over any runs that we aren't looking for
if _, ok := runIds[run.id]; !ok {
continue
}
// Grab the artifacts of everything else
for _, testArtifact := range run.testArtifacts {
artifacts = append(artifacts, testArtifact.artifact)
}
}
return artifacts, nil
}
func (f *fakePlatform) Download(url string, dir safepaths.Absolute) error {
if err := os.MkdirAll(dir.String(), 0755); err != nil {
return err
}
// Now to be consistent, we find the artifact with the provided URL.
// It's a bit janky to iterate the runs, to find the right artifact
// rather than keying directly to it, but it allows the setup of the
// fake platform to be declarative rather than imperative.
// Think fakePlatform { artifacts: ... } rather than fakePlatform.makeArtifactAvailable()
for _, run := range f.runs {
for _, testArtifact := range run.testArtifacts {
if testArtifact.artifact.DownloadURL == url {
for _, file := range testArtifact.files {
path := filepath.Join(dir.String(), file)
return os.WriteFile(path, []byte{}, 0600)
}
}
}
}
return errors.New("no artifact matches the provided URL")
}
func Test_runDownload(t *testing.T) {
tests := []struct {
name string
opts DownloadOptions
mockAPI func(*mockPlatform)
promptStubs func(*prompter.MockPrompter)
wantErr string
name string
opts DownloadOptions
platform *fakePlatform
promptStubs func(*prompter.MockPrompter)
expectedFiles []string
wantErr string
}{
{
name: "download non-expired",
name: "download non-expired to relative directory",
opts: DownloadOptions{
RunID: "2345",
DestinationDir: "./tmp",
Names: []string(nil),
},
mockAPI: func(p *mockPlatform) {
p.On("List", "2345").Return([]shared.Artifact{
platform: &fakePlatform{
runs: []run{
{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: false,
id: "2345",
testArtifacts: []testArtifact{
{
artifact: shared.Artifact{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: false,
},
files: []string{
"artifact-1-file",
},
},
{
artifact: shared.Artifact{
Name: "expired-artifact",
DownloadURL: "http://download.com/expired.zip",
Expired: true,
},
files: []string{
"expired",
},
},
{
artifact: shared.Artifact{
Name: "artifact-2",
DownloadURL: "http://download.com/artifact2.zip",
Expired: false,
},
files: []string{
"artifact-2-file",
},
},
},
},
{
Name: "expired-artifact",
DownloadURL: "http://download.com/expired.zip",
Expired: true,
},
{
Name: "artifact-2",
DownloadURL: "http://download.com/artifact2.zip",
Expired: false,
},
}, nil)
p.On("Download", "http://download.com/artifact1.zip", filepath.FromSlash("tmp/artifact-1")).Return(nil)
p.On("Download", "http://download.com/artifact2.zip", filepath.FromSlash("tmp/artifact-2")).Return(nil)
},
},
expectedFiles: []string{
filepath.Join("artifact-1", "artifact-1-file"),
filepath.Join("artifact-2", "artifact-2-file"),
},
},
{
name: "no valid artifacts",
name: "download non-expired to absolute directory",
opts: DownloadOptions{
RunID: "2345",
DestinationDir: ".",
Names: []string(nil),
DestinationDir: "/tmp",
},
mockAPI: func(p *mockPlatform) {
p.On("List", "2345").Return([]shared.Artifact{
platform: &fakePlatform{
runs: []run{
{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: true,
id: "2345",
testArtifacts: []testArtifact{
{
artifact: shared.Artifact{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: false,
},
files: []string{
"artifact-1-file",
},
},
{
artifact: shared.Artifact{
Name: "expired-artifact",
DownloadURL: "http://download.com/expired.zip",
Expired: true,
},
files: []string{
"expired",
},
},
{
artifact: shared.Artifact{
Name: "artifact-2",
DownloadURL: "http://download.com/artifact2.zip",
Expired: false,
},
files: []string{
"artifact-2-file",
},
},
},
},
{
Name: "artifact-2",
DownloadURL: "http://download.com/artifact2.zip",
Expired: true,
},
}, nil)
},
},
wantErr: "no valid artifacts found to download",
expectedFiles: []string{
filepath.Join("artifact-1", "artifact-1-file"),
filepath.Join("artifact-2", "artifact-2-file"),
},
},
{
name: "all artifacts are expired",
opts: DownloadOptions{
RunID: "2345",
},
platform: &fakePlatform{
runs: []run{
{
id: "2345",
testArtifacts: []testArtifact{
{
artifact: shared.Artifact{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: true,
},
files: []string{
"artifact-1-file",
},
},
{
artifact: shared.Artifact{
Name: "artifact-2",
DownloadURL: "http://download.com/artifact2.zip",
Expired: true,
},
files: []string{
"artifact-2-file",
},
},
},
},
},
},
expectedFiles: []string{},
wantErr: "no valid artifacts found to download",
},
{
name: "no name matches",
opts: DownloadOptions{
RunID: "2345",
DestinationDir: ".",
Names: []string{"artifact-3"},
RunID: "2345",
Names: []string{"artifact-3"},
},
mockAPI: func(p *mockPlatform) {
p.On("List", "2345").Return([]shared.Artifact{
platform: &fakePlatform{
runs: []run{
{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: false,
id: "2345",
testArtifacts: []testArtifact{
{
artifact: shared.Artifact{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: false,
},
files: []string{
"artifact-1-file",
},
},
{
artifact: shared.Artifact{
Name: "artifact-2",
DownloadURL: "http://download.com/artifact2.zip",
Expired: false,
},
files: []string{
"artifact-2-file",
},
},
},
},
{
Name: "artifact-2",
DownloadURL: "http://download.com/artifact2.zip",
Expired: false,
},
}, nil)
},
},
expectedFiles: []string{},
wantErr: "no artifact matches any of the names or patterns provided",
},
{
name: "pattern matches",
opts: DownloadOptions{
RunID: "2345",
FilePatterns: []string{"artifact-*"},
},
platform: &fakePlatform{
runs: []run{
{
id: "2345",
testArtifacts: []testArtifact{
{
artifact: shared.Artifact{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: false,
},
files: []string{
"artifact-1-file",
},
},
{
artifact: shared.Artifact{
Name: "non-artifact-2",
DownloadURL: "http://download.com/non-artifact-2.zip",
Expired: false,
},
files: []string{
"non-artifact-2-file",
},
},
{
artifact: shared.Artifact{
Name: "artifact-3",
DownloadURL: "http://download.com/artifact3.zip",
Expired: false,
},
files: []string{
"artifact-3-file",
},
},
},
},
},
},
expectedFiles: []string{
filepath.Join("artifact-1", "artifact-1-file"),
filepath.Join("artifact-3", "artifact-3-file"),
},
wantErr: "no artifact matches any of the names or patterns provided",
},
{
name: "no pattern matches",
opts: DownloadOptions{
RunID: "2345",
DestinationDir: ".",
FilePatterns: []string{"artifiction-*"},
RunID: "2345",
FilePatterns: []string{"artifiction-*"},
},
mockAPI: func(p *mockPlatform) {
p.On("List", "2345").Return([]shared.Artifact{
platform: &fakePlatform{
runs: []run{
{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: false,
id: "2345",
testArtifacts: []testArtifact{
{
artifact: shared.Artifact{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: false,
},
files: []string{
"artifact-1-file",
},
},
{
artifact: shared.Artifact{
Name: "artifact-2",
DownloadURL: "http://download.com/artifact2.zip",
Expired: false,
},
files: []string{
"artifact-2-file",
},
},
},
},
{
Name: "artifact-2",
DownloadURL: "http://download.com/artifact2.zip",
Expired: false,
},
}, nil)
},
},
expectedFiles: []string{},
wantErr: "no artifact matches any of the names or patterns provided",
},
{
name: "want specific single artifact",
opts: DownloadOptions{
RunID: "2345",
Names: []string{"non-artifact-2"},
},
platform: &fakePlatform{
runs: []run{
{
id: "2345",
testArtifacts: []testArtifact{
{
artifact: shared.Artifact{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: false,
},
files: []string{
"artifact-1-file",
},
},
{
artifact: shared.Artifact{
Name: "non-artifact-2",
DownloadURL: "http://download.com/non-artifact-2.zip",
Expired: false,
},
files: []string{
"non-artifact-2-file",
},
},
{
artifact: shared.Artifact{
Name: "artifact-3",
DownloadURL: "http://download.com/artifact3.zip",
Expired: false,
},
files: []string{
"artifact-3-file",
},
},
},
},
},
},
expectedFiles: []string{
filepath.Join("non-artifact-2-file"),
},
},
{
name: "want specific multiple artifacts",
opts: DownloadOptions{
RunID: "2345",
Names: []string{"artifact-1", "artifact-3"},
},
platform: &fakePlatform{
runs: []run{
{
id: "2345",
testArtifacts: []testArtifact{
{
artifact: shared.Artifact{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: false,
},
files: []string{
"artifact-1-file",
},
},
{
artifact: shared.Artifact{
Name: "non-artifact-2",
DownloadURL: "http://download.com/non-artifact-2.zip",
Expired: false,
},
files: []string{
"non-artifact-2-file",
},
},
{
artifact: shared.Artifact{
Name: "artifact-3",
DownloadURL: "http://download.com/artifact3.zip",
Expired: false,
},
files: []string{
"artifact-3-file",
},
},
},
},
},
},
expectedFiles: []string{
filepath.Join("artifact-1", "artifact-1-file"),
filepath.Join("artifact-3", "artifact-3-file"),
},
},
{
name: "avoid redownloading files of the same name",
opts: DownloadOptions{
RunID: "2345",
},
platform: &fakePlatform{
runs: []run{
{
id: "2345",
testArtifacts: []testArtifact{
{
artifact: shared.Artifact{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: false,
},
files: []string{
"artifact-1-file",
},
},
{
artifact: shared.Artifact{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact2.zip",
Expired: false,
},
files: []string{
"artifact-2-file",
},
},
},
},
},
},
expectedFiles: []string{
filepath.Join("artifact-1", "artifact-1-file"),
},
wantErr: "no artifact matches any of the names or patterns provided",
},
{
name: "prompt to select artifact",
opts: DownloadOptions{
RunID: "",
DoPrompt: true,
DestinationDir: ".",
Names: []string(nil),
RunID: "",
DoPrompt: true,
Names: []string(nil),
},
mockAPI: func(p *mockPlatform) {
p.On("List", "").Return([]shared.Artifact{
platform: &fakePlatform{
runs: []run{
{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: false,
id: "2345",
testArtifacts: []testArtifact{
{
artifact: shared.Artifact{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: false,
},
files: []string{
"artifact-1-file",
},
},
{
artifact: shared.Artifact{
Name: "expired-artifact",
DownloadURL: "http://download.com/expired.zip",
Expired: true,
},
files: []string{
"expired",
},
},
},
},
{
Name: "expired-artifact",
DownloadURL: "http://download.com/expired.zip",
Expired: true,
id: "6789",
testArtifacts: []testArtifact{
{
artifact: shared.Artifact{
Name: "artifact-2",
DownloadURL: "http://download.com/artifact2.zip",
Expired: false,
},
files: []string{
"artifact-2-file",
},
},
},
},
{
Name: "artifact-2",
DownloadURL: "http://download.com/artifact2.zip",
Expired: false,
},
{
Name: "artifact-2",
DownloadURL: "http://download.com/artifact2.also.zip",
Expired: false,
},
}, nil)
p.On("Download", "http://download.com/artifact2.zip", ".").Return(nil)
},
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterMultiSelect("Select artifacts to download:", nil, []string{"artifact-1", "artifact-2"},
func(_ string, _, opts []string) ([]int, error) {
return []int{1}, nil
for i, o := range opts {
if o == "artifact-2" {
return []int{i}, nil
}
}
return nil, fmt.Errorf("no artifact-2 found in %v", opts)
})
},
expectedFiles: []string{
filepath.Join("artifact-2-file"),
},
},
{
name: "handling artifact name with path traversal exploit",
opts: DownloadOptions{
RunID: "2345",
},
platform: &fakePlatform{
runs: []run{
{
id: "2345",
testArtifacts: []testArtifact{
{
artifact: shared.Artifact{
Name: "..",
DownloadURL: "http://download.com/artifact1.zip",
Expired: false,
},
files: []string{
"etc/passwd",
},
},
},
},
},
},
expectedFiles: []string{},
wantErr: "error downloading ..: would result in path traversal",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
opts := &tt.opts
if opts.DestinationDir == "" {
opts.DestinationDir = t.TempDir()
} else {
opts.DestinationDir = filepath.Join(t.TempDir(), opts.DestinationDir)
}
ios, _, stdout, stderr := iostreams.Test()
opts.IO = ios
opts.Platform = newMockPlatform(t, tt.mockAPI)
opts.Platform = tt.platform
pm := prompter.NewMockPrompter(t)
opts.Prompter = pm
@ -310,34 +738,31 @@ func Test_runDownload(t *testing.T) {
require.NoError(t, err)
}
// Check that the exact number of files exist
require.Equal(t, len(tt.expectedFiles), countFilesInDirRecursively(t, opts.DestinationDir))
// Then check that the exact files are correct
for _, name := range tt.expectedFiles {
require.FileExists(t, filepath.Join(opts.DestinationDir, name))
}
assert.Equal(t, "", stdout.String())
assert.Equal(t, "", stderr.String())
})
}
}
type mockPlatform struct {
mock.Mock
}
func countFilesInDirRecursively(t *testing.T, dir string) int {
t.Helper()
func newMockPlatform(t *testing.T, config func(*mockPlatform)) *mockPlatform {
m := &mockPlatform{}
m.Test(t)
t.Cleanup(func() {
m.AssertExpectations(t)
})
if config != nil {
config(m)
}
return m
}
count := 0
require.NoError(t, filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
require.NoError(t, err)
if !info.IsDir() {
count++
}
return nil
}))
func (p *mockPlatform) List(runID string) ([]shared.Artifact, error) {
args := p.Called(runID)
return args.Get(0).([]shared.Artifact), args.Error(1)
}
func (p *mockPlatform) Download(url string, dir string) error {
args := p.Called(url, dir)
return args.Error(0)
return count
}

View file

@ -9,6 +9,7 @@ import (
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/safepaths"
"github.com/cli/cli/v2/pkg/cmd/run/shared"
)
@ -21,11 +22,11 @@ func (p *apiPlatform) List(runID string) ([]shared.Artifact, error) {
return shared.ListArtifacts(p.client, p.repo, runID)
}
func (p *apiPlatform) Download(url string, dir string) error {
func (p *apiPlatform) Download(url string, dir safepaths.Absolute) error {
return downloadArtifact(p.client, url, dir)
}
func downloadArtifact(httpClient *http.Client, url, destDir string) error {
func downloadArtifact(httpClient *http.Client, url string, destDir safepaths.Absolute) error {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err

View file

@ -9,6 +9,7 @@ import (
"testing"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/safepaths"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -58,7 +59,8 @@ func Test_List_perRepository(t *testing.T) {
func Test_Download(t *testing.T) {
tmpDir := t.TempDir()
destDir := filepath.Join(tmpDir, "artifact")
destDir, err := safepaths.ParseAbsolute(filepath.Join(tmpDir, "artifact"))
require.NoError(t, err)
reg := &httpmock.Registry{}
defer reg.Verify(t)
@ -70,8 +72,7 @@ func Test_Download(t *testing.T) {
api := &apiPlatform{
client: &http.Client{Transport: reg},
}
err := api.Download("https://api.github.com/repos/OWNER/REPO/actions/artifacts/12345/zip", destDir)
require.NoError(t, err)
require.NoError(t, api.Download("https://api.github.com/repos/OWNER/REPO/actions/artifacts/12345/zip", destDir))
var paths []string
parentPrefix := tmpDir + string(filepath.Separator)

View file

@ -2,11 +2,13 @@ package download
import (
"archive/zip"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/cli/cli/v2/internal/safepaths"
)
const (
@ -15,12 +17,17 @@ const (
execMode os.FileMode = 0755
)
func extractZip(zr *zip.Reader, destDir string) error {
func extractZip(zr *zip.Reader, destDir safepaths.Absolute) error {
for _, zf := range zr.File {
fpath := filepath.Join(destDir, filepath.FromSlash(zf.Name))
if !filepathDescendsFrom(fpath, destDir) {
continue
fpath, err := destDir.Join(zf.Name)
if err != nil {
var pathTraversalError safepaths.PathTraversalError
if errors.As(err, &pathTraversalError) {
continue
}
return err
}
if err := extractZipFile(zf, fpath); err != nil {
return fmt.Errorf("error extracting %q: %w", zf.Name, err)
}
@ -28,10 +35,10 @@ func extractZip(zr *zip.Reader, destDir string) error {
return nil
}
func extractZipFile(zf *zip.File, dest string) (extractErr error) {
func extractZipFile(zf *zip.File, dest safepaths.Absolute) (extractErr error) {
zm := zf.Mode()
if zm.IsDir() {
extractErr = os.MkdirAll(dest, dirMode)
extractErr = os.MkdirAll(dest.String(), dirMode)
return
}
@ -42,14 +49,12 @@ func extractZipFile(zf *zip.File, dest string) (extractErr error) {
}
defer f.Close()
if dir := filepath.Dir(dest); dir != "." {
if extractErr = os.MkdirAll(dir, dirMode); extractErr != nil {
return
}
if extractErr = os.MkdirAll(filepath.Dir(dest.String()), dirMode); extractErr != nil {
return
}
var df *os.File
if df, extractErr = os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_EXCL, getPerm(zm)); extractErr != nil {
if df, extractErr = os.OpenFile(dest.String(), os.O_WRONLY|os.O_CREATE|os.O_EXCL, getPerm(zm)); extractErr != nil {
return
}
@ -69,15 +74,3 @@ func getPerm(m os.FileMode) os.FileMode {
}
return execMode
}
func filepathDescendsFrom(p, dir string) bool {
p = filepath.Clean(p)
dir = filepath.Clean(dir)
if dir == "." && !filepath.IsAbs(p) {
return !strings.HasPrefix(p, ".."+string(filepath.Separator))
}
if !strings.HasSuffix(dir, string(filepath.Separator)) {
dir += string(filepath.Separator)
}
return strings.HasPrefix(p, dir)
}

View file

@ -6,12 +6,14 @@ import (
"path/filepath"
"testing"
"github.com/cli/cli/v2/internal/safepaths"
"github.com/stretchr/testify/require"
)
func Test_extractZip(t *testing.T) {
tmpDir := t.TempDir()
extractPath := filepath.Join(tmpDir, "artifact")
extractPath, err := safepaths.ParseAbsolute(filepath.Join(tmpDir, "artifact"))
require.NoError(t, err)
zipFile, err := zip.OpenReader("./fixtures/myproject.zip")
require.NoError(t, err)
@ -20,122 +22,6 @@ func Test_extractZip(t *testing.T) {
err = extractZip(&zipFile.Reader, extractPath)
require.NoError(t, err)
_, err = os.Stat(filepath.Join(extractPath, "src", "main.go"))
_, err = os.Stat(filepath.Join(extractPath.String(), "src", "main.go"))
require.NoError(t, err)
}
func Test_filepathDescendsFrom(t *testing.T) {
type args struct {
p string
dir string
}
tests := []struct {
name string
args args
want bool
}{
{
name: "root child",
args: args{
p: filepath.FromSlash("/hoi.txt"),
dir: filepath.FromSlash("/"),
},
want: true,
},
{
name: "abs descendant",
args: args{
p: filepath.FromSlash("/var/logs/hoi.txt"),
dir: filepath.FromSlash("/"),
},
want: true,
},
{
name: "abs trailing slash",
args: args{
p: filepath.FromSlash("/var/logs/hoi.txt"),
dir: filepath.FromSlash("/var/logs/"),
},
want: true,
},
{
name: "abs mismatch",
args: args{
p: filepath.FromSlash("/var/logs/hoi.txt"),
dir: filepath.FromSlash("/var/pids"),
},
want: false,
},
{
name: "abs partial prefix",
args: args{
p: filepath.FromSlash("/var/logs/hoi.txt"),
dir: filepath.FromSlash("/var/log"),
},
want: false,
},
{
name: "rel child",
args: args{
p: filepath.FromSlash("hoi.txt"),
dir: filepath.FromSlash("."),
},
want: true,
},
{
name: "rel descendant",
args: args{
p: filepath.FromSlash("./log/hoi.txt"),
dir: filepath.FromSlash("."),
},
want: true,
},
{
name: "mixed rel styles",
args: args{
p: filepath.FromSlash("./log/hoi.txt"),
dir: filepath.FromSlash("log"),
},
want: true,
},
{
name: "rel clean",
args: args{
p: filepath.FromSlash("cats/../dogs/pug.txt"),
dir: filepath.FromSlash("dogs"),
},
want: true,
},
{
name: "rel mismatch",
args: args{
p: filepath.FromSlash("dogs/pug.txt"),
dir: filepath.FromSlash("dog"),
},
want: false,
},
{
name: "rel breakout",
args: args{
p: filepath.FromSlash("../escape.txt"),
dir: filepath.FromSlash("."),
},
want: false,
},
{
name: "rel sneaky breakout",
args: args{
p: filepath.FromSlash("dogs/../../escape.txt"),
dir: filepath.FromSlash("dogs"),
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := filepathDescendsFrom(tt.args.p, tt.args.dir); got != tt.want {
t.Errorf("filepathDescendsFrom() = %v, want %v", got, tt.want)
}
})
}
}

View file

@ -341,7 +341,7 @@ func TestRerun(t *testing.T) {
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"X 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", "X cool commit, CI (trunk) Feb 23, 2021"},
[]string{"X 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", "X cool commit, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return 2, nil
})

View file

@ -508,7 +508,7 @@ func SelectRun(p Prompter, cs *iostreams.ColorScheme, runs []Run) (string, error
symbol, _ := Symbol(cs, run.Status, run.Conclusion)
candidates = append(candidates,
// TODO truncate commit message, long ones look terrible
fmt.Sprintf("%s %s, %s (%s) %s", symbol, run.Title(), run.WorkflowName(), run.HeadBranch, preciseAgo(now, run.StartedTime())))
fmt.Sprintf("%s %s, %s [%s] %s", symbol, run.Title(), run.WorkflowName(), run.HeadBranch, preciseAgo(now, run.StartedTime())))
}
selected, err := p.Select("Select a workflow run", "", candidates)

View file

@ -543,9 +543,9 @@ func TestViewRun(t *testing.T) {
},
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"},
[]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")
return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021")
})
},
opts: &ViewOptions{
@ -593,9 +593,9 @@ func TestViewRun(t *testing.T) {
},
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"},
[]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")
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", "X sad job"},
@ -646,9 +646,9 @@ func TestViewRun(t *testing.T) {
},
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"},
[]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")
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", "X sad job"},
@ -743,9 +743,9 @@ func TestViewRun(t *testing.T) {
},
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"},
[]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")
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", "X sad job"},
@ -823,7 +823,7 @@ func TestViewRun(t *testing.T) {
},
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"},
[]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
})
@ -876,7 +876,7 @@ func TestViewRun(t *testing.T) {
},
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"},
[]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
})
@ -950,7 +950,7 @@ func TestViewRun(t *testing.T) {
},
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"},
[]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
})
@ -1104,9 +1104,9 @@ func TestViewRun(t *testing.T) {
},
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"},
[]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")
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", "X sad job"},
@ -1155,9 +1155,9 @@ func TestViewRun(t *testing.T) {
},
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"},
[]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")
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", "X sad job"},

View file

@ -272,9 +272,9 @@ func TestWatchRun(t *testing.T) {
httpStubs: successfulRunStubs,
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"* commit1, CI (trunk) Feb 23, 2021", "* commit2, CI (trunk) Feb 23, 2021"},
[]string{"* commit1, CI [trunk] Feb 23, 2021", "* commit2, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "* commit2, CI (trunk) Feb 23, 2021")
return prompter.IndexFor(opts, "* commit2, CI [trunk] Feb 23, 2021")
})
},
wantOut: "\x1b[?1049h\x1b[0;0H\x1b[JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\n* trunk CI · 2\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n ✓ fob the barz\n ✓ barz the fob\n\x1b[?1049l✓ trunk CI · 2\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n ✓ fob the barz\n ✓ barz the fob\n\n✓ Run CI (2) completed with 'success'\n",
@ -290,9 +290,9 @@ func TestWatchRun(t *testing.T) {
httpStubs: failedRunStubs,
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"* commit1, CI (trunk) Feb 23, 2021", "* commit2, CI (trunk) Feb 23, 2021"},
[]string{"* commit1, CI [trunk] Feb 23, 2021", "* commit2, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "* commit2, CI (trunk) Feb 23, 2021")
return prompter.IndexFor(opts, "* commit2, CI [trunk] Feb 23, 2021")
})
},
wantOut: "\x1b[?1049h\x1b[0;0H\x1b[JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\n* trunk CI · 2\nTriggered via push about 59 minutes ago\n\n\x1b[?1049lX trunk CI · 2\nTriggered via push about 59 minutes ago\n\nJOBS\nX sad job in 4m34s (ID 20)\n ✓ barf the quux\n X quux the barf\n\nANNOTATIONS\nX the job is sad\nsad job: blaze.py#420\n\n\nX Run CI (2) completed with 'failure'\n",

View file

@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
# Get the root directory of the repository
rootDir="$(git rev-parse --show-toplevel)"
ghBuildPath="$rootDir/bin/gh"
# Verify an OCI artifact with bundles stored on the GHCR OCI registry
echo "Testing with OCI image ghcr.io/github/artifact-attestations-helm-charts/policy-controller:v0.10.0-github9 with the --bundle-from-oci flag"
if ! $ghBuildPath attestation verify oci://ghcr.io/github/artifact-attestations-helm-charts/policy-controller:v0.10.0-github9 --owner=github --bundle-from-oci; then
echo "Failed to verify oci://ghcr.io/github/artifact-attestations-helm-charts/policy-controller:v0.10.0-github9 with bundles from the GHCR OCI registry"
exit 1
fi