diff --git a/cmd/kosli/attestJira.go b/cmd/kosli/attestJira.go index 6d1daa3a5..e7f10e32e 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" @@ -30,6 +31,7 @@ type attestJiraOptions struct { projectKeys []string issueFields string secondarySource string + trailerKey string ignoreBranchMatch bool assert bool payload JiraAttestationPayload @@ -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'. @@ -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 @@ -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 { @@ -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()) @@ -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) @@ -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") + } + 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) + } 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) issueLog := "" issueFoundCount := 0 @@ -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 { @@ -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) } diff --git a/cmd/kosli/attestJira_test.go b/cmd/kosli/attestJira_test.go index 2e1dfecb2..8bb30f32a 100644 --- a/cmd/kosli/attestJira_test.go +++ b/cmd/kosli/attestJira_test.go @@ -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", + }, + }, + { + 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", + }, + }, + { + 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{ + 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", + }, + }, + { + 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/cmd/kosli/root.go b/cmd/kosli/root.go index f8efa6e7d..751e77d8e 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -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: ' 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." @@ -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. @@ -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 } diff --git a/cmd/kosli/testdata/empty-flag-audit-coverage.json b/cmd/kosli/testdata/empty-flag-audit-coverage.json index 160179dea..84b07a7b5 100644 --- a/cmd/kosli/testdata/empty-flag-audit-coverage.json +++ b/cmd/kosli/testdata/empty-flag-audit-coverage.json @@ -212,6 +212,7 @@ "jira-pat": "string", "jira-project-key": "stringSlice", "jira-secondary-source": "string", + "jira-trailer": "string", "jira-username": "string", "name": "string", "origin-url": "string", diff --git a/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", diff --git a/internal/gitview/gitView.go b/internal/gitview/gitView.go index 08dfed9c9..8bdab9d9f 100644 --- a/internal/gitview/gitView.go +++ b/internal/gitview/gitView.go @@ -276,6 +276,88 @@ func getCommitURL(repoURL, commitHash string) string { } } +// 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), ":") +} + +// 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-- + } + start := end + for start > 0 && strings.TrimSpace(lines[start-1]) != "" { + start-- + } + return strings.Join(lines[start:end], "\n") +} + +// 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 lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(strings.ToLower(trimmed), prefix) { + found = true + colonIdx := strings.IndexByte(trimmed, ':') + if value := strings.TrimSpace(trimmed[colonIdx+1:]); value != "" { + values = append(values, value) + } + } + } + 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 +// "key present but value empty". +func TrailerKeyExists(message, key string) bool { + _, found := scanTrailer(message, key) + 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 { + _, found := scanLines(strings.Split(message, "\n"), key) + return found +} + +// 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. +// 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) func (gv *GitView) ResolveRevision(commitSHAOrRef string) (string, error) { hash, err := gv.repository.ResolveRevision(plumbing.Revision(commitSHAOrRef)) diff --git a/internal/gitview/gitView_test.go b/internal/gitview/gitView_test.go index 8b90f2bf7..63fc1bbbd 100644 --- a/internal/gitview/gitView_test.go +++ b/internal/gitview/gitView_test.go @@ -488,6 +488,177 @@ func initializeRepoAndCommit(repoPath string, commitsNumber int) (*git.Repositor return repo, w, nil } +func (suite *GitViewTestSuite) TestGetTrailerValues() { + for _, tt := range []struct { + name string + message string + key string + expected []string + }{ + { + name: "no trailers returns empty slice", + message: "fix: something\n\nsome body text", + key: "Jira", + expected: []string{}, + }, + { + name: "single matching trailer", + message: "fix: something\n\nJira: BX-123", + key: "Jira", + expected: []string{"BX-123"}, + }, + { + name: "key match is case-insensitive", + message: "fix: something\n\njira: BX-123", + key: "Jira", + expected: []string{"BX-123"}, + }, + { + name: "multiple occurrences of same key", + message: "fix: something\n\nJira: BX-123\nJira: BX-456", + key: "Jira", + expected: []string{"BX-123", "BX-456"}, + }, + { + name: "non-matching trailers are ignored", + message: "fix: something\n\nJira: BX-123\nOna-Environment-Id: ONA-456", + key: "Jira", + expected: []string{"BX-123"}, + }, + { + name: "whitespace trimmed from value", + message: "fix: something\n\nJira: BX-123 ", + key: "Jira", + expected: []string{"BX-123"}, + }, + { + name: "leading whitespace on line is tolerated", + message: "fix: something\n\n Jira: BX-123", + key: "Jira", + expected: []string{"BX-123"}, + }, + { + name: "key supplied with trailing colon still matches", + message: "fix: something\n\nJira: BX-123", + key: "Jira:", + expected: []string{"BX-123"}, + }, + { + name: "key with surrounding whitespace still matches", + message: "fix: something\n\nJira: BX-123", + key: " Jira ", + expected: []string{"BX-123"}, + }, + { + name: "line with empty value is skipped", + message: "fix: something\n\nJira:", + 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"}, + }, + { + 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{}, + }, + { + 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) + require.Equal(suite.T(), tt.expected, result) + }) + } +} + +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, + }, + { + 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) + require.Equal(suite.T(), tt.expected, result) + }) + } +} + +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)) }