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
10 changes: 10 additions & 0 deletions cmd/kosli/attestJira.go
Original file line number Diff line number Diff line change
Expand Up @@ -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^
Comment thread
jbrejner marked this conversation as resolved.
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
Expand Down
30 changes: 19 additions & 11 deletions internal/jira/jira.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 "-<digit>".
// 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)
Comment thread
jbrejner marked this conversation as resolved.
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 {
Expand All @@ -137,11 +145,11 @@ func FindJiraIssueKeys(text string, projectKeys []string) []string {
}
}

// Filter out matches that are always followed by -<digit> in the text
// Filter out matches that are always followed by -<digit> 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)
Expand Down
61 changes: 56 additions & 5 deletions internal/jira/jira_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading