Skip to main content

car_server_core/coder/
fix_issues.rs

1//! File [`DurableFixProposal`]s as issues on the releases repo — the
2//! **reporting** half of self-correction.
3//!
4//! [`super::ab_learnings`] already does the hard part: it folds a dogfooding
5//! round's losses into ranked, evidence-backed proposals that name which CAR
6//! component to change and on what evidence. Until this module existed, that
7//! output was `println!`'d and written to `learnings-<unix>.md`, where nobody
8//! read it unless they had already gone looking — which defeats the purpose of
9//! an automated round.
10//!
11//! ## Why this is a sibling of [`super::merge`] and not an extension of it
12//!
13//! [`super::merge::GitHubApi`] resolves its repository from the checkout's
14//! `origin` remote, because a pull request must land where the branch was
15//! pushed. A defect report must NOT: it belongs on the releases repo, which is
16//! reachable from any deployment that has telemetry, with or without a source
17//! checkout. Folding issue filing into that trait would inherit exactly the
18//! repo-resolution rule that is wrong here, so the seam is separate and the
19//! repository is always an explicit argument.
20//!
21//! That split is the whole reason reporting and correcting can live in
22//! different places: filing needs no source, fixing does.
23//!
24//! ## Idempotence
25//!
26//! Every body carries `<!-- car-fix-signature: <hex> -->`, a hash over the
27//! proposal's *stable identity* — its target component and failure pattern.
28//! Deliberately not the title (wording drifts) and not `evidence_tasks` (those
29//! change every round, which would make each re-run a fresh duplicate).
30//!
31//! The search is scoped to **open** issues. A signature whose issue was closed
32//! and then recurs gets a new report, and that is the intended behaviour rather
33//! than an oversight: recurrence after a fix is a real signal that the fix did
34//! not hold, and silently swallowing it would hide the most interesting case.
35//!
36//! ## The marker is a dedup id, and the report target is public
37//!
38//! `Parslee-ai/car-releases` is world-writable: anyone with a GitHub account
39//! can open an issue on it. The signature marker is plain text in that public
40//! repository, so anyone can paste one into an issue of their own — which used
41//! to be enough to make this module see `AlreadyOpen` and suppress a real
42//! defect report indefinitely. Deduplication therefore honours a marker only
43//! on an issue that [`super::provenance::resolve_tier`] puts at
44//! [`ProvenanceTier::Runtime`]: filed by the account this runtime
45//! authenticates as, carrying a signature recomputed here from a proposal held
46//! in memory. A marker on anyone else's issue is recorded and ignored
47//! (`Parslee-ai/car#1081`).
48
49use serde::{Deserialize, Serialize};
50use sha2::{Digest, Sha256};
51
52use std::time::SystemTime;
53
54use super::ab_learnings::{Confidence, DurableFixProposal};
55use super::merge::GhError;
56use super::provenance::{
57    resolve_tier, LocalSignatures, PermissionOracle, ProvenanceRecord, ProvenanceTier, RawIssue,
58};
59
60/// The default report target. Not the source repo: see the module docs.
61pub const DEFAULT_REPORT_REPO: &str = "Parslee-ai/car-releases";
62
63/// A tentative proposal must have recurred at least this many times before it
64/// is worth a human's attention. A one-off tentative signal is noise, and a
65/// tracker that fills with noise stops being read at all.
66const TENTATIVE_MIN_EVIDENCE: usize = 2;
67
68/// The GitHub issue operations reporting needs, behind a seam.
69///
70/// Same idiom as [`super::merge::GitHubApi`] and
71/// [`super::contract::derive_contract`]: the interesting behaviour here is
72/// deduplication and the noise gate, and neither is testable against a live
73/// tracker.
74pub trait IssueApi: Send + Sync {
75    /// Every **open** issue on `repo`, as the tracker returned it.
76    ///
77    /// Returns [`RawIssue`], which carries no accessor for its text: matching a
78    /// marker is a predicate, and reading a body requires a resolved trust
79    /// tier. See [`super::provenance`].
80    fn list_open_issues(&self, repo: &str) -> Result<Vec<RawIssue>, GhError>;
81
82    /// Open an issue and return its number.
83    fn create_issue(
84        &self,
85        repo: &str,
86        title: &str,
87        body: &str,
88        labels: &[String],
89    ) -> Result<u64, GhError>;
90}
91
92/// The stable identity of a proposed fix: what to change, and what pattern
93/// motivated it. See the module docs for what is deliberately excluded.
94pub fn proposal_signature(proposal: &DurableFixProposal) -> String {
95    let mut hasher = Sha256::new();
96    hasher.update(proposal.target_component.trim().to_lowercase().as_bytes());
97    // A NUL separator, so ("ab", "c") and ("a", "bc") cannot collide.
98    hasher.update([0u8]);
99    hasher.update(proposal.pattern.trim().to_lowercase().as_bytes());
100    format!("{:x}", hasher.finalize())[..16].to_string()
101}
102
103/// The machine-readable handle embedded in every body.
104pub fn signature_marker(signature: &str) -> String {
105    format!("<!-- car-fix-signature: {signature} -->")
106}
107
108/// The signature carried by `body`, if it carries exactly one well-formed
109/// marker.
110///
111/// Strict about the shape — hex only — so a body cannot smuggle other text
112/// through the field, and so the value compared against locally recomputed
113/// signatures is the same alphabet [`proposal_signature`] produces.
114pub fn parse_signature_marker(body: &str) -> Option<&str> {
115    const OPEN: &str = "<!-- car-fix-signature: ";
116    const CLOSE: &str = " -->";
117    // Every occurrence, not just the first. `render_issue_body` interpolates
118    // model-authored prose ahead of the marker, so a proposal whose text
119    // happens to contain the literal opener would otherwise void the real
120    // marker underneath it and re-file that report every round.
121    let mut rest = body;
122    while let Some(open) = rest.find(OPEN) {
123        let after = &rest[open + OPEN.len()..];
124        if let Some(end) = after.find(CLOSE) {
125            let signature = &after[..end];
126            if !signature.is_empty() && signature.chars().all(|c| c.is_ascii_hexdigit()) {
127                return Some(signature);
128            }
129        }
130        rest = &rest[open + OPEN.len()..];
131    }
132    None
133}
134
135/// Whether this proposal clears the bar for filing.
136///
137/// [`Confidence::Strong`] means a recurring, diagnosable pattern already
138/// mapped to a component — that is a defect report on its own. `Tentative`
139/// means the specific fix still needs a human reading the failures, which is
140/// only worth filing once the pattern has actually repeated.
141pub fn clears_reporting_bar(proposal: &DurableFixProposal) -> bool {
142    match proposal.confidence {
143        Confidence::Strong => true,
144        Confidence::Tentative => proposal.evidence_count >= TENTATIVE_MIN_EVIDENCE,
145    }
146}
147
148/// Render one proposal as an issue body, marker included.
149pub fn render_issue_body(proposal: &DurableFixProposal, signature: &str) -> String {
150    let mut body = String::new();
151    body.push_str("_Filed automatically by `car coder-ab` from a dogfooding round._\n\n");
152
153    body.push_str("## Pattern\n\n");
154    body.push_str(proposal.pattern.trim());
155    body.push_str("\n\n## Where to look\n\n");
156    body.push_str(&format!("- **Component**: {}\n", proposal.target_component));
157    body.push_str(&format!(
158        "- **Hint**: {} _(best-effort — verify before editing)_\n",
159        proposal.target_hint
160    ));
161
162    body.push_str("\n## Proposed change\n\n");
163    body.push_str(proposal.proposed_change.trim());
164
165    body.push_str("\n\n## Evidence\n\n");
166    body.push_str(&format!(
167        "{} occurrence(s), confidence {:?}, kind {:?}, priority {}.\n",
168        proposal.evidence_count, proposal.confidence, proposal.kind, proposal.priority
169    ));
170    if !proposal.evidence_tasks.is_empty() {
171        body.push_str("\nTasks evidencing this pattern:\n\n");
172        for task in &proposal.evidence_tasks {
173            body.push_str(&format!("- `{task}`\n"));
174        }
175    }
176
177    body.push_str(
178        "\n---\n\nThis is a *class* of change, not a patch — the synthesizer narrows where to \
179         look, it does not invent the specific fix.\n\n",
180    );
181    body.push_str(&signature_marker(signature));
182    body.push('\n');
183    body
184}
185
186/// What one proposal's report attempt did.
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "snake_case", tag = "outcome")]
189pub enum ReportOutcome {
190    /// A new issue was opened.
191    Filed { number: u64 },
192    /// An open issue already carries this signature.
193    AlreadyOpen { number: u64 },
194    /// Below the reporting bar; see [`clears_reporting_bar`].
195    BelowBar,
196    /// The tracker call failed. Reporting is best-effort: one failure must not
197    /// abort the round, and it must not be silent either.
198    Failed { error: String },
199}
200
201/// One proposal and what happened to it.
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203pub struct ReportRecord {
204    pub title: String,
205    pub signature: String,
206    #[serde(flatten)]
207    pub outcome: ReportOutcome,
208    /// The trust tier resolved for every open issue that carried this
209    /// signature. Empty when nothing did.
210    #[serde(default, skip_serializing_if = "Vec::is_empty")]
211    pub provenance: Vec<ProvenanceRecord>,
212    /// Issues that carried this signature and resolved to
213    /// [`ProvenanceTier::Public`] — a stranger's issue wearing our marker. They
214    /// do not suppress the report, and they are not silent either.
215    #[serde(default, skip_serializing_if = "Vec::is_empty")]
216    pub public_marker_carriers: Vec<u64>,
217}
218
219/// How many marker-carrying issues one proposal will resolve a tier for.
220///
221/// Each non-runtime resolution costs a live permission lookup, so an attacker
222/// who papered the tracker with copies of one marker would otherwise turn each
223/// reporting round into hundreds of API calls. Runtime-authored candidates are
224/// checked first and cost nothing, so this cap cannot hide our own issue behind
225/// a pile of forgeries.
226const MAX_MARKER_CARRIERS: usize = 5;
227
228/// File every proposal that clears the bar, skipping signatures already open on
229/// an issue this runtime actually filed.
230///
231/// Best-effort by construction: a tracker failure is recorded against that
232/// proposal and the rest still go. A dogfooding round that produced real
233/// findings must not lose them because the tracker was briefly unreachable.
234pub fn report_proposals(
235    api: &dyn IssueApi,
236    oracle: &dyn PermissionOracle,
237    repo: &str,
238    proposals: &[DurableFixProposal],
239    labels: &[String],
240) -> Vec<ReportRecord> {
241    // Recomputed here, from proposals held in memory. This set is the whole
242    // content of the `runtime` tier: a marker matches only if we just derived
243    // the same hex ourselves.
244    let local = LocalSignatures::from_proposals(proposals);
245    // One listing for the whole round. It used to be one per proposal, which
246    // fetched up to `SCAN_LIMIT` bodies again for every finding.
247    let open = api.list_open_issues(repo);
248    // The account we authenticate as, resolved once per round at read time. It
249    // is this process's own identity, not a revocable grant on someone else's
250    // account, so ordering candidates by it is safe.
251    //
252    // A failure here is fatal to the round for the same reason a failed listing
253    // is: without it nothing can reach the `runtime` tier, so every proposal
254    // would re-file. Filing blind duplicates is worse than saying we could not
255    // check.
256    let viewer = oracle.viewer_login();
257
258    proposals
259        .iter()
260        .map(|proposal| {
261            let signature = proposal_signature(proposal);
262            let mut record = ReportRecord {
263                title: proposal.title.clone(),
264                signature: signature.clone(),
265                outcome: ReportOutcome::BelowBar,
266                provenance: Vec::new(),
267                public_marker_carriers: Vec::new(),
268            };
269            if !clears_reporting_bar(proposal) {
270                return record;
271            }
272            // If we cannot tell whether it is already open, filing blind would
273            // duplicate — whether the gap is the listing or our own identity.
274            let rows = match (&open, &viewer) {
275                (Ok(rows), Ok(_)) => rows,
276                (Err(e), _) | (_, Err(e)) => {
277                    record.outcome = ReportOutcome::Failed {
278                        error: e.to_string(),
279                    };
280                    return record;
281                }
282            };
283            let already_open = resolve_marker_carriers(
284                rows,
285                &signature,
286                oracle,
287                &local,
288                viewer.as_deref().ok(),
289                &mut record,
290            );
291            if let Some(number) = already_open {
292                record.outcome = ReportOutcome::AlreadyOpen { number };
293                return record;
294            }
295            let body = render_issue_body(proposal, &signature);
296            record.outcome = match api.create_issue(repo, &proposal.title, &body, labels) {
297                Ok(number) => ReportOutcome::Filed { number },
298                Err(e) => ReportOutcome::Failed {
299                    error: e.to_string(),
300                },
301            };
302            record
303        })
304        .collect()
305}
306
307/// Resolve a tier for the issues carrying `signature` and return the number of
308/// the first one whose author has write access, recording every decision.
309fn resolve_marker_carriers(
310    rows: &[RawIssue],
311    signature: &str,
312    oracle: &dyn PermissionOracle,
313    local: &LocalSignatures,
314    viewer: Option<&str>,
315    record: &mut ReportRecord,
316) -> Option<u64> {
317    let mut carriers: Vec<&RawIssue> = rows
318        .iter()
319        .filter(|row| row.carries_marker(signature))
320        .collect();
321    // Ours first, so the cap below can only ever drop forgeries.
322    carriers.sort_by_key(|row| match viewer {
323        Some(v) if v.eq_ignore_ascii_case(row.author_login()) => 0,
324        _ => 1,
325    });
326
327    for row in carriers.into_iter().take(MAX_MARKER_CARRIERS) {
328        let tiered = resolve_tier((*row).clone(), oracle, local, SystemTime::now());
329        record.provenance.push(tiered.record().clone());
330        if tiered.tier() == ProvenanceTier::Public {
331            record.public_marker_carriers.push(tiered.number());
332            continue;
333        }
334        // Write access to the tracker. Stop here: further lookups cost live
335        // `gh` calls and cannot change the answer.
336        return Some(tiered.number());
337    }
338    None
339}
340
341/// A one-line-per-proposal summary for the round's stdout.
342pub fn render_report(records: &[ReportRecord], repo: &str) -> String {
343    if records.is_empty() {
344        return "issue reporting: no proposals to report".to_string();
345    }
346    let mut out = format!("issue reporting → {repo}\n");
347    for record in records {
348        let line = match &record.outcome {
349            ReportOutcome::Filed { number } => format!("filed #{number}"),
350            ReportOutcome::AlreadyOpen { number } => format!("already open as #{number}"),
351            ReportOutcome::BelowBar => "skipped (below reporting bar)".to_string(),
352            ReportOutcome::Failed { error } => format!("FAILED: {error}"),
353        };
354        out.push_str(&format!("  {} — {}\n", record.title, line));
355        if !record.public_marker_carriers.is_empty() {
356            // Not a failure, and not silent: someone else's issue is wearing
357            // this runtime's dedup marker, which is exactly what a marker read
358            // as an auth token would have honoured.
359            out.push_str(&format!(
360                "    note: signature also appears on issue(s) {} filed by accounts with no write \
361                 access to the tracker — ignored for deduplication\n",
362                record
363                    .public_marker_carriers
364                    .iter()
365                    .map(|n| format!("#{n}"))
366                    .collect::<Vec<_>>()
367                    .join(", ")
368            ));
369        }
370    }
371    out
372}
373
374// ---------------------------------------------------------------------------
375// The real `gh` CLI
376// ---------------------------------------------------------------------------
377
378/// How many open issues to scan when looking for an existing signature.
379///
380/// Bounded because the scan is a full body fetch. A tracker with more than this
381/// many open issues carrying auto-filed signatures is itself the problem.
382const SCAN_LIMIT: usize = 200;
383
384/// Parse a real `gh issue list --json …` payload into [`RawIssue`]s.
385///
386/// A free function so an integration test can drive it over output captured
387/// from GitHub itself. A hand-written fixture proves a parser handles what its
388/// author imagined; only real output proves it handles what GitHub sends.
389pub fn parse_issue_list(repo: &str, out: &str) -> Result<Vec<RawIssue>, GhError> {
390    let rows: Vec<IssueRow> = serde_json::from_str(out).map_err(|e| GhError {
391        message: format!("could not parse `gh issue list` output: {e}"),
392        stderr: String::new(),
393    })?;
394    Ok(rows
395        .into_iter()
396        .map(|row| {
397            let login = row.author.map(|a| a.login).unwrap_or_default();
398            let labels = row.labels.into_iter().map(|l| l.name).collect();
399            let created_ms = chrono::DateTime::parse_from_rfc3339(&row.created_at)
400                .map(|d| d.timestamp_millis().max(0) as u64)
401                .unwrap_or(0);
402            RawIssue::new(
403                repo, row.number, login, row.title, row.body, labels, created_ms,
404            )
405        })
406        .collect())
407}
408
409/// [`IssueApi`] over the real GitHub CLI.
410pub struct GhIssues;
411
412impl IssueApi for GhIssues {
413    /// Deliberately a bounded **list and local match**, not `--search`.
414    ///
415    /// GitHub's search index is eventually consistent: an issue filed seconds
416    /// ago does not reliably appear in `--search` results yet. Two rounds in
417    /// quick succession would each search, each find nothing, and each file —
418    /// which is precisely the duplicate deduplication exists to prevent.
419    /// Listing open issues and matching the marker in the body we already
420    /// fetched has no such window.
421    ///
422    /// The author is fetched with the body because provenance comes from the
423    /// account, and an issue whose author is unknown cannot be tiered.
424    fn list_open_issues(&self, repo: &str) -> Result<Vec<RawIssue>, GhError> {
425        let args: Vec<String> = vec![
426            "issue".into(),
427            "list".into(),
428            "--repo".into(),
429            repo.into(),
430            "--state".into(),
431            "open".into(),
432            "--limit".into(),
433            SCAN_LIMIT.to_string(),
434            "--json".into(),
435            "number,title,body,author,labels,createdAt".into(),
436        ];
437        let out = super::merge::gh(std::path::Path::new("."), &args)?;
438        let rows: Vec<IssueRow> = serde_json::from_str(&out).map_err(|e| GhError {
439            message: format!("could not parse `gh issue list` output: {e}"),
440            stderr: String::new(),
441        })?;
442        Ok(rows
443            .into_iter()
444            .map(|row| {
445                let login = row.author.map(|a| a.login).unwrap_or_default();
446                let labels = row.labels.into_iter().map(|l| l.name).collect();
447                let created_ms = chrono::DateTime::parse_from_rfc3339(&row.created_at)
448                    .map(|d| d.timestamp_millis().max(0) as u64)
449                    .unwrap_or(0);
450                RawIssue::new(
451                    repo, row.number, login, row.title, row.body, labels, created_ms,
452                )
453            })
454            .collect())
455    }
456
457    fn create_issue(
458        &self,
459        repo: &str,
460        title: &str,
461        body: &str,
462        labels: &[String],
463    ) -> Result<u64, GhError> {
464        let mut args: Vec<String> = vec![
465            "issue".into(),
466            "create".into(),
467            "--repo".into(),
468            repo.into(),
469            "--title".into(),
470            title.into(),
471            "--body".into(),
472            body.into(),
473        ];
474        for label in labels {
475            args.push("--label".into());
476            args.push(label.clone());
477        }
478        let out = super::merge::gh(std::path::Path::new("."), &args)?;
479        parse_issue_number(&out).ok_or_else(|| GhError {
480            message: "`gh issue create` succeeded but printed no issue URL".to_string(),
481            stderr: String::new(),
482        })
483    }
484}
485
486#[derive(Deserialize)]
487struct IssueRow {
488    number: u64,
489    title: String,
490    body: String,
491    #[serde(default)]
492    labels: Vec<IssueLabel>,
493    /// RFC3339. `default` so an older `gh` that omits it degrades to epoch
494    /// rather than failing the whole scan — a missing timestamp sorts an issue
495    /// first, which is wrong but visible, where a failed parse is silent.
496    #[serde(default, rename = "createdAt")]
497    created_at: String,
498    /// `null` for a ghost (deleted) account, and the report target is a public
499    /// repo where one such issue anywhere in the scan would otherwise fail the
500    /// whole parse and turn every proposal in the round into `Failed`. An issue
501    /// with no author cannot be tiered above `public`, which an empty login
502    /// already is.
503    #[serde(default)]
504    author: Option<IssueAuthor>,
505}
506
507#[derive(Deserialize)]
508struct IssueLabel {
509    #[serde(default)]
510    name: String,
511}
512
513#[derive(Deserialize)]
514struct IssueAuthor {
515    #[serde(default)]
516    login: String,
517}
518
519/// `gh issue create` prints the issue URL; the number is its last path segment.
520fn parse_issue_number(output: &str) -> Option<u64> {
521    output
522        .split_whitespace()
523        .last()?
524        .rsplit('/')
525        .next()?
526        .parse()
527        .ok()
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use crate::coder::ab_learnings::ProposalKind;
534    use crate::coder::provenance::RepoPermission;
535    use std::sync::Mutex;
536
537    fn proposal(
538        component: &str,
539        pattern: &str,
540        confidence: Confidence,
541        evidence: usize,
542    ) -> DurableFixProposal {
543        DurableFixProposal {
544            title: format!("fix {component}"),
545            pattern: pattern.to_string(),
546            target_component: component.to_string(),
547            target_hint: "coder/native_loop.rs".to_string(),
548            proposed_change: "widen the guard".to_string(),
549            evidence_tasks: vec!["task-1".to_string()],
550            evidence_count: evidence,
551            kind: ProposalKind::HarnessAddressable,
552            confidence,
553            priority: 10,
554        }
555    }
556
557    /// The account this runtime files as, in tests.
558    const BOT: &str = "car-bot";
559
560    #[derive(Default)]
561    struct FakeIssues {
562        /// Open issues as (number, author login, body).
563        open: Vec<(u64, String, String)>,
564        created: Mutex<Vec<(String, String, String)>>,
565        fail_list: bool,
566        fail_create: bool,
567    }
568
569    struct FakeOracle {
570        viewer: String,
571        maintainers: Vec<String>,
572        fail_identity: bool,
573    }
574
575    impl Default for FakeOracle {
576        fn default() -> Self {
577            Self {
578                viewer: BOT.to_string(),
579                maintainers: Vec::new(),
580                fail_identity: false,
581            }
582        }
583    }
584
585    impl PermissionOracle for FakeOracle {
586        fn viewer_login(&self) -> Result<String, GhError> {
587            if self.fail_identity {
588                return Err(GhError {
589                    message: "gh api user failed".into(),
590                    stderr: String::new(),
591                });
592            }
593            Ok(self.viewer.clone())
594        }
595
596        fn permission(&self, _repo: &str, login: &str) -> Result<RepoPermission, GhError> {
597            Ok(if self.maintainers.iter().any(|m| m == login) {
598                RepoPermission::Write
599            } else {
600                RepoPermission::None
601            })
602        }
603    }
604
605    impl IssueApi for FakeIssues {
606        fn list_open_issues(&self, repo: &str) -> Result<Vec<RawIssue>, GhError> {
607            if self.fail_list {
608                return Err(GhError {
609                    message: "list exploded".into(),
610                    stderr: String::new(),
611                });
612            }
613            Ok(self
614                .open
615                .iter()
616                .map(|(number, author, body)| {
617                    RawIssue::new(repo, *number, author, "an open issue", body, Vec::new(), 0)
618                })
619                .collect())
620        }
621
622        fn create_issue(
623            &self,
624            repo: &str,
625            title: &str,
626            body: &str,
627            _labels: &[String],
628        ) -> Result<u64, GhError> {
629            if self.fail_create {
630                return Err(GhError {
631                    message: "create exploded".into(),
632                    stderr: String::new(),
633                });
634            }
635            let mut created = self.created.lock().unwrap();
636            created.push((repo.to_string(), title.to_string(), body.to_string()));
637            Ok(900 + created.len() as u64)
638        }
639    }
640
641    #[test]
642    fn signature_is_stable_across_calls() {
643        let p = proposal(
644            "native_loop",
645            "tool results never returned",
646            Confidence::Strong,
647            3,
648        );
649        assert_eq!(proposal_signature(&p), proposal_signature(&p));
650    }
651
652    #[test]
653    fn signature_ignores_volatile_fields() {
654        // Title wording and per-round evidence must NOT change identity, or every
655        // re-run files a duplicate.
656        let mut a = proposal(
657            "native_loop",
658            "tool results never returned",
659            Confidence::Strong,
660            3,
661        );
662        let mut b = a.clone();
663        b.title = "completely different wording".into();
664        b.evidence_tasks = vec!["task-9".into(), "task-12".into()];
665        b.evidence_count = 11;
666        b.priority = 1;
667        assert_eq!(proposal_signature(&a), proposal_signature(&b));
668
669        // But the identity fields do change it.
670        a.target_component = "contract".into();
671        assert_ne!(proposal_signature(&a), proposal_signature(&b));
672    }
673
674    #[test]
675    fn separator_prevents_field_boundary_collision() {
676        let a = proposal("ab", "c", Confidence::Strong, 1);
677        let b = proposal("a", "bc", Confidence::Strong, 1);
678        assert_ne!(proposal_signature(&a), proposal_signature(&b));
679    }
680
681    #[test]
682    fn strong_always_clears_tentative_needs_recurrence() {
683        assert!(clears_reporting_bar(&proposal(
684            "c",
685            "p",
686            Confidence::Strong,
687            1
688        )));
689        assert!(!clears_reporting_bar(&proposal(
690            "c",
691            "p",
692            Confidence::Tentative,
693            1
694        )));
695        assert!(clears_reporting_bar(&proposal(
696            "c",
697            "p",
698            Confidence::Tentative,
699            2
700        )));
701    }
702
703    #[test]
704    fn body_carries_the_marker_that_dedupe_reads() {
705        let p = proposal("native_loop", "thrash", Confidence::Strong, 3);
706        let sig = proposal_signature(&p);
707        let body = render_issue_body(&p, &sig);
708        assert!(body.contains(&signature_marker(&sig)));
709        // The dedupe path matches on the bare signature, so that must appear too.
710        assert!(body.contains(&sig));
711        assert!(body.contains("native_loop"));
712        assert!(body.contains("widen the guard"));
713    }
714
715    #[test]
716    fn files_new_proposals_and_skips_already_open_ones() {
717        let already = proposal("contract", "derivation drifts", Confidence::Strong, 4);
718        let sig = proposal_signature(&already);
719        let api = FakeIssues {
720            open: vec![(
721                77,
722                BOT.to_string(),
723                format!("stale text {}", signature_marker(&sig)),
724            )],
725            ..Default::default()
726        };
727        let fresh = proposal("native_loop", "thrash", Confidence::Strong, 2);
728        let noise = proposal("router", "maybe", Confidence::Tentative, 1);
729
730        let records = report_proposals(
731            &api,
732            &FakeOracle::default(),
733            "acme/releases",
734            &[already, fresh, noise],
735            &[],
736        );
737
738        assert_eq!(
739            records[0].outcome,
740            ReportOutcome::AlreadyOpen { number: 77 }
741        );
742        assert_eq!(records[1].outcome, ReportOutcome::Filed { number: 901 });
743        assert_eq!(records[2].outcome, ReportOutcome::BelowBar);
744        // Exactly one create call, and it went to the named repo.
745        let created = api.created.lock().unwrap();
746        assert_eq!(created.len(), 1);
747        assert_eq!(created[0].0, "acme/releases");
748    }
749
750    #[test]
751    fn a_tracker_failure_is_recorded_and_does_not_abort_the_round() {
752        let api = FakeIssues {
753            fail_create: true,
754            ..Default::default()
755        };
756        let records = report_proposals(
757            &api,
758            &FakeOracle::default(),
759            "acme/releases",
760            &[
761                proposal("a", "one", Confidence::Strong, 2),
762                proposal("b", "two", Confidence::Strong, 2),
763            ],
764            &[],
765        );
766        // Both were attempted — the first failure did not stop the second.
767        assert_eq!(records.len(), 2);
768        assert!(matches!(records[0].outcome, ReportOutcome::Failed { .. }));
769        assert!(matches!(records[1].outcome, ReportOutcome::Failed { .. }));
770    }
771
772    #[test]
773    fn a_lookup_failure_does_not_file_blind() {
774        // If we cannot tell whether it is already open, filing would duplicate.
775        let api = FakeIssues {
776            fail_list: true,
777            ..Default::default()
778        };
779        let records = report_proposals(
780            &api,
781            &FakeOracle::default(),
782            "acme/releases",
783            &[proposal("a", "one", Confidence::Strong, 2)],
784            &[],
785        );
786        assert!(matches!(records[0].outcome, ReportOutcome::Failed { .. }));
787        assert!(api.created.lock().unwrap().is_empty());
788    }
789
790    #[test]
791    fn a_stranger_wearing_our_marker_cannot_suppress_a_real_report() {
792        // The report target is world-writable and the marker is plain text in
793        // it. Before provenance, this pasted marker made the round say
794        // "already open" and the defect was never reported.
795        let p = proposal("contract", "derivation drifts", Confidence::Strong, 4);
796        let sig = proposal_signature(&p);
797        let api = FakeIssues {
798            open: vec![(
799                4242,
800                "drive-by".to_string(),
801                format!("unrelated rant {}", signature_marker(&sig)),
802            )],
803            ..Default::default()
804        };
805
806        let records = report_proposals(&api, &FakeOracle::default(), "acme/releases", &[p], &[]);
807
808        assert_eq!(records[0].outcome, ReportOutcome::Filed { number: 901 });
809        assert_eq!(records[0].public_marker_carriers, vec![4242]);
810        // The tier decision is recorded, not just acted on.
811        assert_eq!(records[0].provenance.len(), 1);
812        assert_eq!(records[0].provenance[0].tier, ProvenanceTier::Public);
813        // And it is said out loud in the round's summary.
814        let rendered = render_report(&records, "acme/releases");
815        assert!(rendered.contains("#4242"));
816        assert!(rendered.contains("ignored for deduplication"));
817    }
818
819    #[test]
820    fn a_teammates_report_still_dedupes() {
821        // There is no dedicated bot account: `runtime` is whoever ran
822        // `gh auth login`. If only that account's issues counted, the next
823        // operator's round would re-file every report the last one filed and
824        // announce their legitimate issues as forgeries.
825        let p = proposal("contract", "derivation drifts", Confidence::Strong, 4);
826        let sig = proposal_signature(&p);
827        let api = FakeIssues {
828            open: vec![(50, "a-maintainer".to_string(), signature_marker(&sig))],
829            ..Default::default()
830        };
831        let oracle = FakeOracle {
832            maintainers: vec!["a-maintainer".to_string()],
833            ..Default::default()
834        };
835
836        let records = report_proposals(&api, &oracle, "acme/releases", &[p], &[]);
837
838        assert_eq!(
839            records[0].outcome,
840            ReportOutcome::AlreadyOpen { number: 50 }
841        );
842        assert_eq!(records[0].provenance[0].tier, ProvenanceTier::Maintainer);
843        assert!(records[0].public_marker_carriers.is_empty());
844        assert!(api.created.lock().unwrap().is_empty());
845    }
846
847    #[test]
848    fn an_identity_failure_does_not_file_blind() {
849        // Without knowing who we are, nothing can reach a trusted tier, so
850        // every proposal would re-file. Say so instead.
851        let p = proposal("contract", "derivation drifts", Confidence::Strong, 4);
852        let sig = proposal_signature(&p);
853        let api = FakeIssues {
854            open: vec![(50, BOT.to_string(), signature_marker(&sig))],
855            ..Default::default()
856        };
857        let oracle = FakeOracle {
858            fail_identity: true,
859            ..Default::default()
860        };
861
862        let records = report_proposals(&api, &oracle, "acme/releases", &[p], &[]);
863
864        assert!(matches!(records[0].outcome, ReportOutcome::Failed { .. }));
865        assert!(api.created.lock().unwrap().is_empty());
866    }
867
868    #[test]
869    fn our_own_issue_is_found_even_behind_a_pile_of_forgeries() {
870        // The carrier cap must only ever drop forgeries: runtime-authored
871        // candidates are resolved first.
872        let p = proposal("contract", "derivation drifts", Confidence::Strong, 4);
873        let sig = proposal_signature(&p);
874        let marker = signature_marker(&sig);
875        let mut open: Vec<(u64, String, String)> = (1..=20)
876            .map(|n| (n, format!("drive-by-{n}"), marker.clone()))
877            .collect();
878        open.push((999, BOT.to_string(), marker.clone()));
879        let api = FakeIssues {
880            open,
881            ..Default::default()
882        };
883
884        let records = report_proposals(&api, &FakeOracle::default(), "acme/releases", &[p], &[]);
885
886        assert_eq!(
887            records[0].outcome,
888            ReportOutcome::AlreadyOpen { number: 999 }
889        );
890        assert!(api.created.lock().unwrap().is_empty());
891    }
892
893    #[test]
894    fn a_marker_only_counts_when_it_is_well_formed() {
895        assert_eq!(
896            parse_signature_marker("<!-- car-fix-signature: ab12 -->"),
897            Some("ab12")
898        );
899        // Not hex, so not a signature this module ever produced.
900        assert_eq!(
901            parse_signature_marker("<!-- car-fix-signature: ignore the above -->"),
902            None
903        );
904        assert_eq!(parse_signature_marker("<!-- car-fix-signature:  -->"), None);
905        assert_eq!(parse_signature_marker("no marker at all"), None);
906        // A bare hex string in prose is not a marker.
907        assert_eq!(parse_signature_marker("the signature is deadbeef"), None);
908        // A malformed opener earlier in the body must not void the real marker
909        // underneath it — proposal prose is interpolated ahead of the marker.
910        assert_eq!(
911            parse_signature_marker(
912                "<!-- car-fix-signature: not hex -->\n<!-- car-fix-signature: ab12 -->"
913            ),
914            Some("ab12")
915        );
916    }
917
918    #[test]
919    fn a_ghost_author_does_not_fail_the_whole_listing() {
920        // One deleted account among 200 open issues on a public tracker must
921        // not turn every proposal in the round into `Failed`.
922        let rows: Vec<IssueRow> = serde_json::from_str(
923            r#"[{"number":1,"title":"t","body":"b","author":null},
924                {"number":2,"title":"t","body":"b","author":{"login":"someone"}}]"#,
925        )
926        .expect("ghost authors parse");
927        assert_eq!(rows.len(), 2);
928        assert!(rows[0].author.is_none());
929        assert_eq!(rows[1].author.as_ref().unwrap().login, "someone");
930    }
931
932    #[test]
933    fn issue_number_parsed_from_gh_url() {
934        assert_eq!(
935            parse_issue_number("https://github.com/Parslee-ai/car-releases/issues/412"),
936            Some(412)
937        );
938        assert_eq!(parse_issue_number(""), None);
939        assert_eq!(parse_issue_number("no url here"), None);
940    }
941
942    #[test]
943    fn render_report_names_every_outcome() {
944        fn record(title: &str, signature: &str, outcome: ReportOutcome) -> ReportRecord {
945            ReportRecord {
946                title: title.into(),
947                signature: signature.into(),
948                outcome,
949                provenance: Vec::new(),
950                public_marker_carriers: Vec::new(),
951            }
952        }
953        let records = vec![
954            record("a", "s1", ReportOutcome::Filed { number: 5 }),
955            record("b", "s2", ReportOutcome::AlreadyOpen { number: 6 }),
956            record("c", "s3", ReportOutcome::BelowBar),
957            record(
958                "d",
959                "s4",
960                ReportOutcome::Failed {
961                    error: "boom".into(),
962                },
963            ),
964        ];
965        let out = render_report(&records, "acme/releases");
966        assert!(out.contains("acme/releases"));
967        assert!(out.contains("filed #5"));
968        assert!(out.contains("already open as #6"));
969        assert!(out.contains("below reporting bar"));
970        assert!(out.contains("FAILED: boom"));
971    }
972}