diff --git a/cmd/kosli/attestJira.go b/cmd/kosli/attestJira.go index 8b22b9760..20b3e645d 100644 --- a/cmd/kosli/attestJira.go +++ b/cmd/kosli/attestJira.go @@ -29,6 +29,7 @@ type attestJiraOptions struct { projectKeys []string issueFields string secondarySource string + trailerKey string ignoreBranchMatch bool assert bool payload JiraAttestationPayload @@ -260,6 +261,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) @@ -301,19 +303,27 @@ 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. - searchTexts := []string{commitInfo.Message} - if !o.ignoreBranchMatch { - searchTexts = append(searchTexts, commitInfo.Branch) - } - if o.secondarySource != "" { - searchTexts = append(searchTexts, o.secondarySource) + // 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 != "" { + 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) + } else { + searchTexts := []string{commitInfo.Message} + if !o.ignoreBranchMatch { + searchTexts = append(searchTexts, commitInfo.Branch) + } + if o.secondarySource != "" { + searchTexts = append(searchTexts, o.secondarySource) + } + combinedText := strings.Join(searchTexts, "\n") + issueIDs = jira.FindJiraIssueKeys(combinedText, 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) } - combinedText := strings.Join(searchTexts, "\n") - issueIDs := jira.FindJiraIssueKeys(combinedText, 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) + logger.Debug("the following Jira references are found: %v", issueIDs) issueLog := "" issueFoundCount := 0 diff --git a/cmd/kosli/attestJira_test.go b/cmd/kosli/attestJira_test.go index 6676932a2..62e888b40 100644 --- a/cmd/kosli/attestJira_test.go +++ b/cmd/kosli/attestJira_test.go @@ -331,6 +331,41 @@ 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 + --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 commit message or branch name\n", + additionalConfig: jiraTestsAdditionalConfig{ + 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 144650b3d..68de77382 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." 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 73765f822..3df38dc8d 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 e9365817e..a7e98a56d 100644 --- a/internal/gitview/gitView.go +++ b/internal/gitview/gitView.go @@ -316,6 +316,23 @@ func (gv *GitView) MatchPatternInCommitMessageORBranchName(pattern, commitSHA, s return matches, commitInfo, nil } +// 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(key) + ":" + for _, line := range strings.Split(message, "\n") { + if strings.HasPrefix(strings.ToLower(line), prefix) { + value := strings.TrimSpace(line[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 2ff79040f..ed2fd002e 100644 --- a/internal/gitview/gitView_test.go +++ b/internal/gitview/gitView_test.go @@ -644,6 +644,57 @@ 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"}, + }, + } { + 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)) }