diff --git a/cmd/kosli/listArtifacts.go b/cmd/kosli/listArtifacts.go index e74d77c3a..cb898ce23 100644 --- a/cmd/kosli/listArtifacts.go +++ b/cmd/kosli/listArtifacts.go @@ -11,7 +11,7 @@ import ( "github.com/spf13/cobra" ) -const listArtifactsShortDesc = `List artifacts in a flow. ` +const listArtifactsShortDesc = `List artifacts in a flow or repo. ` const listArtifactsLongDesc = listArtifactsShortDesc + `The results are paginated and ordered from latest to oldest. By default, the page limit is 15 artifacts per page. @@ -23,6 +23,12 @@ kosli list artifacts \ --api-token yourAPIToken \ --org yourOrgName +# list the last 15 artifacts for a repo: +kosli list artifacts \ + --repo yourRepoName \ + --api-token yourAPIToken \ + --org yourOrgName + # list the last 30 artifacts for a flow: kosli list artifacts \ --flow yourFlowName \ @@ -42,8 +48,11 @@ kosli list artifacts \ type listArtifactsOptions struct { listOptions flowName string + repoName string } +var filter string + func newListArtifactsCmd(out io.Writer) *cobra.Command { o := new(listArtifactsOptions) cmd := &cobra.Command{ @@ -57,6 +66,10 @@ func newListArtifactsCmd(out io.Writer) *cobra.Command { if err != nil { return ErrorBeforePrintingUsage(cmd, err.Error()) } + err = MuXRequiredFlags(cmd, []string{"flow", "repo"}, true) + if err != nil { + return err + } return o.validate(cmd) }, RunE: func(cmd *cobra.Command, args []string) error { @@ -65,19 +78,23 @@ func newListArtifactsCmd(out io.Writer) *cobra.Command { } cmd.Flags().StringVarP(&o.flowName, "flow", "f", "", flowNameFlag) + cmd.Flags().StringVar(&o.repoName, "repo", "", repoNameFlag) addListFlags(cmd, &o.listOptions) - err := RequireFlags(cmd, []string{"flow"}) - if err != nil { - logger.Error("failed to configure required flags: %v", err) - } - return cmd } func (o *listArtifactsOptions) run(out io.Writer) error { - url := fmt.Sprintf("%s/api/v2/artifacts/%s/%s?page=%d&per_page=%d", - global.Host, global.Org, o.flowName, o.pageNumber, o.pageLimit) + var url string + if o.flowName != "" { + filter = "flow" + url = fmt.Sprintf("%s/api/v2/artifacts/%s/%s?page=%d&per_page=%d", + global.Host, global.Org, o.flowName, o.pageNumber, o.pageLimit) + } else { + filter = "repo" + url = fmt.Sprintf("%s/api/v2/repos/%s/artifacts/%s?page=%d&per_page=%d", + global.Host, global.Org, o.repoName, o.pageNumber, o.pageLimit) + } reqParams := &requests.RequestParams{ Method: http.MethodGet, @@ -97,7 +114,15 @@ func (o *listArtifactsOptions) run(out io.Writer) error { } func printArtifactsListAsTable(raw string, out io.Writer, page int) error { - var artifacts []map[string]interface{} + if filter == "flow" { + return printArtifactsListForFlow(raw, out, page) + } else { + return printArtifactsListForRepo(raw, out, page) + } +} + +func printArtifactsListForFlow(raw string, out io.Writer, page int) error { + var artifacts []map[string]any err := json.Unmarshal([]byte(raw), &artifacts) if err != nil { return err @@ -115,7 +140,6 @@ func printArtifactsListAsTable(raw string, out io.Writer, page int) error { header := []string{"COMMIT", "ARTIFACT", "STATE", "CREATED_AT"} rows := []string{} for _, artifact := range artifacts { - gitCommit := artifact["git_commit"].(string)[:7] artifactName := artifact["filename"].(string) @@ -137,3 +161,69 @@ func printArtifactsListAsTable(raw string, out io.Writer, page int) error { return nil } + +func printArtifactsListForRepo(raw string, out io.Writer, page int) error { + var response map[string]any + err := json.Unmarshal([]byte(raw), &response) + if err != nil { + return err + } + + embedded, ok := response["_embedded"].(map[string]any) + if !ok { + return fmt.Errorf("artifacts not found in response") + } + artifactsRaw, ok := embedded["artifacts"] + if !ok { + return fmt.Errorf("artifacts not found in response") + } + artifactsSlice, ok := artifactsRaw.([]any) + if !ok { + return fmt.Errorf("artifacts not found in response") + } + artifacts := make([]map[string]any, len(artifactsSlice)) + for i, v := range artifactsSlice { + artifact, ok := v.(map[string]any) + if !ok { + return fmt.Errorf("invalid artifact format in response") + } + artifacts[i] = artifact + } + if len(artifacts) == 0 { + msg := "No artifacts were found" + if page != 1 { + msg = fmt.Sprintf("%s at page number %d", msg, page) + } + logger.Info(msg + ".") + return nil + } + + header := []string{"COMMIT", "ARTIFACT", "STATE", "CREATED_AT"} + rows := []string{} + for _, artifact := range artifacts { + gitCommit := artifact["commit"].(string)[:7] + artifactName := artifact["name"].(string) + + artifactDigest := artifact["fingerprint"].(string) + compliant := artifact["compliant_in_trail"].(bool) + artifactState := "COMPLIANT" + if !compliant { + artifactState = "NON-COMPLIANT" + } + createdAt, err := formattedTimestamp(artifact["created_at"], true) + if err != nil { + return err + } + + row := fmt.Sprintf("%s\tName: %s\t%s\t%s", gitCommit, artifactName, artifactState, createdAt) + rows = append(rows, row) + row = fmt.Sprintf("\tFingerprint: %s\t\t", artifactDigest) + rows = append(rows, row) + rows = append(rows, "\t\t\t") + + } + tabFormattedPrint(out, header, rows) + + return nil + +} diff --git a/cmd/kosli/listArtifacts_test.go b/cmd/kosli/listArtifacts_test.go index 2553bfaa2..2e5b642f3 100644 --- a/cmd/kosli/listArtifacts_test.go +++ b/cmd/kosli/listArtifacts_test.go @@ -19,6 +19,7 @@ type ListArtifactsCommandTestSuite struct { artifactName string artifactPath string fingerprint string + repoName string } func (suite *ListArtifactsCommandTestSuite) SetupTest() { @@ -40,16 +41,32 @@ func (suite *ListArtifactsCommandTestSuite) SetupTest() { var err error suite.fingerprint, err = GetSha256Digest(suite.artifactPath, fingerprintOptions, logger) require.NoError(suite.Suite.T(), err) - CreateArtifact(suite.flowName2, suite.fingerprint, suite.artifactName, suite.Suite.T()) + suite.repoName = "kosli/dev" + SetEnvVars(map[string]string{ + "GITHUB_RUN_NUMBER": "1234", + "GITHUB_SERVER_URL": "https://github.com", + "GITHUB_REPOSITORY": suite.repoName, + "GITHUB_REPOSITORY_ID": "1234567890", + }, suite.Suite.T()) + CreateArtifactOnTrail(suite.flowName2, "trail-1", "backend", suite.fingerprint, suite.artifactName, suite.Suite.T()) +} + +func (suite *ListArtifactsCommandTestSuite) TearDownTest() { + UnSetEnvVars(map[string]string{ + "GITHUB_RUN_NUMBER": "", + "GITHUB_SERVER_URL": "", + "GITHUB_REPOSITORY": "", + "GITHUB_REPOSITORY_ID": "", + }, suite.Suite.T()) } func (suite *ListArtifactsCommandTestSuite) TestListArtifactsCmd() { tests := []cmdTestCase{ { wantError: true, - name: "missing flow flag causes an error", + name: "missing both flow and repo flags causes an error", cmd: fmt.Sprintf(`list artifacts %s`, suite.defaultKosliArguments), - golden: "Error: required flag(s) \"flow\" not set\n", + golden: "Error: at least one of --flow, --repo is required\n", }, { wantError: true, @@ -57,6 +74,12 @@ func (suite *ListArtifactsCommandTestSuite) TestListArtifactsCmd() { cmd: fmt.Sprintf(`list artifacts --flow non-existing %s`, suite.defaultKosliArguments), goldenRegex: "^Error: Flow named 'non-existing' does not exist for organization 'docs-cmd-test-user'", }, + { + wantError: true, + name: "non-existing repo causes an error", + cmd: fmt.Sprintf(`list artifacts --repo non-existing %s`, suite.defaultKosliArguments), + goldenRegex: "^Error: Repo 'non-existing' not found", + }, // TODO: the correct error is overwritten by the hack flag value check in root.go { wantError: true, @@ -85,6 +108,11 @@ func (suite *ListArtifactsCommandTestSuite) TestListArtifactsCmd() { cmd: fmt.Sprintf(`list artifacts --flow %s %s`, suite.flowName2, suite.defaultKosliArguments), goldenFile: "output/list/list-artifacts.txt", }, + { + name: "listing artifacts on a repo works", + cmd: fmt.Sprintf(`list artifacts --repo %s %s`, suite.repoName, suite.defaultKosliArguments), + goldenFile: "output/list/list-artifacts.txt", + }, } runTestCmd(suite.Suite.T(), tests) diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 083e48122..7bdaf9908 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -258,6 +258,7 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, getAttestationTrailNameFlag = "[conditional] The name of the Kosli trail for the attestation. Cannot be used together with --fingerprint or --attestation-id." getAttestationFlowNameFlag = "[conditional] The name of the Kosli flow for the attestation. Required if ATTESTATION-NAME provided. Cannot be used together with --attestation-id." attestationIDFlag = "[conditional] The unique identifier of the attestation to retrieve. Cannot be used together with ATTESTATION-NAME." + repoNameFlag = "The name of a git repo as it is registered in Kosli. e.g kosli-dev/cli" ) var global *GlobalOpts