use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum IssueReferenceKind {
GithubIssue,
CcdId,
Ambiguous,
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub(crate) struct IssueReference {
pub kind: IssueReferenceKind,
pub number: u64,
pub source: &'static str,
pub raw_reference: String,
pub context: String,
}
impl IssueReference {
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn display(&self) -> String {
match self.kind {
IssueReferenceKind::GithubIssue => format!("GH#{}", self.number),
IssueReferenceKind::CcdId => format!("ccd#{}", self.number),
IssueReferenceKind::Ambiguous => format!("issue #{}", self.number),
}
}
}
pub(crate) fn collect_issue_references(
branch: &str,
handoff_title: &str,
immediate_actions: &[String],
) -> Vec<IssueReference> {
let mut references = Vec::new();
references.extend(collect_branch_references(branch));
references.extend(collect_text_references("handoff_title", handoff_title));
for action in immediate_actions {
references.extend(collect_text_references("immediate_action", action));
}
dedupe_references(&mut references);
references
}
fn dedupe_references(references: &mut Vec<IssueReference>) {
let mut seen = BTreeSet::new();
references.retain(|reference| {
seen.insert((
reference.kind,
reference.number,
reference.source,
reference.raw_reference.clone(),
reference.context.clone(),
))
});
}
fn collect_branch_references(branch: &str) -> Vec<IssueReference> {
let mut references = Vec::new();
for segment in branch.split('/') {
if let Some(number) = strip_prefixed_number(segment, "issue-") {
references.push(IssueReference {
kind: IssueReferenceKind::GithubIssue,
number,
source: "branch",
raw_reference: format!("issue-{number}"),
context: branch.to_owned(),
});
continue;
}
if let Some(number) = strip_prefixed_number(segment, "gh-") {
references.push(IssueReference {
kind: IssueReferenceKind::GithubIssue,
number,
source: "branch",
raw_reference: format!("gh-{number}"),
context: branch.to_owned(),
});
continue;
}
if let Some(number) = strip_prefixed_number(segment, "ccd-") {
references.push(IssueReference {
kind: IssueReferenceKind::Ambiguous,
number,
source: "branch",
raw_reference: format!("ccd-{number}"),
context: branch.to_owned(),
});
continue;
}
if let Some(number) = leading_branch_number(segment) {
references.push(IssueReference {
kind: IssueReferenceKind::GithubIssue,
number,
source: "branch",
raw_reference: segment.to_owned(),
context: branch.to_owned(),
});
}
}
references
}
fn strip_prefixed_number(segment: &str, prefix: &str) -> Option<u64> {
let suffix = segment.strip_prefix(prefix)?;
let digits = suffix
.chars()
.take_while(|ch| ch.is_ascii_digit())
.collect::<String>();
if digits.is_empty() {
return None;
}
let remainder = &suffix[digits.len()..];
if remainder.is_empty()
|| remainder.starts_with('-')
|| remainder.starts_with('_')
|| remainder.starts_with('/')
{
return digits.parse().ok();
}
None
}
fn leading_branch_number(segment: &str) -> Option<u64> {
let digits = segment
.chars()
.take_while(|ch| ch.is_ascii_digit())
.collect::<String>();
if digits.is_empty() {
return None;
}
let remainder = &segment[digits.len()..];
if remainder.is_empty() || remainder.starts_with('-') || remainder.starts_with('_') {
return digits.parse().ok();
}
None
}
fn collect_text_references(source: &'static str, text: &str) -> Vec<IssueReference> {
let bytes = text.as_bytes();
let mut references = Vec::new();
let mut index = 0usize;
while index < bytes.len() {
if bytes[index] != b'#' {
index += 1;
continue;
}
let digit_start = index + 1;
let digit_end = consume_digits(bytes, digit_start);
if digit_end == digit_start {
index += 1;
continue;
}
let prefix_start = contiguous_ascii_alpha_start(bytes, index);
let prefix = text[prefix_start..index].to_ascii_lowercase();
let previous = if prefix_start == 0 {
None
} else {
Some(bytes[prefix_start - 1])
};
let kind = if prefix == "ccd" {
Some(IssueReferenceKind::CcdId)
} else if prefix == "gh" {
Some(IssueReferenceKind::GithubIssue)
} else if previous.is_some_and(|byte| byte.is_ascii_alphanumeric()) {
None
} else {
Some(IssueReferenceKind::GithubIssue)
};
let Some(kind) = kind else {
index = digit_end;
continue;
};
let raw_start = if matches!(
kind,
IssueReferenceKind::CcdId | IssueReferenceKind::GithubIssue
) && (prefix == "ccd" || prefix == "gh")
{
prefix_start
} else {
index
};
if let Ok(number) = text[digit_start..digit_end].parse() {
references.push(IssueReference {
kind,
number,
source,
raw_reference: text[raw_start..digit_end].to_owned(),
context: text.trim().to_owned(),
});
}
index = digit_end;
}
references
}
fn contiguous_ascii_alpha_start(bytes: &[u8], end: usize) -> usize {
let mut start = end;
while start > 0 && bytes[start - 1].is_ascii_alphabetic() {
start -= 1;
}
start
}
fn consume_digits(bytes: &[u8], start: usize) -> usize {
let mut end = start;
while end < bytes.len() && bytes[end].is_ascii_digit() {
end += 1;
}
end
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_text_and_branch_issue_references() {
let references = collect_issue_references(
"codex/ccd-46-retry-backoff",
"Next Session: Verify #122 follow-up",
&["Review ccd#46 before merge.".to_owned()],
);
assert!(references.iter().any(|reference| {
reference.source == "branch"
&& reference.kind == IssueReferenceKind::Ambiguous
&& reference.number == 46
}));
assert!(references.iter().any(|reference| {
reference.source == "handoff_title"
&& reference.kind == IssueReferenceKind::GithubIssue
&& reference.number == 122
}));
assert!(references.iter().any(|reference| {
reference.source == "immediate_action"
&& reference.kind == IssueReferenceKind::CcdId
&& reference.number == 46
}));
}
}