From 6bddeb0618649b25044d8c3351f8e1e022e24248 Mon Sep 17 00:00:00 2001 From: Jens Brejner Date: Wed, 24 Jun 2026 15:42:32 +0200 Subject: [PATCH] fix(attest jira): match Jira keys case-insensitively by uppercasing commit text Previously `FindJiraIssueKeys` applied the regex to the original commit message, so mixed-case keys like `tDIE-12419` were silently missed. The fix uppercases the full input text before matching, meaning all returned keys are in canonical uppercase form (which Jira's API accepts regardless of the original casing). - `FindJiraIssueKeys`: uppercase text before regex, dedup and CVE filter on the uppercased copy so all case variants collapse into one result - `MakeJiraIssueKeyPattern`: add `\b` word boundary anchor, uppercase caller-supplied project keys for consistency - `attestJira` help text: document case-insensitive matching and the `note-1`/`PROJ-42` false-positive corner case Co-Authored-By: Claude Sonnet 4.6 --- cmd/kosli/attestJira.go | 10 +++++++ internal/jira/jira.go | 30 ++++++++++++------- internal/jira/jira_test.go | 61 ++++++++++++++++++++++++++++++++++---- 3 files changed, 85 insertions(+), 16 deletions(-) diff --git a/cmd/kosli/attestJira.go b/cmd/kosli/attestJira.go index c51a1c1bb..7f58c552d 100644 --- a/cmd/kosli/attestJira.go +++ b/cmd/kosli/attestJira.go @@ -42,6 +42,16 @@ argument for Jira issue references of the form: 'at least 2 characters long, starting with an uppercase letter project key followed by dash and one or more digits'. +Matching is case-insensitive: ^proj-42^ and ^PROJ-42^ in a commit message are both +recognised and returned as ^PROJ-42^. Any token that matches the Jira key format +(a word boundary, two or more letters/digits starting with a letter, a dash, and one +or more digits) is treated as a candidate, regardless of whether it is an intentional +Jira reference. For example, a commit message ^see note-1 for context, fixes PROJ-42^ +will look up both ^NOTE-1^ and ^PROJ-42^ in Jira. If ^NOTE-1^ does not exist, the +attestation will be non-compliant even though ^PROJ-42^ is valid. +Use ^--jira-project-key^ to restrict matching to one or more known project keys and +avoid unintended candidates. + Any candidate match is automatically excluded if every occurrence in the parsed text is immediately followed by a hyphen and a digit — for example, ^CVE-2026-41284^ is excluded because ^CVE-2026^ would be followed by ^-4^. This applies across all parsed sources diff --git a/internal/jira/jira.go b/internal/jira/jira.go index 52192cbb5..2ba96445f 100644 --- a/internal/jira/jira.go +++ b/internal/jira/jira.go @@ -108,26 +108,34 @@ func (jc *JiraConfig) GetJiraIssueInfo(issueID string, issueFields string) (*Jir } func MakeJiraIssueKeyPattern(projectKeys []string) string { - // Jira issue keys consist of [project-key]-[sequential-number] - // project key must be at least 2 characters long and start with an uppercase letter + // Jira issue keys consist of [project-key]-[sequential-number]. + // FindJiraIssueKeys uppercases the text before applying this pattern, so the + // pattern only needs to handle uppercase. Project keys supplied by the caller + // are also uppercased here for the same reason. // more info: https://support.atlassian.com/jira-software-cloud/docs/what-is-an-issue/#Workingwithissues-Projectandissuekeys if len(projectKeys) == 0 { - return `[A-Z][A-Z0-9]{1,9}-[0-9]+` - } else { - return `(` + strings.Join(projectKeys, "|") + `)-[0-9]+` + return `\b[A-Z][A-Z0-9]{1,9}-[0-9]+` + } + upper := make([]string, len(projectKeys)) + for i, k := range projectKeys { + upper[i] = strings.ToUpper(k) } + return `\b(` + strings.Join(upper, "|") + `)-[0-9]+` } // FindJiraIssueKeys finds all Jira issue keys in text, filtering out // partial matches from multi-segment identifiers like CVE-2026-41284. -// A match is discarded if every occurrence in text is immediately -// followed by "-". +// Matching is case-insensitive: the text is uppercased before the regex +// is applied, so all returned keys are in canonical uppercase form. +// A match is discarded if every occurrence in the uppercased text is +// immediately followed by a hyphen and a digit. func FindJiraIssueKeys(text string, projectKeys []string) []string { + upperText := strings.ToUpper(text) pattern := MakeJiraIssueKeyPattern(projectKeys) re := regexp.MustCompile(pattern) - candidates := re.FindAllString(text, -1) + candidates := re.FindAllString(upperText, -1) - // Deduplicate + // Deduplicate (all candidates are already uppercase). seen := make(map[string]struct{}) var unique []string for _, c := range candidates { @@ -137,11 +145,11 @@ func FindJiraIssueKeys(text string, projectKeys []string) []string { } } - // Filter out matches that are always followed by - in the text + // Filter out matches that are always followed by - in the uppercased text. dashDigit := regexp.MustCompile(`^-\d`) var result []string for _, m := range unique { - if isPartialMultiSegment(text, m, dashDigit) { + if isPartialMultiSegment(upperText, m, dashDigit) { continue } result = append(result, m) diff --git a/internal/jira/jira_test.go b/internal/jira/jira_test.go index 741dbd151..84acefa48 100644 --- a/internal/jira/jira_test.go +++ b/internal/jira/jira_test.go @@ -18,15 +18,15 @@ func TestMakeJiraIssueKey(t *testing.T) { { name: "Empty project keys", projectKeys: []string{}, - want: `[A-Z][A-Z0-9]{1,9}-[0-9]+`, + want: `\b[A-Z][A-Z0-9]{1,9}-[0-9]+`, matches: []string{ "ABC-123", "A1-456", "XY-789", }, nonMatches: []string{ - "abc-123", // project key should start with uppercase - "A-123", // project key too short + "abc-123", // pattern is uppercase-only; FindJiraIssueKeys uppercases the text before applying it + "A-123", // project key too short (only 1 char) "1A-123", // project key starts with a number "ABC_123", // wrong separator "ABC-", // missing number @@ -36,19 +36,31 @@ func TestMakeJiraIssueKey(t *testing.T) { { name: "With project keys", projectKeys: []string{"ABC", "XYZ"}, - want: `(ABC|XYZ)-[0-9]+`, // Currently empty in the function implementation + want: `\b(ABC|XYZ)-[0-9]+`, matches: []string{ "ABC-123", "XYZ-789", }, nonMatches: []string{ - "xyz-123", // project key should start with uppercase + "xyz-123", // pattern is uppercase-only; text is uppercased by FindJiraIssueKeys before matching "ABC_123", // wrong separator "ABC-", // missing number "-123", // missing project key "DEF-123", // wrong project key }, }, + { + name: "With lowercase project keys they are uppercased in the pattern", + projectKeys: []string{"abc", "xyz"}, + want: `\b(ABC|XYZ)-[0-9]+`, + matches: []string{ + "ABC-123", + "XYZ-789", + }, + nonMatches: []string{ + "DEF-123", + }, + }, } for _, tt := range tests { @@ -163,6 +175,45 @@ func TestFindJiraIssueKeys(t *testing.T) { projectKeys: []string{}, want: nil, }, + { + name: "lowercase key is matched and returned uppercase", + text: "tDIE-12419:Update test9882.ts", + projectKeys: []string{}, + want: []string{"TDIE-12419"}, + }, + { + name: "lowercase prefix does not produce phantom substring match", + text: "tDIE-12419 is the only ticket", + projectKeys: []string{}, + want: []string{"TDIE-12419"}, + }, + { + name: "lowercase key alongside CVE is matched and CVE is filtered", + text: "proj-42 fixes CVE-2026-41284", + projectKeys: []string{}, + want: []string{"PROJ-42"}, + }, + { + name: "mixed-case duplicate keys are deduplicated", + text: "tDIE-12419 and TDIE-12419", + projectKeys: []string{}, + want: []string{"TDIE-12419"}, + }, + { + name: "CVE-like lowercase occurrence first does not suppress standalone uppercase occurrence", + text: "tdie-12419-5 foo TDIE-12419", + projectKeys: []string{}, + want: []string{"TDIE-12419"}, + }, + { + name: "prose word matching Jira key format is treated as a candidate alongside a real key", + // "note-1" matches the pattern and becomes NOTE-1; if NOTE-1 does not exist in + // Jira, the attestation will be non-compliant even though PROJ-42 is a valid + // reference. Use --jira-project-key to restrict matching to known project keys. + text: "see note-1 for context, fixes PROJ-42", + projectKeys: []string{}, + want: []string{"NOTE-1", "PROJ-42"}, + }, } for _, tt := range tests {