diff --git a/cmd/kosli/attestJira.go b/cmd/kosli/attestJira.go index 6d1daa3a5..d12b7bc67 100644 --- a/cmd/kosli/attestJira.go +++ b/cmd/kosli/attestJira.go @@ -30,6 +30,7 @@ type attestJiraOptions struct { projectKeys []string issueFields string secondarySource string + trailerKey string ignoreBranchMatch bool assert bool payload JiraAttestationPayload @@ -38,8 +39,13 @@ type attestJiraOptions struct { const attestJiraShortDesc = `Report a jira attestation to an artifact or a trail in a Kosli flow. ` const attestJiraLongDesc = attestJiraShortDesc + ` -Parses the given commit's message, current branch name or the content of the ^--jira-secondary-source^ -argument for Jira issue references of the form: +By default, parses the given commit's message, current branch name, or the content of the +^--jira-secondary-source^ argument for Jira issue references of the form. +Use ^--jira-trailer^ to read issue keys exclusively from a named git trailer line instead +(e.g. ^Jira: PROJ-42^); when set, the commit message body, branch name, and +^--jira-secondary-source^ are not scanned. + +Jira issue references have the form: 'at least 2 characters long, starting with an uppercase letter project key followed by dash and one or more digits'. @@ -59,13 +65,16 @@ because ^CVE-2026^ would be followed by ^-4^. This applies across all parsed sou (commit message, branch name, and secondary source). Note: if your Jira project key collides with this pattern (e.g. a project key of ^CVE^), an issue reference that happens to be the prefix of a longer hyphenated number (such as a CVE -identifier) will be filtered out. Use ^--jira-secondary-source^ with a different identifier -format as a workaround. +identifier) will be filtered out. Use ^--jira-trailer^ to read issue keys from a dedicated +git trailer line (e.g. ^Jira: CVE-42^), which bypasses pattern-scanning entirely. +Alternatively, use ^--jira-secondary-source^ with a different identifier format. If you want to restrict the Jira issue matching to a specific project, use the ^--jira-project-key^ flag to specify your own project key. You can specify multiple project keys if needed. If the ^--ignore-branch-match^ is set, the branch name is not parsed for a match. +^--ignore-branch-match^ has no effect when ^--jira-trailer^ is set, since the branch is +never scanned in trailer mode. The found issue references will be checked against Jira to confirm their existence. The attestation is reported in all cases, and its compliance status depends on referencing @@ -190,6 +199,20 @@ kosli attest jira \ --jira-api-token yourJiraAPIToken \ --api-token yourAPIToken \ --org yourOrgName + +# read the jira issue key exclusively from a git trailer line (e.g. "Jira: PROJ-42") +# bypasses commit message and branch scanning entirely — useful when project keys +# collide with patterns like CVE identifiers +kosli attest jira \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --jira-trailer Jira \ + --jira-base-url https://kosli.atlassian.net \ + --jira-username user@domain.com \ + --jira-api-token yourJiraAPIToken \ + --api-token yourAPIToken \ + --org yourOrgName ` func newAttestJiraCmd(out io.Writer) *cobra.Command { @@ -234,6 +257,11 @@ func newAttestJiraCmd(out io.Writer) *cobra.Command { return err } + err = MuXRequiredFlags(cmd, []string{"jira-trailer", "jira-secondary-source"}, false) + if err != nil { + return err + } + err = ValidateSliceValues(o.redactedCommitInfo, allowedCommitRedactionValues) if err != nil { return fmt.Errorf("%s for --redact-commit-info", err.Error()) @@ -263,6 +291,7 @@ func newAttestJiraCmd(out io.Writer) *cobra.Command { cmd.Flags().StringSliceVar(&o.projectKeys, "jira-project-key", []string{}, jiraProjectKeyFlag) cmd.Flags().StringVar(&o.issueFields, "jira-issue-fields", "", jiraIssueFieldFlag) cmd.Flags().StringVar(&o.secondarySource, "jira-secondary-source", "", jiraSecondarySourceFlag) + cmd.Flags().StringVar(&o.trailerKey, "jira-trailer", "", jiraTrailerFlag) cmd.Flags().BoolVar(&o.ignoreBranchMatch, "ignore-branch-match", false, ignoreBranchMatchFlag) cmd.Flags().BoolVar(&o.assert, "assert", false, attestationAssertFlag) @@ -304,11 +333,30 @@ func (o *attestJiraOptions) run(args []string) error { return err } - // Search commit message, branch name, and secondary source for Jira issue keys, - // filtering out false positives from multi-segment identifiers like CVE-2026-41284. - issueIDs := jira.FindJiraIssueKeys(jiraSearchText(commitInfo, o.secondarySource, o.ignoreBranchMatch), o.projectKeys) - logger.Debug("Checked for Jira issue references in Git commit %s on branch %s commit message:\n%s", commitInfo.Sha1, commitInfo.Branch, commitInfo.Message) - logger.Debug("the following Jira references are found in commit message or branch name: %v", issueIDs) + // Find Jira issue keys either from a named git trailer or by scanning the + // commit message, branch name, and secondary source. + var issueIDs []string + if o.trailerKey != "" { + if o.ignoreBranchMatch { + logger.Warn("--ignore-branch-match has no effect when --jira-trailer is set") + } + trailerValues := gitview.GetTrailerValues(commitInfo.Message, o.trailerKey) + combinedTrailerText := strings.Join(trailerValues, "\n") + issueIDs = jira.FindJiraIssueKeys(combinedTrailerText, o.projectKeys) + logger.Debug("Checked for Jira issue references in trailer '%s' of Git commit %s: %v", o.trailerKey, commitInfo.Sha1, trailerValues) + if len(trailerValues) > 0 && len(issueIDs) == 0 { + logger.Warn("trailer '%s' was found but contained no valid Jira issue keys: %v", o.trailerKey, trailerValues) + } + } else { + issueIDs = jira.FindJiraIssueKeys(jiraSearchText(commitInfo, o.secondarySource, o.ignoreBranchMatch), o.projectKeys) + logger.Debug("Checked for Jira issue references in Git commit %s on branch %s commit message:\n%s", commitInfo.Sha1, commitInfo.Branch, commitInfo.Message) + } + logger.Debug("the following Jira references are found: %v", issueIDs) + + issueSource := "commit message or branch name" + if o.trailerKey != "" { + issueSource = fmt.Sprintf("trailer '%s'", o.trailerKey) + } issueLog := "" issueFoundCount := 0 @@ -368,7 +416,7 @@ func (o *attestJiraOptions) run(args []string) error { if err != nil { errString = fmt.Sprintf("%s\nError: ", err.Error()) } - err = fmt.Errorf("%sno Jira references are found in commit message or branch name", errString) + err = fmt.Errorf("%sno Jira references are found in %s", errString, issueSource) } if issueFoundCount != len(issueIDs) && o.assert && !global.DryRun { @@ -381,8 +429,8 @@ func (o *attestJiraOptions) run(args []string) error { for _, reason := range unconfirmedReasons { reasonLog += fmt.Sprintf("\n\treason: %s", reason) } - err = fmt.Errorf("%s%s from references found in commit message or branch name%s%s", errString, - jiraAssertHeadline(len(issueIDs)-issueFoundCount-len(unconfirmedIDs), len(unconfirmedIDs)), issueLog, reasonLog) + err = fmt.Errorf("%s%s from references found in %s%s%s", errString, + jiraAssertHeadline(len(issueIDs)-issueFoundCount-len(unconfirmedIDs), len(unconfirmedIDs)), issueSource, issueLog, reasonLog) } return wrapAttestationError(err) } diff --git a/cmd/kosli/attestJira_test.go b/cmd/kosli/attestJira_test.go index 2e1dfecb2..7739d1d35 100644 --- a/cmd/kosli/attestJira_test.go +++ b/cmd/kosli/attestJira_test.go @@ -366,6 +366,62 @@ func (suite *AttestJiraCommandTestSuite) TestAttestJiraCmd() { cmd: fmt.Sprintf("attest jira --name .foo --commit HEAD --jira-base-url https://kosli-test.atlassian.net %s", suite.defaultKosliArguments), golden: "Error: failed to parse attestation name: invalid attestation name format: .foo\n", }, + { + name: "27 can attest jira using --jira-trailer to extract issue key from commit trailer", + cmd: fmt.Sprintf(`attest jira --name bar + --jira-base-url https://kosli-test.atlassian.net + --jira-trailer Jira + --assert + --repo-root %s %s`, suite.tmpDir, suite.defaultKosliArguments), + golden: "jira attestation 'bar' is reported to trail: test-123\n", + additionalConfig: jiraTestsAdditionalConfig{ + commitMessage: "fix: some change\n\nJira: EX-1\nOna-Environment-Id: ONA-999", + }, + }, + { + name: "28 --jira-trailer with no matching trailer produces no issue IDs (non-compliant but reported)", + cmd: fmt.Sprintf(`attest jira --name bar + --jira-base-url https://kosli-test.atlassian.net + --jira-trailer Jira + --repo-root %s %s`, suite.tmpDir, suite.defaultKosliArguments), + golden: "jira attestation 'bar' is reported to trail: test-123\n", + additionalConfig: jiraTestsAdditionalConfig{ + commitMessage: "fix: some change with no jira trailer", + }, + }, + { + wantError: true, + name: "29 --jira-trailer with --assert fails when trailer is absent", + cmd: fmt.Sprintf(`attest jira --name bar + --jira-base-url https://kosli-test.atlassian.net + --jira-trailer Jira + --assert + --repo-root %s %s`, suite.tmpDir, suite.defaultKosliArguments), + golden: "jira attestation 'bar' is reported to trail: test-123\nError: no Jira references are found in trailer 'Jira'\n", + additionalConfig: jiraTestsAdditionalConfig{ + commitMessage: "fix: some change with no jira trailer", + }, + }, + { + wantError: true, + name: "30 --jira-trailer and --jira-secondary-source are mutually exclusive", + cmd: fmt.Sprintf("attest jira --name bar --jira-base-url https://kosli-test.atlassian.net --jira-trailer Jira --jira-secondary-source foo --commit HEAD --repo-root %s %s", suite.tmpDir, suite.defaultKosliArguments), + golden: "Error: only one of --jira-trailer, --jira-secondary-source is allowed\n", + }, + { + wantError: true, + name: "31 --jira-trailer does not scan branch name even when branch contains a Jira key", + cmd: fmt.Sprintf(`attest jira --name bar + --jira-base-url https://kosli-test.atlassian.net + --jira-trailer Jira + --assert + --repo-root %s %s`, suite.tmpDir, suite.defaultKosliArguments), + golden: "jira attestation 'bar' is reported to trail: test-123\nError: no Jira references are found in trailer 'Jira'\n", + additionalConfig: jiraTestsAdditionalConfig{ + branchName: "EX-1-some-feature", + commitMessage: "fix: some change with no jira trailer", + }, + }, } for _, test := range tests { diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index f8efa6e7d..d15030026 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -169,6 +169,7 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, jiraIssueFieldFlag = "[optional] The comma separated list of fields to include from the Jira issue. Default no fields are included. '*all' will give all fields." jiraSecondarySourceFlag = "[optional] An optional string to search for Jira ticket reference, e.g. '--jira-secondary-source ${{ github.head_ref }}'" ignoreBranchMatchFlag = "Ignore branch name when searching for Jira ticket reference." + jiraTrailerFlag = "[optional] The git trailer key to use as the sole source of Jira issue references (e.g. '--jira-trailer Jira' extracts the value of 'Jira: ' lines from the commit message). When set, the commit message body and branch name are not scanned. Mutually exclusive with --jira-secondary-source." envDescriptionFlag = "[optional] The environment description." flowDescriptionFlag = "[optional] The Kosli flow description." trailDescriptionFlag = "[optional] The Kosli trail description." diff --git a/cmd/kosli/testdata/empty-flag-audit-coverage.json b/cmd/kosli/testdata/empty-flag-audit-coverage.json index 160179dea..84b07a7b5 100644 --- a/cmd/kosli/testdata/empty-flag-audit-coverage.json +++ b/cmd/kosli/testdata/empty-flag-audit-coverage.json @@ -212,6 +212,7 @@ "jira-pat": "string", "jira-project-key": "stringSlice", "jira-secondary-source": "string", + "jira-trailer": "string", "jira-username": "string", "name": "string", "origin-url": "string", diff --git a/internal/gitview/gitView.go b/internal/gitview/gitView.go index 08dfed9c9..ea1f3c23c 100644 --- a/internal/gitview/gitView.go +++ b/internal/gitview/gitView.go @@ -276,6 +276,24 @@ func getCommitURL(repoURL, commitHash string) string { } } +// GetTrailerValues extracts the values of all trailer lines in a commit message +// that match the given key. The key comparison is case-insensitive. Trailer lines +// have the format ": ". Returns an empty (non-nil) slice if none are found. +func GetTrailerValues(message, key string) []string { + result := []string{} + prefix := strings.ToLower(strings.TrimRight(strings.TrimSpace(key), ":")) + ":" + for _, line := range strings.Split(message, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(strings.ToLower(trimmed), prefix) { + value := strings.TrimSpace(trimmed[len(prefix):]) + if value != "" { + result = append(result, value) + } + } + } + return result +} + // ResolveRevision returns an explicit commit SHA1 from commit SHA or ref (e.g. HEAD~2) func (gv *GitView) ResolveRevision(commitSHAOrRef string) (string, error) { hash, err := gv.repository.ResolveRevision(plumbing.Revision(commitSHAOrRef)) diff --git a/internal/gitview/gitView_test.go b/internal/gitview/gitView_test.go index 8b90f2bf7..9332fa235 100644 --- a/internal/gitview/gitView_test.go +++ b/internal/gitview/gitView_test.go @@ -488,6 +488,75 @@ func initializeRepoAndCommit(repoPath string, commitsNumber int) (*git.Repositor return repo, w, nil } +func (suite *GitViewTestSuite) TestGetTrailerValues() { + for _, tt := range []struct { + name string + message string + key string + expected []string + }{ + { + name: "no trailers returns empty slice", + message: "fix: something\n\nsome body text", + key: "Jira", + expected: []string{}, + }, + { + name: "single matching trailer", + message: "fix: something\n\nJira: BX-123", + key: "Jira", + expected: []string{"BX-123"}, + }, + { + name: "key match is case-insensitive", + message: "fix: something\n\njira: BX-123", + key: "Jira", + expected: []string{"BX-123"}, + }, + { + name: "multiple occurrences of same key", + message: "fix: something\n\nJira: BX-123\nJira: BX-456", + key: "Jira", + expected: []string{"BX-123", "BX-456"}, + }, + { + name: "non-matching trailers are ignored", + message: "fix: something\n\nJira: BX-123\nOna-Environment-Id: ONA-456", + key: "Jira", + expected: []string{"BX-123"}, + }, + { + name: "whitespace trimmed from value", + message: "fix: something\n\nJira: BX-123 ", + key: "Jira", + expected: []string{"BX-123"}, + }, + { + name: "leading whitespace on line is tolerated", + message: "fix: something\n\n Jira: BX-123", + key: "Jira", + expected: []string{"BX-123"}, + }, + { + name: "key supplied with trailing colon still matches", + message: "fix: something\n\nJira: BX-123", + key: "Jira:", + expected: []string{"BX-123"}, + }, + { + name: "key with surrounding whitespace still matches", + message: "fix: something\n\nJira: BX-123", + key: " Jira ", + expected: []string{"BX-123"}, + }, + } { + suite.Run(tt.name, func() { + result := GetTrailerValues(tt.message, tt.key) + require.Equal(suite.T(), tt.expected, result) + }) + } +} + func TestGitViewTestSuite(t *testing.T) { suite.Run(t, new(GitViewTestSuite)) }