From 8ebcaf2c344ef231c8b3e121d3dd68cefe84709b Mon Sep 17 00:00:00 2001 From: vidhu bala Date: Wed, 19 Aug 2026 16:12:23 +0100 Subject: [PATCH 01/22] feat(jira): add --jira-trailer flag to extract issue key from git trailer When --jira-trailer is set, the command reads lines of the form ': ' from the commit message and uses those values as the sole source of Jira issue references, skipping the full commit message and branch name scan. This avoids false positives from other trailers (e.g. Ona-Environment-Id) whose values happen to match the Jira key pattern. Co-Authored-By: Claude Sonnet 4.6 --- cmd/kosli/attestJira.go | 34 +++++++++++++-------- cmd/kosli/attestJira_test.go | 35 ++++++++++++++++++++++ cmd/kosli/root.go | 1 + internal/gitview/gitView.go | 17 +++++++++++ internal/gitview/gitView_test.go | 51 ++++++++++++++++++++++++++++++++ 5 files changed, 126 insertions(+), 12 deletions(-) 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/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)) } From 7f8d11d952a8fc963a4ef7a9e5c425e2bb59ae3a Mon Sep 17 00:00:00 2001 From: vidhu bala Date: Wed, 19 Aug 2026 17:46:33 +0100 Subject: [PATCH 02/22] test: register --jira-trailer in empty-flag-audit coverage Co-Authored-By: Claude Sonnet 4.6 --- cmd/kosli/testdata/empty-flag-audit-coverage.json | 1 + 1 file changed, 1 insertion(+) 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", From 2d7398a7458065c52aaedac1054df64a24970fce Mon Sep 17 00:00:00 2001 From: Vidhu Bala Date: Tue, 25 Aug 2026 15:48:58 +0100 Subject: [PATCH 03/22] Update cmd/kosli/attestJira_test.go Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- cmd/kosli/attestJira_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/kosli/attestJira_test.go b/cmd/kosli/attestJira_test.go index 71aa703fa..14e6f6ce9 100644 --- a/cmd/kosli/attestJira_test.go +++ b/cmd/kosli/attestJira_test.go @@ -371,6 +371,7 @@ func (suite *AttestJiraCommandTestSuite) TestAttestJiraCmd() { 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{ From 11c68b5a2957037bdacafbab985aad23f638a49b Mon Sep 17 00:00:00 2001 From: vidhu bala Date: Tue, 25 Aug 2026 22:56:51 +0100 Subject: [PATCH 04/22] fix(attest jira): add --jira-trailer flag with edge case fixes and accurate help - Trim leading whitespace from trailer lines so editor-indented or git-log-formatted messages (4-space indent) match correctly - Strip trailing colon from --jira-trailer value so "Jira:" and "Jira" both produce the same prefix - Error when --jira-trailer and --jira-secondary-source are both set (they are mutually exclusive; secondary source is silently ignored in trailer mode) - Warn when --ignore-branch-match is set alongside --jira-trailer (it has no effect in trailer mode) - Warn when a trailer is found but contains no valid Jira issue keys - Thread issueSource through both --assert error messages so trailer mode names the trailer rather than "commit message or branch name" - Update Long description to document trailer mode, its interaction with --ignore-branch-match, and its use as the preferred CVE-collision fix - Add --jira-trailer example to attestJiraExample Co-Authored-By: Claude Sonnet 4.6 --- cmd/kosli/attestJira.go | 52 +++++++++++++++++++++++++++----- cmd/kosli/attestJira_test.go | 2 +- internal/gitview/gitView.go | 7 +++-- internal/gitview/gitView_test.go | 12 ++++++++ 4 files changed, 62 insertions(+), 11 deletions(-) diff --git a/cmd/kosli/attestJira.go b/cmd/kosli/attestJira.go index f7c99152f..d12b7bc67 100644 --- a/cmd/kosli/attestJira.go +++ b/cmd/kosli/attestJira.go @@ -39,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'. @@ -60,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 @@ -191,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 { @@ -235,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()) @@ -310,16 +337,27 @@ func (o *attestJiraOptions) run(args []string) error { // 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 unconfirmedIDs := []string{} @@ -378,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 { @@ -391,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 14e6f6ce9..57c9006aa 100644 --- a/cmd/kosli/attestJira_test.go +++ b/cmd/kosli/attestJira_test.go @@ -397,7 +397,7 @@ func (suite *AttestJiraCommandTestSuite) TestAttestJiraCmd() { --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", + 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", }, diff --git a/internal/gitview/gitView.go b/internal/gitview/gitView.go index b2e016511..2ef22ce7d 100644 --- a/internal/gitview/gitView.go +++ b/internal/gitview/gitView.go @@ -281,10 +281,11 @@ func getCommitURL(repoURL, commitHash string) string { // 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) + ":" + prefix := strings.ToLower(strings.TrimRight(key, ":")) + ":" for _, line := range strings.Split(message, "\n") { - if strings.HasPrefix(strings.ToLower(line), prefix) { - value := strings.TrimSpace(line[len(prefix):]) + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(strings.ToLower(trimmed), prefix) { + value := strings.TrimSpace(trimmed[len(prefix):]) if value != "" { result = append(result, value) } diff --git a/internal/gitview/gitView_test.go b/internal/gitview/gitView_test.go index 00a6b49f3..845baa957 100644 --- a/internal/gitview/gitView_test.go +++ b/internal/gitview/gitView_test.go @@ -531,6 +531,18 @@ func (suite *GitViewTestSuite) TestGetTrailerValues() { 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"}, + }, } { suite.Run(tt.name, func() { result := GetTrailerValues(tt.message, tt.key) From d1665911e7c714f1095dc006b596ed5a51527a59 Mon Sep 17 00:00:00 2001 From: vidhu bala Date: Thu, 27 Aug 2026 16:51:59 +0100 Subject: [PATCH 05/22] fix(jira trailer): trim whitespace from trailer key; add branch-not-scanned test - TrimSpace the key in GetTrailerValues before TrimRight(key, ":") - Add unit test: key with surrounding whitespace still matches - Add integration test 30: --jira-trailer does not scan branch name Co-Authored-By: Claude Sonnet 4.6 --- cmd/kosli/attestJira_test.go | 14 ++++++++++++++ internal/gitview/gitView.go | 2 +- internal/gitview/gitView_test.go | 6 ++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/cmd/kosli/attestJira_test.go b/cmd/kosli/attestJira_test.go index 57c9006aa..45fabe40c 100644 --- a/cmd/kosli/attestJira_test.go +++ b/cmd/kosli/attestJira_test.go @@ -402,6 +402,20 @@ func (suite *AttestJiraCommandTestSuite) TestAttestJiraCmd() { commitMessage: "fix: some change with no jira trailer", }, }, + { + wantError: true, + name: "30 --jira-trailer does not scan branch name even when it 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/internal/gitview/gitView.go b/internal/gitview/gitView.go index 2ef22ce7d..ea1f3c23c 100644 --- a/internal/gitview/gitView.go +++ b/internal/gitview/gitView.go @@ -281,7 +281,7 @@ func getCommitURL(repoURL, commitHash string) string { // 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(key, ":")) + ":" + 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) { diff --git a/internal/gitview/gitView_test.go b/internal/gitview/gitView_test.go index 845baa957..9332fa235 100644 --- a/internal/gitview/gitView_test.go +++ b/internal/gitview/gitView_test.go @@ -543,6 +543,12 @@ func (suite *GitViewTestSuite) TestGetTrailerValues() { 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) From b2fb1c3a8257c3e18979832c46c03d3db6a86efb Mon Sep 17 00:00:00 2001 From: Vidhu Bala Date: Thu, 27 Aug 2026 18:18:42 +0100 Subject: [PATCH 06/22] Update cmd/kosli/root.go Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- cmd/kosli/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 67e658c89..d15030026 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -169,7 +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." + 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." From 678021bf14194316dc0fbf9a9426e7a838dc566e Mon Sep 17 00:00:00 2001 From: vidhu bala Date: Thu, 27 Aug 2026 18:19:25 +0100 Subject: [PATCH 07/22] test(attest jira): pin --jira-trailer/--jira-secondary-source mutual exclusion Co-Authored-By: Claude Sonnet 4.6 --- cmd/kosli/attestJira_test.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cmd/kosli/attestJira_test.go b/cmd/kosli/attestJira_test.go index 45fabe40c..7739d1d35 100644 --- a/cmd/kosli/attestJira_test.go +++ b/cmd/kosli/attestJira_test.go @@ -404,7 +404,13 @@ func (suite *AttestJiraCommandTestSuite) TestAttestJiraCmd() { }, { wantError: true, - name: "30 --jira-trailer does not scan branch name even when it contains a Jira key", + 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 From 3226b5d4696956890f1bfca404cd2c8909af7bf9 Mon Sep 17 00:00:00 2001 From: vidhu bala Date: Fri, 28 Aug 2026 12:05:34 +0100 Subject: [PATCH 08/22] docs(attest jira): fix inaccurate and contradictory help text - Drop dangling "of the form" from long desc opening line - Replace "are not scanned" with explicit mutual-exclusion statement - Replace "bypasses pattern-scanning entirely" with scoped claim - Add "Mutually exclusive with --jira-trailer" to jiraSecondarySourceFlag Co-Authored-By: Claude Sonnet 4.6 --- cmd/kosli/attestJira.go | 13 ++++++++----- cmd/kosli/root.go | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/cmd/kosli/attestJira.go b/cmd/kosli/attestJira.go index d12b7bc67..07f4d16ae 100644 --- a/cmd/kosli/attestJira.go +++ b/cmd/kosli/attestJira.go @@ -40,10 +40,10 @@ const attestJiraShortDesc = `Report a jira attestation to an artifact or a trail const attestJiraLongDesc = attestJiraShortDesc + ` 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. +^--jira-secondary-source^ argument for Jira issue references. 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. +(e.g. ^Jira: PROJ-42^); when set, the commit message body and branch name are not scanned. +^--jira-trailer^ and ^--jira-secondary-source^ are mutually exclusive. Jira issue references have the form: 'at least 2 characters long, starting with an uppercase letter project key followed by @@ -66,8 +66,11 @@ because ^CVE-2026^ would be followed by ^-4^. This applies across all parsed sou 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-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. +git trailer line (e.g. ^Jira: CVE-42^), which confines scanning to the trailer value and +removes collisions caused by surrounding commit text; write the issue key alone in the +trailer value, not embedded in a longer hyphenated string (e.g. ^Jira: CVE-2026-41284^ +would still be filtered out). 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. diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index d15030026..482025291 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -167,7 +167,7 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, jiraPATFlag = "Jira personal access token (for self-hosted Jira)" jiraProjectKeyFlag = "[optional] Jira project key to match against. Can be repeated, or given as a comma-separated list. Defaults to matching any jira project key." 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 }}'" + jiraSecondarySourceFlag = "[optional] An optional string to search for Jira ticket reference, e.g. '--jira-secondary-source ${{ github.head_ref }}'. Mutually exclusive with --jira-trailer." 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." From 044a76a950eaa5dd3ceb853f8dcf881ce57047cf Mon Sep 17 00:00:00 2001 From: vidhu bala Date: Fri, 28 Aug 2026 12:24:48 +0100 Subject: [PATCH 09/22] fix(attest jira): reject blank-ish --jira-trailer; test empty trailer value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Validate that --jira-trailer collapses to a non-empty key after TrimSpace+TrimRight(":"), preventing silent non-compliance - Add integration test 31 pinning the blank-ish key error path - Rename old test 31 → 32 - Add unit test: line with empty value is skipped in GetTrailerValues Co-Authored-By: Claude Sonnet 4.6 --- cmd/kosli/attestJira.go | 4 ++++ cmd/kosli/attestJira_test.go | 8 +++++++- internal/gitview/gitView_test.go | 6 ++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/cmd/kosli/attestJira.go b/cmd/kosli/attestJira.go index 07f4d16ae..a87b76a2d 100644 --- a/cmd/kosli/attestJira.go +++ b/cmd/kosli/attestJira.go @@ -265,6 +265,10 @@ func newAttestJiraCmd(out io.Writer) *cobra.Command { return err } + if cmd.Flags().Changed("jira-trailer") && strings.TrimRight(strings.TrimSpace(o.trailerKey), ":") == "" { + return fmt.Errorf("--jira-trailer cannot be empty") + } + err = ValidateSliceValues(o.redactedCommitInfo, allowedCommitRedactionValues) if err != nil { return fmt.Errorf("%s for --redact-commit-info", err.Error()) diff --git a/cmd/kosli/attestJira_test.go b/cmd/kosli/attestJira_test.go index 7739d1d35..4d234ade2 100644 --- a/cmd/kosli/attestJira_test.go +++ b/cmd/kosli/attestJira_test.go @@ -410,7 +410,13 @@ func (suite *AttestJiraCommandTestSuite) TestAttestJiraCmd() { }, { wantError: true, - name: "31 --jira-trailer does not scan branch name even when branch contains a Jira key", + name: "31 --jira-trailer with a blank-ish value is rejected", + cmd: fmt.Sprintf("attest jira --name bar --jira-base-url https://kosli-test.atlassian.net --jira-trailer : --commit HEAD --repo-root %s %s", suite.tmpDir, suite.defaultKosliArguments), + golden: "Error: --jira-trailer cannot be empty\n", + }, + { + wantError: true, + name: "32 --jira-trailer does not scan branch name even when it contains a Jira key", cmd: fmt.Sprintf(`attest jira --name bar --jira-base-url https://kosli-test.atlassian.net --jira-trailer Jira diff --git a/internal/gitview/gitView_test.go b/internal/gitview/gitView_test.go index 9332fa235..9b5d69a37 100644 --- a/internal/gitview/gitView_test.go +++ b/internal/gitview/gitView_test.go @@ -549,6 +549,12 @@ func (suite *GitViewTestSuite) TestGetTrailerValues() { key: " Jira ", expected: []string{"BX-123"}, }, + { + name: "line with empty value is skipped", + message: "fix: something\n\nJira:", + key: "Jira", + expected: []string{}, + }, } { suite.Run(tt.name, func() { result := GetTrailerValues(tt.message, tt.key) From 549461884fce52b2246a799b3a886e770e9eec54 Mon Sep 17 00:00:00 2001 From: vidhu bala Date: Fri, 28 Aug 2026 12:42:03 +0100 Subject: [PATCH 10/22] fix(gitview): extract NormalizeTrailerKey; fix Unicode slice bug; align error wording - Extract gitview.NormalizeTrailerKey to deduplicate TrimSpace+TrimRight(":") used in both GetTrailerValues and attestJira validation - Fix GetTrailerValues to slice by colon index rather than len(lowercased prefix), avoiding wrong offset when key's lowercase form has different byte length - Update GetTrailerValues doc comment: document empty-value drop, any-line semantics, and key normalisation behaviour - Align blank-ish --jira-trailer error to repo-wide wording: "flag '--jira-trailer' was given an empty value" - Add unit test pinning the Unicode key case Co-Authored-By: Claude Sonnet 4.6 --- cmd/kosli/attestJira.go | 4 ++-- cmd/kosli/attestJira_test.go | 2 +- internal/gitview/gitView.go | 22 +++++++++++++++++----- internal/gitview/gitView_test.go | 6 ++++++ 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/cmd/kosli/attestJira.go b/cmd/kosli/attestJira.go index a87b76a2d..88f9597bb 100644 --- a/cmd/kosli/attestJira.go +++ b/cmd/kosli/attestJira.go @@ -265,8 +265,8 @@ func newAttestJiraCmd(out io.Writer) *cobra.Command { return err } - if cmd.Flags().Changed("jira-trailer") && strings.TrimRight(strings.TrimSpace(o.trailerKey), ":") == "" { - return fmt.Errorf("--jira-trailer cannot be empty") + if cmd.Flags().Changed("jira-trailer") && gitview.NormalizeTrailerKey(o.trailerKey) == "" { + return fmt.Errorf("flag '--jira-trailer' was given an empty value") } err = ValidateSliceValues(o.redactedCommitInfo, allowedCommitRedactionValues) diff --git a/cmd/kosli/attestJira_test.go b/cmd/kosli/attestJira_test.go index 4d234ade2..616874970 100644 --- a/cmd/kosli/attestJira_test.go +++ b/cmd/kosli/attestJira_test.go @@ -412,7 +412,7 @@ func (suite *AttestJiraCommandTestSuite) TestAttestJiraCmd() { wantError: true, name: "31 --jira-trailer with a blank-ish value is rejected", cmd: fmt.Sprintf("attest jira --name bar --jira-base-url https://kosli-test.atlassian.net --jira-trailer : --commit HEAD --repo-root %s %s", suite.tmpDir, suite.defaultKosliArguments), - golden: "Error: --jira-trailer cannot be empty\n", + golden: "Error: flag '--jira-trailer' was given an empty value\n", }, { wantError: true, diff --git a/internal/gitview/gitView.go b/internal/gitview/gitView.go index ea1f3c23c..4e1e1544e 100644 --- a/internal/gitview/gitView.go +++ b/internal/gitview/gitView.go @@ -276,16 +276,28 @@ 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. +// NormalizeTrailerKey strips surrounding whitespace and a trailing colon from key, +// returning the canonical form used for matching and validation. +func NormalizeTrailerKey(key string) string { + return strings.TrimRight(strings.TrimSpace(key), ":") +} + +// GetTrailerValues returns the values of every line in a commit message of the form +// ": " that matches the given key. The key comparison is case-insensitive; +// surrounding whitespace on both the key and the line is ignored, and a trailing ":" +// on the key is tolerated. Lines with an empty value are skipped — a bare "Jira:" line +// produces no entry and does not trigger caller-side warnings that check for a non-empty +// result. Note: this scans every line in the message, not only lines in the final +// paragraph as git interpret-trailers defines them. 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), ":")) + ":" + prefix := strings.ToLower(NormalizeTrailerKey(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):]) + colonIdx := strings.IndexByte(trimmed, ':') + value := strings.TrimSpace(trimmed[colonIdx+1:]) if value != "" { result = append(result, value) } diff --git a/internal/gitview/gitView_test.go b/internal/gitview/gitView_test.go index 9b5d69a37..772da4c67 100644 --- a/internal/gitview/gitView_test.go +++ b/internal/gitview/gitView_test.go @@ -555,6 +555,12 @@ func (suite *GitViewTestSuite) TestGetTrailerValues() { key: "Jira", expected: []string{}, }, + { + name: "key whose lowercase is shorter than the original still extracts correct value", + message: "fix: something\n\nİ: BX-123", + key: "İ", + expected: []string{"BX-123"}, + }, } { suite.Run(tt.name, func() { result := GetTrailerValues(tt.message, tt.key) From e6c984da3de1ce4782340610fed65509dc54e694 Mon Sep 17 00:00:00 2001 From: vidhu bala Date: Fri, 28 Aug 2026 12:48:17 +0100 Subject: [PATCH 11/22] fix(attest jira): warn when trailer key present but value is empty - Add gitview.TrailerKeyExists to detect a key line regardless of whether its value is empty, complementing GetTrailerValues which skips empty values - Update warning condition to use TrailerKeyExists so a bare "Jira:" line now triggers "trailer found but contained no valid Jira issue keys" - Update GetTrailerValues doc comment to reference TrailerKeyExists - Add unit tests for TrailerKeyExists Co-Authored-By: Claude Sonnet 4.6 --- cmd/kosli/attestJira.go | 2 +- internal/gitview/gitView.go | 22 ++++++++++++++---- internal/gitview/gitView_test.go | 39 ++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/cmd/kosli/attestJira.go b/cmd/kosli/attestJira.go index 88f9597bb..b39eccd3f 100644 --- a/cmd/kosli/attestJira.go +++ b/cmd/kosli/attestJira.go @@ -351,7 +351,7 @@ func (o *attestJiraOptions) run(args []string) error { 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 { + if gitview.TrailerKeyExists(commitInfo.Message, o.trailerKey) && len(issueIDs) == 0 { logger.Warn("trailer '%s' was found but contained no valid Jira issue keys: %v", o.trailerKey, trailerValues) } } else { diff --git a/internal/gitview/gitView.go b/internal/gitview/gitView.go index 4e1e1544e..aba91556e 100644 --- a/internal/gitview/gitView.go +++ b/internal/gitview/gitView.go @@ -282,14 +282,26 @@ func NormalizeTrailerKey(key string) string { return strings.TrimRight(strings.TrimSpace(key), ":") } +// TrailerKeyExists reports whether any line in the commit message matches the given key, +// regardless of whether the value is empty. Use this alongside GetTrailerValues to +// distinguish "key not present" from "key present but value empty". +func TrailerKeyExists(message, key string) bool { + prefix := strings.ToLower(NormalizeTrailerKey(key)) + ":" + for _, line := range strings.Split(message, "\n") { + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(line)), prefix) { + return true + } + } + return false +} + // GetTrailerValues returns the values of every line in a commit message of the form // ": " that matches the given key. The key comparison is case-insensitive; // surrounding whitespace on both the key and the line is ignored, and a trailing ":" -// on the key is tolerated. Lines with an empty value are skipped — a bare "Jira:" line -// produces no entry and does not trigger caller-side warnings that check for a non-empty -// result. Note: this scans every line in the message, not only lines in the final -// paragraph as git interpret-trailers defines them. Returns an empty (non-nil) slice -// if none are found. +// on the key is tolerated. Lines with an empty value are skipped — use TrailerKeyExists +// to detect a key that is present but has no value. Note: this scans every line in the +// message, not only lines in the final paragraph as git interpret-trailers defines them. +// Returns an empty (non-nil) slice if none are found. func GetTrailerValues(message, key string) []string { result := []string{} prefix := strings.ToLower(NormalizeTrailerKey(key)) + ":" diff --git a/internal/gitview/gitView_test.go b/internal/gitview/gitView_test.go index 772da4c67..66d11c979 100644 --- a/internal/gitview/gitView_test.go +++ b/internal/gitview/gitView_test.go @@ -569,6 +569,45 @@ func (suite *GitViewTestSuite) TestGetTrailerValues() { } } +func (suite *GitViewTestSuite) TestTrailerKeyExists() { + for _, tt := range []struct { + name string + message string + key string + expected bool + }{ + { + name: "key present with value", + message: "fix: something\n\nJira: BX-123", + key: "Jira", + expected: true, + }, + { + name: "key present with empty value", + message: "fix: something\n\nJira:", + key: "Jira", + expected: true, + }, + { + name: "key absent", + message: "fix: something\n\nsome body text", + key: "Jira", + expected: false, + }, + { + name: "key match is case-insensitive", + message: "fix: something\n\njira: BX-123", + key: "Jira", + expected: true, + }, + } { + suite.Run(tt.name, func() { + result := TrailerKeyExists(tt.message, tt.key) + require.Equal(suite.T(), tt.expected, result) + }) + } +} + func TestGitViewTestSuite(t *testing.T) { suite.Run(t, new(GitViewTestSuite)) } From 056f581b4513e1d467c3be2bc3cdbd2082b8fc01 Mon Sep 17 00:00:00 2001 From: vidhu bala Date: Fri, 28 Aug 2026 13:08:47 +0100 Subject: [PATCH 12/22] fix(attest jira): normalise trailer key before use in run() Apply NormalizeTrailerKey once at the top of the trailer block so that tolerated flag forms (trailing colon, surrounding whitespace) appear in their canonical form in debug logs, warnings, and --assert error messages. Co-Authored-By: Claude Sonnet 4.6 --- cmd/kosli/attestJira.go | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/cmd/kosli/attestJira.go b/cmd/kosli/attestJira.go index b39eccd3f..5840be0da 100644 --- a/cmd/kosli/attestJira.go +++ b/cmd/kosli/attestJira.go @@ -342,17 +342,20 @@ func (o *attestJiraOptions) run(args []string) error { // Find Jira issue keys either from a named git trailer or by scanning the // commit message, branch name, and secondary source. + trailerKey := gitview.NormalizeTrailerKey(o.trailerKey) var issueIDs []string - if o.trailerKey != "" { + issueSource := "commit message or branch name" + if trailerKey != "" { + issueSource = fmt.Sprintf("trailer '%s'", trailerKey) if o.ignoreBranchMatch { logger.Warn("--ignore-branch-match has no effect when --jira-trailer is set") } - trailerValues := gitview.GetTrailerValues(commitInfo.Message, o.trailerKey) + trailerValues := gitview.GetTrailerValues(commitInfo.Message, 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 gitview.TrailerKeyExists(commitInfo.Message, o.trailerKey) && len(issueIDs) == 0 { - logger.Warn("trailer '%s' was found but contained no valid Jira issue keys: %v", o.trailerKey, trailerValues) + logger.Debug("Checked for Jira issue references in trailer '%s' of Git commit %s: %v", trailerKey, commitInfo.Sha1, trailerValues) + if gitview.TrailerKeyExists(commitInfo.Message, trailerKey) && len(issueIDs) == 0 { + logger.Warn("trailer '%s' was found but contained no valid Jira issue keys: %v", trailerKey, trailerValues) } } else { issueIDs = jira.FindJiraIssueKeys(jiraSearchText(commitInfo, o.secondarySource, o.ignoreBranchMatch), o.projectKeys) @@ -360,11 +363,6 @@ func (o *attestJiraOptions) run(args []string) error { } 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 unconfirmedIDs := []string{} From c013c913f5ae5ddf397666fe920cf21545a32460 Mon Sep 17 00:00:00 2001 From: vidhu bala Date: Fri, 28 Aug 2026 13:18:36 +0100 Subject: [PATCH 13/22] test(attest jira): pin TrailerKeyExists warning path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add test 32: trailer present with non-Jira value triggers the "trailer found but no valid Jira issue keys" warning. Rename old 32 → 33. Co-Authored-By: Claude Sonnet 4.6 --- cmd/kosli/attestJira_test.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/cmd/kosli/attestJira_test.go b/cmd/kosli/attestJira_test.go index 616874970..17070c4ff 100644 --- a/cmd/kosli/attestJira_test.go +++ b/cmd/kosli/attestJira_test.go @@ -414,9 +414,20 @@ func (suite *AttestJiraCommandTestSuite) TestAttestJiraCmd() { cmd: fmt.Sprintf("attest jira --name bar --jira-base-url https://kosli-test.atlassian.net --jira-trailer : --commit HEAD --repo-root %s %s", suite.tmpDir, suite.defaultKosliArguments), golden: "Error: flag '--jira-trailer' was given an empty value\n", }, + { + name: "32 --jira-trailer warns when trailer value is present but not a valid Jira key", + 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: "[warning] trailer 'Jira' was found but contained no valid Jira issue keys: [not-a-key]\njira attestation 'bar' is reported to trail: test-123\n", + additionalConfig: jiraTestsAdditionalConfig{ + commitMessage: "fix: some change\n\nJira: not-a-key", + }, + }, { wantError: true, - name: "32 --jira-trailer does not scan branch name even when it contains a Jira key", + name: "33 --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 From 6cb8c8d22293f586a86e27262970544618314d44 Mon Sep 17 00:00:00 2001 From: vidhu bala Date: Fri, 28 Aug 2026 13:31:25 +0100 Subject: [PATCH 14/22] test(attest jira): pin bare-trailer warning; fix example comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add test 32: bare Jira: line triggers TrailerKeyExists warning path - Rename old 32/33 → 33/34 - Fix example comment: replace "bypasses...entirely" with scoped claim matching the long desc caveat (trailer value still pattern-matched) Co-Authored-By: Claude Sonnet 4.6 --- cmd/kosli/attestJira.go | 5 +++-- cmd/kosli/attestJira_test.go | 15 +++++++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/cmd/kosli/attestJira.go b/cmd/kosli/attestJira.go index 5840be0da..7d6d926b5 100644 --- a/cmd/kosli/attestJira.go +++ b/cmd/kosli/attestJira.go @@ -204,8 +204,9 @@ kosli attest jira \ --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 +# confines scanning to the trailer value — useful when project keys collide with +# patterns like CVE identifiers; write the issue key alone (e.g. "Jira: CVE-42"), +# not embedded in a longer hyphenated string ("Jira: CVE-2026-41284" is still filtered) kosli attest jira \ --name yourAttestationName \ --flow yourFlowName \ diff --git a/cmd/kosli/attestJira_test.go b/cmd/kosli/attestJira_test.go index 17070c4ff..c3c36f1e7 100644 --- a/cmd/kosli/attestJira_test.go +++ b/cmd/kosli/attestJira_test.go @@ -415,7 +415,18 @@ func (suite *AttestJiraCommandTestSuite) TestAttestJiraCmd() { golden: "Error: flag '--jira-trailer' was given an empty value\n", }, { - name: "32 --jira-trailer warns when trailer value is present but not a valid Jira key", + name: "32 --jira-trailer warns when trailer key is present but value is empty", + 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: "[warning] trailer 'Jira' was found but contained no valid Jira issue keys: []\njira attestation 'bar' is reported to trail: test-123\n", + additionalConfig: jiraTestsAdditionalConfig{ + commitMessage: "fix: some change\n\nJira:", + }, + }, + { + name: "33 --jira-trailer warns when trailer value is present but not a valid Jira key", cmd: fmt.Sprintf(`attest jira --name bar --jira-base-url https://kosli-test.atlassian.net --jira-trailer Jira @@ -427,7 +438,7 @@ func (suite *AttestJiraCommandTestSuite) TestAttestJiraCmd() { }, { wantError: true, - name: "33 --jira-trailer does not scan branch name even when branch contains a Jira key", + name: "34 --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 From 0ccf62f08cfdea58b1ba91aad78d6dd70af6618b Mon Sep 17 00:00:00 2001 From: vidhu bala Date: Fri, 28 Aug 2026 14:10:51 +0100 Subject: [PATCH 15/22] test(attest jira): pin --ignore-branch-match warning; add jira-trailer to audit spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add test 34: --ignore-branch-match warns it has no effect in trailer mode - Rename old 34 → 35 - Add jira-trailer to flags_to_test and flag_values in empty-flag-audit spec.json Co-Authored-By: Claude Sonnet 4.6 --- cmd/kosli/attestJira_test.go | 14 +++++++++++++- hack/empty-flag-audit/spec.json | 2 ++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/cmd/kosli/attestJira_test.go b/cmd/kosli/attestJira_test.go index c3c36f1e7..ff9535983 100644 --- a/cmd/kosli/attestJira_test.go +++ b/cmd/kosli/attestJira_test.go @@ -436,9 +436,21 @@ func (suite *AttestJiraCommandTestSuite) TestAttestJiraCmd() { commitMessage: "fix: some change\n\nJira: not-a-key", }, }, + { + name: "34 --ignore-branch-match warns that it has no effect in trailer mode", + cmd: fmt.Sprintf(`attest jira --name bar + --jira-base-url https://kosli-test.atlassian.net + --jira-trailer Jira + --ignore-branch-match + --repo-root %s %s`, suite.tmpDir, suite.defaultKosliArguments), + golden: "[warning] --ignore-branch-match has no effect when --jira-trailer is set\njira attestation 'bar' is reported to trail: test-123\n", + additionalConfig: jiraTestsAdditionalConfig{ + commitMessage: "fix: some change\n\nJira: EX-1", + }, + }, { wantError: true, - name: "34 --jira-trailer does not scan branch name even when branch contains a Jira key", + name: "35 --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 diff --git a/hack/empty-flag-audit/spec.json b/hack/empty-flag-audit/spec.json index 03586c647..10b844ad4 100644 --- a/hack/empty-flag-audit/spec.json +++ b/hack/empty-flag-audit/spec.json @@ -836,6 +836,7 @@ "jira-pat", "jira-project-key", "jira-secondary-source", + "jira-trailer", "jira-username", "name", "origin-url", @@ -871,6 +872,7 @@ "jira-pat": "probe-jira-pat", "jira-project-key": "probe-jira-project-key", "jira-secondary-source": "probe-jira-secondary-source", + "jira-trailer": "Jira", "jira-username": "probe-jira-username", "name": "{name}", "origin-url": "http://example.com", From d0c1d8951a71ec27a8dcacbfe8a89eea81e110c7 Mon Sep 17 00:00:00 2001 From: vidhu bala Date: Tue, 1 Sep 2026 16:58:53 +0100 Subject: [PATCH 16/22] fix(gitview): enforce git trailer semantics; reject invalid trailer keys - Restrict GetTrailerValues/TrailerKeyExists to the final paragraph of the commit message, matching git interpret-trailers semantics - Extract trailerBlock helper and scanTrailer to share one matcher - Extract emptyFlagValueError to avoid duplicating the error wording - Reject --jira-trailer keys containing colons or spaces (not valid in git) - Add unit test: key in commit body but not final paragraph is not matched - Add integration test 32: internal colon in key is rejected Co-Authored-By: Claude Sonnet 4.6 --- cmd/kosli/attestJira.go | 5 ++- cmd/kosli/attestJira_test.go | 14 +++++-- cmd/kosli/root.go | 9 +++- internal/gitview/gitView.go | 72 +++++++++++++++++++++----------- internal/gitview/gitView_test.go | 6 +++ 5 files changed, 76 insertions(+), 30 deletions(-) diff --git a/cmd/kosli/attestJira.go b/cmd/kosli/attestJira.go index 7d6d926b5..8e5982507 100644 --- a/cmd/kosli/attestJira.go +++ b/cmd/kosli/attestJira.go @@ -267,7 +267,10 @@ func newAttestJiraCmd(out io.Writer) *cobra.Command { } if cmd.Flags().Changed("jira-trailer") && gitview.NormalizeTrailerKey(o.trailerKey) == "" { - return fmt.Errorf("flag '--jira-trailer' was given an empty value") + return emptyFlagValueError("jira-trailer") + } + if cmd.Flags().Changed("jira-trailer") && strings.ContainsAny(gitview.NormalizeTrailerKey(o.trailerKey), ": ") { + return fmt.Errorf("flag '--jira-trailer' is not a valid trailer key: trailer keys cannot contain colons or spaces") } err = ValidateSliceValues(o.redactedCommitInfo, allowedCommitRedactionValues) diff --git a/cmd/kosli/attestJira_test.go b/cmd/kosli/attestJira_test.go index ff9535983..31b3b596e 100644 --- a/cmd/kosli/attestJira_test.go +++ b/cmd/kosli/attestJira_test.go @@ -415,7 +415,13 @@ func (suite *AttestJiraCommandTestSuite) TestAttestJiraCmd() { golden: "Error: flag '--jira-trailer' was given an empty value\n", }, { - name: "32 --jira-trailer warns when trailer key is present but value is empty", + wantError: true, + name: "32 --jira-trailer with an internal colon is rejected", + cmd: fmt.Sprintf("attest jira --name bar --jira-base-url https://kosli-test.atlassian.net --jira-trailer A:B --commit HEAD --repo-root %s %s", suite.tmpDir, suite.defaultKosliArguments), + golden: "Error: flag '--jira-trailer' is not a valid trailer key: trailer keys cannot contain colons or spaces\n", + }, + { + name: "33 --jira-trailer warns when trailer key is present but value is empty", cmd: fmt.Sprintf(`attest jira --name bar --jira-base-url https://kosli-test.atlassian.net --jira-trailer Jira @@ -426,7 +432,7 @@ func (suite *AttestJiraCommandTestSuite) TestAttestJiraCmd() { }, }, { - name: "33 --jira-trailer warns when trailer value is present but not a valid Jira key", + name: "34 --jira-trailer warns when trailer value is present but not a valid Jira key", cmd: fmt.Sprintf(`attest jira --name bar --jira-base-url https://kosli-test.atlassian.net --jira-trailer Jira @@ -437,7 +443,7 @@ func (suite *AttestJiraCommandTestSuite) TestAttestJiraCmd() { }, }, { - name: "34 --ignore-branch-match warns that it has no effect in trailer mode", + name: "35 --ignore-branch-match warns that it has no effect in trailer mode", cmd: fmt.Sprintf(`attest jira --name bar --jira-base-url https://kosli-test.atlassian.net --jira-trailer Jira @@ -450,7 +456,7 @@ func (suite *AttestJiraCommandTestSuite) TestAttestJiraCmd() { }, { wantError: true, - name: "35 --jira-trailer does not scan branch name even when branch contains a Jira key", + name: "36 --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 diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 482025291..05b4f5d28 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -495,6 +495,13 @@ func refuseEmptyFlagValues(cmd *cobra.Command) { } // reportEmptyFlagValue gives every flag one wording for an empty value. pflag +// emptyFlagValueError returns the canonical error for a flag that was given an +// empty value, used by both the flag-error hook and any manual validation that +// catches forms the hook cannot see (e.g. whitespace-only strings). +func emptyFlagValueError(name string) error { + return fmt.Errorf("flag '--%s' was given an empty value", name) +} + // reports a refused value in its own words and wraps the cause, so the cause is // what says whether this is the empty-value rule speaking. func reportEmptyFlagValue(cmd *cobra.Command, err error) error { @@ -503,7 +510,7 @@ func reportEmptyFlagValue(cmd *cobra.Command, err error) error { } var invalid *pflag.InvalidValueError if errors.As(err, &invalid) { - return fmt.Errorf("flag '--%s' was given an empty value", invalid.GetFlag().Name) + return emptyFlagValueError(invalid.GetFlag().Name) } return err } diff --git a/internal/gitview/gitView.go b/internal/gitview/gitView.go index aba91556e..3394f6bb2 100644 --- a/internal/gitview/gitView.go +++ b/internal/gitview/gitView.go @@ -282,40 +282,64 @@ func NormalizeTrailerKey(key string) string { return strings.TrimRight(strings.TrimSpace(key), ":") } -// TrailerKeyExists reports whether any line in the commit message matches the given key, -// regardless of whether the value is empty. Use this alongside GetTrailerValues to -// distinguish "key not present" from "key present but value empty". -func TrailerKeyExists(message, key string) bool { - prefix := strings.ToLower(NormalizeTrailerKey(key)) + ":" - for _, line := range strings.Split(message, "\n") { - if strings.HasPrefix(strings.ToLower(strings.TrimSpace(line)), prefix) { - return true - } +// trailerBlock returns the final paragraph of a commit message — the contiguous +// block of non-blank lines at the end, which is where git interpret-trailers +// looks for trailer lines. +func trailerBlock(message string) string { + lines := strings.Split(message, "\n") + end := len(lines) + for end > 0 && strings.TrimSpace(lines[end-1]) == "" { + end-- } - return false + start := end + for start > 0 && strings.TrimSpace(lines[start-1]) != "" { + start-- + } + return strings.Join(lines[start:end], "\n") } -// GetTrailerValues returns the values of every line in a commit message of the form -// ": " that matches the given key. The key comparison is case-insensitive; -// surrounding whitespace on both the key and the line is ignored, and a trailing ":" -// on the key is tolerated. Lines with an empty value are skipped — use TrailerKeyExists -// to detect a key that is present but has no value. Note: this scans every line in the -// message, not only lines in the final paragraph as git interpret-trailers defines them. -// Returns an empty (non-nil) slice if none are found. -func GetTrailerValues(message, key string) []string { - result := []string{} +// scanTrailer does one pass over the trailer block of the commit message, +// returning all non-empty values for lines matching key and whether any +// matching line was found at all (including lines with an empty value). +// Both GetTrailerValues and TrailerKeyExists delegate here so they always +// agree on what "matching" means. +func scanTrailer(message, key string) (values []string, found bool) { prefix := strings.ToLower(NormalizeTrailerKey(key)) + ":" - for _, line := range strings.Split(message, "\n") { + for _, line := range strings.Split(trailerBlock(message), "\n") { trimmed := strings.TrimSpace(line) if strings.HasPrefix(strings.ToLower(trimmed), prefix) { + found = true colonIdx := strings.IndexByte(trimmed, ':') - value := strings.TrimSpace(trimmed[colonIdx+1:]) - if value != "" { - result = append(result, value) + if value := strings.TrimSpace(trimmed[colonIdx+1:]); value != "" { + values = append(values, value) } } } - return result + return +} + +// TrailerKeyExists reports whether any line in the final paragraph of the commit +// message matches the given key, regardless of whether the value is empty. +// Use this alongside GetTrailerValues to distinguish "key not present" from +// "key present but value empty". +func TrailerKeyExists(message, key string) bool { + _, found := scanTrailer(message, key) + return found +} + +// GetTrailerValues returns the values of every line in the final paragraph of a +// commit message of the form ": " that matches the given key, matching +// the semantics of git interpret-trailers. The key comparison is case-insensitive; +// surrounding whitespace on both the key and the line is ignored, and a trailing ":" +// on the key is tolerated. Lines with an empty value are skipped — use TrailerKeyExists +// to detect a key that is present but has no value. +// Returns an empty (non-nil) slice if none are found. +func GetTrailerValues(message, key string) []string { + values, _ := scanTrailer(message, key) + if values == nil { + return []string{} + } + return values } // ResolveRevision returns an explicit commit SHA1 from commit SHA or ref (e.g. HEAD~2) diff --git a/internal/gitview/gitView_test.go b/internal/gitview/gitView_test.go index 66d11c979..cc09a9959 100644 --- a/internal/gitview/gitView_test.go +++ b/internal/gitview/gitView_test.go @@ -561,6 +561,12 @@ func (suite *GitViewTestSuite) TestGetTrailerValues() { key: "İ", expected: []string{"BX-123"}, }, + { + name: "key in commit body but not final paragraph is not matched", + message: "fix: something\n\nJira: BX-123\n\nsome body text", + key: "Jira", + expected: []string{}, + }, } { suite.Run(tt.name, func() { result := GetTrailerValues(tt.message, tt.key) From ae06b043c68904d281736745b7fd293d8af4f6fe Mon Sep 17 00:00:00 2001 From: vidhu bala Date: Tue, 1 Sep 2026 17:26:32 +0100 Subject: [PATCH 17/22] docs: use plain language for trailer block scanning semantics Replace "matching git interpret-trailers semantics" and similar phrasing with plain descriptions of what the code actually does: only the last block of lines is scanned (everything after the final blank line, or the whole message if there is no blank line). Also fix a misplaced doc comment for reportEmptyFlagValue/ emptyFlagValueError in root.go, and add a unit test case for a single-paragraph commit message with no blank lines. Co-Authored-By: Claude Sonnet 4.6 --- cmd/kosli/attestJira.go | 4 +++- cmd/kosli/root.go | 4 ++-- internal/gitview/gitView.go | 7 ++++--- internal/gitview/gitView_test.go | 6 ++++++ 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/cmd/kosli/attestJira.go b/cmd/kosli/attestJira.go index 8e5982507..ad38fe7e9 100644 --- a/cmd/kosli/attestJira.go +++ b/cmd/kosli/attestJira.go @@ -42,7 +42,9 @@ const attestJiraLongDesc = attestJiraShortDesc + ` By default, parses the given commit's message, current branch name, or the content of the ^--jira-secondary-source^ argument for Jira issue references. 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 and branch name are not scanned. +(e.g. ^Jira: PROJ-42^); only the last block of lines in the commit message is scanned +(everything after the final blank line, or the whole message if there is no blank line). +The commit message body and branch name are not scanned. ^--jira-trailer^ and ^--jira-secondary-source^ are mutually exclusive. Jira issue references have the form: diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 05b4f5d28..3b6664ec9 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -169,7 +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 }}'. Mutually exclusive with --jira-trailer." 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." + 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 final paragraph of 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." @@ -494,7 +494,6 @@ func refuseEmptyFlagValues(cmd *cobra.Command) { } } -// reportEmptyFlagValue gives every flag one wording for an empty value. pflag // emptyFlagValueError returns the canonical error for a flag that was given an // empty value, used by both the flag-error hook and any manual validation that // catches forms the hook cannot see (e.g. whitespace-only strings). @@ -502,6 +501,7 @@ func emptyFlagValueError(name string) error { return fmt.Errorf("flag '--%s' was given an empty value", name) } +// reportEmptyFlagValue gives every flag one wording for an empty value. pflag // reports a refused value in its own words and wraps the cause, so the cause is // what says whether this is the empty-value rule speaking. func reportEmptyFlagValue(cmd *cobra.Command, err error) error { diff --git a/internal/gitview/gitView.go b/internal/gitview/gitView.go index 3394f6bb2..2bf88718e 100644 --- a/internal/gitview/gitView.go +++ b/internal/gitview/gitView.go @@ -327,9 +327,10 @@ func TrailerKeyExists(message, key string) bool { return found } -// GetTrailerValues returns the values of every line in the final paragraph of a -// commit message of the form ": " that matches the given key, matching -// the semantics of git interpret-trailers. The key comparison is case-insensitive; +// GetTrailerValues returns the values of every line in the last block of a commit +// message of the form ": " that matches the given key. Only the last +// block of lines is scanned — everything after the final blank line, or the whole +// message if there is no blank line. The key comparison is case-insensitive; // surrounding whitespace on both the key and the line is ignored, and a trailing ":" // on the key is tolerated. Lines with an empty value are skipped — use TrailerKeyExists // to detect a key that is present but has no value. diff --git a/internal/gitview/gitView_test.go b/internal/gitview/gitView_test.go index cc09a9959..cc3349811 100644 --- a/internal/gitview/gitView_test.go +++ b/internal/gitview/gitView_test.go @@ -567,6 +567,12 @@ func (suite *GitViewTestSuite) TestGetTrailerValues() { key: "Jira", expected: []string{}, }, + { + name: "single-paragraph message (subject only) is matched", + message: "Jira: BX-123", + key: "Jira", + expected: []string{"BX-123"}, + }, } { suite.Run(tt.name, func() { result := GetTrailerValues(tt.message, tt.key) From da06c97cfabc71e1bd415bc36dd474b756f3c0c7 Mon Sep 17 00:00:00 2001 From: vidhu bala Date: Tue, 1 Sep 2026 17:34:27 +0100 Subject: [PATCH 18/22] fix(attest jira): split trailer warning into three distinct cases Previously a single warning fired when TrailerKeyExists was true but no issue IDs were found, which conflated three different situations and produced misleading messages: - Trailer absent entirely: now warns that the key was not found in the last paragraph of the commit message, helping users diagnose cases where their trailer line is in the message body rather than the footer. - Key present but value empty: now says "had no value" instead of "contained no valid Jira issue keys: []". - Value present but yielding no IDs: distinguishes between a project-key filter mismatch ("did not match project filter") and a format problem ("did not contain valid Jira issue keys"), so users are not told their key format is wrong when it is actually filtered out. Also update golden strings for tests 28, 33, 34, 36 and add test 37 for the project-key-filter case. Co-Authored-By: Claude Sonnet 4.6 --- cmd/kosli/attestJira.go | 26 +++++++++++++++++++------- cmd/kosli/attestJira_test.go | 22 +++++++++++++++++----- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/cmd/kosli/attestJira.go b/cmd/kosli/attestJira.go index ad38fe7e9..4914f17f3 100644 --- a/cmd/kosli/attestJira.go +++ b/cmd/kosli/attestJira.go @@ -9,6 +9,7 @@ import ( "regexp" "slices" "strings" + "unicode" "github.com/kosli-dev/cli/internal/gitview" "github.com/kosli-dev/cli/internal/jira" @@ -268,11 +269,14 @@ func newAttestJiraCmd(out io.Writer) *cobra.Command { return err } - if cmd.Flags().Changed("jira-trailer") && gitview.NormalizeTrailerKey(o.trailerKey) == "" { - return emptyFlagValueError("jira-trailer") - } - if cmd.Flags().Changed("jira-trailer") && strings.ContainsAny(gitview.NormalizeTrailerKey(o.trailerKey), ": ") { - return fmt.Errorf("flag '--jira-trailer' is not a valid trailer key: trailer keys cannot contain colons or spaces") + if cmd.Flags().Changed("jira-trailer") { + normalizedKey := gitview.NormalizeTrailerKey(o.trailerKey) + if normalizedKey == "" { + return emptyFlagValueError("jira-trailer") + } + if strings.Contains(normalizedKey, ":") || strings.IndexFunc(normalizedKey, unicode.IsSpace) >= 0 { + return fmt.Errorf("flag '--jira-trailer' is not a valid trailer key: trailer keys cannot contain colons or whitespace") + } } err = ValidateSliceValues(o.redactedCommitInfo, allowedCommitRedactionValues) @@ -360,8 +364,16 @@ func (o *attestJiraOptions) run(args []string) error { 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", trailerKey, commitInfo.Sha1, trailerValues) - if gitview.TrailerKeyExists(commitInfo.Message, trailerKey) && len(issueIDs) == 0 { - logger.Warn("trailer '%s' was found but contained no valid Jira issue keys: %v", trailerKey, trailerValues) + if !gitview.TrailerKeyExists(commitInfo.Message, trailerKey) { + logger.Warn("trailer '%s' was not found in the last paragraph of the commit message", trailerKey) + } else if len(trailerValues) == 0 { + logger.Warn("trailer '%s' was found but had no value", trailerKey) + } else if len(issueIDs) == 0 { + if len(o.projectKeys) > 0 { + logger.Warn("trailer '%s' values %v did not match project filter %v", trailerKey, trailerValues, o.projectKeys) + } else { + logger.Warn("trailer '%s' values %v did not contain valid Jira issue keys", trailerKey, trailerValues) + } } } else { issueIDs = jira.FindJiraIssueKeys(jiraSearchText(commitInfo, o.secondarySource, o.ignoreBranchMatch), o.projectKeys) diff --git a/cmd/kosli/attestJira_test.go b/cmd/kosli/attestJira_test.go index 31b3b596e..9396aaf5e 100644 --- a/cmd/kosli/attestJira_test.go +++ b/cmd/kosli/attestJira_test.go @@ -384,7 +384,7 @@ func (suite *AttestJiraCommandTestSuite) TestAttestJiraCmd() { --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", + golden: "[warning] trailer 'Jira' was not found in the last paragraph of the commit message\njira attestation 'bar' is reported to trail: test-123\n", additionalConfig: jiraTestsAdditionalConfig{ commitMessage: "fix: some change with no jira trailer", }, @@ -418,7 +418,7 @@ func (suite *AttestJiraCommandTestSuite) TestAttestJiraCmd() { wantError: true, name: "32 --jira-trailer with an internal colon is rejected", cmd: fmt.Sprintf("attest jira --name bar --jira-base-url https://kosli-test.atlassian.net --jira-trailer A:B --commit HEAD --repo-root %s %s", suite.tmpDir, suite.defaultKosliArguments), - golden: "Error: flag '--jira-trailer' is not a valid trailer key: trailer keys cannot contain colons or spaces\n", + golden: "Error: flag '--jira-trailer' is not a valid trailer key: trailer keys cannot contain colons or whitespace\n", }, { name: "33 --jira-trailer warns when trailer key is present but value is empty", @@ -426,7 +426,7 @@ func (suite *AttestJiraCommandTestSuite) TestAttestJiraCmd() { --jira-base-url https://kosli-test.atlassian.net --jira-trailer Jira --repo-root %s %s`, suite.tmpDir, suite.defaultKosliArguments), - golden: "[warning] trailer 'Jira' was found but contained no valid Jira issue keys: []\njira attestation 'bar' is reported to trail: test-123\n", + golden: "[warning] trailer 'Jira' was found but had no value\njira attestation 'bar' is reported to trail: test-123\n", additionalConfig: jiraTestsAdditionalConfig{ commitMessage: "fix: some change\n\nJira:", }, @@ -437,7 +437,7 @@ func (suite *AttestJiraCommandTestSuite) TestAttestJiraCmd() { --jira-base-url https://kosli-test.atlassian.net --jira-trailer Jira --repo-root %s %s`, suite.tmpDir, suite.defaultKosliArguments), - golden: "[warning] trailer 'Jira' was found but contained no valid Jira issue keys: [not-a-key]\njira attestation 'bar' is reported to trail: test-123\n", + golden: "[warning] trailer 'Jira' values [not-a-key] did not contain valid Jira issue keys\njira attestation 'bar' is reported to trail: test-123\n", additionalConfig: jiraTestsAdditionalConfig{ commitMessage: "fix: some change\n\nJira: not-a-key", }, @@ -462,12 +462,24 @@ func (suite *AttestJiraCommandTestSuite) TestAttestJiraCmd() { --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", + golden: "[warning] trailer 'Jira' was not found in the last paragraph of the commit message\njira 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", }, }, + { + name: "37 --jira-trailer warns when trailer value does not match --jira-project-key filter", + cmd: fmt.Sprintf(`attest jira --name bar + --jira-base-url https://kosli-test.atlassian.net + --jira-trailer Jira + --jira-project-key ABC + --repo-root %s %s`, suite.tmpDir, suite.defaultKosliArguments), + golden: "[warning] trailer 'Jira' values [EX-1] did not match project filter [ABC]\njira attestation 'bar' is reported to trail: test-123\n", + additionalConfig: jiraTestsAdditionalConfig{ + commitMessage: "fix: some change\n\nJira: EX-1", + }, + }, } for _, test := range tests { From 7cc641b5432195609aec4fad92f1f0a7fa619ed8 Mon Sep 17 00:00:00 2001 From: vidhu bala Date: Tue, 1 Sep 2026 17:43:40 +0100 Subject: [PATCH 19/22] docs(attest jira): fix contradictory wording in --jira-trailer long desc "The commit message body is not scanned" contradicted the preceding sentence which says the last block of the commit message is scanned. Replace with "The rest of the commit message" to avoid the collision. Co-Authored-By: Claude Sonnet 4.6 --- cmd/kosli/attestJira.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/kosli/attestJira.go b/cmd/kosli/attestJira.go index 4914f17f3..4ca86e20b 100644 --- a/cmd/kosli/attestJira.go +++ b/cmd/kosli/attestJira.go @@ -45,7 +45,7 @@ By default, parses the given commit's message, current branch name, or the conte Use ^--jira-trailer^ to read issue keys exclusively from a named git trailer line instead (e.g. ^Jira: PROJ-42^); only the last block of lines in the commit message is scanned (everything after the final blank line, or the whole message if there is no blank line). -The commit message body and branch name are not scanned. +The rest of the commit message and branch name are not scanned. ^--jira-trailer^ and ^--jira-secondary-source^ are mutually exclusive. Jira issue references have the form: From 67630b67353f7435e48c6d401f08801def81a94d Mon Sep 17 00:00:00 2001 From: vidhu bala Date: Tue, 1 Sep 2026 17:52:18 +0100 Subject: [PATCH 20/22] fix(attest jira): distinguish trailer absent vs outside last block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add TrailerKeyExistsAnywhere to gitview, which scans the whole commit message rather than only the final paragraph. Use it to split the "trailer not found" warning into two cases: - Key exists somewhere in the message but not in the last block: warns "a '' line was found outside the last block of the commit message and was ignored" — names the actual mistake for squash-merge bodies where Jira: appears in the middle. - Key absent from the message entirely: warns "trailer '' was not found in the commit message". Also fix test 29 whose golden was missed in da06c97c (same commit message as tests 28 and 36, so the warning fires there too), update tests 28, 29 and 36 to the new wording, and add test 38 for the outside-last-block case. Co-Authored-By: Claude Sonnet 4.6 --- cmd/kosli/attestJira.go | 6 +++++- cmd/kosli/attestJira_test.go | 17 ++++++++++++++--- internal/gitview/gitView.go | 14 ++++++++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/cmd/kosli/attestJira.go b/cmd/kosli/attestJira.go index 4ca86e20b..e7f10e32e 100644 --- a/cmd/kosli/attestJira.go +++ b/cmd/kosli/attestJira.go @@ -365,7 +365,11 @@ func (o *attestJiraOptions) run(args []string) error { issueIDs = jira.FindJiraIssueKeys(combinedTrailerText, o.projectKeys) logger.Debug("Checked for Jira issue references in trailer '%s' of Git commit %s: %v", trailerKey, commitInfo.Sha1, trailerValues) if !gitview.TrailerKeyExists(commitInfo.Message, trailerKey) { - logger.Warn("trailer '%s' was not found in the last paragraph of the commit message", trailerKey) + if gitview.TrailerKeyExistsAnywhere(commitInfo.Message, trailerKey) { + logger.Warn("a '%s' line was found outside the last block of the commit message and was ignored", trailerKey) + } else { + logger.Warn("trailer '%s' was not found in the commit message", trailerKey) + } } else if len(trailerValues) == 0 { logger.Warn("trailer '%s' was found but had no value", trailerKey) } else if len(issueIDs) == 0 { diff --git a/cmd/kosli/attestJira_test.go b/cmd/kosli/attestJira_test.go index 9396aaf5e..8bb30f32a 100644 --- a/cmd/kosli/attestJira_test.go +++ b/cmd/kosli/attestJira_test.go @@ -384,7 +384,7 @@ func (suite *AttestJiraCommandTestSuite) TestAttestJiraCmd() { --jira-base-url https://kosli-test.atlassian.net --jira-trailer Jira --repo-root %s %s`, suite.tmpDir, suite.defaultKosliArguments), - golden: "[warning] trailer 'Jira' was not found in the last paragraph of the commit message\njira attestation 'bar' is reported to trail: test-123\n", + golden: "[warning] trailer 'Jira' was not found in the commit message\njira attestation 'bar' is reported to trail: test-123\n", additionalConfig: jiraTestsAdditionalConfig{ commitMessage: "fix: some change with no jira trailer", }, @@ -397,7 +397,7 @@ func (suite *AttestJiraCommandTestSuite) TestAttestJiraCmd() { --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", + golden: "[warning] trailer 'Jira' was not found in the commit message\njira 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", }, @@ -462,7 +462,7 @@ func (suite *AttestJiraCommandTestSuite) TestAttestJiraCmd() { --jira-trailer Jira --assert --repo-root %s %s`, suite.tmpDir, suite.defaultKosliArguments), - golden: "[warning] trailer 'Jira' was not found in the last paragraph of the commit message\njira attestation 'bar' is reported to trail: test-123\nError: no Jira references are found in trailer 'Jira'\n", + golden: "[warning] trailer 'Jira' was not found in the commit message\njira 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", @@ -480,6 +480,17 @@ func (suite *AttestJiraCommandTestSuite) TestAttestJiraCmd() { commitMessage: "fix: some change\n\nJira: EX-1", }, }, + { + name: "38 --jira-trailer warns when trailer key exists outside the last block", + 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: "[warning] a 'Jira' line was found outside the last block of the commit message and was ignored\njira attestation 'bar' is reported to trail: test-123\n", + additionalConfig: jiraTestsAdditionalConfig{ + commitMessage: "feat: thing (#123)\n\n* wip\n\nJira: EX-1\n\n* address review", + }, + }, } for _, test := range tests { diff --git a/internal/gitview/gitView.go b/internal/gitview/gitView.go index 2bf88718e..a43b27026 100644 --- a/internal/gitview/gitView.go +++ b/internal/gitview/gitView.go @@ -327,6 +327,20 @@ func TrailerKeyExists(message, key string) bool { return found } +// TrailerKeyExistsAnywhere reports whether any line in the whole commit message +// matches the given key, regardless of which paragraph it is in. Use this +// alongside TrailerKeyExists to distinguish "key absent entirely" from "key +// present but not in the final paragraph". +func TrailerKeyExistsAnywhere(message, key string) bool { + prefix := strings.ToLower(NormalizeTrailerKey(key)) + ":" + for _, line := range strings.Split(message, "\n") { + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(line)), prefix) { + return true + } + } + return false +} + // GetTrailerValues returns the values of every line in the last block of a commit // message of the form ": " that matches the given key. Only the last // block of lines is scanned — everything after the final blank line, or the whole From 43934bcf2b06c6e1b460ecc972c2cb0db92b46e8 Mon Sep 17 00:00:00 2001 From: vidhu bala Date: Tue, 1 Sep 2026 18:10:03 +0100 Subject: [PATCH 21/22] docs(root): fix contradictory wording in jiraTrailerFlag help "commit message body" contradicted the preceding phrase "final paragraph of the commit message". Use "rest of the commit message" to match the long description. Co-Authored-By: Claude Sonnet 4.6 --- cmd/kosli/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 3b6664ec9..751e77d8e 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -169,7 +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 }}'. Mutually exclusive with --jira-trailer." 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 final paragraph of the commit message). When set, the commit message body and branch name are not scanned. Mutually exclusive with --jira-secondary-source." + 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 final paragraph of the commit message). When set, the rest of the commit message 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." From e7018a5627699622565f856a8b569fa1eaee21b6 Mon Sep 17 00:00:00 2001 From: vidhu bala Date: Tue, 1 Sep 2026 21:08:11 +0100 Subject: [PATCH 22/22] refactor(gitview): extract scanLines to unify trailer matching logic TrailerKeyExistsAnywhere had its own copy of the line-matching loop, diverging from scanTrailer despite needing to agree on what "matching" means. Extract scanLines from scanTrailer; both scanTrailer and TrailerKeyExistsAnywhere now delegate to it, so a single change keeps all three exported helpers in sync. Also add the missing unit coverage: a "key present outside last block" case to TestTrailerKeyExists (must return false) and a new TestTrailerKeyExistsAnywhere table covering key in last block, key outside last block, and key absent entirely. Co-Authored-By: Claude Sonnet 4.6 --- internal/gitview/gitView.go | 29 ++++++++++++------------ internal/gitview/gitView_test.go | 39 ++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/internal/gitview/gitView.go b/internal/gitview/gitView.go index a43b27026..8bdab9d9f 100644 --- a/internal/gitview/gitView.go +++ b/internal/gitview/gitView.go @@ -298,14 +298,13 @@ func trailerBlock(message string) string { return strings.Join(lines[start:end], "\n") } -// scanTrailer does one pass over the trailer block of the commit message, -// returning all non-empty values for lines matching key and whether any -// matching line was found at all (including lines with an empty value). -// Both GetTrailerValues and TrailerKeyExists delegate here so they always -// agree on what "matching" means. -func scanTrailer(message, key string) (values []string, found bool) { +// scanLines does one pass over lines, returning all non-empty values for lines +// matching key and whether any matching line was found at all (including lines +// with an empty value). All exported trailer helpers delegate here so they +// always agree on what "matching" means. +func scanLines(lines []string, key string) (values []string, found bool) { prefix := strings.ToLower(NormalizeTrailerKey(key)) + ":" - for _, line := range strings.Split(trailerBlock(message), "\n") { + for _, line := range lines { trimmed := strings.TrimSpace(line) if strings.HasPrefix(strings.ToLower(trimmed), prefix) { found = true @@ -318,6 +317,13 @@ func scanTrailer(message, key string) (values []string, found bool) { return } +// scanTrailer does one pass over the trailer block of the commit message. +// Both GetTrailerValues and TrailerKeyExists delegate here so they always +// agree on what "matching" means. +func scanTrailer(message, key string) ([]string, bool) { + return scanLines(strings.Split(trailerBlock(message), "\n"), key) +} + // TrailerKeyExists reports whether any line in the final paragraph of the commit // message matches the given key, regardless of whether the value is empty. // Use this alongside GetTrailerValues to distinguish "key not present" from @@ -332,13 +338,8 @@ func TrailerKeyExists(message, key string) bool { // alongside TrailerKeyExists to distinguish "key absent entirely" from "key // present but not in the final paragraph". func TrailerKeyExistsAnywhere(message, key string) bool { - prefix := strings.ToLower(NormalizeTrailerKey(key)) + ":" - for _, line := range strings.Split(message, "\n") { - if strings.HasPrefix(strings.ToLower(strings.TrimSpace(line)), prefix) { - return true - } - } - return false + _, found := scanLines(strings.Split(message, "\n"), key) + return found } // GetTrailerValues returns the values of every line in the last block of a commit diff --git a/internal/gitview/gitView_test.go b/internal/gitview/gitView_test.go index cc3349811..63fc1bbbd 100644 --- a/internal/gitview/gitView_test.go +++ b/internal/gitview/gitView_test.go @@ -612,6 +612,12 @@ func (suite *GitViewTestSuite) TestTrailerKeyExists() { key: "Jira", expected: true, }, + { + name: "key present outside last block is not matched", + message: "fix: something\n\nJira: BX-123\n\n* address review", + key: "Jira", + expected: false, + }, } { suite.Run(tt.name, func() { result := TrailerKeyExists(tt.message, tt.key) @@ -620,6 +626,39 @@ func (suite *GitViewTestSuite) TestTrailerKeyExists() { } } +func (suite *GitViewTestSuite) TestTrailerKeyExistsAnywhere() { + for _, tt := range []struct { + name string + message string + key string + expected bool + }{ + { + name: "key in last block", + message: "fix: something\n\nJira: BX-123", + key: "Jira", + expected: true, + }, + { + name: "key outside last block", + message: "fix: something\n\nJira: BX-123\n\n* address review", + key: "Jira", + expected: true, + }, + { + name: "key absent entirely", + message: "fix: something with no trailer", + key: "Jira", + expected: false, + }, + } { + suite.Run(tt.name, func() { + result := TrailerKeyExistsAnywhere(tt.message, tt.key) + require.Equal(suite.T(), tt.expected, result) + }) + } +} + func TestGitViewTestSuite(t *testing.T) { suite.Run(t, new(GitViewTestSuite)) }