1use 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
60pub const DEFAULT_REPORT_REPO: &str = "Parslee-ai/car-releases";
62
63const TENTATIVE_MIN_EVIDENCE: usize = 2;
67
68pub trait IssueApi: Send + Sync {
75 fn list_open_issues(&self, repo: &str) -> Result<Vec<RawIssue>, GhError>;
81
82 fn create_issue(
84 &self,
85 repo: &str,
86 title: &str,
87 body: &str,
88 labels: &[String],
89 ) -> Result<u64, GhError>;
90}
91
92pub fn proposal_signature(proposal: &DurableFixProposal) -> String {
95 let mut hasher = Sha256::new();
96 hasher.update(proposal.target_component.trim().to_lowercase().as_bytes());
97 hasher.update([0u8]);
99 hasher.update(proposal.pattern.trim().to_lowercase().as_bytes());
100 format!("{:x}", hasher.finalize())[..16].to_string()
101}
102
103pub fn signature_marker(signature: &str) -> String {
105 format!("<!-- car-fix-signature: {signature} -->")
106}
107
108pub fn parse_signature_marker(body: &str) -> Option<&str> {
115 const OPEN: &str = "<!-- car-fix-signature: ";
116 const CLOSE: &str = " -->";
117 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
135pub 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
148pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "snake_case", tag = "outcome")]
189pub enum ReportOutcome {
190 Filed { number: u64 },
192 AlreadyOpen { number: u64 },
194 BelowBar,
196 Failed { error: String },
199}
200
201#[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
211 pub provenance: Vec<ProvenanceRecord>,
212 #[serde(default, skip_serializing_if = "Vec::is_empty")]
216 pub public_marker_carriers: Vec<u64>,
217}
218
219const MAX_MARKER_CARRIERS: usize = 5;
227
228pub fn report_proposals(
235 api: &dyn IssueApi,
236 oracle: &dyn PermissionOracle,
237 repo: &str,
238 proposals: &[DurableFixProposal],
239 labels: &[String],
240) -> Vec<ReportRecord> {
241 let local = LocalSignatures::from_proposals(proposals);
245 let open = api.list_open_issues(repo);
248 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 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
307fn 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 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 return Some(tiered.number());
337 }
338 None
339}
340
341pub 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 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
374const SCAN_LIMIT: usize = 200;
383
384pub 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
409pub struct GhIssues;
411
412impl IssueApi for GhIssues {
413 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 #[serde(default, rename = "createdAt")]
497 created_at: String,
498 #[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
519fn 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 const BOT: &str = "car-bot";
559
560 #[derive(Default)]
561 struct FakeIssues {
562 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 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 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 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 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 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 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 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 assert_eq!(records[0].provenance.len(), 1);
812 assert_eq!(records[0].provenance[0].tier, ProvenanceTier::Public);
813 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 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 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 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 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 assert_eq!(parse_signature_marker("the signature is deadbeef"), None);
908 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 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}