Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 22 additions & 12 deletions cmd/kosli/attestJira.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type attestJiraOptions struct {
projectKeys []string
issueFields string
secondarySource string
trailerKey string
ignoreBranchMatch bool
assert bool
payload JiraAttestationPayload
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Comment on lines +306 to +313

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--jira-trailer silently disables --jira-secondary-source and --ignore-branch-match. Both are accepted without complaint and then have no effect, which is easy to get wrong in a CI pipeline (--jira-secondary-source ${{ github.head_ref }} quietly becoming a no-op is a compliance-relevant silent change). This file already uses the repo's helper for exactly this shape of problem (lines 220–233), so:

err = MuXRequiredFlags(cmd, []string{"jira-trailer", "jira-secondary-source"}, false)
if err != nil {
	return err
}

For --ignore-branch-match a logger.Warn in run() would be enough, since it's already implied by the trailer mode.

Separately: trailer values are still fed through jira.FindJiraIssueKeys, so Jira: EX1 or Jira: 1234 yields nothing at all — no warning, just a non-compliant attestation. Worth a logger.Warn when len(trailerValues) > 0 && len(issueIDs) == 0, since in trailer mode the user has explicitly declared where the key lives and a mismatch is almost certainly a mistake rather than an absent reference.

} 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The --assert failure messages still say "commit message or branch name", which is exactly what trailer mode does not read.

Both assert paths below hardcode the old wording:

  • attestJira.go:374"no Jira references are found in commit message or branch name"
  • attestJira.go:382"missing Jira issues from references found in commit message or branch name"

With --jira-trailer Jira the commit body and branch were never scanned, so a user who hits the first error is told to look in two places the command deliberately ignored. The actual cause is "the commit has no Jira: trailer" (or the trailer value didn't match the Jira key pattern) — a materially different fix on the user's side, and this is the one message they get in a failing CI job.

Test 29 pins the wrong wording as golden (attestJira_test.go:364), so the wording is now covered by a test asserting it, which makes it harder to notice later.

Threading the source through both messages keeps them accurate in either mode:

searchedIn := "commit message or branch name"
if o.trailerKey != "" {
	searchedIn = fmt.Sprintf("the '%s' trailer of the commit message", o.trailerKey)
}

then fmt.Errorf("%sno Jira references are found in %s", errString, searchedIn) and fmt.Errorf("%smissing Jira issues from references found in %s%s", errString, searchedIn, issueLog), with test 29's golden updated to match.

Fix this →


issueLog := ""
issueFoundCount := 0
Expand Down
35 changes: 35 additions & 0 deletions cmd/kosli/attestJira_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
},
Comment on lines +334 to +344

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test 27 does not actually exercise the new behaviour. Its golden is byte-identical to test 28's, and test 28 is the no issue found case — the "reported to trail" line is printed regardless of how many issue IDs were resolved. So this test still passes if GetTrailerValues returns nothing, or if the whole commit message leaks through and ONA-999 is also resolved (a not-found issue produces no output difference either).

Adding --assert makes the assertion meaningful in both directions: it fails if no reference is found (trailer not read) and if ONA-999 leaks in (issueFoundCount != len(issueIDs) → error). That's the actual headline claim of the PR, and nothing currently covers it.

Suggested change
{
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: "27 can attest jira using --jira-trailer to extract issue key from commit trailer",
cmd: fmt.Sprintf(`attest jira --name bar
--jira-base-url https://kosli-test.atlassian.net
--jira-trailer Jira
--assert
--repo-root %s %s`, suite.tmpDir, suite.defaultKosliArguments),
golden: "jira attestation 'bar' is reported to trail: test-123\n",
additionalConfig: jiraTestsAdditionalConfig{
commitMessage: "fix: some change\n\nJira: EX-1\nOna-Environment-Id: ONA-999",
},
},

