diff --git a/cmd/kosli/attestArtifact.go b/cmd/kosli/attestArtifact.go index 1750bd4ff..128a25b06 100644 --- a/cmd/kosli/attestArtifact.go +++ b/cmd/kosli/attestArtifact.go @@ -27,6 +27,7 @@ type attestArtifactOptions struct { repoName string repoURL string repoProvider string + repoNameExplicit bool } type AttestArtifactPayload struct { @@ -133,6 +134,7 @@ func newAttestArtifactCmd(out io.Writer) *cobra.Command { return ValidateRegistryFlags(cmd, o.fingerprintOptions) }, RunE: func(cmd *cobra.Command, args []string) error { + o.repoNameExplicit = cmd.Flags().Changed("repository") return o.run(args) }, } @@ -210,7 +212,7 @@ func (o *attestArtifactOptions) run(args []string) error { if err != nil { logger.Warn("failed to get git repo info. %s", err.Error()) } - o.payload.GitRepoInfo = mergeGitRepoInfo(o.payload.GitRepoInfo, o.repoID, o.repoName, o.repoURL, o.repoProvider) + o.payload.GitRepoInfo = mergeGitRepoInfo(o.payload.GitRepoInfo, o.repoID, o.repoName, o.repoURL, o.repoProvider, o.repoNameExplicit) o.payload.GitCommit = commitInfo.Sha1 o.payload.GitCommitInfo = &commitInfo.BasicCommitInfo diff --git a/cmd/kosli/attestCustom.go b/cmd/kosli/attestCustom.go index 54ff052c9..0391aeff7 100644 --- a/cmd/kosli/attestCustom.go +++ b/cmd/kosli/attestCustom.go @@ -136,6 +136,7 @@ func newAttestCustomCmd(out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") + o.repoNameExplicit = cmd.Flags().Changed("repository") return o.run(args) }, } diff --git a/cmd/kosli/attestDecision.go b/cmd/kosli/attestDecision.go index 5e6c8f469..dcc9a3a2a 100644 --- a/cmd/kosli/attestDecision.go +++ b/cmd/kosli/attestDecision.go @@ -132,6 +132,7 @@ func newAttestDecisionCmd(out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") + o.repoNameExplicit = cmd.Flags().Changed("repository") return o.run(args) }, } diff --git a/cmd/kosli/attestGeneric.go b/cmd/kosli/attestGeneric.go index 7b19f4bcb..e5dc6bbf5 100644 --- a/cmd/kosli/attestGeneric.go +++ b/cmd/kosli/attestGeneric.go @@ -130,6 +130,7 @@ func newAttestGenericCmd(out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") + o.repoNameExplicit = cmd.Flags().Changed("repository") return o.run(args) }, } diff --git a/cmd/kosli/attestJira.go b/cmd/kosli/attestJira.go index 7f58c552d..8b22b9760 100644 --- a/cmd/kosli/attestJira.go +++ b/cmd/kosli/attestJira.go @@ -246,6 +246,7 @@ func newAttestJiraCmd(out io.Writer) *cobra.Command { }, RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") + o.repoNameExplicit = cmd.Flags().Changed("repository") return o.run(args) }, } diff --git a/cmd/kosli/attestJunit.go b/cmd/kosli/attestJunit.go index 57577ade5..a074c622b 100644 --- a/cmd/kosli/attestJunit.go +++ b/cmd/kosli/attestJunit.go @@ -134,6 +134,7 @@ func newAttestJunitCmd(out io.Writer) *cobra.Command { }, RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") + o.repoNameExplicit = cmd.Flags().Changed("repository") return o.run(args) }, } diff --git a/cmd/kosli/attestOverride.go b/cmd/kosli/attestOverride.go index 158429c7b..55fea5c7c 100644 --- a/cmd/kosli/attestOverride.go +++ b/cmd/kosli/attestOverride.go @@ -104,6 +104,7 @@ func newAttestOverrideCmd(out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") + o.repoNameExplicit = cmd.Flags().Changed("repository") return o.run(args) }, } diff --git a/cmd/kosli/attestPRAzure.go b/cmd/kosli/attestPRAzure.go index c445c8e68..e13de8162 100644 --- a/cmd/kosli/attestPRAzure.go +++ b/cmd/kosli/attestPRAzure.go @@ -147,6 +147,7 @@ func newAttestAzurePRCmd(out io.Writer) *cobra.Command { }, RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") + o.repoNameExplicit = cmd.Flags().Changed("repository") o.retriever = azUtils.NewAzureConfig(azureFlagsValues.Token, azureFlagsValues.OrgUrl, azureFlagsValues.Project, o.repoName) return o.run(args) diff --git a/cmd/kosli/attestPRBitbucket.go b/cmd/kosli/attestPRBitbucket.go index c9f988ab5..e4afb11ae 100644 --- a/cmd/kosli/attestPRBitbucket.go +++ b/cmd/kosli/attestPRBitbucket.go @@ -167,6 +167,7 @@ func newAttestBitbucketPRCmd(out io.Writer) *cobra.Command { }, RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") + o.repoNameExplicit = cmd.Flags().Changed("repository") o.getRetriever().(*bbUtils.Config).Repository = o.repoName return o.run(args) }, diff --git a/cmd/kosli/attestPRGithub.go b/cmd/kosli/attestPRGithub.go index 9e96449d0..aae2fac2a 100644 --- a/cmd/kosli/attestPRGithub.go +++ b/cmd/kosli/attestPRGithub.go @@ -142,6 +142,7 @@ func newAttestGithubPRCmd(out io.Writer) *cobra.Command { }, RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") + o.repoNameExplicit = cmd.Flags().Changed("repository") o.retriever = ghUtils.NewGithubRetrieverFunc(githubFlagsValues.Token, githubFlagsValues.BaseURL, githubFlagsValues.Org, o.repoName, global.Debug) return o.run(args) diff --git a/cmd/kosli/attestPRGitlab.go b/cmd/kosli/attestPRGitlab.go index 08b5f8967..a752638e4 100644 --- a/cmd/kosli/attestPRGitlab.go +++ b/cmd/kosli/attestPRGitlab.go @@ -142,6 +142,10 @@ func newAttestGitlabPRCmd(out io.Writer) *cobra.Command { }, RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") + o.repoNameExplicit = cmd.Flags().Changed("repository") + // GitlabConfig.Repository is the short project name (CI_PROJECT_NAME); + // combined with Org (CI_PROJECT_NAMESPACE) it forms the API ProjectID. + // This is separate from repo_info.name, which uses the full CI_PROJECT_PATH. o.getRetriever().(*gitlabUtils.GitlabConfig).Repository = o.repoName return o.run(args) }, diff --git a/cmd/kosli/attestSnyk.go b/cmd/kosli/attestSnyk.go index 3a83eaaff..6c1bb7afb 100644 --- a/cmd/kosli/attestSnyk.go +++ b/cmd/kosli/attestSnyk.go @@ -146,6 +146,7 @@ func newAttestSnykCmd(out io.Writer) *cobra.Command { }, RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") + o.repoNameExplicit = cmd.Flags().Changed("repository") return o.run(args) }, } diff --git a/cmd/kosli/attestSonar.go b/cmd/kosli/attestSonar.go index 962b76645..edc4d186b 100644 --- a/cmd/kosli/attestSonar.go +++ b/cmd/kosli/attestSonar.go @@ -186,6 +186,7 @@ func newAttestSonarCmd(out io.Writer) *cobra.Command { }, RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") + o.repoNameExplicit = cmd.Flags().Changed("repository") return o.run(args) }, } diff --git a/cmd/kosli/attestation.go b/cmd/kosli/attestation.go index 9a8240231..64d5c2d8a 100644 --- a/cmd/kosli/attestation.go +++ b/cmd/kosli/attestation.go @@ -55,6 +55,7 @@ type CommonAttestationOptions struct { repoURL string repoProvider string repoURLExplicit bool + repoNameExplicit bool } func (o *CommonAttestationOptions) run(args []string, payload *CommonAttestationPayload) error { @@ -98,7 +99,7 @@ func (o *CommonAttestationOptions) run(args []string, payload *CommonAttestation if err := validateRepoFlags(o.repoURL, o.repoProvider, o.repoURLExplicit); err != nil { return err } - payload.GitRepoInfo = mergeGitRepoInfo(payload.GitRepoInfo, o.repoID, o.repoName, o.repoURL, o.repoProvider) + payload.GitRepoInfo = mergeGitRepoInfo(payload.GitRepoInfo, o.repoID, o.repoName, o.repoURL, o.repoProvider, o.repoNameExplicit) payload.UserData, err = LoadJsonData(o.userDataFilePath) if err != nil { @@ -119,14 +120,18 @@ func (o *CommonAttestationOptions) run(args []string, payload *CommonAttestation // mergeGitRepoInfo applies flag overrides onto base (which may be nil) and // returns nil if ID, Name, or URL is still empty after merging, so that the // field is omitted from the JSON payload. -func mergeGitRepoInfo(base *gitview.GitRepoInfo, repoID, repoName, repoURL, repoProvider string) *gitview.GitRepoInfo { +// +// --repository only overrides the CI-detected name when set explicitly (or when +// base has none), so its short default doesn't clobber the fuller CI value +// (e.g. GitLab's CI_PROJECT_PATH). +func mergeGitRepoInfo(base *gitview.GitRepoInfo, repoID, repoName, repoURL, repoProvider string, repoNameExplicit bool) *gitview.GitRepoInfo { if base == nil { base = &gitview.GitRepoInfo{} } if repoID != "" { base.ID = repoID } - if repoName != "" { + if repoName != "" && (repoNameExplicit || base.Name == "") { base.Name = repoName } if repoURL != "" { diff --git a/cmd/kosli/attestation_test.go b/cmd/kosli/attestation_test.go index c48810f90..21a0400e2 100644 --- a/cmd/kosli/attestation_test.go +++ b/cmd/kosli/attestation_test.go @@ -9,17 +9,18 @@ import ( func TestMergeGitRepoInfo(t *testing.T) { tests := []struct { - name string - base *gitview.GitRepoInfo - repoID string - repoName string - repoURL string - repoProvider string - wantNil bool - wantID string - wantName string - wantURL string - wantProvider string + name string + base *gitview.GitRepoInfo + repoID string + repoName string + repoURL string + repoProvider string + repoNameExplicit bool + wantNil bool + wantID string + wantName string + wantURL string + wantProvider string }{ { name: "nil when both ID and Name are empty", @@ -64,14 +65,59 @@ func TestMergeGitRepoInfo(t *testing.T) { wantProvider: "github", }, { - name: "flag values override base values", - base: &gitview.GitRepoInfo{ID: "base-id", Name: "base-name", URL: "https://base.example.com"}, - repoID: "override-id", - repoName: "override-name", - wantNil: false, - wantID: "override-id", - wantName: "override-name", - wantURL: "https://base.example.com", + name: "explicit flag values override base values", + base: &gitview.GitRepoInfo{ID: "base-id", Name: "base-name", URL: "https://base.example.com"}, + repoID: "override-id", + repoName: "override-name", + repoNameExplicit: true, + wantNil: false, + wantID: "override-id", + wantName: "override-name", + wantURL: "https://base.example.com", + }, + { + name: "fully-populated base is returned unchanged when no flags are passed", + base: &gitview.GitRepoInfo{ID: "53419335", Name: "cyber-dojo/creator", URL: "https://gitlab.com/cyber-dojo/creator", Provider: "gitlab"}, + wantNil: false, + wantID: "53419335", + wantName: "cyber-dojo/creator", + wantURL: "https://gitlab.com/cyber-dojo/creator", + wantProvider: "gitlab", + }, + { + name: "CI-detected full-path name is preserved when --repository is not set explicitly", + base: &gitview.GitRepoInfo{ID: "53419335", Name: "cyber-dojo/creator", URL: "https://gitlab.com/cyber-dojo/creator"}, + repoID: "53419335", + repoName: "creator", // short CI default from --repository (e.g. GitLab's CI_PROJECT_NAME) + repoURL: "https://gitlab.com/cyber-dojo/creator", + repoNameExplicit: false, + wantNil: false, + wantID: "53419335", + wantName: "cyber-dojo/creator", // full CI_PROJECT_PATH preserved + wantURL: "https://gitlab.com/cyber-dojo/creator", + }, + { + name: "explicit --repository overrides CI-detected full-path name", + base: &gitview.GitRepoInfo{ID: "53419335", Name: "cyber-dojo/creator", URL: "https://gitlab.com/cyber-dojo/creator"}, + repoID: "53419335", + repoName: "my/custom-name", + repoURL: "https://gitlab.com/cyber-dojo/creator", + repoNameExplicit: true, + wantNil: false, + wantID: "53419335", + wantName: "my/custom-name", + wantURL: "https://gitlab.com/cyber-dojo/creator", + }, + { + name: "flag name applied when base has no name even if not explicit", + repoID: "flag-id", + repoName: "flag-name", + repoURL: "https://github.com/org/repo", + repoNameExplicit: false, + wantNil: false, + wantID: "flag-id", + wantName: "flag-name", + wantURL: "https://github.com/org/repo", }, { name: "nil when base has ID but no Name and no flags", @@ -103,7 +149,7 @@ func TestMergeGitRepoInfo(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := mergeGitRepoInfo(tt.base, tt.repoID, tt.repoName, tt.repoURL, tt.repoProvider) + result := mergeGitRepoInfo(tt.base, tt.repoID, tt.repoName, tt.repoURL, tt.repoProvider, tt.repoNameExplicit) if tt.wantNil { assert.Nil(t, result) return diff --git a/cmd/kosli/beginTrail.go b/cmd/kosli/beginTrail.go index 478e2997e..86ac9fe6a 100644 --- a/cmd/kosli/beginTrail.go +++ b/cmd/kosli/beginTrail.go @@ -49,6 +49,7 @@ type beginTrailOptions struct { repoName string repoURL string repoProvider string + repoNameExplicit bool } type TrailPayload struct { @@ -83,6 +84,7 @@ func newBeginTrailCmd(out io.Writer) *cobra.Command { return validateRepoFlags(o.repoURL, o.repoProvider, cmd.Flags().Changed("repo-url")) }, RunE: func(cmd *cobra.Command, args []string) error { + o.repoNameExplicit = cmd.Flags().Changed("repository") return o.run(args) }, } @@ -141,7 +143,7 @@ func (o *beginTrailOptions) run(args []string) error { if err != nil { logger.Warn("failed to get git repo info. %s", err.Error()) } - o.payload.GitRepoInfo = mergeGitRepoInfo(base, o.repoID, o.repoName, o.repoURL, o.repoProvider) + o.payload.GitRepoInfo = mergeGitRepoInfo(base, o.repoID, o.repoName, o.repoURL, o.repoProvider, o.repoNameExplicit) // process external urls o.payload.ExternalURLs, err = processExternalURLs(o.externalURLs, o.externalFingerprints) diff --git a/cmd/kosli/repoInfoWiring_test.go b/cmd/kosli/repoInfoWiring_test.go new file mode 100644 index 000000000..5727b00d1 --- /dev/null +++ b/cmd/kosli/repoInfoWiring_test.go @@ -0,0 +1,114 @@ +package main + +import ( + "os" + "testing" + + "github.com/stretchr/testify/suite" +) + +// RepoInfoWiringTestSuite guards that the CI-detected repo name flows into +// repo_info.name and that an explicit --repository overrides it. It covers the +// three distinct run() paths that build repo_info: the shared +// CommonAttestationOptions.run() (via `attest generic`), `begin trail`, and +// `attest artifact`. +// +// It does NOT cover the `attest pr *` commands: those call the live GitHub / +// GitLab API to gather PR evidence before the dry-run guard, so they can't run +// here without provider tokens (see attestPRGitlab_test.go, which is token-gated). +// +// The original production bug (a short CI *default* clobbering the fuller +// CI-detected name) can't be reproduced under KOSLI_TESTS, which zeroes flag +// defaults; that logic is covered directly by TestMergeGitRepoInfo. This suite +// instead guards the command wiring: an explicit --repository must reach +// repo_info.name. Because the CI base name here is non-empty, a missing +// `repoNameExplicit` assignment would leave the base name in place and fail the +// explicit-override cases. +type RepoInfoWiringTestSuite struct { + suite.Suite + defaultKosliArguments string + origEnv map[string]*string +} + +func (suite *RepoInfoWiringTestSuite) SetupSuite() { + // Simulate GitHub Actions so getGitRepoInfoFromEnvironment() returns a base + // with the full-path name "kosli-dev/cli". Save originals for restoration. + ciEnv := map[string]string{ + "GITHUB_RUN_NUMBER": "1", + "GITHUB_SERVER_URL": "https://github.com", + "GITHUB_REPOSITORY": "kosli-dev/cli", + "GITHUB_REPOSITORY_ID": "123456", + } + suite.origEnv = map[string]*string{} + for k, v := range ciEnv { + if orig, ok := os.LookupEnv(k); ok { + o := orig + suite.origEnv[k] = &o + } else { + suite.origEnv[k] = nil + } + suite.Require().NoError(os.Setenv(k, v)) + } +} + +func (suite *RepoInfoWiringTestSuite) TearDownSuite() { + for k, v := range suite.origEnv { + if v == nil { + suite.Require().NoError(os.Unsetenv(k)) + } else { + suite.Require().NoError(os.Setenv(k, *v)) + } + } +} + +func (suite *RepoInfoWiringTestSuite) SetupTest() { + global = &GlobalOpts{ + ApiToken: "DRY_RUN", + Org: "test-org", + Host: "http://localhost:8001", + DryRun: true, + } + // --repo-root ../.. points git resolution at the real repo root. + suite.defaultKosliArguments = " --repo-root ../.. --dry-run --host http://localhost:8001 --org test-org --api-token DRY_RUN" +} + +func (suite *RepoInfoWiringTestSuite) TestRepoInfoNameWiring() { + const fingerprint = "7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9" + tests := []cmdTestCase{ + { + name: "attest generic: CI-detected full-path name is used by default", + cmd: "attest generic --fingerprint " + fingerprint + " --name foo --flow f --trail t" + suite.defaultKosliArguments, + goldenRegex: `"name": "kosli-dev/cli"`, + }, + { + name: "attest generic: explicit --repository overrides the CI-detected name", + cmd: "attest generic --fingerprint " + fingerprint + " --name foo --flow f --trail t --repository my/explicit-repo" + suite.defaultKosliArguments, + goldenRegex: `"name": "my/explicit-repo"`, + }, + { + name: "begin trail: CI-detected full-path name is used by default", + cmd: "begin trail t --flow f" + suite.defaultKosliArguments, + goldenRegex: `"name": "kosli-dev/cli"`, + }, + { + name: "begin trail: explicit --repository overrides the CI-detected name", + cmd: "begin trail t --flow f --repository my/explicit-repo" + suite.defaultKosliArguments, + goldenRegex: `"name": "my/explicit-repo"`, + }, + { + name: "attest artifact: CI-detected full-path name is used by default", + cmd: "attest artifact foo --fingerprint " + fingerprint + " --name n --flow f --trail t --commit HEAD --build-url b --commit-url c" + suite.defaultKosliArguments, + goldenRegex: `"name": "kosli-dev/cli"`, + }, + { + name: "attest artifact: explicit --repository overrides the CI-detected name", + cmd: "attest artifact foo --fingerprint " + fingerprint + " --name n --flow f --trail t --commit HEAD --build-url b --commit-url c --repository my/explicit-repo" + suite.defaultKosliArguments, + goldenRegex: `"name": "my/explicit-repo"`, + }, + } + runTestCmd(suite.T(), tests) +} + +func TestRepoInfoWiringTestSuite(t *testing.T) { + suite.Run(t, new(RepoInfoWiringTestSuite)) +}