use super::ledger::{LinearIssueRef, LinearLinkSource, TodoLedger};
use once_cell::sync::Lazy;
use regex::Regex;
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct DiscoveredLinear {
pub issue: LinearIssueRef,
#[allow(dead_code)]
pub source: LinearLinkSource,
}
static LINEAR_URL_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r#"https?://linear\.app/[^\s)\]"']+/issue/([A-Za-z]+-\d+)"#)
.expect("linear url regex")
});
static LINEAR_ID_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"\b([A-Z][A-Z0-9]+-\d+)\b").expect("linear id regex")
});
pub fn parse_linear_refs_from_comment(body: &str) -> Vec<String> {
let mut out = Vec::new();
for caps in LINEAR_URL_RE.captures_iter(body) {
if let Some(m) = caps.get(1) {
let id = m.as_str().to_string();
if !out.iter().any(|x: &String| x.eq_ignore_ascii_case(&id)) {
out.push(id);
}
}
}
let lower = body.to_ascii_lowercase();
if lower.contains("linear") || lower.contains("linear.app") {
for caps in LINEAR_ID_RE.captures_iter(body) {
if let Some(m) = caps.get(1) {
let id = m.as_str().to_string();
if !out.iter().any(|x: &String| x.eq_ignore_ascii_case(&id)) {
out.push(id);
}
}
}
}
out
}
pub fn normalize_github_issue_url(url: &str) -> String {
let url = url.trim().trim_end_matches('/');
let base = url
.split('?')
.next()
.unwrap_or(url)
.split('#')
.next()
.unwrap_or(url);
base.to_string()
}
#[cfg(feature = "linear")]
pub async fn discover_linear_for_github_issue(
linear_key: &str,
gh_token: &str,
owner: &str,
repo: &str,
gh_number: u64,
gh_url: Option<&str>,
fingerprint: &str,
) -> Result<Option<DiscoveredLinear>, String> {
if let Some(url) = gh_url.map(normalize_github_issue_url).filter(|u| !u.is_empty()) {
match crate::commands::linear_cmd::issues_for_attachment_url(linear_key, &url).await {
Ok(issues) if !issues.is_empty() => {
let issue = &issues[0];
return Ok(Some(DiscoveredLinear {
issue: LinearIssueRef {
id: issue.id.clone(),
identifier: issue.identifier.clone(),
url: issue.url.clone(),
source: Some(LinearLinkSource::AutoLink),
},
source: LinearLinkSource::AutoLink,
}));
}
Ok(_) => {}
Err(_) => {
}
}
}
match crate::commands::github_cmd::list_comments(gh_token, owner, repo, gh_number).await {
Ok(comments) => {
for c in comments {
let refs = parse_linear_refs_from_comment(&c.body);
for ident in refs {
if let Ok(issue) =
crate::commands::linear_cmd::get_issue(linear_key, &ident).await
{
return Ok(Some(DiscoveredLinear {
issue: LinearIssueRef {
id: issue.id,
identifier: issue.identifier,
url: issue.url,
source: Some(LinearLinkSource::AutoLink),
},
source: LinearLinkSource::AutoLink,
}));
}
}
}
}
Err(_) => {}
}
if !fingerprint.is_empty() {
let query = format!("xbp-todo: {fingerprint}");
let filter = crate::commands::linear_cmd::ListIssuesFilter {
query: Some(query),
include_completed: true,
limit: 10,
..Default::default()
};
if let Ok(issues) = crate::commands::linear_cmd::list_issues(linear_key, filter).await {
let needle = format!("xbp-todo: {fingerprint}");
for issue in issues {
let desc = issue.description.unwrap_or_default();
if desc.contains(&needle) || desc.contains(fingerprint) {
return Ok(Some(DiscoveredLinear {
issue: LinearIssueRef {
id: issue.id,
identifier: issue.identifier,
url: issue.url,
source: Some(LinearLinkSource::Fingerprint),
},
source: LinearLinkSource::Fingerprint,
}));
}
}
}
}
Ok(None)
}
#[cfg(feature = "linear")]
pub async fn wait_for_linear_links(
linear_key: &str,
gh_token: &str,
owner: &str,
repo: &str,
ledger: &mut TodoLedger,
fingerprints: &[String],
wait_secs: u64,
poll_ms: u64,
) -> usize {
if fingerprints.is_empty() || wait_secs == 0 {
return 0;
}
let deadline = std::time::Instant::now() + Duration::from_secs(wait_secs);
let poll = Duration::from_millis(poll_ms.max(100));
let mut linked = 0usize;
loop {
let mut pending = 0usize;
for fp in fingerprints {
let entry = match ledger.entries.get(fp) {
Some(e) if e.linear.is_none() && e.github.is_some() => e.clone(),
_ => continue,
};
pending += 1;
let gh = entry.github.as_ref().unwrap();
match discover_linear_for_github_issue(
linear_key,
gh_token,
owner,
repo,
gh.number,
gh.url.as_deref(),
fp,
)
.await
{
Ok(Some(found)) => {
if let Some(e) = ledger.entries.get_mut(fp) {
e.linear = Some(found.issue);
e.updated_at = Some(chrono::Utc::now().to_rfc3339());
linked += 1;
pending = pending.saturating_sub(1);
println!(
" {} auto-linked {} ← GitHub #{}",
"·".to_string(),
e.linear.as_ref().map(|l| l.identifier.as_str()).unwrap_or("?"),
gh.number
);
}
}
Ok(None) => {}
Err(_) => {}
}
}
if pending == 0 || std::time::Instant::now() >= deadline {
break;
}
tokio::time::sleep(poll).await;
}
linked
}
#[cfg(feature = "linear")]
pub async fn purge_duplicate_linear(
linear_key: &str,
ledger: &mut TodoLedger,
fingerprint: &str,
preferred: &LinearIssueRef,
) -> Result<bool, String> {
let entry = match ledger.entries.get(fingerprint) {
Some(e) => e,
None => return Ok(false),
};
let Some(existing) = entry.linear.as_ref() else {
return Ok(false);
};
if existing.id == preferred.id || existing.identifier == preferred.identifier {
return Ok(false);
}
let existing_src = existing.source.unwrap_or(LinearLinkSource::XbpCreate);
let preferred_src = preferred.source.unwrap_or(LinearLinkSource::XbpCreate);
let (keep, drop) = match (existing_src, preferred_src) {
(LinearLinkSource::XbpCreate, LinearLinkSource::AutoLink)
| (LinearLinkSource::XbpCreate, LinearLinkSource::Fingerprint) => {
(preferred.clone(), existing.clone())
}
(LinearLinkSource::AutoLink, LinearLinkSource::XbpCreate)
| (LinearLinkSource::Fingerprint, LinearLinkSource::XbpCreate) => {
return Ok(false);
}
_ => {
(preferred.clone(), existing.clone())
}
};
let _ = crate::commands::linear_cmd::mark_duplicate_of(
linear_key,
&drop.identifier,
&keep.identifier,
)
.await;
crate::commands::linear_cmd::archive_issue(linear_key, &drop.identifier).await?;
if let Some(e) = ledger.entries.get_mut(fingerprint) {
e.linear = Some(LinearIssueRef {
id: keep.id,
identifier: keep.identifier.clone(),
url: keep.url,
source: keep.source.or(Some(LinearLinkSource::AutoLink)),
});
e.updated_at = Some(chrono::Utc::now().to_rfc3339());
}
println!(
" {} purged Linear {} → kept {}",
"·".to_string(),
drop.identifier,
keep.identifier
);
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_linear_url_from_comment() {
let body = "Linked to https://linear.app/acme/issue/XLX-42/fix-todo for tracking";
let refs = parse_linear_refs_from_comment(body);
assert_eq!(refs, vec!["XLX-42".to_string()]);
}
#[test]
fn parse_bare_identifier_when_linear_mentioned() {
let body = "Linear created XLX-99 for this issue";
let refs = parse_linear_refs_from_comment(body);
assert!(refs.iter().any(|r| r == "XLX-99"));
}
#[test]
fn normalize_strips_trailing_slash() {
assert_eq!(
normalize_github_issue_url("https://github.com/o/r/issues/1/"),
"https://github.com/o/r/issues/1"
);
}
}