{
name: "28 --jira-trailer with no matching trailer produces no issue IDs (non-compliant but reported)",
cmd: fmt.Sprintf(`attest jira --name bar
--jira-base-url https://kosli-test.atlassian.net
--jira-trailer Jira
--repo-root %s %s`, suite.tmpDir, suite.defaultKosliArguments),
golden: "jira attestation 'bar' is reported to trail: test-123\n",
additionalConfig: jiraTestsAdditionalConfig{
commitMessage: "fix: some change with no jira trailer",
},
},
{
wantError: true,
name: "29 --jira-trailer with --assert fails when trailer is absent",
cmd: fmt.Sprintf(`attest jira --name bar
--jira-base-url https://kosli-test.atlassian.net
--jira-trailer Jira
--assert
--repo-root %s %s`, suite.tmpDir, suite.defaultKosliArguments),
golden: "jira attestation 'bar' is reported to trail: test-123\nError: no Jira references are found in commit message or branch name\n",
additionalConfig: jiraTestsAdditionalConfig{
commitMessage: "fix: some change with no jira trailer",
},
},
}

for _, test := range tests {
Expand Down
1 change: 1 addition & 0 deletions cmd/kosli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: <issue-key>' lines from the commit message). When set, the commit message body and branch name are not scanned."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flag help is clear and accurate. The gap is the command's Long description in cmd/kosli/attestJira.go:41–68, which documents the search sources in prose and wasn't updated:

  • Line 41 still says the command "Parses the given commit's message, current branch name or the content of --jira-secondary-source" with no mention of trailer mode.
  • Line 68 documents --ignore-branch-match but not that --jira-trailer supersedes it.
  • Lines 60–63 recommend --jira-secondary-source as the workaround for the CVE--style project-key collision — --jira-trailer is now the better answer to that exact problem and should be mentioned there.

Per the slice checklist in CLAUDE.md ("Does kosli <command> --help reflect the change?"), the prose in Long is part of --help. An attestJiraExample entry would help too, since every other non-obvious flag has one.

envDescriptionFlag = "[optional] The environment description."
flowDescriptionFlag = "[optional] The Kosli flow description."
trailDescriptionFlag = "[optional] The Kosli trail description."
Expand Down
1 change: 1 addition & 0 deletions cmd/kosli/testdata/empty-flag-audit-coverage.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions internal/gitview/gitView.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<key>: <value>". Returns an empty (non-nil) slice if none are found.
Comment on lines +319 to +321

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naming/semantics nit worth resolving before this ships, since the name sets an expectation the implementation doesn't meet: real git trailers (per git interpret-trailers) live only in the last paragraph of the message. This function matches <key>: on any line, including the subject and prose in the body — so fix: EX-1 handled\n\nJira: ask the team which ticket applies would treat the prose line as a trailer value.

For the current use case that leniency is harmless (the value goes through the Jira key regex anyway), but the doc comment should say so explicitly rather than calling them "trailer lines", e.g. "matches any line of the form <key>: <value> anywhere in the message, not only trailers in the final paragraph". Otherwise the next caller will reasonably assume git trailer semantics.

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):])
Comment on lines +325 to +327

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two edge cases in the prefix match:

  1. Leading whitespace is not tolerated. HasPrefix runs on the raw line, so " Jira: EX-1" does not match. Commit messages written through editors/templates or pasted from git log output (which indents by 4 spaces) will silently produce no matches.
  2. A key supplied with a trailing colon breaks silently. --jira-trailer "Jira:" builds the prefix "jira::", which never matches, with no error or warning.

Trimming both sides handles (1) cheaply and keeps everything else identical (TrimSpace on the value already covers \r from CRLF messages):

Suggested change
for _, line := range strings.Split(message, "\n") {
if strings.HasPrefix(strings.ToLower(line), prefix) {
value := strings.TrimSpace(line[len(prefix):])
for _, line := range strings.Split(message, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(strings.ToLower(line), prefix) {
value := strings.TrimSpace(line[len(prefix):])

For (2), consider prefix := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(key), ":")) + ":".

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))
Expand Down
51 changes: 51 additions & 0 deletions internal/gitview/gitView_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Loading