use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum IssueRefKind {
NumericIssue,
ProjectTicket,
Url,
}
#[allow(non_upper_case_globals)]
impl IssueRefKind {
pub const JiraTicket: Self = Self::ProjectTicket;
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IssueReference {
pub kind: IssueRefKind,
pub value: String,
}
const CLOSING_KEYWORDS: &[&str] = &[
"fixes", "fix", "fixed", "closes", "close", "closed", "resolves", "resolve", "resolved",
];
const TICKET_BLOCKLIST: &[&str] = &[
"UTF", "HTTP", "RFC", "CVE", "ISO", "SHA", "SSL", "TLS", "TCP", "UDP", "DNS", "SSH", "API",
"URL", "URI", "XML", "JSON", "YAML", "TOML", "HTML", "CSS", "ANSI", "ASCII", "IEEE", "IETF",
"SMTP", "IMAP", "LDAP", "SAML", "CORS", "CSRF", "ECDSA", "HMAC",
];
const TRACKER_URL_PATTERNS: &[&str] = &[
"/issues/", "/browse/", "linear.app/", "app.shortcut.com/", "notion.so/", ];
pub fn extract_issue_references(body: &str, custom_patterns: &[&str]) -> Vec<IssueReference> {
let mut refs = Vec::new();
extract_urls(body, &mut refs);
extract_numeric_issues(body, &mut refs);
extract_project_tickets(body, &mut refs);
for pattern in custom_patterns {
extract_custom(body, pattern, &mut refs);
}
refs.dedup_by(|a, b| a.value == b.value);
refs
}
pub fn has_issue_linkage(refs: &[IssueReference]) -> bool {
!refs.is_empty()
}
fn extract_numeric_issues(body: &str, refs: &mut Vec<IssueReference>) {
let lower = body.to_lowercase();
let chars: Vec<char> = lower.chars().collect();
let body_chars: Vec<char> = body.chars().collect();
let mut i = 0;
while i < chars.len() {
let mut matched_keyword = false;
for keyword in CLOSING_KEYWORDS {
let kw_chars: Vec<char> = keyword.chars().collect();
if i + kw_chars.len() < chars.len() && chars[i..i + kw_chars.len()] == kw_chars[..] {
let after_kw = i + kw_chars.len();
if i > 0 && chars[i - 1].is_alphanumeric() {
continue;
}
let mut j = after_kw;
while j < chars.len() && chars[j] == ' ' {
j += 1;
}
if j < chars.len()
&& chars[j] == '#'
&& let Some((num_str, end)) = parse_digits(&body_chars, j + 1)
{
let kw_original: String = body_chars[i..i + kw_chars.len()].iter().collect();
let full = format!("{kw_original} #{num_str}");
refs.push(IssueReference {
kind: IssueRefKind::NumericIssue,
value: full,
});
i = end;
matched_keyword = true;
break;
}
}
}
if matched_keyword {
continue;
}
if chars[i] == '#' {
let preceded_ok = i == 0 || (!chars[i - 1].is_alphanumeric() && chars[i - 1] != '&');
if preceded_ok && let Some((num_str, end)) = parse_digits(&body_chars, i + 1) {
refs.push(IssueReference {
kind: IssueRefKind::NumericIssue,
value: format!("#{num_str}"),
});
i = end;
continue;
}
}
i += 1;
}
}
fn parse_digits(chars: &[char], start: usize) -> Option<(String, usize)> {
let mut end = start;
while end < chars.len() && chars[end].is_ascii_digit() {
end += 1;
}
if end == start {
return None;
}
if end < chars.len() {
let next = chars[end];
if next.is_alphanumeric() || next == '_' || next == '-' {
return None;
}
}
let s: String = chars[start..end].iter().collect();
Some((s, end))
}
const LOWERCASE_TICKET_PREFIXES: &[&str] = &["sc"];
fn extract_project_tickets(body: &str, refs: &mut Vec<IssueReference>) {
extract_uppercase_tickets(body, refs);
extract_lowercase_tickets(body, refs);
}
fn extract_uppercase_tickets(body: &str, refs: &mut Vec<IssueReference>) {
let chars: Vec<char> = body.chars().collect();
let mut i = 0;
while i < chars.len() {
if i > 0 && (chars[i - 1].is_alphanumeric() || chars[i - 1] == '-') {
i += 1;
continue;
}
let alpha_start = i;
let mut j = i;
while j < chars.len() && chars[j].is_ascii_uppercase() {
j += 1;
}
let alpha_len = j - alpha_start;
if alpha_len < 2 {
i += 1;
continue;
}
if j >= chars.len() || chars[j] != '-' {
i += 1;
continue;
}
j += 1;
let digit_start = j;
while j < chars.len() && chars[j].is_ascii_digit() {
j += 1;
}
if j == digit_start {
i += 1;
continue;
}
if j < chars.len() && (chars[j].is_alphanumeric() || chars[j] == '-') {
i += 1;
continue;
}
let prefix: String = chars[alpha_start..alpha_start + alpha_len].iter().collect();
if TICKET_BLOCKLIST.iter().any(|b| *b == prefix) {
i = j;
continue;
}
let ticket: String = chars[alpha_start..j].iter().collect();
if !refs.iter().any(|r| r.value.contains(&ticket)) {
refs.push(IssueReference {
kind: IssueRefKind::ProjectTicket,
value: ticket,
});
}
i = j;
}
}
fn extract_lowercase_tickets(body: &str, refs: &mut Vec<IssueReference>) {
let chars: Vec<char> = body.chars().collect();
let mut i = 0;
while i < chars.len() {
if i > 0 && (chars[i - 1].is_alphanumeric() || chars[i - 1] == '-') {
i += 1;
continue;
}
for prefix in LOWERCASE_TICKET_PREFIXES {
let prefix_chars: Vec<char> = prefix.chars().collect();
let plen = prefix_chars.len();
if i + plen >= chars.len() {
continue;
}
let body_slice: String = chars[i..i + plen].iter().collect();
if body_slice.to_ascii_lowercase() != *prefix {
continue;
}
let mut j = i + plen;
if j >= chars.len() || chars[j] != '-' {
continue;
}
j += 1;
let digit_start = j;
while j < chars.len() && chars[j].is_ascii_digit() {
j += 1;
}
if j == digit_start {
continue;
}
if j < chars.len() && (chars[j].is_alphanumeric() || chars[j] == '-') {
continue;
}
let ticket: String = chars[i..j].iter().collect();
if !refs.iter().any(|r| r.value.contains(&ticket)) {
refs.push(IssueReference {
kind: IssueRefKind::ProjectTicket,
value: ticket,
});
}
i = j;
break;
}
i += 1;
}
}
fn extract_urls(body: &str, refs: &mut Vec<IssueReference>) {
let mut search_start = 0;
while search_start < body.len() {
let rest = &body[search_start..];
let offset = rest.find("https://").or_else(|| rest.find("http://"));
let Some(pos) = offset else { break };
let url_start = search_start + pos;
let url_end = body[url_start..]
.find(|c: char| c.is_whitespace() || c == ')' || c == '>' || c == ']')
.map(|e| url_start + e)
.unwrap_or(body.len());
let url = body[url_start..url_end].trim_end_matches(['.', ',']);
if TRACKER_URL_PATTERNS.iter().any(|p| url.contains(p)) {
refs.push(IssueReference {
kind: IssueRefKind::Url,
value: url.to_string(),
});
}
search_start = url_end;
}
}
fn extract_custom(body: &str, pattern: &str, refs: &mut Vec<IssueReference>) {
if pattern.is_empty() {
return;
}
let mut start = 0;
while let Some(pos) = body[start..].find(pattern) {
let abs_pos = start + pos;
let end = abs_pos + pattern.len();
refs.push(IssueReference {
kind: IssueRefKind::Url, value: body[abs_pos..end].to_string(),
});
start = end;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn github_issue_bare_hash() {
let refs = extract_issue_references("Related to #123", &[]);
assert!(has_issue_linkage(&refs));
assert_eq!(refs[0].kind, IssueRefKind::NumericIssue);
assert_eq!(refs[0].value, "#123");
}
#[test]
fn github_issue_fixes_keyword() {
let refs = extract_issue_references("fixes #456", &[]);
assert!(has_issue_linkage(&refs));
assert_eq!(refs[0].value, "fixes #456");
}
#[test]
fn github_issue_closes_keyword() {
let refs = extract_issue_references("Closes #789", &[]);
assert!(has_issue_linkage(&refs));
assert_eq!(refs[0].value, "Closes #789");
}
#[test]
fn github_issue_resolves_keyword() {
let refs = extract_issue_references("resolves #012", &[]);
assert!(has_issue_linkage(&refs));
assert_eq!(refs[0].value, "resolves #012");
}
#[test]
fn jira_ticket() {
let refs = extract_issue_references("See PROJ-789 for details", &[]);
assert!(has_issue_linkage(&refs));
assert_eq!(refs[0].kind, IssueRefKind::JiraTicket);
assert_eq!(refs[0].value, "PROJ-789");
}
#[test]
fn url_github_issues() {
let refs = extract_issue_references("https://github.com/owner/repo/issues/1", &[]);
assert!(has_issue_linkage(&refs));
assert_eq!(refs[0].kind, IssueRefKind::Url);
}
#[test]
fn url_jira_browse() {
let refs = extract_issue_references("See https://jira.example.com/browse/PROJ-123", &[]);
assert!(has_issue_linkage(&refs));
assert_eq!(refs[0].kind, IssueRefKind::Url);
}
#[test]
fn empty_body_no_linkage() {
let refs = extract_issue_references("", &[]);
assert!(!has_issue_linkage(&refs));
}
#[test]
fn no_references_in_body() {
let refs = extract_issue_references("Just a regular PR description.", &[]);
assert!(!has_issue_linkage(&refs));
}
#[test]
fn multiple_mixed_patterns() {
let body = "fixes #123\nAlso related to PROJ-789 and https://github.com/o/r/issues/5";
let refs = extract_issue_references(body, &[]);
assert!(has_issue_linkage(&refs));
assert!(refs.len() >= 3);
let kinds: Vec<&IssueRefKind> = refs.iter().map(|r| &r.kind).collect();
assert!(kinds.contains(&&IssueRefKind::NumericIssue));
assert!(kinds.contains(&&IssueRefKind::JiraTicket));
assert!(kinds.contains(&&IssueRefKind::Url));
}
#[test]
fn custom_pattern() {
let refs = extract_issue_references("Ref: CUSTOM-42", &["CUSTOM-42"]);
assert!(has_issue_linkage(&refs));
}
#[test]
fn hash_in_html_entity_not_matched() {
let refs = extract_issue_references("Use { entity", &[]);
assert!(!has_issue_linkage(&refs));
}
#[test]
fn jira_single_letter_not_matched() {
let refs = extract_issue_references("X-123 should not match", &[]);
assert!(!has_issue_linkage(&refs));
}
#[test]
fn non_ascii_body_with_issue_ref() {
let refs = extract_issue_references("あいう fixes #12", &[]);
assert!(has_issue_linkage(&refs));
assert_eq!(refs[0].value, "fixes #12");
}
#[test]
fn non_ascii_body_bare_hash() {
let refs = extract_issue_references("日本語テスト #99 です", &[]);
assert!(has_issue_linkage(&refs));
assert_eq!(refs[0].value, "#99");
}
#[test]
fn emoji_body_with_issue_ref() {
let refs = extract_issue_references("🎉🎊 closes #42", &[]);
assert!(has_issue_linkage(&refs));
assert_eq!(refs[0].value, "closes #42");
}
#[test]
fn markdown_link_github_issues() {
let body = "See [the issue](https://github.com/o/r/issues/1) for details";
let refs = extract_issue_references(body, &[]);
assert!(has_issue_linkage(&refs));
assert_eq!(refs[0].kind, IssueRefKind::Url);
assert!(refs[0].value.contains("/issues/1"));
}
#[test]
fn markdown_link_jira_browse() {
let body = "Related: [ticket](https://jira.example.com/browse/PROJ-456)";
let refs = extract_issue_references(body, &[]);
assert!(
refs.iter()
.any(|r| r.kind == IssueRefKind::Url && r.value.contains("/browse/"))
);
}
#[test]
fn blocklist_utf8_not_jira() {
let refs = extract_issue_references("Supports UTF-8 encoding", &[]);
assert!(!refs.iter().any(|r| r.kind == IssueRefKind::JiraTicket));
}
#[test]
fn blocklist_http_not_jira() {
let refs = extract_issue_references("Returns HTTP-500 errors", &[]);
assert!(!refs.iter().any(|r| r.kind == IssueRefKind::JiraTicket));
}
#[test]
fn blocklist_rfc_not_jira() {
let refs = extract_issue_references("Per RFC-9110 specification", &[]);
assert!(!refs.iter().any(|r| r.kind == IssueRefKind::JiraTicket));
}
#[test]
fn blocklist_cve_not_jira() {
let refs = extract_issue_references("Fixes CVE-2024 vulnerability", &[]);
assert!(!refs.iter().any(|r| r.kind == IssueRefKind::JiraTicket));
}
#[test]
fn real_jira_ticket_still_works() {
let refs = extract_issue_references("See PROJ-123 and MYAPP-456", &[]);
assert_eq!(
refs.iter()
.filter(|r| r.kind == IssueRefKind::JiraTicket)
.count(),
2
);
assert!(refs.iter().any(|r| r.value == "PROJ-123"));
assert!(refs.iter().any(|r| r.value == "MYAPP-456"));
}
#[test]
fn hash_followed_by_alpha_not_matched() {
let refs = extract_issue_references("#123abc", &[]);
assert!(!has_issue_linkage(&refs));
}
#[test]
fn color_hex_not_matched() {
let refs = extract_issue_references("color: #FF0000", &[]);
assert!(!has_issue_linkage(&refs));
}
#[test]
fn hash_followed_by_period_matched() {
let refs = extract_issue_references("#123.", &[]);
assert!(has_issue_linkage(&refs));
assert_eq!(refs[0].value, "#123");
}
#[test]
fn keyword_hash_followed_by_exclamation_matched() {
let refs = extract_issue_references("fixes #123!", &[]);
assert!(has_issue_linkage(&refs));
assert_eq!(refs[0].value, "fixes #123");
}
#[test]
fn linkage_biconditional() {
let with_refs = extract_issue_references("fixes #1", &[]);
assert!(has_issue_linkage(&with_refs));
let without_refs = extract_issue_references("plain text", &[]);
assert!(!has_issue_linkage(&without_refs));
}
#[test]
fn linear_ticket_matched() {
let refs = extract_issue_references("Implements ENG-456", &[]);
assert!(has_issue_linkage(&refs));
assert_eq!(refs[0].kind, IssueRefKind::ProjectTicket);
assert_eq!(refs[0].value, "ENG-456");
}
#[test]
fn linear_url_matched() {
let refs = extract_issue_references(
"https://linear.app/myteam/issue/ENG-456/implement-feature",
&[],
);
assert!(has_issue_linkage(&refs));
assert_eq!(refs[0].kind, IssueRefKind::Url);
}
#[test]
fn shortcut_ticket_matched() {
let refs = extract_issue_references("Fixes sc-12345", &[]);
assert!(has_issue_linkage(&refs));
assert_eq!(refs[0].kind, IssueRefKind::ProjectTicket);
assert_eq!(refs[0].value, "sc-12345");
}
#[test]
fn shortcut_url_matched() {
let refs =
extract_issue_references("https://app.shortcut.com/myorg/story/12345/fix-bug", &[]);
assert!(has_issue_linkage(&refs));
assert_eq!(refs[0].kind, IssueRefKind::Url);
}
#[test]
fn notion_url_matched() {
let refs = extract_issue_references("https://notion.so/myworkspace/Task-abc123def456", &[]);
assert!(has_issue_linkage(&refs));
assert_eq!(refs[0].kind, IssueRefKind::Url);
}
}