Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8ebcaf2
feat(jira): add --jira-trailer flag to extract issue key from git tra…
vidhu-balad Aug 19, 2026
7f8d11d
test: register --jira-trailer in empty-flag-audit coverage
vidhu-balad Aug 19, 2026
537dd16
Merge branch 'main' into feat/jira-trailer-flag
vidhu-balad Aug 25, 2026
2d7398a
Update cmd/kosli/attestJira_test.go
vidhu-balad Aug 25, 2026
11c68b5
fix(attest jira): add --jira-trailer flag with edge case fixes and ac…
vidhu-balad Aug 25, 2026
d166591
fix(jira trailer): trim whitespace from trailer key; add branch-not-s…
vidhu-balad Aug 27, 2026
b2fb1c3
Update cmd/kosli/root.go
vidhu-balad Aug 27, 2026
678021b
test(attest jira): pin --jira-trailer/--jira-secondary-source mutual …
vidhu-balad Aug 27, 2026
3226b5d
docs(attest jira): fix inaccurate and contradictory help text
vidhu-balad Aug 28, 2026
044a76a
fix(attest jira): reject blank-ish --jira-trailer; test empty trailer…
vidhu-balad Aug 28, 2026
5494618
fix(gitview): extract NormalizeTrailerKey; fix Unicode slice bug; ali…
vidhu-balad Aug 28, 2026
e6c984d
fix(attest jira): warn when trailer key present but value is empty
vidhu-balad Aug 28, 2026
056f581
fix(attest jira): normalise trailer key before use in run()
vidhu-balad Aug 28, 2026
c013c91
test(attest jira): pin TrailerKeyExists warning path
vidhu-balad Aug 28, 2026
6cb8c8d
test(attest jira): pin bare-trailer warning; fix example comment
vidhu-balad Aug 28, 2026
0ccf62f
test(attest jira): pin --ignore-branch-match warning; add jira-traile…
vidhu-balad Aug 28, 2026
d0c1d89
fix(gitview): enforce git trailer semantics; reject invalid trailer keys
vidhu-balad Sep 1, 2026
ae06b04
docs: use plain language for trailer block scanning semantics
vidhu-balad Sep 1, 2026
da06c97
fix(attest jira): split trailer warning into three distinct cases
vidhu-balad Sep 1, 2026
7cc641b
docs(attest jira): fix contradictory wording in --jira-trailer long desc
vidhu-balad Sep 1, 2026
67630b6
fix(attest jira): distinguish trailer absent vs outside last block
vidhu-balad Sep 1, 2026
43934bc
docs(root): fix contradictory wording in jiraTrailerFlag help
vidhu-balad Sep 1, 2026
e7018a5
refactor(gitview): extract scanLines to unify trailer matching logic
vidhu-balad Sep 1, 2026
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
99 changes: 87 additions & 12 deletions cmd/kosli/attestJira.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"regexp"
"slices"
"strings"
"unicode"

"github.com/kosli-dev/cli/internal/gitview"
"github.com/kosli-dev/cli/internal/jira"
Expand All @@ -30,6 +31,7 @@ type attestJiraOptions struct {
projectKeys []string
issueFields string
secondarySource string
trailerKey string
ignoreBranchMatch bool
assert bool
payload JiraAttestationPayload
Expand All @@ -38,8 +40,15 @@ 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.
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 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:
'at least 2 characters long, starting with an uppercase letter project key followed by
dash and one or more digits'.

Expand All @@ -59,13 +68,19 @@ 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 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.

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
Expand Down Expand Up @@ -190,6 +205,21 @@ 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")
# 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 \
--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 {
Expand Down Expand Up @@ -234,6 +264,21 @@ func newAttestJiraCmd(out io.Writer) *cobra.Command {
return err
}

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

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)
if err != nil {
return fmt.Errorf("%s for --redact-commit-info", err.Error())
Expand Down Expand Up @@ -263,6 +308,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 @@ -304,11 +350,40 @@ func (o *attestJiraOptions) run(args []string) error {
return err
}

// Search commit message, branch name, and secondary source for Jira issue keys,
// filtering out false positives from multi-segment identifiers like CVE-2026-41284.
issueIDs := jira.FindJiraIssueKeys(jiraSearchText(commitInfo, o.secondarySource, o.ignoreBranchMatch), o.projectKeys)
logger.Debug("Checked for Jira issue references in Git commit %s on branch %s commit message:\n%s", commitInfo.Sha1, commitInfo.Branch, commitInfo.Message)
logger.Debug("the following Jira references are found in commit message or branch name: %v", issueIDs)
// Find Jira issue keys either from a named git trailer or by scanning the
// commit message, branch name, and secondary source.
trailerKey := gitview.NormalizeTrailerKey(o.trailerKey)
var issueIDs []string
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")
Comment thread
vidhu-balad marked this conversation as resolved.
}
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", trailerKey, commitInfo.Sha1, trailerValues)
if !gitview.TrailerKeyExists(commitInfo.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 {
if len(o.projectKeys) > 0 {
logger.Warn("trailer '%s' values %v did not match project filter %v", trailerKey, trailerValues, o.projectKeys)
Comment thread
vidhu-balad marked this conversation as resolved.
Comment thread
vidhu-balad marked this conversation as resolved.
} 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)
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)
Comment thread
vidhu-balad marked this conversation as resolved.

issueLog := ""
issueFoundCount := 0
Expand Down Expand Up @@ -368,7 +443,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 {
Expand All @@ -381,8 +456,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)
}
Expand Down
125 changes: 125 additions & 0 deletions cmd/kosli/attestJira_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,131 @@ func (suite *AttestJiraCommandTestSuite) TestAttestJiraCmd() {
cmd: fmt.Sprintf("attest jira --name .foo --commit HEAD --jira-base-url https://kosli-test.atlassian.net %s", suite.defaultKosliArguments),
golden: "Error: failed to parse attestation name: invalid attestation name format: .foo\n",
},
{
name: "27 can attest jira using --jira-trailer to extract issue key from commit trailer",
cmd: fmt.Sprintf(`attest jira --name bar
--jira-base-url https://kosli-test.atlassian.net
--jira-trailer Jira
--assert
--repo-root %s %s`, suite.tmpDir, suite.defaultKosliArguments),
golden: "jira attestation 'bar' is reported to trail: test-123\n",
additionalConfig: jiraTestsAdditionalConfig{
commitMessage: "fix: some change\n\nJira: EX-1\nOna-Environment-Id: ONA-999",
},
},
Comment thread
vidhu-balad marked this conversation as resolved.
Comment thread
vidhu-balad marked this conversation as resolved.
{
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: "[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",
},
},
{
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: "[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",
},
},
{
wantError: true,
name: "30 --jira-trailer and --jira-secondary-source are mutually exclusive",
cmd: fmt.Sprintf("attest jira --name bar --jira-base-url https://kosli-test.atlassian.net --jira-trailer Jira --jira-secondary-source foo --commit HEAD --repo-root %s %s", suite.tmpDir, suite.defaultKosliArguments),
golden: "Error: only one of --jira-trailer, --jira-secondary-source is allowed\n",
},
{
wantError: true,
name: "31 --jira-trailer 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: flag '--jira-trailer' was given an empty value\n",
},
{
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 whitespace\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
--repo-root %s %s`, suite.tmpDir, suite.defaultKosliArguments),
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:",
},
},
{
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
--repo-root %s %s`, suite.tmpDir, suite.defaultKosliArguments),
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",
},
},
Comment thread
vidhu-balad marked this conversation as resolved.
{
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
--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: "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
--assert
--repo-root %s %s`, suite.tmpDir, suite.defaultKosliArguments),
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{
Comment thread
vidhu-balad marked this conversation as resolved.
branchName: "EX-1-some-feature",
commitMessage: "fix: some change with no jira trailer",
},
Comment thread
vidhu-balad marked this conversation as resolved.
},
Comment thread
vidhu-balad marked this conversation as resolved.
{
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",
},
},
{
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 {
Expand Down
12 changes: 10 additions & 2 deletions cmd/kosli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,9 @@ 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: <issue-key>' 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."
Expand Down Expand Up @@ -493,6 +494,13 @@ func refuseEmptyFlagValues(cmd *cobra.Command) {
}
}

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

// 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.
Expand All @@ -502,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
}
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",
Comment thread
vidhu-balad marked this conversation as resolved.
"jira-username": "string",
"name": "string",
"origin-url": "string",
Expand Down
2 changes: 2 additions & 0 deletions hack/empty-flag-audit/spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,7 @@
"jira-pat",
"jira-project-key",
"jira-secondary-source",
"jira-trailer",
"jira-username",
"name",
"origin-url",
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading