xbp 10.38.2

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! Discover Linear issues auto-linked from GitHub (comments + attachments) and purge dups.

use super::ledger::{LinearIssueRef, LinearLinkSource, TodoLedger};
use once_cell::sync::Lazy;
use regex::Regex;
use std::time::Duration;

/// Linear issue discovered as linked to a GitHub issue / TODO fingerprint.
#[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")
});

/// Parse Linear issue identifiers / URLs from a GitHub comment body.
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);
            }
        }
    }
    // Also catch bare TEAM-123 when comment looks Linear-related.
    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
}

/// Normalize GitHub issue HTML URL for attachment lookups.
pub fn normalize_github_issue_url(url: &str) -> String {
    let url = url.trim().trim_end_matches('/');
    // Drop query/fragment.
    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> {
    // 1) Linear attachmentsForURL
    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(_) => {
                // Soft: attachment query may fail; continue to comments.
            }
        }
        // Also try without .git or with api-style — already normalized.
    }

    // 2) GitHub comments (Linear bot posts issue links)
    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(_) => {}
    }

    // 3) Fingerprint search in Linear descriptions
    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)
}

/// Poll until all fingerprints have Linear links or timeout.
#[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
}

/// If ledger has an xbp-created Linear issue and discovery finds a different auto-linked one,
/// prefer the auto-linked issue and archive the xbp-created duplicate.
#[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);
    }
    // Prefer auto_link / fingerprint over xbp_create; otherwise keep existing if preferred is xbp.
    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) => {
            // existing already preferred
            return Ok(false);
        }
        _ => {
            // Prefer preferred (newly discovered) as it has GH attachment
            (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"
        );
    }
}