diff --git a/cmd/kosli/attestArtifact_test.go b/cmd/kosli/attestArtifact_test.go index f5924d9bb..3c09cefda 100644 --- a/cmd/kosli/attestArtifact_test.go +++ b/cmd/kosli/attestArtifact_test.go @@ -121,7 +121,7 @@ func (suite *AttestArtifactCommandTestSuite) TestAttestArtifactCmd() { wantError: true, name: "fails when --repo-provider is not an allowed value", cmd: fmt.Sprintf("attest artifact testdata/file1 --fingerprint 7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9 --name cli --commit HEAD --build-url http://www.example.com --commit-url http://www.example.com --repo-provider jenkins %s", suite.defaultKosliArguments), - golden: "Error: --repo-provider 'jenkins' is not allowed. Must be one of: github, gitlab, bitbucket, bitbucket_cloud, bitbucket_dc, azure-devops, azure_devops_services, azure_devops_server\n", + golden: "Error: --repo-provider 'jenkins' is not allowed. Must be one of: github, gitlab, bitbucket, bitbucket_cloud, bitbucket_dc, azure-devops, azure_devops_services, azure_devops_server, git, subversion\n", }, { name: "can attest with all repo flags", diff --git a/cmd/kosli/attestGeneric_test.go b/cmd/kosli/attestGeneric_test.go index 19065287f..8daf389c9 100644 --- a/cmd/kosli/attestGeneric_test.go +++ b/cmd/kosli/attestGeneric_test.go @@ -183,7 +183,7 @@ func (suite *AttestGenericCommandTestSuite) TestAttestGenericCmd() { wantError: true, name: "fails when --repo-provider is not an allowed value", cmd: fmt.Sprintf("attest generic --name foo --fingerprint 7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9 --repo-provider jenkins %s", suite.defaultKosliArguments), - golden: "Error: --repo-provider 'jenkins' is not allowed. Must be one of: github, gitlab, bitbucket, bitbucket_cloud, bitbucket_dc, azure-devops, azure_devops_services, azure_devops_server\n", + golden: "Error: --repo-provider 'jenkins' is not allowed. Must be one of: github, gitlab, bitbucket, bitbucket_cloud, bitbucket_dc, azure-devops, azure_devops_services, azure_devops_server, git, subversion\n", }, { name: "can attest with all repo flags", diff --git a/cmd/kosli/attestOverride_test.go b/cmd/kosli/attestOverride_test.go index f4b0e0da6..7148e3f1a 100644 --- a/cmd/kosli/attestOverride_test.go +++ b/cmd/kosli/attestOverride_test.go @@ -90,7 +90,7 @@ func (suite *AttestOverrideCommandTestSuite) TestAttestOverrideCmd() { wantError: true, name: "fails when --repo-provider is not an allowed value", cmd: fmt.Sprintf("attest override --name foo --fingerprint 7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9 --reason r --original-attestation-type generic --new-compliance-status=true --repo-provider jenkins %s", suite.defaultKosliArguments), - golden: "Error: --repo-provider 'jenkins' is not allowed. Must be one of: github, gitlab, bitbucket, bitbucket_cloud, bitbucket_dc, azure-devops, azure_devops_services, azure_devops_server\n", + golden: "Error: --repo-provider 'jenkins' is not allowed. Must be one of: github, gitlab, bitbucket, bitbucket_cloud, bitbucket_dc, azure-devops, azure_devops_services, azure_devops_server, git, subversion\n", }, { name: "can override an attestation against a trail (compliant)", diff --git a/cmd/kosli/attestPRAzure_test.go b/cmd/kosli/attestPRAzure_test.go index 4c13176c5..eb3c722b7 100644 --- a/cmd/kosli/attestPRAzure_test.go +++ b/cmd/kosli/attestPRAzure_test.go @@ -129,7 +129,7 @@ func (suite *AttestAzurePRCommandTestSuite) TestAttestAzurePRCmd() { wantError: true, name: "15 fails when --repo-provider is not an allowed value", cmd: fmt.Sprintf("attest pullrequest azure --name foo --commit HEAD --azure-token fake --azure-org-url https://dev.azure.com/myorg --project myproject --repository myrepo --repo-provider jenkins %s", suite.defaultKosliArguments), - golden: "Error: --repo-provider 'jenkins' is not allowed. Must be one of: github, gitlab, bitbucket, bitbucket_cloud, bitbucket_dc, azure-devops, azure_devops_services, azure_devops_server\n", + golden: "Error: --repo-provider 'jenkins' is not allowed. Must be one of: github, gitlab, bitbucket, bitbucket_cloud, bitbucket_dc, azure-devops, azure_devops_services, azure_devops_server, git, subversion\n", }, { wantError: true, diff --git a/cmd/kosli/attestPRBitbucket_test.go b/cmd/kosli/attestPRBitbucket_test.go index 19421e734..962eb2b84 100644 --- a/cmd/kosli/attestPRBitbucket_test.go +++ b/cmd/kosli/attestPRBitbucket_test.go @@ -168,7 +168,7 @@ func (suite *AttestBitbucketPRCommandTestSuite) TestAttestBitbucketPRCmd() { wantError: true, name: "18 fails when --repo-provider is not an allowed value", cmd: fmt.Sprintf("attest pullrequest bitbucket --name foo --commit %s --bitbucket-access-token fake --bitbucket-workspace myworkspace --repository myrepo --repo-provider jenkins %s", suite.commitWithPR, suite.defaultKosliArguments), - golden: "Error: --repo-provider 'jenkins' is not allowed. Must be one of: github, gitlab, bitbucket, bitbucket_cloud, bitbucket_dc, azure-devops, azure_devops_services, azure_devops_server\n", + golden: "Error: --repo-provider 'jenkins' is not allowed. Must be one of: github, gitlab, bitbucket, bitbucket_cloud, bitbucket_dc, azure-devops, azure_devops_services, azure_devops_server, git, subversion\n", }, { wantError: true, diff --git a/cmd/kosli/attestPRGithub_test.go b/cmd/kosli/attestPRGithub_test.go index e16b97271..93d1e5022 100644 --- a/cmd/kosli/attestPRGithub_test.go +++ b/cmd/kosli/attestPRGithub_test.go @@ -175,7 +175,7 @@ func (suite *AttestGithubPRCommandTestSuite) TestAttestGithubPRCmd() { wantError: true, name: "19 fails when --repo-provider is not an allowed value", cmd: fmt.Sprintf("attest pullrequest github --name foo --commit %s --github-org myorg --repository myrepo --repo-provider jenkins %s", suite.commitWithPR, suite.defaultKosliArguments), - golden: "Error: --repo-provider 'jenkins' is not allowed. Must be one of: github, gitlab, bitbucket, bitbucket_cloud, bitbucket_dc, azure-devops, azure_devops_services, azure_devops_server\n", + golden: "Error: --repo-provider 'jenkins' is not allowed. Must be one of: github, gitlab, bitbucket, bitbucket_cloud, bitbucket_dc, azure-devops, azure_devops_services, azure_devops_server, git, subversion\n", }, { wantError: true, diff --git a/cmd/kosli/attestPRGitlab_test.go b/cmd/kosli/attestPRGitlab_test.go index 5b39d6e1b..1a10e1798 100644 --- a/cmd/kosli/attestPRGitlab_test.go +++ b/cmd/kosli/attestPRGitlab_test.go @@ -161,7 +161,7 @@ func (suite *AttestGitlabPRCommandTestSuite) TestAttestGitlabPRCmd() { wantError: true, name: "17 fails when --repo-provider is not an allowed value", cmd: fmt.Sprintf("attest pullrequest gitlab --name foo --commit %s --gitlab-token fake --gitlab-org myorg --repository myrepo --repo-provider jenkins %s", suite.commitWithPR, suite.defaultKosliArguments), - golden: "Error: --repo-provider 'jenkins' is not allowed. Must be one of: github, gitlab, bitbucket, bitbucket_cloud, bitbucket_dc, azure-devops, azure_devops_services, azure_devops_server\n", + golden: "Error: --repo-provider 'jenkins' is not allowed. Must be one of: github, gitlab, bitbucket, bitbucket_cloud, bitbucket_dc, azure-devops, azure_devops_services, azure_devops_server, git, subversion\n", }, { wantError: true, diff --git a/cmd/kosli/attestation.go b/cmd/kosli/attestation.go index 95afc3102..22a1d45ee 100644 --- a/cmd/kosli/attestation.go +++ b/cmd/kosli/attestation.go @@ -123,7 +123,9 @@ func (o *CommonAttestationOptions) run(args []string, payload *CommonAttestation // // --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). +// An explicit --repository points at a +// (possibly different) repo, so any CI-detected NamespacePath/AdditionalInfo +// is cleared along with it rather than left describing the old one. func mergeGitRepoInfo(base *gitview.GitRepoInfo, repoID, repoName, repoURL, repoProvider string, repoNameExplicit bool) *gitview.GitRepoInfo { if base == nil { base = &gitview.GitRepoInfo{} @@ -131,7 +133,11 @@ func mergeGitRepoInfo(base *gitview.GitRepoInfo, repoID, repoName, repoURL, repo if repoID != "" { base.ID = repoID } - if repoName != "" && (repoNameExplicit || base.Name == "") { + if repoName != "" && repoNameExplicit { + base.Name = repoName + base.NamespacePath = nil + base.AdditionalInfo = nil + } else if repoName != "" && base.Name == "" { base.Name = repoName } if repoURL != "" { @@ -147,11 +153,17 @@ func mergeGitRepoInfo(base *gitview.GitRepoInfo, repoID, repoName, repoURL, repo return base } -var allowedRepoProviders = map[string]struct{}{ - "github": {}, "gitlab": {}, - "bitbucket": {}, "bitbucket_cloud": {}, "bitbucket_dc": {}, - "azure-devops": {}, "azure_devops_services": {}, "azure_devops_server": {}, -} +// repoProviderList is the single source of truth for the --repo-provider +// allowed values +const repoProviderList = "github, gitlab, bitbucket, bitbucket_cloud, bitbucket_dc, azure-devops, azure_devops_services, azure_devops_server, git, subversion" + +var allowedRepoProviders = func() map[string]struct{} { + m := make(map[string]struct{}) + for _, provider := range strings.Split(repoProviderList, ", ") { + m[provider] = struct{}{} + } + return m +}() func validateRepoFlags(repoURL, repoProvider string, validateURL bool) error { if repoURL != "" && validateURL { @@ -162,8 +174,7 @@ func validateRepoFlags(repoURL, repoProvider string, validateURL bool) error { } if repoProvider != "" { if _, ok := allowedRepoProviders[repoProvider]; !ok { - return fmt.Errorf("--repo-provider '%s' is not allowed. Must be one of: github, gitlab, "+ - "bitbucket, bitbucket_cloud, bitbucket_dc, azure-devops, azure_devops_services, azure_devops_server", repoProvider) + return fmt.Errorf("--repo-provider '%s' is not allowed. Must be one of: %s", repoProvider, repoProviderList) } } return nil @@ -294,7 +305,10 @@ func getGitRepoInfoFromGitLab() *gitview.GitRepoInfo { func getGitRepoInfoFromBitbucket() *gitview.GitRepoInfo { repoFullName := os.Getenv("BITBUCKET_REPO_FULL_NAME") - workspace, _, _ := strings.Cut(repoFullName, "/") + workspace, _, hasWorkspace := strings.Cut(repoFullName, "/") + if !hasWorkspace { + workspace = "" + } var additionalInfo map[string]interface{} if projectKey := os.Getenv("BITBUCKET_PROJECT_KEY"); projectKey != "" { @@ -305,8 +319,7 @@ func getGitRepoInfoFromBitbucket() *gitview.GitRepoInfo { URL: os.Getenv("BITBUCKET_GIT_HTTP_ORIGIN"), Name: repoFullName, ID: os.Getenv("BITBUCKET_REPO_UUID"), - // Bitbucket Pipelines (the only CI this WhichCI() branch detects, via - // BITBUCKET_BUILD_NUMBER) exists for Bitbucket Cloud only, so this is + // Bitbucket Pipelines exists for Bitbucket Cloud only, so this is // a known fact rather than a heuristic. Self-hosted Data Center users // run a different CI and must pass --repo-provider bitbucket_dc themselves. Provider: "bitbucket_cloud", @@ -319,12 +332,55 @@ func getGitRepoInfoFromBitbucket() *gitview.GitRepoInfo { } func getGitRepoInfoFromAzureDevops() *gitview.GitRepoInfo { - return &gitview.GitRepoInfo{ - URL: os.Getenv("BUILD_REPOSITORY_URI"), - Name: azureFullPathRepoName(), - ID: os.Getenv("BUILD_REPOSITORY_ID"), - Provider: azureDevopsProvider(), - NamespacePath: azureNamespacePath(), + buildRepositoryProvider := os.Getenv("BUILD_REPOSITORY_PROVIDER") + repoName := os.Getenv("BUILD_REPOSITORY_NAME") + teamProject := os.Getenv("SYSTEM_TEAMPROJECT") + collectionURI := parseAzureCollectionURI() + + info := &gitview.GitRepoInfo{ + URL: os.Getenv("BUILD_REPOSITORY_URI"), + Name: repoName, + ID: os.Getenv("BUILD_REPOSITORY_ID"), + Provider: azureRepoProvider(buildRepositoryProvider, collectionURI), + } + + // Only genuine Azure Repos Git repos (TfsGit) get a composed path; other + // sources keep the bare BUILD_REPOSITORY_NAME. Empty ⇒ older agent, assume TfsGit. + // repoName == "" is left bare too (rather than composing a trailing-slash + // "collection/project/" name) so mergeGitRepoInfo's empty-Name guard applies. + switch buildRepositoryProvider { + case "TfsGit", "": + if repoName != "" && collectionURI.valid && collectionURI.collection != "" && teamProject != "" { + info.NamespacePath = []string{collectionURI.collection, teamProject} + info.Name = strings.Join(append(info.NamespacePath, repoName), "/") + } + } + + return info +} + +// azureRepoProvider maps BUILD_REPOSITORY_PROVIDER to a Kosli repo provider. +// Anything not recognised as an external source (including TFVC and unmapped +// values) is treated as Azure DevOps-hosted and refined via collectionURI. +// Shared with the --repo-provider flag default so the two can't disagree. +func azureRepoProvider(buildRepositoryProvider string, collectionURI azureCollectionURI) string { + switch buildRepositoryProvider { + case "GitHub", "GitHubEnterprise": + return "github" + case "Bitbucket": + return "bitbucket_cloud" + case "Git": + return "git" + case "Svn": + return "subversion" + default: + if !collectionURI.valid { + return "azure-devops" + } + if collectionURI.isServices { + return "azure_devops_services" + } + return "azure_devops_server" } } @@ -337,80 +393,42 @@ func splitNonEmpty(s string) []string { return strings.Split(s, "/") } -// azureDevopsProvider refines the coarse "azure-devops" provider to -// azure_devops_services or azure_devops_server based on SYSTEM_COLLECTIONURI's -// host (dev.azure.com / *.visualstudio.com are cloud-hosted Services; -// anything else is on-prem Server). Falls back to the coarse value when the -// collection URI is missing or has no host, so the server's own URL-based -// derivation fallback still applies. -func azureDevopsProvider() string { - parsed, err := url.Parse(os.Getenv("SYSTEM_COLLECTIONURI")) - if err != nil || parsed.Host == "" { - return "azure-devops" - } - host := strings.ToLower(parsed.Host) - if host == "dev.azure.com" || strings.HasSuffix(host, ".visualstudio.com") { - return "azure_devops_services" - } - return "azure_devops_server" -} - -// azureFullPathRepoName composes the full namespace path for an Azure DevOps -// repo (`Collection/Project/repo` on-prem, `Org/Project/repo` in Services) -// from SYSTEM_COLLECTIONURI and SYSTEM_TEAMPROJECT, since BUILD_REPOSITORY_NAME -// alone is just the bare repo name. Falls back to the bare name if either -// piece is unavailable, so unupgraded setups degrade gracefully. -func azureFullPathRepoName() string { - repoName := os.Getenv("BUILD_REPOSITORY_NAME") - collection, teamProject, ok := azureCollectionAndProject() - if !ok { - return repoName - } - return fmt.Sprintf("%s/%s/%s", collection, teamProject, repoName) -} - -// azureNamespacePath returns the [collection, project] path ahead of the repo -// name itself, or nil when either piece is unavailable. -func azureNamespacePath() []string { - collection, teamProject, ok := azureCollectionAndProject() - if !ok { - return nil - } - return []string{collection, teamProject} +// azureCollectionURI holds the pieces derived from parsing SYSTEM_COLLECTIONURI +// once, shared by provider refinement (services vs on-prem server) and +// namespace/name composition so the two can't classify the same URI +// differently. +type azureCollectionURI struct { + valid bool // URI parsed and had a non-empty host + isServices bool // cloud-hosted Services host, vs on-prem Server + collection string // org/collection name; empty if it couldn't be determined } -// azureCollectionAndProject extracts the collection/org name and -// SYSTEM_TEAMPROJECT. ok is false if either piece is missing or -// SYSTEM_COLLECTIONURI can't be parsed into a non-empty collection. +// parseAzureCollectionURI classifies SYSTEM_COLLECTIONURI's host as +// cloud-hosted Services (*.dev.azure.com or the legacy +// *.visualstudio.com) vs on-prem Server, and extracts the collection/org name. // -// On *.visualstudio.com hosts (the legacy Services URL format) the org name -// is the subdomain, not a path segment - e.g. https://fabrikam.visualstudio.com/ -// has an empty path. Everywhere else (dev.azure.com/MyOrg, on-prem Server +// On *.visualstudio.com hosts the org name is the first label of the +// subdomain, not a path segment. Everywhere else (dev.azure.com/MyOrg, on-prem Server // collection URIs) the collection is the last path segment. -func azureCollectionAndProject() (collection, teamProject string, ok bool) { - teamProject = os.Getenv("SYSTEM_TEAMPROJECT") - collectionURI := os.Getenv("SYSTEM_COLLECTIONURI") - if teamProject == "" || collectionURI == "" { - return "", "", false - } - - parsed, err := url.Parse(collectionURI) +func parseAzureCollectionURI() azureCollectionURI { + parsed, err := url.Parse(os.Getenv("SYSTEM_COLLECTIONURI")) if err != nil || parsed.Host == "" { - return "", "", false + return azureCollectionURI{} } - if host := strings.ToLower(parsed.Host); strings.HasSuffix(host, ".visualstudio.com") { - collection = strings.TrimSuffix(host, ".visualstudio.com") - return collection, teamProject, collection != "" - } + host := strings.ToLower(parsed.Host) + result := azureCollectionURI{valid: true} - segments := strings.Split(strings.Trim(parsed.Path, "/"), "/") - collection = segments[len(segments)-1] - if collection == "" { - return "", "", false + if strings.HasSuffix(host, ".visualstudio.com") { + result.isServices = true + result.collection, _, _ = strings.Cut(host, ".") + return result } - return collection, teamProject, true + result.isServices = host == "dev.azure.com" || strings.HasSuffix(host, ".dev.azure.com") + segments := strings.Split(strings.Trim(parsed.Path, "/"), "/") + result.collection = segments[len(segments)-1] + return result } func getGitRepoInfoFromCircleci() *gitview.GitRepoInfo { @@ -420,9 +438,3 @@ func getGitRepoInfoFromCircleci() *gitview.GitRepoInfo { Provider: "circleci", } } - -// func getGitRepoInfoFromCodeBuild() *gitview.GitRepoInfo { -// return &gitview.GitRepoInfo{ -// URL: os.Getenv("CODEBUILD_SOURCE_REPO_URL"), -// } -// } diff --git a/cmd/kosli/attestation_test.go b/cmd/kosli/attestation_test.go index 884cfcf73..bf74b591f 100644 --- a/cmd/kosli/attestation_test.go +++ b/cmd/kosli/attestation_test.go @@ -5,22 +5,25 @@ import ( "github.com/kosli-dev/cli/internal/gitview" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestMergeGitRepoInfo(t *testing.T) { tests := []struct { - 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 string + base *gitview.GitRepoInfo + repoID string + repoName string + repoURL string + repoProvider string + repoNameExplicit bool + wantNil bool + wantID string + wantName string + wantURL string + wantProvider string + wantNamespacePath []string + wantAdditionalInfo map[string]interface{} }{ { name: "nil when both ID and Name are empty", @@ -108,6 +111,35 @@ func TestMergeGitRepoInfo(t *testing.T) { wantName: "my/custom-name", wantURL: "https://gitlab.com/cyber-dojo/creator", }, + { + name: "explicit --repository override clears stale CI-detected NamespacePath/AdditionalInfo", + base: &gitview.GitRepoInfo{ + ID: "repo-id", Name: "MyOrg/Payment/my-repo", URL: "https://dev.azure.com/MyOrg/Payment/_git/my-repo", + NamespacePath: []string{"MyOrg", "Payment"}, AdditionalInfo: map[string]interface{}{"project_key": "PAY"}, + }, + repoName: "my-fork/repo", + repoProvider: "github", + repoNameExplicit: true, + wantNil: false, + wantID: "repo-id", + wantName: "my-fork/repo", + wantURL: "https://dev.azure.com/MyOrg/Payment/_git/my-repo", + wantProvider: "github", + }, + { + name: "CI-detected NamespacePath/AdditionalInfo are preserved when --repository is not set explicitly", + base: &gitview.GitRepoInfo{ + ID: "repo-id", Name: "MyOrg/Payment/my-repo", URL: "https://dev.azure.com/MyOrg/Payment/_git/my-repo", + NamespacePath: []string{"MyOrg", "Payment"}, AdditionalInfo: map[string]interface{}{"project_key": "PAY"}, + }, + repoNameExplicit: false, + wantNil: false, + wantID: "repo-id", + wantName: "MyOrg/Payment/my-repo", + wantURL: "https://dev.azure.com/MyOrg/Payment/_git/my-repo", + wantNamespacePath: []string{"MyOrg", "Payment"}, + wantAdditionalInfo: map[string]interface{}{"project_key": "PAY"}, + }, { name: "flag name applied when base has no name even if not explicit", repoID: "flag-id", @@ -159,19 +191,22 @@ func TestMergeGitRepoInfo(t *testing.T) { assert.Equal(t, tt.wantName, result.Name) assert.Equal(t, tt.wantURL, result.URL) assert.Equal(t, tt.wantProvider, result.Provider) + assert.Equal(t, tt.wantNamespacePath, result.NamespacePath) + assert.Equal(t, tt.wantAdditionalInfo, result.AdditionalInfo) }) } } func TestGetGitRepoInfoFromAzureDevops(t *testing.T) { tests := []struct { - name string - systemCollectionURI string - systemTeamProject string - buildRepositoryName string - wantName string - wantProvider string - wantNamespacePath []string + name string + systemCollectionURI string + systemTeamProject string + buildRepositoryName string + buildRepositoryProvider string + wantName string + wantProvider string + wantNamespacePath []string }{ { name: "Azure DevOps Services composes Org/Project/repo", @@ -182,6 +217,96 @@ func TestGetGitRepoInfoFromAzureDevops(t *testing.T) { wantProvider: "azure_devops_services", wantNamespacePath: []string{"MyOrg", "Payment"}, }, + { + name: "TfsGit provider composes full path and refines provider (regression guard)", + systemCollectionURI: "https://dev.azure.com/MyOrg/", + systemTeamProject: "Payment", + buildRepositoryName: "my-repo", + buildRepositoryProvider: "TfsGit", + wantName: "MyOrg/Payment/my-repo", + wantProvider: "azure_devops_services", + wantNamespacePath: []string{"MyOrg", "Payment"}, + }, + { + name: "empty provider defaults to TfsGit behaviour (back-compat)", + systemCollectionURI: "https://dev.azure.com/MyOrg/", + systemTeamProject: "Payment", + buildRepositoryName: "my-repo", + buildRepositoryProvider: "", + wantName: "MyOrg/Payment/my-repo", + wantProvider: "azure_devops_services", + wantNamespacePath: []string{"MyOrg", "Payment"}, + }, + { + name: "GitHub-hosted repo built on Azure Pipelines: bare name, relabeled to github, no namespace", + systemCollectionURI: "https://dev.azure.com/MyOrg/", + systemTeamProject: "Payment", + buildRepositoryName: "my-org/my-repo", + buildRepositoryProvider: "GitHub", + wantName: "my-org/my-repo", + wantProvider: "github", + wantNamespacePath: nil, + }, + { + name: "GitHub Enterprise-hosted repo built on Azure Pipelines: bare name, relabeled to github, no namespace", + systemCollectionURI: "https://dev.azure.com/MyOrg/", + systemTeamProject: "Payment", + buildRepositoryName: "my-org/my-repo", + buildRepositoryProvider: "GitHubEnterprise", + wantName: "my-org/my-repo", + wantProvider: "github", + wantNamespacePath: nil, + }, + { + name: "Bitbucket-hosted repo built on Azure Pipelines: bare name, relabeled to bitbucket_cloud, no namespace", + systemCollectionURI: "https://dev.azure.com/MyOrg/", + systemTeamProject: "Payment", + buildRepositoryName: "my-repo", + buildRepositoryProvider: "Bitbucket", + wantName: "my-repo", + wantProvider: "bitbucket_cloud", + wantNamespacePath: nil, + }, + { + name: "generic external Git repo built on Azure Pipelines: bare name, relabeled to git, no namespace", + systemCollectionURI: "https://dev.azure.com/MyOrg/", + systemTeamProject: "Payment", + buildRepositoryName: "my-repo", + buildRepositoryProvider: "Git", + wantName: "my-repo", + wantProvider: "git", + wantNamespacePath: nil, + }, + { + name: "Subversion repo built on Azure Pipelines: bare name, relabeled to subversion, no namespace", + systemCollectionURI: "https://dev.azure.com/MyOrg/", + systemTeamProject: "Payment", + buildRepositoryName: "my-repo", + buildRepositoryProvider: "Svn", + wantName: "my-repo", + wantProvider: "subversion", + wantNamespacePath: nil, + }, + { + name: "unmapped provider is treated as an Azure DevOps-hosted repo (refined), no path composition", + systemCollectionURI: "https://dev.azure.com/MyOrg/", + systemTeamProject: "Payment", + buildRepositoryName: "my-repo", + buildRepositoryProvider: "SomeFutureProvider", + wantName: "my-repo", + wantProvider: "azure_devops_services", + wantNamespacePath: nil, + }, + { + name: "TFVC repo (not git-based): bare name, refined Azure provider, no namespace", + systemCollectionURI: "https://dev.azure.com/MyOrg/", + systemTeamProject: "Payment", + buildRepositoryName: "my-repo", + buildRepositoryProvider: "TfsVersionControl", + wantName: "my-repo", + wantProvider: "azure_devops_services", + wantNamespacePath: nil, + }, { name: "Azure DevOps Services on a *.visualstudio.com host", systemCollectionURI: "https://fabrikam.visualstudio.com/", @@ -191,6 +316,24 @@ func TestGetGitRepoInfoFromAzureDevops(t *testing.T) { wantProvider: "azure_devops_services", wantNamespacePath: []string{"fabrikam", "Payment"}, }, + { + name: "Azure DevOps Services on a legacy *.vsrm.visualstudio.com release-pipeline host extracts the org, not org.vsrm", + systemCollectionURI: "https://fabrikam.vsrm.visualstudio.com/", + systemTeamProject: "Payment", + buildRepositoryName: "my-repo", + wantName: "fabrikam/Payment/my-repo", + wantProvider: "azure_devops_services", + wantNamespacePath: []string{"fabrikam", "Payment"}, + }, + { + name: "Azure DevOps Services on a vsrm.dev.azure.com release-pipeline host still composes and refines to services", + systemCollectionURI: "https://vsrm.dev.azure.com/MyOrg/", + systemTeamProject: "Payment", + buildRepositoryName: "my-repo", + wantName: "MyOrg/Payment/my-repo", + wantProvider: "azure_devops_services", + wantNamespacePath: []string{"MyOrg", "Payment"}, + }, { name: "Azure DevOps Server (on-prem) composes Collection/Project/repo", systemCollectionURI: "https://tfs.corp.local/tfs/PRDCollection/", @@ -243,6 +386,7 @@ func TestGetGitRepoInfoFromAzureDevops(t *testing.T) { t.Setenv("SYSTEM_COLLECTIONURI", tt.systemCollectionURI) t.Setenv("SYSTEM_TEAMPROJECT", tt.systemTeamProject) t.Setenv("BUILD_REPOSITORY_NAME", tt.buildRepositoryName) + t.Setenv("BUILD_REPOSITORY_PROVIDER", tt.buildRepositoryProvider) t.Setenv("BUILD_REPOSITORY_URI", "https://dev.azure.com/MyOrg/Payment/_git/my-repo") t.Setenv("BUILD_REPOSITORY_ID", "repo-id") @@ -255,35 +399,81 @@ func TestGetGitRepoInfoFromAzureDevops(t *testing.T) { } } +// TestAzureRepoProvider guards the helper shared by getGitRepoInfoFromAzureDevops +// and the --repo-provider flag default (DefaultValue): both resolve the provider +// the same way, so the flag default can't clobber the CI-detected value in +// mergeGitRepoInfo (the bug where an Azure-built GitHub repo was mislabelled +// azure-devops). +func TestAzureRepoProvider(t *testing.T) { + tests := []struct { + name string + buildRepositoryProvider string + systemCollectionURI string + want string + }{ + {name: "GitHub source maps to github", buildRepositoryProvider: "GitHub", systemCollectionURI: "https://dev.azure.com/MyOrg/", want: "github"}, + {name: "GitHub Enterprise source maps to github", buildRepositoryProvider: "GitHubEnterprise", systemCollectionURI: "https://dev.azure.com/MyOrg/", want: "github"}, + {name: "Bitbucket source maps to bitbucket_cloud", buildRepositoryProvider: "Bitbucket", systemCollectionURI: "https://dev.azure.com/MyOrg/", want: "bitbucket_cloud"}, + {name: "generic Git source maps to git", buildRepositoryProvider: "Git", systemCollectionURI: "https://dev.azure.com/MyOrg/", want: "git"}, + {name: "SVN source maps to subversion", buildRepositoryProvider: "Svn", systemCollectionURI: "https://dev.azure.com/MyOrg/", want: "subversion"}, + {name: "TfsGit refines to services on dev.azure.com", buildRepositoryProvider: "TfsGit", systemCollectionURI: "https://dev.azure.com/MyOrg/", want: "azure_devops_services"}, + {name: "empty provider refines to services on dev.azure.com", buildRepositoryProvider: "", systemCollectionURI: "https://dev.azure.com/MyOrg/", want: "azure_devops_services"}, + {name: "TFVC is Azure-hosted and refines to services", buildRepositoryProvider: "TfsVersionControl", systemCollectionURI: "https://dev.azure.com/MyOrg/", want: "azure_devops_services"}, + {name: "unmapped provider is Azure-hosted and refines to on-prem server", buildRepositoryProvider: "SomeFutureProvider", systemCollectionURI: "https://tfs.corp.local/tfs/PRDCollection/", want: "azure_devops_server"}, + {name: "Azure-hosted degrades to coarse azure-devops without a collection URI", buildRepositoryProvider: "TfsGit", systemCollectionURI: "", want: "azure-devops"}, + {name: "vsrm.dev.azure.com (classic release pipelines) is still Services", buildRepositoryProvider: "TfsGit", systemCollectionURI: "https://vsrm.dev.azure.com/MyOrg/", want: "azure_devops_services"}, + {name: "legacy org.vsrm.visualstudio.com (classic release pipelines) is still Services", buildRepositoryProvider: "TfsGit", systemCollectionURI: "https://fabrikam.vsrm.visualstudio.com/", want: "azure_devops_services"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("BUILD_REPOSITORY_PROVIDER", tt.buildRepositoryProvider) + t.Setenv("SYSTEM_COLLECTIONURI", tt.systemCollectionURI) + assert.Equal(t, tt.want, azureRepoProvider(tt.buildRepositoryProvider, parseAzureCollectionURI())) + }) + } +} + func TestGetGitRepoInfoFromBitbucket(t *testing.T) { tests := []struct { - name string - bitbucketProjectKey string - wantAdditionalInfo map[string]interface{} + name string + bitbucketRepoFullName string + bitbucketProjectKey string + wantNamespacePath []string + wantAdditionalInfo map[string]interface{} }{ { - name: "with project key", - bitbucketProjectKey: "PROJ", - wantAdditionalInfo: map[string]interface{}{"project_key": "PROJ"}, + name: "with project key", + bitbucketRepoFullName: "myteam/my-repo", + bitbucketProjectKey: "PROJ", + wantNamespacePath: []string{"myteam"}, + wantAdditionalInfo: map[string]interface{}{"project_key": "PROJ"}, }, { - name: "without project key", - bitbucketProjectKey: "", - wantAdditionalInfo: nil, + name: "without project key", + bitbucketRepoFullName: "myteam/my-repo", + bitbucketProjectKey: "", + wantNamespacePath: []string{"myteam"}, + wantAdditionalInfo: nil, + }, + { + name: "BITBUCKET_REPO_FULL_NAME with no '/' (malformed/simulated env) has no namespace", + bitbucketRepoFullName: "my-repo", + wantNamespacePath: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Setenv("BITBUCKET_GIT_HTTP_ORIGIN", "https://bitbucket.org/myteam/my-repo.git") - t.Setenv("BITBUCKET_REPO_FULL_NAME", "myteam/my-repo") + t.Setenv("BITBUCKET_REPO_FULL_NAME", tt.bitbucketRepoFullName) t.Setenv("BITBUCKET_REPO_UUID", "repo-uuid") t.Setenv("BITBUCKET_PROJECT_KEY", tt.bitbucketProjectKey) result := getGitRepoInfoFromBitbucket() assert.Equal(t, "bitbucket_cloud", result.Provider) - assert.Equal(t, []string{"myteam"}, result.NamespacePath) + assert.Equal(t, tt.wantNamespacePath, result.NamespacePath) assert.Equal(t, tt.wantAdditionalInfo, result.AdditionalInfo) }) } @@ -342,6 +532,8 @@ func TestValidateRepoFlags(t *testing.T) { {name: "coarse azure-devops is allowed", repoProvider: "azure-devops"}, {name: "azure_devops_services is allowed", repoProvider: "azure_devops_services"}, {name: "azure_devops_server is allowed", repoProvider: "azure_devops_server"}, + {name: "git is allowed", repoProvider: "git"}, + {name: "subversion is allowed", repoProvider: "subversion"}, {name: "unrecognised provider is rejected", repoProvider: "made-up-provider", wantError: true}, } @@ -349,7 +541,8 @@ func TestValidateRepoFlags(t *testing.T) { t.Run(tt.name, func(t *testing.T) { err := validateRepoFlags("", tt.repoProvider, false) if tt.wantError { - assert.Error(t, err) + require.Error(t, err) + assert.Equal(t, "--repo-provider 'made-up-provider' is not allowed. Must be one of: "+repoProviderList, err.Error()) } else { assert.NoError(t, err) } diff --git a/cmd/kosli/beginTrail_test.go b/cmd/kosli/beginTrail_test.go index 1736c3233..4ff61a961 100644 --- a/cmd/kosli/beginTrail_test.go +++ b/cmd/kosli/beginTrail_test.go @@ -106,7 +106,7 @@ func (suite *BeginTrailCommandTestSuite) TestBeginTrailCmd() { wantError: true, name: "fails when --repo-provider is not an allowed value", cmd: fmt.Sprintf("begin trail test-123 --flow %s --repo-provider jenkins %s", suite.flowName, suite.defaultKosliArguments), - golden: "Error: --repo-provider 'jenkins' is not allowed. Must be one of: github, gitlab, bitbucket, bitbucket_cloud, bitbucket_dc, azure-devops, azure_devops_services, azure_devops_server\n", + golden: "Error: --repo-provider 'jenkins' is not allowed. Must be one of: github, gitlab, bitbucket, bitbucket_cloud, bitbucket_dc, azure-devops, azure_devops_services, azure_devops_server, git, subversion\n", }, } diff --git a/cmd/kosli/cli_utils.go b/cmd/kosli/cli_utils.go index d881c3ce7..caf050d0e 100644 --- a/cmd/kosli/cli_utils.go +++ b/cmd/kosli/cli_utils.go @@ -207,11 +207,10 @@ func DefaultValue(ci, flag string) string { _, inDocs := os.LookupEnv("DOCS") _, inTests := os.LookupEnv("KOSLI_TESTS") if !inDocs && !inTests { - // Azure DevOps Services vs Server can't be captured as a static - // template like the other CI defaults: it depends on the - // SYSTEM_COLLECTIONURI host at runtime. + // Shares azureRepoProvider() with getGitRepoInfoFromAzureDevops so this + // default can't clobber the CI-detected provider in mergeGitRepoInfo. if ci == azureDevops && flag == "repo-provider" { - return azureDevopsProvider() + return azureRepoProvider(os.Getenv("BUILD_REPOSITORY_PROVIDER"), parseAzureCollectionURI()) } if v, ok := ciTemplates[ci][flag]; ok { result := os.ExpandEnv(v) diff --git a/cmd/kosli/cli_utils_test.go b/cmd/kosli/cli_utils_test.go index e23bdaff8..ef3e3bd4a 100644 --- a/cmd/kosli/cli_utils_test.go +++ b/cmd/kosli/cli_utils_test.go @@ -73,6 +73,14 @@ func (suite *CliUtilsTestSuite) TestWhichCI() { }, } { suite.Run(t.name, func() { + // WhichCI() uses os.LookupEnv (presence, not value), so these must be + // unset outright rather than set to "" - clearing them before each case + // keeps the test deterministic even when the developer's shell has one + // exported ambiently (e.g. TF_BUILD, from manually simulating Azure + // Pipelines). + for _, envVar := range whichCIEnvVars { + require.NoError(suite.T(), os.Unsetenv(envVar)) + } suite.setEnvVars(t.envVars) actual := WhichCI() // clean up @@ -82,6 +90,13 @@ func (suite *CliUtilsTestSuite) TestWhichCI() { } } +// whichCIEnvVars lists every env var WhichCI() checks, so tests can clear +// ambient values from the developer's shell before asserting on its result. +var whichCIEnvVars = []string{ + "BITBUCKET_BUILD_NUMBER", "GITHUB_RUN_NUMBER", "TEAMCITY_VERSION", + "GITLAB_CI", "TF_BUILD", "CIRCLECI", "CODEBUILD_CI", "JENKINS_URL", +} + func (suite *CliUtilsTestSuite) TestDefaultValue() { type args struct { ci string @@ -254,9 +269,12 @@ func (suite *CliUtilsTestSuite) TestDefaultValue() { { name: "Lookup repo-provider for Azure DevOps on dev.azure.com refines to the Services value.", args: args{ - ci: azureDevops, - flag: "repo-provider", - envVars: map[string]string{"SYSTEM_COLLECTIONURI": "https://dev.azure.com/MyOrg/"}, + ci: azureDevops, + flag: "repo-provider", + // BUILD_REPOSITORY_PROVIDER is explicitly cleared (not just SYSTEM_COLLECTIONURI + // set) so this is deterministic even if the developer's shell has it exported + // ambiently, e.g. BUILD_REPOSITORY_PROVIDER=Svn from manual Azure Pipelines testing. + envVars: map[string]string{"SYSTEM_COLLECTIONURI": "https://dev.azure.com/MyOrg/", "BUILD_REPOSITORY_PROVIDER": ""}, unsetTestsEnvVar: true, }, want: "azure_devops_services", @@ -266,7 +284,7 @@ func (suite *CliUtilsTestSuite) TestDefaultValue() { args: args{ ci: azureDevops, flag: "repo-provider", - envVars: map[string]string{"SYSTEM_COLLECTIONURI": "https://tfs.corp.local/tfs/PRDCollection/"}, + envVars: map[string]string{"SYSTEM_COLLECTIONURI": "https://tfs.corp.local/tfs/PRDCollection/", "BUILD_REPOSITORY_PROVIDER": ""}, unsetTestsEnvVar: true, }, want: "azure_devops_server", @@ -276,6 +294,7 @@ func (suite *CliUtilsTestSuite) TestDefaultValue() { args: args{ ci: azureDevops, flag: "repo-provider", + envVars: map[string]string{"BUILD_REPOSITORY_PROVIDER": "", "SYSTEM_COLLECTIONURI": ""}, unsetTestsEnvVar: true, }, want: "azure-devops", diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 4d4151580..fdda45aee 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -186,7 +186,7 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, commitUrlFlag = "The url for the git commit that created the artifact. (defaulted in some CIs: https://docs.kosli.com/integrations/ci_cd )." repoIDFlag = "[conditional] The stable, unique identifier for the repository in your VCS provider (e.g. a numeric ID). Do not use the repository name as it can change if the repo is renamed. All three of --repo-id, --repo-url and --repository must be set to record repository information (defaulted in some CIs: https://docs.kosli.com/integrations/ci_cd )." repoURLFlag = "[conditional] The URL of the repository. Must be a valid URL. All three of --repo-id, --repo-url and --repository must be set to record repository information (defaulted in some CIs: https://docs.kosli.com/integrations/ci_cd )." - repoProviderFlag = "[optional] The source code hosting provider. One of: github, gitlab, bitbucket, bitbucket_cloud, bitbucket_dc, azure-devops, azure_devops_services, azure_devops_server (defaulted in some CIs: https://docs.kosli.com/integrations/ci_cd )." + repoProviderFlag = "[optional] The source code hosting provider. One of: " + repoProviderList + " (defaulted in some CIs: https://docs.kosli.com/integrations/ci_cd )." bbUsernameFlag = "Bitbucket username. Only needed if you use --bitbucket-password" bbPasswordFlag = "Bitbucket App password. See [Bitbucket authentication](https://developer.atlassian.com/cloud/bitbucket/rest/intro/#authentication) for more details." bbAccessTokenFlag = "Bitbucket repo/project/workspace access token. See [Bitbucket access tokens](https://developer.atlassian.com/cloud/bitbucket/rest/intro/#access-tokens) for more details." diff --git a/cmd/kosli/testdata/output/docs/mintlify/snyk.md b/cmd/kosli/testdata/output/docs/mintlify/snyk.md index 896618f6a..99112d8d1 100644 --- a/cmd/kosli/testdata/output/docs/mintlify/snyk.md +++ b/cmd/kosli/testdata/output/docs/mintlify/snyk.md @@ -53,7 +53,7 @@ In other CI systems, set them explicitly to capture repository metadata. | `--registry-password` string | [conditional] The container registry password or access token. Only required if you want to read container image SHA256 digest from a remote container registry and it is not already accessible via Docker/Podman auth files or a credential helper. | | `--registry-username` string | [conditional] The container registry username. Only required if you want to read container image SHA256 digest from a remote container registry and it is not already accessible via Docker/Podman auth files or a credential helper. | | `--repo-id` string | [conditional] The stable, unique identifier for the repository in your VCS provider (e.g. a numeric ID). Do not use the repository name as it can change if the repo is renamed. All three of `--repo-id`, `--repo-url` and `--repository` must be set to record repository information (defaulted in some CIs: [docs](/integrations/ci_cd) ). | -| `--repo-provider` string | [optional] The source code hosting provider. One of: github, gitlab, bitbucket, bitbucket_cloud, bitbucket_dc, azure-devops, azure_devops_services, azure_devops_server (defaulted in some CIs: [docs](/integrations/ci_cd) ). | +| `--repo-provider` string | [optional] The source code hosting provider. One of: github, gitlab, bitbucket, bitbucket_cloud, bitbucket_dc, azure-devops, azure_devops_services, azure_devops_server, git, subversion (defaulted in some CIs: [docs](/integrations/ci_cd) ). | | `--repo-root` string | [defaulted] The directory where the source git repository is available. Only used if `--commit` is used or defaulted in CI, see [docs](/integrations/ci_cd/#defaulted-kosli-command-flags-from-ci-variables) . (default ".") | | `--repo-url` string | [conditional] The URL of the repository. Must be a valid URL. All three of `--repo-id`, `--repo-url` and `--repository` must be set to record repository information (defaulted in some CIs: [docs](/integrations/ci_cd) ). | | `--repository` string | [conditional] The name of the repository (e.g. owner/repo-name). All three of `--repo-id`, `--repo-url` and `--repository` must be set to record repository information (defaulted in some CIs: [docs](/integrations/ci_cd) ). |