1use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use std::io::Write;
15use std::path::{Path, PathBuf};
16use std::process::Command;
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub enum TerminationReason {
23 DonePRGreen,
24 DoneAdvisory,
25 NoWork,
26 Budget,
27 NoProgress,
28 Interrupted,
29 Aborted,
30}
31
32#[derive(Debug, Serialize)]
34pub struct LoopCheckOutput {
35 pub decision: String, pub termination_reason: Option<TerminationReason>,
37 pub message: String,
38 pub fires: u64,
39 pub fingerprint: Option<String>,
40}
41
42#[derive(Debug)]
46struct Manifest {
47 session_id: Option<String>,
48 created_at: Option<String>,
49 attended: bool, advisory: bool,
51 no_ship: bool,
52 no_external: bool,
53 legacy_status: Option<String>, budget_wall_clock_cap_minutes: Option<Result<u64, String>>,
56 budget_cost_cap_usd: Option<Result<f64, String>>,
58}
59
60impl Default for Manifest {
61 fn default() -> Self {
62 Self {
63 session_id: None,
64 created_at: None,
65 attended: true, advisory: false,
67 no_ship: false,
68 no_external: false,
69 legacy_status: None,
70 budget_wall_clock_cap_minutes: None, budget_cost_cap_usd: None, }
73 }
74}
75
76fn parse_manifest(content: &str) -> Option<Manifest> {
80 let content = content.trim_start();
81 if !content.starts_with("---") {
82 return None;
83 }
84 let after_first = &content[3..];
85 let end = after_first.find("\n---")?;
87 let body = &after_first[..end];
88
89 let mut m = Manifest {
90 attended: true, ..Default::default()
92 };
93
94 for line in body.lines() {
95 let line = line.trim();
96 if line.is_empty() || line.starts_with('#') {
97 continue;
98 }
99 if let Some((k, v)) = line.split_once(':') {
100 let k = k.trim();
101 let v = v.trim().trim_matches(|c| c == '"' || c == '\'');
104 match k {
105 "session_id" => m.session_id = Some(v.to_string()),
106 "created_at" => m.created_at = Some(v.to_string()),
107 "attended" => m.attended = v == "true",
108 "advisory" => m.advisory = v == "true",
109 "no_ship" => m.no_ship = v == "true",
110 "no_external" => m.no_external = v == "true",
111 "status" => {
112 let upper = v.to_uppercase();
113 if matches!(upper.as_str(), "COMPLETE" | "BLOCKED" | "ABORTED") {
114 m.legacy_status = Some(upper);
115 }
116 }
117 "budget_wall_clock_cap_minutes" => {
118 let stripped = v
121 .split_once('#')
122 .map(|(before, _)| before.trim())
123 .unwrap_or(v);
124 m.budget_wall_clock_cap_minutes = Some(stripped.parse::<u64>().map_err(|_| {
125 eprintln!(
126 "loop-check: malformed budget cap 'budget_wall_clock_cap_minutes: {v}' - failing closed; fix the config"
127 );
128 v.to_string()
129 }));
130 }
131 "budget_cost_cap_usd" => {
132 let stripped = v
133 .split_once('#')
134 .map(|(before, _)| before.trim())
135 .unwrap_or(v);
136 m.budget_cost_cap_usd = Some(stripped.parse::<f64>().map_err(|_| {
137 eprintln!(
138 "loop-check: malformed budget cap 'budget_cost_cap_usd: {v}' - failing closed; fix the config"
139 );
140 v.to_string()
141 }));
142 }
143 _ => {}
144 }
145 }
146 }
147 Some(m)
148}
149
150#[derive(Debug, Default)]
153struct Settings {
154 attended_wall_cap_minutes: Option<Result<u64, String>>,
157 attended_cost_cap_usd: Option<Result<f64, String>>,
159 unattended_wall_cap_minutes: Option<Result<u64, String>>,
161 unattended_cost_cap_usd: Option<Result<f64, String>>,
163 flat_budget_cap: Option<Result<f64, String>>,
165 ci_declared_none: bool,
167 external_reviewers: Vec<String>,
169 required_bots: Option<Vec<String>>,
176}
177
178fn strip_inline_comment(raw: &str) -> &str {
183 if raw.starts_with('#') {
184 return "";
185 }
186 match raw.find(" #").or_else(|| raw.find("\t#")) {
187 Some(i) => raw[..i].trim_end(),
188 None => raw,
189 }
190}
191
192fn parse_settings(content: &str) -> Settings {
195 let mut s = Settings::default();
196 let mut in_config = false;
197 let mut in_budget = false;
198 let mut in_attended = false;
199 let mut in_unattended = false;
200 let mut in_ci = false;
201 let mut in_review = false;
202 let mut collecting_reviewers = false;
203 let mut collecting_required_bots = false;
204
205 let unit = content
209 .lines()
210 .filter(|l| !l.trim().is_empty() && !l.trim_start().starts_with('#'))
211 .map(|l| l.len() - l.trim_start().len())
212 .find(|&i| i > 0)
213 .unwrap_or(2);
214
215 for line in content.lines() {
216 if line.trim_start().starts_with('#') || line.trim().is_empty() {
217 continue;
218 }
219 let raw_indent = line.len() - line.trim_start().len();
220 let indent = (raw_indent / unit) * 2;
223 let trimmed = line.trim();
224
225 if indent == 0 {
227 in_config = trimmed.starts_with("config:");
228 collecting_reviewers = false;
229 collecting_required_bots = false;
230 if !in_config {
231 in_budget = false;
232 in_attended = false;
233 in_unattended = false;
234 in_ci = false;
235 in_review = false;
236 }
237 if let Some(rest) = trimmed.strip_prefix("budget_cap:") {
239 let raw = rest.trim();
240 s.flat_budget_cap = Some(raw.parse::<f64>().map_err(|_| {
241 eprintln!(
242 "loop-check: malformed budget cap 'budget_cap: {raw}' - failing closed; fix the config"
243 );
244 raw.to_string()
245 }));
246 }
247 continue;
248 }
249
250 if in_config && indent == 2 {
252 in_budget = trimmed.starts_with("budget:");
253 in_ci = trimmed.starts_with("ci:");
254 in_review = trimmed.starts_with("review:");
255 collecting_reviewers = trimmed.starts_with("external_reviewers:");
256 collecting_required_bots = false;
257 if !in_budget {
258 in_attended = false;
259 in_unattended = false;
260 }
261 continue;
262 }
263
264 if in_config && in_budget && indent == 4 {
266 in_attended = trimmed.starts_with("attended:");
267 in_unattended = trimmed.starts_with("unattended:");
268 continue;
269 }
270
271 if in_config && in_ci && indent == 4 {
272 if let Some(rest) = trimmed.strip_prefix("declared_none:") {
273 s.ci_declared_none = rest.trim() == "true";
274 }
275 continue;
276 }
277
278 if in_config && in_review && indent == 4 {
280 if let Some(rest) = trimmed.strip_prefix("required_bots:") {
281 let raw = strip_inline_comment(rest.trim());
285 if raw == "[]" {
286 s.required_bots = Some(Vec::new());
289 collecting_required_bots = false;
290 } else if raw.is_empty() {
291 collecting_required_bots = true;
296 } else if raw.starts_with('[') && raw.ends_with(']') {
297 let inner = &raw[1..raw.len() - 1];
299 let items: Vec<String> = inner
300 .split(',')
301 .map(|p| p.trim().trim_matches(|c| c == '"' || c == '\'').to_string())
302 .filter(|p| !p.is_empty())
303 .collect();
304 s.required_bots = Some(items);
305 collecting_required_bots = false;
306 } else {
307 eprintln!(
309 "loop-check: malformed config.review.required_bots '{raw}' (not a list) - using code default"
310 );
311 s.required_bots = None;
312 collecting_required_bots = false;
313 }
314 } else {
315 collecting_required_bots = false;
316 }
317 continue;
318 }
319
320 if in_config && collecting_required_bots && trimmed.starts_with('-') {
322 let bot = strip_inline_comment(trimmed.trim_start_matches('-').trim())
323 .trim()
324 .trim_matches(|c| c == '"' || c == '\'')
325 .to_string();
326 if !bot.is_empty() {
327 s.required_bots.get_or_insert_with(Vec::new).push(bot);
328 }
329 continue;
330 }
331
332 if in_config && in_budget && (in_attended || in_unattended) && indent == 6 {
334 if let Some(rest) = trimmed.strip_prefix("wall_clock_cap_minutes:") {
335 let raw = rest.trim();
336 let parsed = raw.parse::<u64>().map_err(|_| {
337 let which = if in_attended { "attended" } else { "unattended" };
338 eprintln!(
339 "loop-check: malformed budget cap '{which}.wall_clock_cap_minutes: {raw}' - failing closed; fix the config"
340 );
341 raw.to_string()
342 });
343 if in_attended {
344 s.attended_wall_cap_minutes = Some(parsed);
345 } else {
346 s.unattended_wall_cap_minutes = Some(parsed);
347 }
348 }
349 if let Some(rest) = trimmed.strip_prefix("cost_cap_usd:") {
350 let raw = rest.trim();
351 let parsed = raw.parse::<f64>().map_err(|_| {
352 let which = if in_attended { "attended" } else { "unattended" };
353 eprintln!(
354 "loop-check: malformed budget cap '{which}.cost_cap_usd: {raw}' - failing closed; fix the config"
355 );
356 raw.to_string()
357 });
358 if in_attended {
359 s.attended_cost_cap_usd = Some(parsed);
360 } else {
361 s.unattended_cost_cap_usd = Some(parsed);
362 }
363 }
364 continue;
365 }
366
367 if in_config && collecting_reviewers && trimmed.starts_with('-') {
369 let reviewer = trimmed.trim_start_matches('-').trim().to_string();
370 if !reviewer.is_empty() {
371 s.external_reviewers.push(reviewer);
372 }
373 }
374 }
375 s
376}
377
378fn session_cost_from_ledger(ledger_path: &Path, session_id: &str) -> f64 {
382 let Ok(content) = std::fs::read_to_string(ledger_path) else {
383 return 0.0;
384 };
385 let Ok(arr) = serde_json::from_str::<Value>(&content) else {
386 return 0.0;
387 };
388 let Some(entries) = arr.as_array() else {
389 return 0.0;
390 };
391 let mut total = 0.0_f64;
392 for entry in entries {
393 if entry.get("session_id").and_then(|v| v.as_str()) == Some(session_id) {
394 if let Some(c) = entry.get("cost_usd").and_then(|v| v.as_f64()) {
395 total += c;
396 }
397 }
398 }
399 total
400}
401
402#[derive(Debug, PartialEq)]
405enum Intent {
406 Promise,
407 Aborted { reason: String },
408 None,
409}
410
411fn extract_assistant_text(val: &Value) -> String {
412 if let Some(s) = val.pointer("/message/content").and_then(|v| v.as_str()) {
414 return s.to_string();
415 }
416 if let Some(arr) = val.pointer("/message/content").and_then(|v| v.as_array()) {
418 let mut parts = Vec::new();
419 for block in arr {
420 if block.get("type").and_then(|t| t.as_str()) == Some("text") {
422 if let Some(t) = block.get("text").and_then(|v| v.as_str()) {
423 parts.push(t.to_string());
424 }
425 }
426 }
427 return parts.join(" ");
428 }
429 if let Some(s) = val.get("content").and_then(|v| v.as_str()) {
431 return s.to_string();
432 }
433 String::new()
434}
435
436fn detect_intent_from_text(text: &str) -> Intent {
438 if let Some(aborted_start) = text.find("<aborted") {
440 if let Some(gt) = text[aborted_start..].find('>') {
442 let tag_text = &text[aborted_start..aborted_start + gt + 1];
443 let reason = parse_xml_attr(tag_text, "reason").unwrap_or_default();
444 return Intent::Aborted { reason };
445 }
446 }
447 if text.contains("<promise>") {
448 return Intent::Promise;
449 }
450 Intent::None
451}
452
453fn parse_xml_attr(tag_text: &str, attr: &str) -> Option<String> {
454 let pattern = format!(r#"{attr}=""#);
455 let start = tag_text.find(&pattern)? + pattern.len();
456 let end = tag_text[start..].find('"')?;
457 Some(tag_text[start..start + end].to_string())
458}
459
460fn extract_last_assistant_message(hook_input: &str) -> Option<String> {
466 let val: Value = serde_json::from_str(hook_input).ok()?;
467 let s = val.get("last_assistant_message")?.as_str()?;
468 let trimmed = s.trim();
469 if trimmed.is_empty() {
470 None
471 } else {
472 Some(trimmed.to_string())
473 }
474}
475
476fn detect_intent(
483 last_assistant_message: Option<&str>,
484 transcript_path: &Path,
485) -> (Intent, &'static str) {
486 match last_assistant_message {
487 Some(text) => (detect_intent_from_text(text), "payload"),
488 None => (detect_intent_full(transcript_path), "transcript"),
489 }
490}
491
492const INTENT_LOOKBACK_ENTRIES: usize = 5;
500
501fn detect_intent_full(transcript_path: &Path) -> Intent {
502 let Ok(content) = std::fs::read_to_string(transcript_path) else {
503 return Intent::None;
504 };
505
506 let lines: Vec<&str> = content.lines().collect();
507 let mut scanned: usize = 0;
508 for line in lines.iter().rev() {
509 let line = line.trim();
510 if line.is_empty() {
511 continue;
512 }
513 let Ok(val) = serde_json::from_str::<Value>(line) else {
514 continue;
515 };
516 let role = val
517 .pointer("/message/role")
518 .or_else(|| val.get("role"))
519 .and_then(|v| v.as_str())
520 .unwrap_or("");
521 if role != "assistant" {
522 continue;
523 }
524 let text = extract_assistant_text(&val);
525 if text.is_empty() {
526 continue;
527 }
528 match detect_intent_from_text(&text) {
529 Intent::None => {
530 scanned += 1;
531 if scanned >= INTENT_LOOKBACK_ENTRIES {
532 return Intent::None;
533 }
534 }
535 tagged => return tagged,
536 }
537 }
538 Intent::None
539}
540
541#[derive(Debug, Clone, Copy, PartialEq, Eq)]
547enum PrState {
548 Open,
549 Merged,
550 Closed,
551 None,
553}
554
555impl PrState {
556 fn from_gh_str(s: &str) -> Self {
557 match s {
558 "OPEN" => PrState::Open,
559 "MERGED" => PrState::Merged,
560 "CLOSED" => PrState::Closed,
561 _ => PrState::None,
562 }
563 }
564
565 fn as_str(&self) -> &'static str {
566 match self {
567 PrState::Open => "OPEN",
568 PrState::Merged => "MERGED",
569 PrState::Closed => "CLOSED",
570 PrState::None => "none",
571 }
572 }
573
574 fn is_open_or_merged(&self) -> bool {
575 matches!(self, PrState::Open | PrState::Merged)
576 }
577}
578
579#[derive(Debug, Clone, PartialEq, Eq)]
582enum CiConclusion {
583 Success,
584 Failure(Option<String>),
586 Pending,
587 Skipped,
589 None,
591}
592
593impl CiConclusion {
594 fn render(&self) -> String {
595 match self {
596 CiConclusion::Success => "SUCCESS".to_string(),
597 CiConclusion::Failure(Some(name)) => format!("FAILURE:{name}"),
598 CiConclusion::Failure(None) => "FAILURE".to_string(),
599 CiConclusion::Pending => "PENDING".to_string(),
600 CiConclusion::Skipped => "skipped".to_string(),
601 CiConclusion::None => "none".to_string(),
602 }
603 }
604
605 fn is_ok(&self) -> bool {
606 matches!(self, CiConclusion::Success | CiConclusion::Skipped)
607 }
608}
609
610#[derive(Debug)]
611struct PrInfo {
612 state: PrState,
613 number: i64,
614 head_oid: String,
617 ci_conclusion: CiConclusion,
618 latest_review_ts: String,
621 reviewed: bool, missing_bots: Vec<String>,
625 unaddressed_findings: Vec<Finding>,
628 review_skipped: bool,
632}
633
634fn git_head_sha(git_bin: &str, cwd: &Path) -> String {
635 let out = Command::new(git_bin)
636 .args(["rev-parse", "HEAD"])
637 .current_dir(cwd)
638 .output();
639 match out {
640 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
641 _ => "unknown".to_string(),
642 }
643}
644
645fn is_no_pr_stderr(stderr: &[u8]) -> bool {
652 String::from_utf8_lossy(stderr)
653 .to_lowercase()
654 .contains("no pull requests found")
655}
656
657fn stderr_tail(bytes: &[u8]) -> String {
659 let s = String::from_utf8_lossy(bytes);
660 let s = s.trim();
661 if s.len() <= 200 {
662 s.to_string()
663 } else {
664 let mut start = s.len() - 200;
667 while start < s.len() && !s.is_char_boundary(start) {
668 start += 1;
669 }
670 s[start..].to_string()
671 }
672}
673
674fn read_pr_info(
676 gh_bin: &str,
677 cwd: &Path,
678 ci_declared_none: bool,
679 no_external: bool,
680 required_bots: &[String],
681 external_reviewers: &[String],
682) -> Result<PrInfo, (String, String)> {
683 let pr_view_out = Command::new(gh_bin)
685 .args([
686 "pr",
687 "view",
688 "--json",
689 "state,number,headRefName,headRefOid",
690 ])
691 .current_dir(cwd)
692 .output()
693 .map_err(|e| ("pr_view".to_string(), e.to_string()))?;
694
695 if !pr_view_out.status.success() {
696 if is_no_pr_stderr(&pr_view_out.stderr) {
697 return Ok(PrInfo {
701 state: PrState::None,
702 number: 0,
703 head_oid: String::new(),
704 ci_conclusion: CiConclusion::None,
705 latest_review_ts: "none".to_string(),
706 reviewed: false,
707 missing_bots: Vec::new(),
708 unaddressed_findings: Vec::new(),
709 review_skipped: false,
710 });
711 }
712 return Err(("pr_view".to_string(), stderr_tail(&pr_view_out.stderr)));
713 }
714
715 let pr_json: Value = serde_json::from_slice(&pr_view_out.stdout)
716 .map_err(|_| ("pr_view_parse".to_string(), String::new()))?;
717
718 let state = PrState::from_gh_str(
719 pr_json
720 .get("state")
721 .and_then(|v| v.as_str())
722 .unwrap_or("none"),
723 );
724 let number = pr_json.get("number").and_then(|v| v.as_i64()).unwrap_or(0);
725 let head_oid = pr_json
726 .get("headRefOid")
727 .and_then(|v| v.as_str())
728 .unwrap_or("")
729 .to_string();
730
731 let ci_conclusion = if ci_declared_none {
733 CiConclusion::Skipped
734 } else {
735 let checks_out = Command::new(gh_bin)
736 .args(["pr", "checks", "--json", "name,state,bucket"])
737 .current_dir(cwd)
738 .output()
739 .map_err(|e| ("pr_checks".to_string(), e.to_string()))?;
740
741 if !checks_out.status.success() {
742 return Err(("pr_checks".to_string(), stderr_tail(&checks_out.stderr)));
743 }
744
745 let checks: Value = serde_json::from_slice(&checks_out.stdout)
746 .map_err(|_| ("pr_checks_parse".to_string(), String::new()))?;
747
748 compute_ci_conclusion(&checks).map_err(|e| (e, String::new()))?
749 };
750
751 let review_skipped = no_external || required_bots.is_empty();
756 let (latest_review_ts, reviewed, missing_bots, unaddressed_findings) = if review_skipped {
757 ("none".to_string(), true, Vec::new(), Vec::new()) } else {
759 let reviews_out = Command::new(gh_bin)
761 .args(["pr", "view", "--json", "reviews,comments"])
762 .current_dir(cwd)
763 .output()
764 .map_err(|e| ("pr_reviews".to_string(), e.to_string()))?;
765
766 if !reviews_out.status.success() {
767 return Err(("pr_reviews".to_string(), stderr_tail(&reviews_out.stderr)));
768 }
769
770 let reviews_json: Value = serde_json::from_slice(&reviews_out.stdout)
771 .map_err(|_| ("pr_reviews_parse".to_string(), String::new()))?;
772
773 let info = compute_review_info(&reviews_json, required_bots);
774
775 let comments_out = Command::new(gh_bin)
780 .args([
781 "api",
782 &format!("repos/{{owner}}/{{repo}}/pulls/{number}/comments"),
783 "--paginate",
784 ])
785 .current_dir(cwd)
786 .output()
787 .map_err(|e| ("pulls_comments".to_string(), e.to_string()))?;
788
789 if !comments_out.status.success() {
790 return Err((
791 "pulls_comments".to_string(),
792 stderr_tail(&comments_out.stderr),
793 ));
794 }
795
796 let mut inline_comments: Vec<Value> = Vec::new();
797 for page in serde_json::Deserializer::from_slice(&comments_out.stdout).into_iter::<Value>()
798 {
799 let page = page.map_err(|_| ("pulls_comments_parse".to_string(), String::new()))?;
800 match page.as_array() {
801 Some(arr) => inline_comments.extend(arr.iter().cloned()),
802 None => return Err(("pulls_comments_parse".to_string(), String::new())),
803 }
804 }
805
806 let has_blocking_candidate = inline_comments.iter().any(|c| {
809 c.get("in_reply_to_id").and_then(|v| v.as_i64()).is_none()
810 && blocking_severity(c.get("body").and_then(|v| v.as_str()).unwrap_or("")).is_some()
811 });
812 let commit_dates: Vec<String> = if has_blocking_candidate {
813 let commits_out = Command::new(gh_bin)
814 .args(["pr", "view", "--json", "commits"])
815 .current_dir(cwd)
816 .output()
817 .map_err(|e| ("pr_commits".to_string(), e.to_string()))?;
818 if !commits_out.status.success() {
819 return Err(("pr_commits".to_string(), stderr_tail(&commits_out.stderr)));
820 }
821 let commits_json: Value = serde_json::from_slice(&commits_out.stdout)
822 .map_err(|_| ("pr_commits_parse".to_string(), String::new()))?;
823 commits_json
824 .get("commits")
825 .and_then(|v| v.as_array())
826 .map(|arr| {
827 arr.iter()
828 .filter_map(|c| {
829 c.get("committedDate")
830 .and_then(|v| v.as_str())
831 .map(|s| s.to_string())
832 })
833 .collect()
834 })
835 .unwrap_or_default()
836 } else {
837 Vec::new()
838 };
839
840 let (inline_ts, unaddressed) = compute_unaddressed_findings(
841 &inline_comments,
842 &commit_dates,
843 required_bots,
844 external_reviewers,
845 );
846
847 let activity_ts = max_ts(&info.latest_ts, &inline_ts);
851 let reviewed = info.all_required_passed() && unaddressed.is_empty();
852 (activity_ts, reviewed, info.missing_bots, unaddressed)
853 };
854
855 Ok(PrInfo {
856 state,
857 number,
858 head_oid,
859 ci_conclusion,
860 latest_review_ts,
861 reviewed,
862 missing_bots,
863 unaddressed_findings,
864 review_skipped,
865 })
866}
867
868fn compute_ci_conclusion(checks: &Value) -> Result<CiConclusion, String> {
869 let arr = match checks.as_array() {
870 Some(a) => a,
871 None => return Err("pr_checks_parse".to_string()),
872 };
873
874 if arr.is_empty() {
875 return Ok(CiConclusion::None);
877 }
878
879 let bucket_of = |check: &Value| -> String {
886 check
887 .get("bucket")
888 .and_then(|v| v.as_str())
889 .unwrap_or("")
890 .to_lowercase()
891 };
892
893 if let Some(failing) = arr
894 .iter()
895 .find(|c| matches!(bucket_of(c).as_str(), "fail" | "cancel"))
896 {
897 let name = failing
898 .get("name")
899 .and_then(|v| v.as_str())
900 .unwrap_or("unknown");
901 return Ok(CiConclusion::Failure(Some(name.to_string())));
902 }
903 if arr
904 .iter()
905 .any(|c| !matches!(bucket_of(c).as_str(), "pass" | "skipping"))
906 {
907 return Ok(CiConclusion::Pending);
908 }
909 Ok(CiConclusion::Success)
910}
911
912const KNOWN_BOTS: &[&str] = &["chatgpt-codex-connector", "gemini-code-assist"];
914
915const DEFAULT_REQUIRED_BOTS: &[&str] = &[];
922
923fn resolved_required_bots(settings: &Settings) -> Vec<String> {
924 match &settings.required_bots {
925 Some(list) => list.clone(),
926 None => DEFAULT_REQUIRED_BOTS
927 .iter()
928 .map(|s| s.to_string())
929 .collect(),
930 }
931}
932
933fn login_matches_bot(login: &str, bot: &str) -> bool {
937 !bot.is_empty() && login.to_lowercase().contains(&bot.to_lowercase())
938}
939
940fn is_bot_reviewer(login: &str, external_reviewers: &[String]) -> bool {
941 if !external_reviewers.is_empty() {
942 let login_lower = login.to_lowercase();
943 if external_reviewers
945 .iter()
946 .any(|r| login_lower.contains(&r.to_lowercase()))
947 {
948 return true;
949 }
950 }
953 login.ends_with("[bot]") || KNOWN_BOTS.iter().any(|&b| login.contains(b))
955}
956
957#[derive(Debug)]
959struct ReviewInfo {
960 latest_ts: String,
962 missing_bots: Vec<String>,
967}
968
969impl ReviewInfo {
970 fn all_required_passed(&self) -> bool {
972 self.missing_bots.is_empty()
973 }
974}
975
976fn compute_review_info(reviews_json: &Value, required_bots: &[String]) -> ReviewInfo {
977 let reviews = reviews_json
978 .get("reviews")
979 .and_then(|v| v.as_array())
980 .map(|v| v.as_slice())
981 .unwrap_or(&[]);
982 let comments = reviews_json
983 .get("comments")
984 .and_then(|v| v.as_array())
985 .map(|v| v.as_slice())
986 .unwrap_or(&[]);
987
988 let mut latest_ts = String::new(); let mut passed: Vec<bool> = vec![false; required_bots.len()];
990
991 for r in reviews {
992 let login = r
993 .pointer("/author/login")
994 .and_then(|v| v.as_str())
995 .unwrap_or("");
996 let submitted_at = r.get("submittedAt").and_then(|v| v.as_str()).unwrap_or("");
997 let state = r.get("state").and_then(|v| v.as_str()).unwrap_or("");
998
999 if !submitted_at.is_empty() && submitted_at > latest_ts.as_str() {
1000 latest_ts = submitted_at.to_string();
1001 }
1002
1003 if !state.is_empty() {
1004 for (i, bot) in required_bots.iter().enumerate() {
1005 if login_matches_bot(login, bot) {
1006 passed[i] = true;
1007 }
1008 }
1009 }
1010 }
1011
1012 for c in comments {
1013 let created_at = c.get("createdAt").and_then(|v| v.as_str()).unwrap_or("");
1014 if !created_at.is_empty() && created_at > latest_ts.as_str() {
1015 latest_ts = created_at.to_string();
1016 }
1017 }
1018
1019 let final_ts = if latest_ts.is_empty() {
1020 "none".to_string()
1021 } else {
1022 latest_ts
1023 };
1024
1025 let missing_bots: Vec<String> = required_bots
1026 .iter()
1027 .zip(passed.iter())
1028 .filter(|(_, ok)| !**ok)
1029 .map(|(bot, _)| bot.clone())
1030 .collect();
1031
1032 ReviewInfo {
1033 latest_ts: final_ts,
1034 missing_bots,
1035 }
1036}
1037
1038#[derive(Debug, Clone)]
1043struct Finding {
1044 id: i64,
1045 author: String,
1047 path: String,
1048 line: i64,
1049 created_at: String,
1050 severity: &'static str,
1052}
1053
1054fn blocking_severity(body: &str) -> Option<&'static str> {
1063 if body.contains("![P1 Badge]") || body.contains("badge/P1-") {
1064 return Some("P1");
1065 }
1066 if body.contains("![critical]") || body.contains("critical-priority.svg") {
1067 return Some("critical");
1068 }
1069 if body.contains("![high]") || body.contains("high-priority.svg") {
1070 return Some("high");
1071 }
1072 None
1073}
1074
1075fn max_ts(a: &str, b: &str) -> String {
1082 if let (Ok(da), Ok(db)) = (a.parse::<DateTime<Utc>>(), b.parse::<DateTime<Utc>>()) {
1083 return if da >= db {
1084 a.to_string()
1085 } else {
1086 b.to_string()
1087 };
1088 }
1089 let a_real = !a.is_empty() && a != "none";
1090 let b_real = !b.is_empty() && b != "none";
1091 match (a_real, b_real) {
1092 (true, true) => {
1093 if a >= b {
1094 a.to_string()
1095 } else {
1096 b.to_string()
1097 }
1098 }
1099 (true, false) => a.to_string(),
1100 (false, true) => b.to_string(),
1101 (false, false) => "none".to_string(),
1102 }
1103}
1104
1105const WONTFIX_MARKER: &str = "wontfix:";
1108
1109fn ts_after(a: &str, b: &str) -> bool {
1116 match (a.parse::<DateTime<Utc>>(), b.parse::<DateTime<Utc>>()) {
1117 (Ok(da), Ok(db)) => da > db,
1118 _ => false,
1119 }
1120}
1121
1122fn compute_unaddressed_findings(
1131 comments: &[Value],
1132 commit_dates: &[String],
1133 required_bots: &[String],
1134 external_reviewers: &[String],
1135) -> (String, Vec<Finding>) {
1136 let mut latest_ts = String::new();
1137 let mut candidates: Vec<Finding> = Vec::new();
1138 let mut replies: std::collections::HashMap<i64, Vec<String>> = std::collections::HashMap::new();
1140
1141 for c in comments {
1142 let created_at = c.get("created_at").and_then(|v| v.as_str()).unwrap_or("");
1143 if !created_at.is_empty() && created_at > latest_ts.as_str() {
1144 latest_ts = created_at.to_string();
1145 }
1146
1147 let login = c
1148 .pointer("/user/login")
1149 .and_then(|v| v.as_str())
1150 .unwrap_or("");
1151 let body = c.get("body").and_then(|v| v.as_str()).unwrap_or("");
1152 let in_reply_to = c.get("in_reply_to_id").and_then(|v| v.as_i64());
1153
1154 match in_reply_to {
1155 Some(parent_id) => {
1156 if !is_bot_reviewer(login, external_reviewers) {
1158 replies.entry(parent_id).or_default().push(body.to_string());
1159 }
1160 }
1161 None => {
1162 let by_required_bot = required_bots
1165 .iter()
1166 .any(|bot| login_matches_bot(login, bot));
1167 if by_required_bot {
1168 if let Some(severity) = blocking_severity(body) {
1169 let Some(id) = c.get("id").and_then(|v| v.as_i64()) else {
1176 eprintln!(
1177 "loop-check: skipping blocking finding with missing id (author={login})"
1178 );
1179 continue;
1180 };
1181 candidates.push(Finding {
1182 id,
1183 author: login.to_string(),
1184 path: c
1185 .get("path")
1186 .and_then(|v| v.as_str())
1187 .unwrap_or("unknown")
1188 .to_string(),
1189 line: c
1190 .get("line")
1191 .and_then(|v| v.as_i64())
1192 .or_else(|| c.get("original_line").and_then(|v| v.as_i64()))
1193 .unwrap_or(0),
1194 created_at: created_at.to_string(),
1195 severity,
1196 });
1197 }
1198 }
1199 }
1200 }
1201 }
1202
1203 let unaddressed: Vec<Finding> = candidates
1204 .into_iter()
1205 .filter(|f| {
1206 let non_bot_replies = replies.get(&f.id);
1207 let has_reply = non_bot_replies.map(|r| !r.is_empty()).unwrap_or(false);
1208 if !has_reply {
1209 return true; }
1211 let commit_after = commit_dates.iter().any(|d| ts_after(d, &f.created_at));
1212 let wontfix = non_bot_replies
1213 .map(|rs| rs.iter().any(|b| b.to_lowercase().contains(WONTFIX_MARKER)))
1214 .unwrap_or(false);
1215 !(commit_after || wontfix)
1216 })
1217 .collect();
1218
1219 let final_ts = if latest_ts.is_empty() {
1220 "none".to_string()
1221 } else {
1222 latest_ts
1223 };
1224 (final_ts, unaddressed)
1225}
1226
1227fn make_fingerprint(
1230 head_sha: &str,
1231 pr_state: &str,
1232 ci_conclusion: &str,
1233 latest_ts: &str,
1234) -> String {
1235 format!("{head_sha}|{pr_state}|{ci_conclusion}|{latest_ts}")
1236}
1237
1238fn read_prior_fires(
1245 events_path: &Path,
1246 session_id: &str,
1247 current_fp: &str,
1248) -> (u64, u64, Option<String>) {
1249 let Ok(content) = std::fs::read_to_string(events_path) else {
1250 return (0, 0, None);
1251 };
1252
1253 let mut total: u64 = 0;
1254
1255 for line in content.lines() {
1256 let Ok(val) = serde_json::from_str::<Value>(line) else {
1257 continue;
1258 };
1259 if val.get("type").and_then(|v| v.as_str()) != Some("loop_check") {
1260 continue;
1261 }
1262 if val.pointer("/data/session_id").and_then(|v| v.as_str()) != Some(session_id) {
1263 continue;
1264 }
1265 total += 1;
1266 }
1267
1268 let mut consecutive: u64 = 0;
1271 let mut last_fp: Option<String> = None;
1272 for line in content.lines().rev() {
1273 let Ok(val) = serde_json::from_str::<Value>(line) else {
1274 continue;
1275 };
1276 if val.get("type").and_then(|v| v.as_str()) != Some("loop_check") {
1277 continue;
1278 }
1279 if val.pointer("/data/session_id").and_then(|v| v.as_str()) != Some(session_id) {
1280 continue;
1281 }
1282 if val
1287 .pointer("/data/fp_read_failed")
1288 .and_then(|v| v.as_bool())
1289 == Some(true)
1290 {
1291 continue;
1292 }
1293 let fp = val
1294 .pointer("/data/fingerprint")
1295 .and_then(|v| v.as_str())
1296 .unwrap_or("");
1297 if last_fp.is_none() && !fp.is_empty() {
1299 last_fp = Some(fp.to_string());
1300 }
1301 if fp == current_fp {
1302 consecutive += 1;
1303 } else {
1304 break;
1305 }
1306 }
1307
1308 (total, consecutive, last_fp)
1309}
1310
1311#[derive(Debug, Serialize)]
1320struct LoopEventEnvelope<'a> {
1321 ts: String,
1322 #[serde(rename = "type")]
1323 event_type: &'a str,
1324 source: &'static str,
1325 data: serde_json::Value,
1326}
1327
1328pub(crate) fn now_rfc3339_utc() -> String {
1331 let now = chrono::Utc::now();
1333 now.format("%Y-%m-%dT%H:%M:%SZ").to_string()
1334}
1335
1336fn append_loop_event(path: &Path, event_type: &str, data: serde_json::Value) {
1339 let env = LoopEventEnvelope {
1340 ts: now_rfc3339_utc(),
1341 event_type,
1342 source: "hook",
1343 data,
1344 };
1345 let Ok(mut line) = serde_json::to_string(&env) else {
1346 eprintln!("loop-check: failed to serialize event {event_type}");
1347 return;
1348 };
1349 line.push('\n');
1350
1351 if let Some(parent) = path.parent() {
1353 let _ = std::fs::create_dir_all(parent);
1354 }
1355
1356 match std::fs::OpenOptions::new()
1357 .create(true)
1358 .append(true)
1359 .open(path)
1360 {
1361 Ok(mut f) => {
1362 if let Err(e) = f.write_all(line.as_bytes()) {
1363 eprintln!(
1364 "loop-check: failed to write event {event_type} to {}: {e}",
1365 path.display()
1366 );
1367 }
1368 }
1369 Err(e) => {
1370 eprintln!(
1371 "loop-check: failed to open events file {}: {e}",
1372 path.display()
1373 );
1374 }
1375 }
1376}
1377
1378pub(crate) fn emit_to_both(
1385 project_events: &Path,
1386 global_events: &Path,
1387 event_type: &str,
1388 data: serde_json::Value,
1389) {
1390 append_loop_event(project_events, event_type, data.clone());
1391 if project_events != global_events {
1392 append_loop_event(global_events, event_type, data);
1393 }
1394}
1395
1396fn check_cancel_sentinel(cwd: &Path, created_at: &Option<String>) -> bool {
1399 let sentinel = cwd.join(".fno/.target-cancelled");
1400 let tombstone = cwd.join(".fno/.target-cancelled-final");
1401
1402 for path in &[&tombstone, &sentinel] {
1403 if !path.exists() {
1404 continue;
1405 }
1406 if let Some(ca) = created_at {
1408 if let Ok(parsed_ca) = ca.parse::<DateTime<Utc>>() {
1409 if let Ok(meta) = std::fs::metadata(path) {
1410 if let Ok(modified) = meta.modified() {
1411 let sentinel_time: DateTime<Utc> = modified.into();
1412 if sentinel_time >= parsed_ca {
1413 return true;
1414 }
1415 continue;
1417 }
1418 }
1419 }
1420 return true;
1422 }
1423 return true;
1424 }
1425 false
1426}
1427
1428#[derive(Debug, PartialEq)]
1431enum BudgetTrip {
1432 WallClock,
1433 Cost,
1434}
1435
1436enum ResolvedCap<T> {
1441 Absent,
1442 Valid(T),
1443 Malformed(String),
1444}
1445
1446fn resolve_cap<T: Copy>(cap: &Option<Result<T, String>>) -> ResolvedCap<T> {
1447 match cap {
1448 None => ResolvedCap::Absent,
1449 Some(Ok(v)) => ResolvedCap::Valid(*v),
1450 Some(Err(raw)) => ResolvedCap::Malformed(raw.clone()),
1451 }
1452}
1453
1454fn check_budget(
1455 manifest: &Manifest,
1456 settings: &Settings,
1457 now: &DateTime<Utc>,
1458 ledger_path: &Path,
1459) -> Option<BudgetTrip> {
1460 let attended = manifest.attended;
1461
1462 let wall_cap = match resolve_cap(&manifest.budget_wall_clock_cap_minutes) {
1464 ResolvedCap::Absent => {
1465 if attended {
1466 resolve_cap(&settings.attended_wall_cap_minutes)
1467 } else {
1468 resolve_cap(&settings.unattended_wall_cap_minutes)
1469 }
1470 }
1471 other => other,
1472 };
1473
1474 match wall_cap {
1475 ResolvedCap::Malformed(raw) => {
1476 eprintln!("loop-check: malformed budget cap '{raw}' - failing closed; fix the config");
1477 return Some(BudgetTrip::WallClock);
1478 }
1479 ResolvedCap::Valid(cap) => {
1480 if let Some(ca_str) = &manifest.created_at {
1481 if let Ok(created) = ca_str.parse::<DateTime<Utc>>() {
1482 let duration = now.signed_duration_since(created);
1484 let elapsed_min = if duration.num_minutes() < 0 {
1485 0u64
1486 } else {
1487 duration.num_minutes() as u64
1488 };
1489 if elapsed_min >= cap {
1490 return Some(BudgetTrip::WallClock);
1491 }
1492 }
1493 }
1494 }
1495 ResolvedCap::Absent => {}
1496 }
1497
1498 let cost_cap = match resolve_cap(&manifest.budget_cost_cap_usd) {
1500 ResolvedCap::Absent => {
1501 let nested = if attended {
1502 resolve_cap(&settings.attended_cost_cap_usd)
1503 } else {
1504 resolve_cap(&settings.unattended_cost_cap_usd)
1505 };
1506 match nested {
1507 ResolvedCap::Absent => resolve_cap(&settings.flat_budget_cap),
1508 other => other,
1509 }
1510 }
1511 other => other,
1512 };
1513
1514 match cost_cap {
1515 ResolvedCap::Malformed(raw) => {
1516 eprintln!("loop-check: malformed budget cap '{raw}' - failing closed; fix the config");
1517 Some(BudgetTrip::Cost)
1518 }
1519 ResolvedCap::Valid(cap) => {
1520 if let Some(session_id) = &manifest.session_id {
1521 let cost = session_cost_from_ledger(ledger_path, session_id);
1522 if cost >= cap {
1523 return Some(BudgetTrip::Cost);
1524 }
1525 }
1526 None
1527 }
1528 ResolvedCap::Absent => None,
1529 }
1530}
1531
1532#[derive(Debug)]
1538struct LoopCheckArgs {
1539 state_path: PathBuf,
1540 transcript_path: PathBuf,
1541 cwd: PathBuf,
1542 global_settings_path: Option<PathBuf>,
1545 events_path: Option<PathBuf>,
1546 global_events_path: Option<PathBuf>,
1547 settings_path: Option<PathBuf>,
1548 ledger_path: Option<PathBuf>,
1549 now_override: Option<String>,
1550 gh_bin: String,
1551 git_bin: String,
1552 hook_input_stdin: bool,
1557}
1558
1559fn parse_args(args: &[String]) -> Result<LoopCheckArgs, String> {
1560 let mut state_path: Option<PathBuf> = None;
1561 let mut transcript_path: Option<PathBuf> = None;
1562 let mut cwd: Option<PathBuf> = None;
1563 let mut global_settings_path: Option<PathBuf> = None;
1564 let mut events_path: Option<PathBuf> = None;
1565 let mut global_events_path: Option<PathBuf> = None;
1566 let mut settings_path: Option<PathBuf> = None;
1567 let mut ledger_path: Option<PathBuf> = None;
1568 let mut now_override: Option<String> = None;
1569 let mut gh_bin = std::env::var("FNO_LOOPCHECK_GH_BIN").unwrap_or_else(|_| "gh".to_string());
1570 let mut git_bin = std::env::var("FNO_LOOPCHECK_GIT_BIN").unwrap_or_else(|_| "git".to_string());
1571 let mut hook_input_stdin = false;
1572
1573 let args = if args.first().map(|s| s.as_str()) == Some("loop-check") {
1575 &args[1..]
1576 } else {
1577 args
1578 };
1579
1580 let mut i = 0;
1581 while i < args.len() {
1582 let arg = &args[i];
1583 if let Some(val) = try_flag_value(arg, "--state", args, &mut i) {
1586 state_path = Some(PathBuf::from(val));
1587 } else if let Some(val) = try_flag_value(arg, "--transcript", args, &mut i) {
1588 transcript_path = Some(PathBuf::from(val));
1589 } else if let Some(val) = try_flag_value(arg, "--cwd", args, &mut i) {
1590 cwd = Some(PathBuf::from(val));
1591 } else if let Some(val) = try_flag_value(arg, "--events", args, &mut i) {
1592 events_path = Some(PathBuf::from(val));
1593 } else if let Some(val) = try_flag_value(arg, "--global-events", args, &mut i) {
1594 global_events_path = Some(PathBuf::from(val));
1595 } else if let Some(val) = try_flag_value(arg, "--settings", args, &mut i) {
1596 settings_path = Some(PathBuf::from(val));
1597 } else if let Some(val) = try_flag_value(arg, "--global-settings", args, &mut i) {
1598 global_settings_path = Some(PathBuf::from(val));
1599 } else if let Some(val) = try_flag_value(arg, "--ledger", args, &mut i) {
1600 ledger_path = Some(PathBuf::from(val));
1601 } else if let Some(val) = try_flag_value(arg, "--now", args, &mut i) {
1602 now_override = Some(val);
1603 } else if let Some(val) = try_flag_value(arg, "--gh-bin", args, &mut i) {
1604 gh_bin = val;
1605 } else if let Some(val) = try_flag_value(arg, "--git-bin", args, &mut i) {
1606 git_bin = val;
1607 } else if arg == "--hook-input-stdin" {
1608 hook_input_stdin = true;
1611 }
1612 i += 1;
1613 }
1614
1615 let state_path = state_path.ok_or_else(|| "--state is required".to_string())?;
1617 let transcript_path = transcript_path.ok_or_else(|| "--transcript is required".to_string())?;
1618 let cwd = cwd.ok_or_else(|| "--cwd is required".to_string())?;
1619
1620 Ok(LoopCheckArgs {
1621 state_path,
1622 transcript_path,
1623 cwd,
1624 global_settings_path,
1625 events_path,
1626 global_events_path,
1627 settings_path,
1628 ledger_path,
1629 now_override,
1630 gh_bin,
1631 git_bin,
1632 hook_input_stdin,
1633 })
1634}
1635
1636fn try_flag_value(arg: &str, flag: &str, args: &[String], i: &mut usize) -> Option<String> {
1637 if arg == flag {
1638 *i += 1;
1639 args.get(*i).cloned()
1640 } else if let Some(val) = arg.strip_prefix(&format!("{flag}=")) {
1641 Some(val.to_string())
1642 } else {
1643 None
1644 }
1645}
1646
1647pub fn decide(args: &[String]) -> (i32, String) {
1650 let parsed = match parse_args(args) {
1653 Ok(p) => p,
1654 Err(e) => {
1655 let out = serde_json::json!({ "error": e });
1656 return (2, out.to_string());
1657 }
1658 };
1659
1660 let state_path = parsed.state_path.clone();
1661 let transcript_path = parsed.transcript_path.clone();
1662 let cwd = parsed.cwd.clone();
1663
1664 let last_assistant_message: Option<String> = if parsed.hook_input_stdin {
1672 match std::io::read_to_string(std::io::stdin()) {
1673 Ok(s) => extract_last_assistant_message(&s),
1674 Err(e) => {
1675 eprintln!(
1676 "loop-check: failed to read hook input from stdin: {e}; falling back to transcript scan"
1677 );
1678 None
1679 }
1680 }
1681 } else {
1682 None
1683 };
1684
1685 let manifest_content = match std::fs::read_to_string(&state_path) {
1687 Ok(c) => c,
1688 Err(e) => {
1689 eprintln!(
1690 "loop-check: cannot read state file {}: {e}",
1691 state_path.display()
1692 );
1693 let out = allow_output(
1694 "allow",
1695 None,
1696 "corrupt/missing manifest; allowing exit",
1697 0,
1698 None,
1699 );
1700 return (0, out);
1701 }
1702 };
1703
1704 let manifest = match parse_manifest(&manifest_content) {
1705 Some(m) => m,
1706 None => {
1707 eprintln!("loop-check: corrupt manifest (no frontmatter)");
1708 let out = allow_output(
1709 "allow",
1710 None,
1711 "corrupt manifest (no frontmatter); allowing exit",
1712 0,
1713 None,
1714 );
1715 return (0, out);
1716 }
1717 };
1718
1719 let project_events = parsed
1721 .events_path
1722 .clone()
1723 .unwrap_or_else(|| cwd.join(".fno/events.jsonl"));
1724
1725 let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
1726 let global_events = parsed
1727 .global_events_path
1728 .clone()
1729 .unwrap_or_else(|| PathBuf::from(&home).join(".fno/events.jsonl"));
1730
1731 let ledger_path = parsed
1732 .ledger_path
1733 .clone()
1734 .unwrap_or_else(|| cwd.join(".fno/ledger.json"));
1735
1736 let settings = if let Some(ref explicit) = parsed.settings_path {
1742 if let Ok(sc) = std::fs::read_to_string(explicit) {
1743 parse_settings(&sc)
1744 } else {
1745 Settings::default()
1746 }
1747 } else {
1748 let global_path = parsed
1749 .global_settings_path
1750 .clone()
1751 .unwrap_or_else(|| PathBuf::from(&home).join(".fno/settings.yaml"));
1752 let mut merged = std::fs::read_to_string(&global_path)
1753 .map(|sc| parse_settings(&sc))
1754 .unwrap_or_default();
1755 if let Ok(sc) = std::fs::read_to_string(cwd.join(".fno/settings.yaml")) {
1756 let local = parse_settings(&sc);
1757 if local.attended_wall_cap_minutes.is_some() {
1758 merged.attended_wall_cap_minutes = local.attended_wall_cap_minutes;
1759 }
1760 if local.attended_cost_cap_usd.is_some() {
1761 merged.attended_cost_cap_usd = local.attended_cost_cap_usd;
1762 }
1763 if local.unattended_wall_cap_minutes.is_some() {
1764 merged.unattended_wall_cap_minutes = local.unattended_wall_cap_minutes;
1765 }
1766 if local.unattended_cost_cap_usd.is_some() {
1767 merged.unattended_cost_cap_usd = local.unattended_cost_cap_usd;
1768 }
1769 if local.flat_budget_cap.is_some() {
1770 merged.flat_budget_cap = local.flat_budget_cap;
1771 }
1772 if local.ci_declared_none {
1773 merged.ci_declared_none = true;
1774 }
1775 if !local.external_reviewers.is_empty() {
1776 merged.external_reviewers = local.external_reviewers;
1777 }
1778 if local.required_bots.is_some() {
1779 merged.required_bots = local.required_bots;
1782 }
1783 }
1784 merged
1785 };
1786
1787 let required_bots = resolved_required_bots(&settings);
1789
1790 let now: DateTime<Utc> = if let Some(ref s) = parsed.now_override {
1792 s.parse().unwrap_or_else(|_| Utc::now())
1793 } else {
1794 Utc::now()
1795 };
1796
1797 let session_id = manifest
1798 .session_id
1799 .clone()
1800 .unwrap_or_else(|| "unknown".to_string());
1801 let emit = |event_type: &str, data: serde_json::Value| {
1802 emit_to_both(&project_events, &global_events, event_type, data);
1803 };
1804
1805 if check_cancel_sentinel(&cwd, &manifest.created_at) {
1807 emit(
1808 "termination",
1809 serde_json::json!({
1810 "session_id": session_id,
1811 "reason": "Interrupted",
1812 "message": "cancel sentinel present"
1813 }),
1814 );
1815 return (
1816 0,
1817 allow_output(
1818 "allow",
1819 Some(TerminationReason::Interrupted),
1820 "cancel sentinel present; exiting",
1821 0,
1822 None,
1823 ),
1824 );
1825 }
1826
1827 if let Some(ref status) = manifest.legacy_status {
1829 emit(
1830 "loop_check_legacy_manifest",
1831 serde_json::json!({
1832 "session_id": session_id,
1833 "status": status
1834 }),
1835 );
1836 return (
1837 0,
1838 allow_output(
1839 "allow",
1840 None,
1841 &format!("legacy manifest status={status}; allowing exit"),
1842 0,
1843 None,
1844 ),
1845 );
1846 }
1847
1848 if let Some(trip) = check_budget(&manifest, &settings, &now, &ledger_path) {
1850 let axis = match &trip {
1851 BudgetTrip::WallClock => "wall_clock",
1852 BudgetTrip::Cost => "cost",
1853 };
1854 emit(
1855 "termination",
1856 serde_json::json!({
1857 "session_id": session_id,
1858 "reason": "Budget",
1859 "axis": axis,
1860 "message": format!("budget exceeded (axis={axis})")
1861 }),
1862 );
1863 return (
1864 0,
1865 allow_output(
1866 "allow",
1867 Some(TerminationReason::Budget),
1868 &format!("budget exceeded (axis={axis})"),
1869 0,
1870 None,
1871 ),
1872 );
1873 }
1874
1875 let gh_bin = &parsed.gh_bin;
1880 let gh_available = {
1881 match Command::new(gh_bin).arg("--version").output() {
1885 Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
1886 Err(_) => false,
1887 Ok(_) => true, }
1889 };
1890
1891 if !gh_available {
1892 if !manifest.attended && !manifest.advisory {
1893 emit(
1895 "termination",
1896 serde_json::json!({
1897 "session_id": session_id,
1898 "reason": "Interrupted",
1899 "message": "gh binary not found; unattended sessions require gh"
1900 }),
1901 );
1902 return (
1903 0,
1904 allow_output(
1905 "allow",
1906 Some(TerminationReason::Interrupted),
1907 "gh binary not found; unattended sessions require gh",
1908 0,
1909 None,
1910 ),
1911 );
1912 }
1913 emit(
1918 "loop_advisory_mode",
1919 serde_json::json!({
1920 "session_id": session_id,
1921 "attended": manifest.attended
1922 }),
1923 );
1924 let (advisory_intent, _advisory_intent_source) =
1925 detect_intent(last_assistant_message.as_deref(), &transcript_path);
1926 if let Intent::Aborted { ref reason } = advisory_intent {
1927 emit(
1928 "termination",
1929 serde_json::json!({
1930 "session_id": session_id,
1931 "reason": "Aborted",
1932 "message": reason
1933 }),
1934 );
1935 return (
1936 0,
1937 allow_output(
1938 "allow",
1939 Some(TerminationReason::Aborted),
1940 "aborted tag detected (advisory mode)",
1941 0,
1942 None,
1943 ),
1944 );
1945 }
1946 if advisory_intent == Intent::Promise {
1947 emit(
1948 "termination",
1949 serde_json::json!({
1950 "session_id": session_id,
1951 "reason": "DoneAdvisory",
1952 "message": "promise accepted in advisory mode (gh unavailable)"
1953 }),
1954 );
1955 return (
1956 0,
1957 allow_output(
1958 "allow",
1959 Some(TerminationReason::DoneAdvisory),
1960 "promise accepted in advisory mode (gh unavailable)",
1961 0,
1962 None,
1963 ),
1964 );
1965 }
1966 return (
1967 0,
1968 allow_output(
1969 "block",
1970 None,
1971 "gh binary not found; running in advisory mode (promise + budget only)",
1972 0,
1973 None,
1974 ),
1975 );
1976 }
1977
1978 let (intent, intent_source) =
1980 detect_intent(last_assistant_message.as_deref(), &transcript_path);
1981 let git_bin = &parsed.git_bin;
1982 let head_sha = git_head_sha(git_bin, &cwd);
1983
1984 let backstop_n: u64 = if manifest.attended { 5 } else { 3 };
1988
1989 let fp_read_result = Command::new(gh_bin)
1995 .args(["pr", "view", "--json", "state,number,headRefName"])
1996 .current_dir(&cwd)
1997 .output();
1998 let (fp_pr_state, fp_ci, fp_review_ts, fp_read_failed) = match fp_read_result {
1999 Ok(o) if o.status.success() => {
2000 let pv: Value = serde_json::from_slice(&o.stdout).unwrap_or(Value::Null);
2001 let state =
2002 PrState::from_gh_str(pv.get("state").and_then(|v| v.as_str()).unwrap_or("none"));
2003
2004 let ci = match Command::new(gh_bin)
2006 .args(["pr", "checks", "--json", "name,state,bucket"])
2007 .current_dir(&cwd)
2008 .output()
2009 {
2010 Ok(co) if co.status.success() => {
2011 let cv: Value = serde_json::from_slice(&co.stdout).unwrap_or(Value::Null);
2012 compute_ci_conclusion(&cv).unwrap_or(CiConclusion::None)
2013 }
2014 _ => CiConclusion::None,
2015 };
2016
2017 let rv_ts = if !manifest.no_external && !required_bots.is_empty() {
2020 match Command::new(gh_bin)
2021 .args(["pr", "view", "--json", "reviews,comments"])
2022 .current_dir(&cwd)
2023 .output()
2024 {
2025 Ok(ro) if ro.status.success() => {
2026 let rv: Value = serde_json::from_slice(&ro.stdout).unwrap_or(Value::Null);
2027 compute_review_info(&rv, &required_bots).latest_ts
2028 }
2029 _ => "none".to_string(),
2030 }
2031 } else {
2032 "none".to_string()
2033 };
2034
2035 (state, ci, rv_ts, false)
2036 }
2037 Ok(o) if is_no_pr_stderr(&o.stderr) => {
2041 (PrState::None, CiConclusion::None, "none".to_string(), false)
2042 }
2043 _ => (PrState::None, CiConclusion::None, "none".to_string(), true),
2046 };
2047
2048 let tentative_fp = make_fingerprint(
2050 &head_sha,
2051 fp_pr_state.as_str(),
2052 &fp_ci.render(),
2053 &fp_review_ts,
2054 );
2055
2056 let (prior_fires, consecutive_unchanged, last_recorded_fp) =
2059 read_prior_fires(&project_events, &session_id, &tentative_fp);
2060
2061 let fingerprint = if fp_read_failed {
2064 last_recorded_fp.unwrap_or(tentative_fp)
2065 } else {
2066 tentative_fp
2067 };
2068
2069 let consecutive_unchanged = if fp_read_failed {
2072 let (_, streak, _) = read_prior_fires(&project_events, &session_id, &fingerprint);
2074 streak
2075 } else {
2076 consecutive_unchanged
2077 };
2078
2079 let this_fire = prior_fires + 1;
2080 let consecutive_after = if fp_read_failed {
2084 consecutive_unchanged
2085 } else {
2086 consecutive_unchanged + 1
2087 };
2088
2089 let backstop_tripped = consecutive_after >= backstop_n;
2090
2091 const MUTE_PROBE_N: u64 = 2;
2100
2101 if intent != Intent::None || backstop_tripped || consecutive_after >= MUTE_PROBE_N {
2103 if let Intent::Aborted { ref reason } = intent {
2105 emit(
2106 "termination",
2107 serde_json::json!({
2108 "session_id": session_id,
2109 "reason": "Aborted",
2110 "message": reason
2111 }),
2112 );
2113 emit(
2114 "loop_check",
2115 serde_json::json!({
2116 "session_id": session_id,
2117 "fingerprint": fingerprint,
2118 "fires": this_fire,
2119 "consecutive_unchanged": consecutive_after,
2120 "decision": "allow",
2121 "intent": "aborted",
2122 "intent_source": intent_source,
2123 "pr_state": fp_pr_state.as_str(),
2124 "ci": fp_ci.render(),
2125 "reviewed": false,
2126 "fp_read_failed": fp_read_failed
2127 }),
2128 );
2129 return (
2130 0,
2131 allow_output(
2132 "allow",
2133 Some(TerminationReason::Aborted),
2134 "aborted tag detected",
2135 this_fire,
2136 Some(fingerprint),
2137 ),
2138 );
2139 }
2140
2141 if (manifest.no_ship || manifest.advisory) && intent == Intent::Promise {
2143 emit(
2144 "termination",
2145 serde_json::json!({
2146 "session_id": session_id,
2147 "reason": "DoneAdvisory",
2148 "message": "promise in advisory/no_ship unit"
2149 }),
2150 );
2151 emit(
2152 "loop_check",
2153 serde_json::json!({
2154 "session_id": session_id,
2155 "fingerprint": fingerprint,
2156 "fires": this_fire,
2157 "consecutive_unchanged": consecutive_after,
2158 "decision": "allow",
2159 "intent": "promise",
2160 "intent_source": intent_source,
2161 "pr_state": fp_pr_state.as_str(),
2162 "ci": fp_ci.render(),
2163 "reviewed": true,
2164 "fp_read_failed": fp_read_failed
2165 }),
2166 );
2167 return (
2168 0,
2169 allow_output(
2170 "allow",
2171 Some(TerminationReason::DoneAdvisory),
2172 "promise + advisory unit; done",
2173 this_fire,
2174 Some(fingerprint),
2175 ),
2176 );
2177 }
2178
2179 let done_result = run_done(
2181 gh_bin,
2182 &cwd,
2183 settings.ci_declared_none,
2184 manifest.no_external,
2185 &required_bots,
2186 &settings.external_reviewers,
2187 );
2188
2189 match done_result {
2190 Ok(pr_info) => {
2191 let (fingerprint, consecutive_after) = if !fp_read_failed {
2201 let done_fp = make_fingerprint(
2202 &head_sha,
2203 fp_pr_state.as_str(),
2204 &fp_ci.render(),
2205 &max_ts(&fp_review_ts, &pr_info.latest_review_ts),
2206 );
2207 if done_fp != fingerprint {
2208 let (_, streak, _) =
2209 read_prior_fires(&project_events, &session_id, &done_fp);
2210 (done_fp, streak + 1)
2211 } else {
2212 (fingerprint, consecutive_after)
2213 }
2214 } else {
2215 (fingerprint, consecutive_after)
2216 };
2217 let backstop_tripped = consecutive_after >= backstop_n;
2218
2219 let ci_ok = pr_info.ci_conclusion.is_ok();
2220 let pr_open = pr_info.state.is_open_or_merged();
2221 let head_shipped = !pr_info.head_oid.is_empty() && pr_info.head_oid == head_sha;
2227
2228 if pr_open && ci_ok && pr_info.reviewed && head_shipped {
2229 emit(
2230 "termination",
2231 serde_json::json!({
2232 "session_id": session_id,
2233 "reason": "DonePRGreen",
2234 "message": format!("PR #{} green and reviewed", pr_info.number)
2235 }),
2236 );
2237 emit(
2238 "loop_check",
2239 serde_json::json!({
2240 "session_id": session_id,
2241 "fingerprint": fingerprint,
2242 "fires": this_fire,
2243 "consecutive_unchanged": consecutive_after,
2244 "decision": "allow",
2245 "intent": if intent == Intent::Promise { "promise" } else { "backstop" },
2246 "intent_source": intent_source,
2247 "pr_state": pr_info.state.as_str(),
2248 "ci": pr_info.ci_conclusion.render(),
2249 "reviewed": pr_info.reviewed,
2250 "review_skipped": pr_info.review_skipped,
2251 "unaddressed_blocking": pr_info.unaddressed_findings.len(),
2252 "fp_read_failed": fp_read_failed
2253 }),
2254 );
2255 return (
2256 0,
2257 allow_output(
2258 "allow",
2259 Some(TerminationReason::DonePRGreen),
2260 &format!("PR #{} is green and reviewed", pr_info.number),
2261 this_fire,
2262 Some(fingerprint),
2263 ),
2264 );
2265 }
2266
2267 if backstop_tripped && (!pr_open || !ci_ok || !pr_info.reviewed) {
2268 emit(
2270 "termination",
2271 serde_json::json!({
2272 "session_id": session_id,
2273 "reason": "NoProgress",
2274 "message": format!("fingerprint unchanged for {} consecutive fires; PR not done", consecutive_after)
2275 }),
2276 );
2277 emit(
2278 "loop_check",
2279 serde_json::json!({
2280 "session_id": session_id,
2281 "fingerprint": fingerprint,
2282 "fires": this_fire,
2283 "consecutive_unchanged": consecutive_after,
2284 "decision": "allow",
2285 "intent": "backstop",
2286 "intent_source": intent_source,
2287 "pr_state": pr_info.state.as_str(),
2288 "ci": pr_info.ci_conclusion.render(),
2289 "reviewed": pr_info.reviewed,
2290 "review_skipped": pr_info.review_skipped,
2291 "unaddressed_blocking": pr_info.unaddressed_findings.len(),
2292 "fp_read_failed": fp_read_failed
2293 }),
2294 );
2295 return (0, allow_output(
2296 "allow",
2297 Some(TerminationReason::NoProgress),
2298 &format!(
2299 "fingerprint unchanged for {} fires; HEAD={}, PR={}, CI={}, reviewed={}",
2300 consecutive_after, &head_sha[..8.min(head_sha.len())],
2301 pr_info.state.as_str(), pr_info.ci_conclusion.render(), pr_info.reviewed
2302 ),
2303 this_fire,
2304 Some(fingerprint),
2305 ));
2306 }
2307
2308 let reason = crate::nudge::append_inbox_nudge(
2311 &build_block_reason(&pr_info, &head_sha),
2312 &cwd,
2313 &session_id,
2314 );
2315 emit(
2316 "loop_check",
2317 serde_json::json!({
2318 "session_id": session_id,
2319 "fingerprint": fingerprint,
2320 "fires": this_fire,
2321 "consecutive_unchanged": consecutive_after,
2322 "decision": "block",
2323 "intent": if intent == Intent::Promise { "promise" } else { "none" },
2324 "intent_source": intent_source,
2325 "pr_state": pr_info.state.as_str(),
2326 "ci": pr_info.ci_conclusion.render(),
2327 "reviewed": pr_info.reviewed,
2328 "review_skipped": pr_info.review_skipped,
2329 "unaddressed_blocking": pr_info.unaddressed_findings.len(),
2330 "fp_read_failed": fp_read_failed
2331 }),
2332 );
2333 return (
2334 0,
2335 allow_output("block", None, &reason, this_fire, Some(fingerprint)),
2336 );
2337 }
2338 Err((failed_read, failed_stderr)) => {
2339 emit(
2348 "loop_check_gh_error",
2349 serde_json::json!({
2350 "session_id": session_id,
2351 "read": failed_read,
2352 "stderr_tail": failed_stderr
2353 }),
2354 );
2355 emit(
2356 "loop_check",
2357 serde_json::json!({
2358 "session_id": session_id,
2359 "fingerprint": fingerprint,
2360 "fires": this_fire,
2361 "consecutive_unchanged": consecutive_after,
2362 "decision": "block",
2363 "intent": if intent == Intent::Promise { "promise" } else { "none" },
2364 "intent_source": intent_source,
2365 "pr_state": "unknown",
2366 "ci": "unknown",
2367 "reviewed": false,
2368 "fp_read_failed": true
2369 }),
2370 );
2371 return (
2372 0,
2373 allow_output(
2374 "block",
2375 None,
2376 &format!("gh read '{failed_read}' failed; retrying next fire"),
2377 this_fire,
2378 Some(fingerprint),
2379 ),
2380 );
2381 }
2382 }
2383 }
2384
2385 emit(
2387 "loop_check",
2388 serde_json::json!({
2389 "session_id": session_id,
2390 "fingerprint": fingerprint,
2391 "fires": this_fire,
2392 "consecutive_unchanged": consecutive_after,
2393 "decision": "block",
2394 "intent": "none",
2395 "intent_source": intent_source,
2396 "pr_state": fp_pr_state.as_str(),
2397 "ci": fp_ci.render(),
2398 "reviewed": false,
2399 "fp_read_failed": fp_read_failed
2400 }),
2401 );
2402
2403 let continue_msg = crate::nudge::append_inbox_nudge(
2406 "continue working; no completion signal",
2407 &cwd,
2408 &session_id,
2409 );
2410 (
2411 0,
2412 allow_output("block", None, &continue_msg, this_fire, Some(fingerprint)),
2413 )
2414}
2415
2416fn run_done(
2417 gh_bin: &str,
2418 cwd: &Path,
2419 ci_declared_none: bool,
2420 no_external: bool,
2421 required_bots: &[String],
2422 external_reviewers: &[String],
2423) -> Result<PrInfo, (String, String)> {
2424 read_pr_info(
2425 gh_bin,
2426 cwd,
2427 ci_declared_none,
2428 no_external,
2429 required_bots,
2430 external_reviewers,
2431 )
2432}
2433
2434fn build_block_reason(pr: &PrInfo, local_head: &str) -> String {
2435 if !pr.state.is_open_or_merged() {
2436 return format!(
2437 "no PR for HEAD (pr_state={}); keep working",
2438 pr.state.as_str()
2439 );
2440 }
2441
2442 if !pr.head_oid.is_empty() && pr.head_oid != local_head {
2443 return format!(
2444 "PR #{} head {} != local HEAD {}: push the latest commits before completing",
2445 pr.number,
2446 &pr.head_oid[..8.min(pr.head_oid.len())],
2447 &local_head[..8.min(local_head.len())]
2448 );
2449 }
2450
2451 if !pr.ci_conclusion.is_ok() {
2452 if pr.ci_conclusion == CiConclusion::None {
2453 return format!(
2454 "no CI checks found on PR #{}; declare ci.declared_none: true in settings if intentional",
2455 pr.number
2456 );
2457 }
2458 if pr.ci_conclusion == CiConclusion::Pending {
2463 return format!(
2464 "CI still running on PR #{}; wait for it to finish",
2465 pr.number
2466 );
2467 }
2468 let check_name = match &pr.ci_conclusion {
2469 CiConclusion::Failure(Some(name)) => name.as_str(),
2470 _ => "CI",
2471 };
2472 return format!("CI red on PR #{}: {} failed", pr.number, check_name);
2473 }
2474
2475 if !pr.reviewed {
2476 if !pr.missing_bots.is_empty() {
2477 return format!(
2480 "PR #{}: {} has not reviewed",
2481 pr.number,
2482 pr.missing_bots.join(", ")
2483 );
2484 }
2485 if !pr.unaddressed_findings.is_empty() {
2486 let f = &pr.unaddressed_findings[0];
2488 let more = if pr.unaddressed_findings.len() > 1 {
2489 format!(" [+{} more]", pr.unaddressed_findings.len() - 1)
2490 } else {
2491 String::new()
2492 };
2493 return format!(
2494 "PR #{}: {} {} at {}:{} unaddressed (reply in-thread or wontfix:){}",
2495 pr.number, f.author, f.severity, f.path, f.line, more
2496 );
2497 }
2498 return format!("PR #{} not yet reviewed by a bot reviewer", pr.number);
2499 }
2500
2501 format!("PR #{} done() returned false (unknown reason)", pr.number)
2502}
2503
2504fn allow_output(
2505 decision: &str,
2506 termination_reason: Option<TerminationReason>,
2507 message: &str,
2508 fires: u64,
2509 fingerprint: Option<String>,
2510) -> String {
2511 let out = LoopCheckOutput {
2512 decision: decision.to_string(),
2513 termination_reason,
2514 message: message.to_string(),
2515 fires,
2516 fingerprint,
2517 };
2518 serde_json::to_string(&out).unwrap_or_else(|_| r#"{"decision":"allow","termination_reason":null,"message":"serialization error","fires":0,"fingerprint":null}"#.to_string())
2519}
2520
2521pub fn run_loop_check(args: &[String]) -> i32 {
2526 let (code, json) = decide(args);
2527 println!("{json}");
2528 code
2529}
2530
2531pub fn run_loop_check_capture(args: &[String]) -> (i32, String) {
2534 decide(args)
2535}
2536
2537#[cfg(test)]
2540mod tests {
2541 use super::*;
2542
2543 #[test]
2544 fn parse_manifest_minimal() {
2545 let content =
2546 "---\nsession_id: abc\ncreated_at: 2026-06-05T00:00:00Z\nattended: true\n---\n";
2547 let m = parse_manifest(content).unwrap();
2548 assert_eq!(m.session_id.as_deref(), Some("abc"));
2549 assert_eq!(m.created_at.as_deref(), Some("2026-06-05T00:00:00Z"));
2550 assert!(m.attended);
2551 assert!(m.legacy_status.is_none());
2552 }
2553
2554 #[test]
2555 fn parse_manifest_legacy_complete() {
2556 let content =
2557 "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nstatus: COMPLETE\n---\n";
2558 let m = parse_manifest(content).unwrap();
2559 assert_eq!(m.legacy_status.as_deref(), Some("COMPLETE"));
2560 }
2561
2562 #[test]
2563 fn parse_manifest_legacy_blocked() {
2564 let content =
2565 "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nstatus: BLOCKED\n---\n";
2566 let m = parse_manifest(content).unwrap();
2567 assert_eq!(m.legacy_status.as_deref(), Some("BLOCKED"));
2568 }
2569
2570 #[test]
2571 fn parse_manifest_no_ship() {
2572 let content = "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nno_ship: true\n---\n";
2573 let m = parse_manifest(content).unwrap();
2574 assert!(m.no_ship);
2575 assert!(!m.no_external);
2576 }
2577
2578 #[test]
2579 fn parse_manifest_strips_quotes() {
2580 let content = "---\nsession_id: \"s-quoted\"\ncreated_at: '2026-06-05T00:00:00Z'\n---\n";
2582 let m = parse_manifest(content).unwrap();
2583 assert_eq!(m.session_id.as_deref(), Some("s-quoted"));
2584 assert_eq!(m.created_at.as_deref(), Some("2026-06-05T00:00:00Z"));
2585 }
2586
2587 #[test]
2588 fn parse_settings_four_space_indent() {
2589 let yaml = "config:\n budget:\n unattended:\n cost_cap_usd: 7.5\n ci:\n declared_none: true\n";
2591 let s = parse_settings(yaml);
2592 assert_eq!(s.unattended_cost_cap_usd, Some(Ok(7.5)));
2593 assert!(s.ci_declared_none);
2594 }
2595
2596 #[test]
2597 fn stderr_tail_multibyte_boundary_no_panic() {
2598 let mut payload = String::new();
2600 while payload.len() < 300 {
2601 payload.push('\u{00e9}'); }
2603 let tail = stderr_tail(payload.as_bytes());
2604 assert!(tail.len() <= 200);
2605 assert!(!tail.is_empty());
2606 }
2607
2608 #[test]
2609 fn parse_manifest_attended_default_true() {
2610 let content = "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\n---\n";
2611 let m = parse_manifest(content).unwrap();
2612 assert!(m.attended, "attended should default to true when absent");
2613 }
2614
2615 #[test]
2616 fn parse_manifest_budget_caps() {
2617 let content =
2618 "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nbudget_wall_clock_cap_minutes: 120\nbudget_cost_cap_usd: 5.0\n---\n";
2619 let m = parse_manifest(content).unwrap();
2620 assert_eq!(m.budget_wall_clock_cap_minutes, Some(Ok(120)));
2621 assert_eq!(m.budget_cost_cap_usd, Some(Ok(5.0)));
2622 }
2623
2624 #[test]
2625 fn parse_manifest_no_frontmatter_returns_none() {
2626 let content = "no frontmatter here";
2627 assert!(parse_manifest(content).is_none());
2628 }
2629
2630 #[test]
2631 fn parse_settings_flat_budget_cap() {
2632 let yaml = "budget_cap: 2.5\n";
2633 let s = parse_settings(yaml);
2634 assert_eq!(s.flat_budget_cap, Some(Ok(2.5)));
2635 }
2636
2637 #[test]
2638 fn parse_settings_nested_budget() {
2639 let yaml = "config:\n budget:\n attended:\n wall_clock_cap_minutes: 90\n cost_cap_usd: 10.0\n unattended:\n wall_clock_cap_minutes: 60\n cost_cap_usd: 5.0\n";
2640 let s = parse_settings(yaml);
2641 assert_eq!(s.attended_wall_cap_minutes, Some(Ok(90)));
2642 assert_eq!(s.attended_cost_cap_usd, Some(Ok(10.0)));
2643 assert_eq!(s.unattended_wall_cap_minutes, Some(Ok(60)));
2644 assert_eq!(s.unattended_cost_cap_usd, Some(Ok(5.0)));
2645 }
2646
2647 #[test]
2648 fn parse_settings_ci_declared_none() {
2649 let yaml = "config:\n ci:\n declared_none: true\n";
2650 let s = parse_settings(yaml);
2651 assert!(s.ci_declared_none);
2652 }
2653
2654 #[test]
2655 fn parse_settings_comments_ignored() {
2656 let yaml = "# top comment\nbudget_cap: 1.0\n# another\nconfig:\n # inner\n ci:\n declared_none: true\n";
2657 let s = parse_settings(yaml);
2658 assert_eq!(s.flat_budget_cap, Some(Ok(1.0)));
2659 assert!(s.ci_declared_none);
2660 }
2661
2662 #[test]
2663 fn detect_intent_promise() {
2664 let tmp = tempfile::tempdir().unwrap();
2665 let path = tmp.path().join("t.jsonl");
2666 let line = serde_json::json!({
2667 "message": {"role": "assistant", "content": "done <promise>COMPLETE</promise>"}
2668 });
2669 std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
2670 assert_eq!(detect_intent_full(&path), Intent::Promise);
2671 }
2672
2673 #[test]
2674 fn detect_intent_aborted_beats_promise() {
2675 let tmp = tempfile::tempdir().unwrap();
2676 let path = tmp.path().join("t.jsonl");
2677 let line = serde_json::json!({
2679 "message": {"role": "assistant", "content": "<aborted reason=\"user\">done</aborted>"}
2680 });
2681 std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
2682 assert!(matches!(detect_intent_full(&path), Intent::Aborted { .. }));
2683 }
2684
2685 #[test]
2686 fn detect_intent_tool_result_ignored() {
2687 let tmp = tempfile::tempdir().unwrap();
2689 let path = tmp.path().join("t.jsonl");
2690 let user_line = serde_json::json!({
2691 "message": {"role": "user", "content": "<promise>fake</promise>"}
2692 });
2693 std::fs::write(&path, serde_json::to_string(&user_line).unwrap() + "\n").unwrap();
2694 assert_eq!(detect_intent_full(&path), Intent::None);
2695 }
2696
2697 #[test]
2698 fn detect_intent_none_when_no_assistant() {
2699 let tmp = tempfile::tempdir().unwrap();
2700 let path = tmp.path().join("t.jsonl");
2701 let line = serde_json::json!({"message": {"role": "user", "content": "go"}});
2702 std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
2703 assert_eq!(detect_intent_full(&path), Intent::None);
2704 }
2705
2706 #[test]
2707 fn detect_intent_array_content_blocks() {
2708 let tmp = tempfile::tempdir().unwrap();
2709 let path = tmp.path().join("t.jsonl");
2710 let line = serde_json::json!({
2711 "message": {
2712 "role": "assistant",
2713 "content": [
2714 {"type": "text", "text": "<promise>done</promise>"},
2715 {"type": "tool_use", "name": "Bash"}
2716 ]
2717 }
2718 });
2719 std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
2720 assert_eq!(detect_intent_full(&path), Intent::Promise);
2721 }
2722
2723 #[test]
2724 fn extract_last_assistant_message_plain_string() {
2725 let payload = r#"{"transcript_path":"/t.jsonl","last_assistant_message":" done <promise>MISSION COMPLETE: x</promise> "}"#;
2726 assert_eq!(
2727 extract_last_assistant_message(payload).as_deref(),
2728 Some("done <promise>MISSION COMPLETE: x</promise>")
2729 );
2730 }
2731
2732 #[test]
2733 fn extract_last_assistant_message_degrades_to_none() {
2734 assert_eq!(
2737 extract_last_assistant_message(r#"{"transcript_path":"/t.jsonl"}"#),
2738 None
2739 );
2740 assert_eq!(extract_last_assistant_message("not json {"), None);
2741 assert_eq!(
2742 extract_last_assistant_message(r#"{"last_assistant_message":{"text":"obj"}}"#),
2743 None
2744 );
2745 assert_eq!(
2746 extract_last_assistant_message(r#"{"last_assistant_message":" "}"#),
2747 None
2748 );
2749 }
2750
2751 #[test]
2752 fn detect_intent_payload_promise_wins_over_stale_transcript() {
2753 let tmp = tempfile::tempdir().unwrap();
2756 let path = tmp.path().join("t.jsonl");
2757 let line = serde_json::json!({
2758 "message": {"role": "assistant", "content": "still working on it"}
2759 });
2760 std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
2761 let (intent, source) =
2762 detect_intent(Some("<promise>MISSION COMPLETE: done</promise>"), &path);
2763 assert_eq!(intent, Intent::Promise);
2764 assert_eq!(source, "payload");
2765 }
2766
2767 #[test]
2768 fn detect_intent_payload_no_tag_is_authoritative() {
2769 let tmp = tempfile::tempdir().unwrap();
2772 let path = tmp.path().join("t.jsonl");
2773 let line = serde_json::json!({
2774 "message": {"role": "assistant", "content": "<promise>old stale promise</promise>"}
2775 });
2776 std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
2777 let (intent, source) = detect_intent(Some("moving on to other work"), &path);
2778 assert_eq!(intent, Intent::None);
2779 assert_eq!(source, "payload");
2780 }
2781
2782 #[test]
2783 fn detect_intent_payload_aborted_beats_promise() {
2784 let (intent, source) = detect_intent(
2785 Some("<promise>done</promise> <aborted reason=\"kill\">stop</aborted>"),
2786 Path::new("/nonexistent"),
2787 );
2788 assert!(matches!(intent, Intent::Aborted { ref reason } if reason == "kill"));
2789 assert_eq!(source, "payload");
2790 }
2791
2792 #[test]
2793 fn detect_intent_absent_payload_falls_back_to_transcript() {
2794 let tmp = tempfile::tempdir().unwrap();
2795 let path = tmp.path().join("t.jsonl");
2796 let line = serde_json::json!({
2797 "message": {"role": "assistant", "content": "<promise>COMPLETE</promise>"}
2798 });
2799 std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
2800 let (intent, source) = detect_intent(None, &path);
2801 assert_eq!(intent, Intent::Promise);
2802 assert_eq!(source, "transcript");
2803 }
2804
2805 #[test]
2806 fn detect_intent_lookback_finds_promise_behind_block_feedback() {
2807 let tmp = tempfile::tempdir().unwrap();
2811 let path = tmp.path().join("t.jsonl");
2812 let mut content = String::new();
2813 for text in [
2814 "<promise>MISSION COMPLETE: shipped</promise>",
2815 "acknowledged the block; checking CI",
2816 "CI is still pending, waiting",
2817 ] {
2818 let line = serde_json::json!({
2819 "message": {"role": "assistant", "content": text}
2820 });
2821 content.push_str(&serde_json::to_string(&line).unwrap());
2822 content.push('\n');
2823 }
2824 std::fs::write(&path, content).unwrap();
2825 assert_eq!(detect_intent_full(&path), Intent::Promise);
2826 }
2827
2828 #[test]
2829 fn detect_intent_lookback_bound_holds() {
2830 let tmp = tempfile::tempdir().unwrap();
2834 let path = tmp.path().join("t.jsonl");
2835 let mut content = String::new();
2836 let line = serde_json::json!({
2837 "message": {"role": "assistant", "content": "<promise>stale</promise>"}
2838 });
2839 content.push_str(&serde_json::to_string(&line).unwrap());
2840 content.push('\n');
2841 for i in 0..INTENT_LOOKBACK_ENTRIES {
2842 let line = serde_json::json!({
2843 "message": {"role": "assistant", "content": format!("pivoted work step {i}")}
2844 });
2845 content.push_str(&serde_json::to_string(&line).unwrap());
2846 content.push('\n');
2847 }
2848 std::fs::write(&path, content).unwrap();
2849 assert_eq!(detect_intent_full(&path), Intent::None);
2850 }
2851
2852 #[test]
2853 fn parse_args_hook_input_stdin_flag() {
2854 let args: Vec<String> = [
2855 "loop-check",
2856 "--state",
2857 "/s.md",
2858 "--transcript",
2859 "/t.jsonl",
2860 "--cwd",
2861 "/w",
2862 "--hook-input-stdin",
2863 ]
2864 .iter()
2865 .map(|s| s.to_string())
2866 .collect();
2867 let parsed = parse_args(&args).unwrap();
2868 assert!(parsed.hook_input_stdin);
2869 assert_eq!(parsed.cwd, PathBuf::from("/w"));
2871 }
2872
2873 #[test]
2874 fn block_reason_pending_ci_is_not_red() {
2875 let pr = PrInfo {
2879 state: PrState::Open,
2880 number: 455,
2881 head_oid: "abc".to_string(),
2882 ci_conclusion: CiConclusion::Pending,
2883 latest_review_ts: "none".to_string(),
2884 reviewed: false,
2885 missing_bots: vec![],
2886 unaddressed_findings: vec![],
2887 review_skipped: false,
2888 };
2889 let reason = build_block_reason(&pr, "abc");
2890 assert!(
2891 reason.contains("still running"),
2892 "pending CI must not read as red; got: {reason}"
2893 );
2894 assert!(!reason.contains("failed"), "got: {reason}");
2895 }
2896
2897 #[test]
2898 fn fingerprint_format() {
2899 let fp = make_fingerprint("sha123", "OPEN", "SUCCESS", "2026-06-05T01:00:00Z");
2900 assert_eq!(fp, "sha123|OPEN|SUCCESS|2026-06-05T01:00:00Z");
2901 }
2902
2903 #[test]
2904 fn ci_conclusion_failure_extracts_name() {
2905 let checks = serde_json::json!([
2906 {"name": "unit-tests", "state": "FAILURE", "bucket": "fail"}
2907 ]);
2908 let result = compute_ci_conclusion(&checks).unwrap();
2909 assert_eq!(
2910 result,
2911 CiConclusion::Failure(Some("unit-tests".to_string()))
2912 );
2913 let rendered = result.render();
2914 assert!(rendered.starts_with("FAILURE:"), "got: {rendered}");
2915 assert!(rendered.contains("unit-tests"), "got: {rendered}");
2916 }
2917
2918 #[test]
2920 fn ci_conclusion_cancel_is_failure() {
2921 let checks = serde_json::json!([
2922 {"name": "ci", "state": "SUCCESS", "bucket": "pass"},
2923 {"name": "deploy", "state": "CANCELLED", "bucket": "cancel"}
2924 ]);
2925 assert_eq!(
2926 compute_ci_conclusion(&checks).unwrap(),
2927 CiConclusion::Failure(Some("deploy".to_string()))
2928 );
2929 }
2930
2931 #[test]
2933 fn ci_conclusion_bucket_vocabulary() {
2934 let green = serde_json::json!([
2935 {"name": "ci", "state": "SUCCESS", "bucket": "pass"},
2936 {"name": "publish", "state": "SKIPPED", "bucket": "skipping"}
2937 ]);
2938 assert_eq!(
2939 compute_ci_conclusion(&green).unwrap(),
2940 CiConclusion::Success
2941 );
2942
2943 let pending = serde_json::json!([
2944 {"name": "ci", "state": "SUCCESS", "bucket": "pass"},
2945 {"name": "smoke", "state": "IN_PROGRESS", "bucket": "pending"}
2946 ]);
2947 assert_eq!(
2948 compute_ci_conclusion(&pending).unwrap(),
2949 CiConclusion::Pending
2950 );
2951 }
2952
2953 #[test]
2955 fn ci_conclusion_unknown_bucket_fails_closed() {
2956 let unknown = serde_json::json!([
2957 {"name": "ci", "state": "SUCCESS", "bucket": "mystery"}
2958 ]);
2959 assert_eq!(
2960 compute_ci_conclusion(&unknown).unwrap(),
2961 CiConclusion::Pending
2962 );
2963
2964 let missing = serde_json::json!([{"name": "ci", "state": "SUCCESS"}]);
2965 assert_eq!(
2966 compute_ci_conclusion(&missing).unwrap(),
2967 CiConclusion::Pending
2968 );
2969 }
2970
2971 #[test]
2972 fn ci_conclusion_empty_returns_none() {
2973 let checks = serde_json::json!([]);
2974 let result = compute_ci_conclusion(&checks).unwrap();
2975 assert_eq!(result, CiConclusion::None);
2976 assert_eq!(result.render(), "none");
2977 }
2978
2979 #[test]
2980 fn ci_conclusion_all_success() {
2981 let checks = serde_json::json!([
2982 {"name": "ci", "state": "SUCCESS", "bucket": "pass"}
2983 ]);
2984 let result = compute_ci_conclusion(&checks).unwrap();
2985 assert_eq!(result, CiConclusion::Success);
2986 assert_eq!(result.render(), "SUCCESS");
2987 }
2988
2989 #[test]
2991 fn pr_state_parses_known_gh_strings() {
2992 assert_eq!(PrState::from_gh_str("OPEN"), PrState::Open);
2993 assert_eq!(PrState::from_gh_str("MERGED"), PrState::Merged);
2994 assert_eq!(PrState::from_gh_str("CLOSED"), PrState::Closed);
2995 assert_eq!(PrState::from_gh_str("none"), PrState::None);
2996 }
2997
2998 #[test]
3001 fn pr_state_unknown_string_fails_closed() {
3002 assert_eq!(PrState::from_gh_str("DRAFT"), PrState::None);
3003 assert_eq!(PrState::from_gh_str(""), PrState::None);
3004 assert_eq!(PrState::from_gh_str("open"), PrState::None);
3005 }
3006
3007 #[test]
3009 fn enum_rendering_byte_identical_to_legacy_strings() {
3010 assert_eq!(PrState::Open.as_str(), "OPEN");
3011 assert_eq!(PrState::Merged.as_str(), "MERGED");
3012 assert_eq!(PrState::Closed.as_str(), "CLOSED");
3013 assert_eq!(PrState::None.as_str(), "none");
3014 assert_eq!(CiConclusion::Success.render(), "SUCCESS");
3015 assert_eq!(
3016 CiConclusion::Failure(Some("lint".into())).render(),
3017 "FAILURE:lint"
3018 );
3019 assert_eq!(CiConclusion::Failure(None).render(), "FAILURE");
3020 assert_eq!(CiConclusion::Pending.render(), "PENDING");
3021 assert_eq!(CiConclusion::Skipped.render(), "skipped");
3022 assert_eq!(CiConclusion::None.render(), "none");
3023 }
3024
3025 #[test]
3027 fn parse_args_missing_required_flags_err() {
3028 let no_state: Vec<String> = vec![
3029 "loop-check".into(),
3030 "--transcript".into(),
3031 "/t".into(),
3032 "--cwd".into(),
3033 "/c".into(),
3034 ];
3035 assert_eq!(
3036 parse_args(&no_state).unwrap_err(),
3037 "--state is required".to_string()
3038 );
3039
3040 let no_transcript: Vec<String> = vec!["loop-check".into(), "--state".into(), "/s".into()];
3041 assert_eq!(
3042 parse_args(&no_transcript).unwrap_err(),
3043 "--transcript is required".to_string()
3044 );
3045
3046 let no_cwd: Vec<String> = vec![
3047 "loop-check".into(),
3048 "--state".into(),
3049 "/s".into(),
3050 "--transcript".into(),
3051 "/t".into(),
3052 ];
3053 assert_eq!(
3054 parse_args(&no_cwd).unwrap_err(),
3055 "--cwd is required".to_string()
3056 );
3057 }
3058
3059 #[test]
3061 fn parse_args_unknown_flag_tolerated() {
3062 let args: Vec<String> = vec![
3063 "loop-check".into(),
3064 "--state".into(),
3065 "/s".into(),
3066 "--transcript".into(),
3067 "/t".into(),
3068 "--cwd".into(),
3069 "/c".into(),
3070 "--future-flag=whatever".into(),
3071 "--another-unknown".into(),
3072 "value".into(),
3073 ];
3074 let parsed = parse_args(&args).expect("unknown flags must be ignored");
3075 assert_eq!(parsed.state_path, PathBuf::from("/s"));
3076 assert_eq!(parsed.transcript_path, PathBuf::from("/t"));
3077 assert_eq!(parsed.cwd, PathBuf::from("/c"));
3078 }
3079
3080 #[test]
3081 fn budget_flat_key_enforces_cost_cap_ab41b13d9d() {
3082 let settings_yaml = "budget_cap: 0.10\n";
3085 let settings = parse_settings(settings_yaml);
3086 assert_eq!(settings.flat_budget_cap, Some(Ok(0.10)));
3087 assert!(settings.attended_cost_cap_usd.is_none());
3089 assert!(settings.unattended_cost_cap_usd.is_none());
3090 let manifest_att = Manifest {
3094 session_id: Some("s1".into()),
3095 created_at: Some("2026-06-05T00:00:00Z".into()),
3096 attended: true,
3097 ..Default::default()
3098 };
3099 let manifest_unatt = Manifest {
3100 session_id: Some("s1".into()),
3101 created_at: Some("2026-06-05T00:00:00Z".into()),
3102 attended: false,
3103 ..Default::default()
3104 };
3105
3106 let tmp = tempfile::tempdir().unwrap();
3108 let ledger = tmp.path().join("ledger.json");
3109 std::fs::write(&ledger, r#"[{"session_id":"s1","cost_usd":0.50}]"#).unwrap();
3110
3111 let now: DateTime<Utc> = "2026-06-05T01:00:00Z".parse().unwrap();
3112
3113 assert_eq!(
3114 check_budget(&manifest_att, &settings, &now, &ledger),
3115 Some(BudgetTrip::Cost),
3116 "flat budget_cap must enforce for attended"
3117 );
3118 assert_eq!(
3119 check_budget(&manifest_unatt, &settings, &now, &ledger),
3120 Some(BudgetTrip::Cost),
3121 "flat budget_cap must enforce for unattended"
3122 );
3123 }
3124
3125 #[test]
3126 fn is_bot_reviewer_known_patterns() {
3127 assert!(is_bot_reviewer("gemini-code-assist[bot]", &[]));
3128 assert!(is_bot_reviewer("chatgpt-codex-connector", &[]));
3129 assert!(is_bot_reviewer("some-bot[bot]", &[]));
3130 assert!(!is_bot_reviewer("human-reviewer", &[]));
3131 }
3132
3133 #[test]
3134 fn is_bot_reviewer_with_external_list() {
3135 let external = vec!["my-bot".to_string()];
3136 assert!(is_bot_reviewer("my-bot", &external));
3138 assert!(is_bot_reviewer("other-bot[bot]", &external));
3141 }
3142
3143 #[test]
3144 fn session_cost_from_ledger_sums_session_only() {
3145 let tmp = tempfile::tempdir().unwrap();
3146 let ledger = tmp.path().join("l.json");
3147 std::fs::write(
3148 &ledger,
3149 r#"[{"session_id":"a","cost_usd":1.0},{"session_id":"b","cost_usd":0.5},{"session_id":"a","cost_usd":0.25}]"#,
3150 )
3151 .unwrap();
3152 let cost = session_cost_from_ledger(&ledger, "a");
3153 assert!((cost - 1.25).abs() < 0.001, "expected 1.25, got {cost}");
3154 }
3155
3156 #[test]
3157 fn session_cost_missing_ledger_returns_zero() {
3158 let cost = session_cost_from_ledger(Path::new("/nonexistent/l.json"), "s");
3159 assert_eq!(cost, 0.0);
3160 }
3161
3162 #[test]
3163 fn allow_output_serializes_correctly() {
3164 let json = allow_output(
3165 "allow",
3166 Some(TerminationReason::DonePRGreen),
3167 "done",
3168 3,
3169 Some("fp".into()),
3170 );
3171 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
3172 assert_eq!(v["decision"], "allow");
3173 assert_eq!(v["termination_reason"], "DonePRGreen");
3175 assert_eq!(v["fires"], 3);
3176 assert_eq!(v["fingerprint"], "fp");
3177 }
3178
3179 #[test]
3180 fn allow_output_null_termination_reason() {
3181 let json = allow_output("block", None, "continue", 1, None);
3182 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
3183 assert!(v["termination_reason"].is_null());
3184 assert!(v["fingerprint"].is_null());
3185 }
3186
3187 #[test]
3188 fn termination_reason_variant_names_byte_identical() {
3189 let cases = [
3192 (TerminationReason::DonePRGreen, "DonePRGreen"),
3193 (TerminationReason::DoneAdvisory, "DoneAdvisory"),
3194 (TerminationReason::NoWork, "NoWork"),
3195 (TerminationReason::Budget, "Budget"),
3196 (TerminationReason::NoProgress, "NoProgress"),
3197 (TerminationReason::Interrupted, "Interrupted"),
3198 (TerminationReason::Aborted, "Aborted"),
3199 ];
3200 for (variant, expected) in cases {
3201 let json = serde_json::to_string(&variant).unwrap();
3202 assert_eq!(
3204 json,
3205 format!("\"{expected}\""),
3206 "variant {expected} serialized incorrectly"
3207 );
3208 }
3209 }
3210
3211 #[test]
3212 fn manifest_default_attended_is_true() {
3213 let m = Manifest::default();
3215 assert!(m.attended, "Manifest::default() must have attended=true");
3216 assert!(!m.advisory);
3217 assert!(!m.no_ship);
3218 assert!(!m.no_external);
3219 assert!(m.session_id.is_none());
3220 assert!(m.budget_cost_cap_usd.is_none());
3221 assert!(m.budget_wall_clock_cap_minutes.is_none());
3222 }
3223
3224 #[test]
3225 fn parse_manifest_malformed_cost_cap_fail_closed() {
3226 let content =
3228 "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nbudget_cost_cap_usd: 5.OO\n---\n";
3229 let m = parse_manifest(content).unwrap();
3230 assert!(
3231 matches!(m.budget_cost_cap_usd, Some(Err(_))),
3232 "malformed cost cap must be Some(Err(...))"
3233 );
3234 }
3235
3236 #[test]
3237 fn parse_manifest_malformed_wall_cap_fail_closed() {
3238 let content =
3239 "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nbudget_wall_clock_cap_minutes: abc\n---\n";
3240 let m = parse_manifest(content).unwrap();
3241 assert!(
3242 matches!(m.budget_wall_clock_cap_minutes, Some(Err(_))),
3243 "malformed wall cap must be Some(Err(...))"
3244 );
3245 }
3246
3247 #[test]
3248 fn parse_settings_malformed_flat_cap_fail_closed() {
3249 let yaml = "budget_cap: not_a_number\n";
3250 let s = parse_settings(yaml);
3251 assert!(
3252 matches!(s.flat_budget_cap, Some(Err(_))),
3253 "malformed flat_budget_cap must be Some(Err(...))"
3254 );
3255 }
3256
3257 #[test]
3258 fn check_budget_malformed_cost_cap_trips_budget() {
3259 let m = Manifest {
3261 session_id: Some("s".into()),
3262 created_at: Some("2026-06-05T00:00:00Z".into()),
3263 budget_cost_cap_usd: Some(Err("5.OO".into())),
3264 ..Default::default()
3265 };
3266 let s = Settings::default();
3267 let now: DateTime<Utc> = "2026-06-05T01:00:00Z".parse().unwrap();
3268 let tmp = tempfile::tempdir().unwrap();
3269 let ledger = tmp.path().join("ledger.json");
3270 std::fs::write(&ledger, r#"[{"session_id":"s","cost_usd":0.0}]"#).unwrap();
3271 assert_eq!(
3272 check_budget(&m, &s, &now, &ledger),
3273 Some(BudgetTrip::Cost),
3274 "malformed cost cap must fail closed"
3275 );
3276 }
3277
3278 #[test]
3279 fn check_budget_absent_cap_is_unlimited() {
3280 let m = Manifest {
3282 session_id: Some("s".into()),
3283 created_at: Some("2026-06-05T00:00:00Z".into()),
3284 ..Default::default()
3285 };
3286 let s = Settings::default();
3287 let now: DateTime<Utc> = "2026-06-05T01:00:00Z".parse().unwrap();
3288 let tmp = tempfile::tempdir().unwrap();
3289 let ledger = tmp.path().join("ledger.json");
3290 std::fs::write(&ledger, r#"[{"session_id":"s","cost_usd":9999.0}]"#).unwrap();
3291 assert_eq!(
3292 check_budget(&m, &s, &now, &ledger),
3293 None,
3294 "absent cap must be unlimited"
3295 );
3296 }
3297
3298 #[test]
3299 fn check_budget_negative_elapsed_no_trip() {
3300 let m = Manifest {
3302 session_id: Some("s".into()),
3303 created_at: Some("2026-06-05T02:00:00Z".into()),
3305 budget_wall_clock_cap_minutes: Some(Ok(30)),
3306 ..Default::default()
3307 };
3308 let s = Settings::default();
3309 let now: DateTime<Utc> = "2026-06-05T01:00:00Z".parse().unwrap();
3311 let tmp = tempfile::tempdir().unwrap();
3312 let ledger = tmp.path().join("ledger.json");
3313 std::fs::write(&ledger, "[]").unwrap();
3314 assert_eq!(
3315 check_budget(&m, &s, &now, &ledger),
3316 None,
3317 "negative elapsed (future created_at) must not trip wall clock cap"
3318 );
3319 }
3320
3321 #[test]
3322 fn is_bot_reviewer_configured_short_names_match_real_logins() {
3323 let external = vec!["gemini".to_string(), "codex".to_string()];
3327 assert!(
3328 is_bot_reviewer("gemini-code-assist[bot]", &external),
3329 "gemini short name must substring-match gemini-code-assist[bot]"
3330 );
3331 assert!(
3332 is_bot_reviewer("chatgpt-codex-connector", &external),
3333 "codex short name must substring-match chatgpt-codex-connector"
3334 );
3335 }
3336
3337 #[test]
3338 fn is_bot_reviewer_configured_list_falls_back_to_bot_heuristic() {
3339 let external = vec!["some-human".to_string()];
3342 assert!(
3343 is_bot_reviewer("gemini-code-assist[bot]", &external),
3344 "configured list with no match must still fall back to [bot] heuristic"
3345 );
3346 }
3347
3348 #[test]
3349 fn is_bot_reviewer_empty_config_human_only_returns_false() {
3350 assert!(
3352 !is_bot_reviewer("alice-the-human", &[]),
3353 "human reviewer with empty config must return false"
3354 );
3355 }
3356
3357 #[test]
3360 fn parse_settings_required_bots_block_list() {
3361 let yaml = "config:\n review:\n required_bots:\n - chatgpt-codex-connector\n - gemini-code-assist\n";
3362 let s = parse_settings(yaml);
3363 assert_eq!(
3364 s.required_bots,
3365 Some(vec![
3366 "chatgpt-codex-connector".to_string(),
3367 "gemini-code-assist".to_string()
3368 ])
3369 );
3370 }
3371
3372 #[test]
3373 fn parse_settings_required_bots_inline_empty_is_declared_empty() {
3374 let yaml = "config:\n review:\n required_bots: []\n";
3377 let s = parse_settings(yaml);
3378 assert_eq!(s.required_bots, Some(Vec::new()));
3379 }
3380
3381 #[test]
3382 fn parse_settings_required_bots_inline_list() {
3383 let yaml = "config:\n review:\n required_bots: [\"codex\", 'gemini']\n";
3384 let s = parse_settings(yaml);
3385 assert_eq!(
3386 s.required_bots,
3387 Some(vec!["codex".to_string(), "gemini".to_string()])
3388 );
3389 }
3390
3391 #[test]
3393 fn parse_settings_required_bots_scalar_malformed_defaults() {
3394 let yaml = "config:\n review:\n required_bots: gemini\n";
3395 let s = parse_settings(yaml);
3396 assert_eq!(s.required_bots, None, "scalar must fail closed to default");
3397 }
3398
3399 #[test]
3402 fn parse_settings_required_bots_bare_key_no_items_defaults() {
3403 let yaml = "config:\n review:\n required_bots:\n ci:\n declared_none: true\n";
3404 let s = parse_settings(yaml);
3405 assert_eq!(
3406 s.required_bots, None,
3407 "bare key must fail closed to default"
3408 );
3409 assert!(s.ci_declared_none, "following keys still parse");
3410 }
3411
3412 #[test]
3416 fn parse_settings_required_bots_inline_comments_stripped() {
3417 let empty = parse_settings("config:\n review:\n required_bots: [] # no review gate\n");
3418 assert_eq!(empty.required_bots, Some(Vec::new()));
3419
3420 let inline = parse_settings(
3421 "config:\n review:\n required_bots: [chatgpt-codex-connector] # required\n",
3422 );
3423 assert_eq!(
3424 inline.required_bots,
3425 Some(vec!["chatgpt-codex-connector".to_string()])
3426 );
3427
3428 let block = parse_settings(
3429 "config:\n review:\n required_bots: # the gate\n - chatgpt-codex-connector # codex\n",
3430 );
3431 assert_eq!(
3432 block.required_bots,
3433 Some(vec!["chatgpt-codex-connector".to_string()])
3434 );
3435
3436 let scalar = parse_settings("config:\n review:\n required_bots: gemini # oops\n");
3438 assert_eq!(scalar.required_bots, None);
3439 }
3440
3441 #[test]
3442 fn parse_settings_required_bots_four_space_indent() {
3443 let yaml =
3444 "config:\n review:\n required_bots:\n - chatgpt-codex-connector\n";
3445 let s = parse_settings(yaml);
3446 assert_eq!(
3447 s.required_bots,
3448 Some(vec!["chatgpt-codex-connector".to_string()])
3449 );
3450 }
3451
3452 #[test]
3453 fn resolved_required_bots_default_is_empty() {
3454 let s = Settings::default();
3457 assert!(
3458 resolved_required_bots(&s).is_empty(),
3459 "absent required_bots config must resolve to no review gate"
3460 );
3461 }
3462
3463 #[test]
3464 fn resolved_required_bots_explicit_list_wins() {
3465 let s = Settings {
3466 required_bots: Some(vec!["my-bot".to_string()]),
3467 ..Default::default()
3468 };
3469 assert_eq!(resolved_required_bots(&s), vec!["my-bot".to_string()]);
3470 let empty = Settings {
3471 required_bots: Some(Vec::new()),
3472 ..Default::default()
3473 };
3474 assert!(resolved_required_bots(&empty).is_empty());
3475 }
3476
3477 #[test]
3478 fn login_matches_bot_cases() {
3479 assert!(login_matches_bot(
3481 "chatgpt-codex-connector",
3482 "chatgpt-codex-connector"
3483 ));
3484 assert!(login_matches_bot(
3485 "chatgpt-codex-connector[bot]",
3486 "chatgpt-codex-connector"
3487 ));
3488 assert!(login_matches_bot("chatgpt-codex-connector", "codex"));
3489 assert!(login_matches_bot("Gemini-Code-Assist[bot]", "gemini"));
3490 assert!(!login_matches_bot("alice-the-human", "codex"));
3491 assert!(!login_matches_bot("anyone", ""));
3493 }
3494
3495 #[test]
3496 fn compute_review_info_per_bot_verdict() {
3497 let required = vec![
3498 "chatgpt-codex-connector".to_string(),
3499 "gemini-code-assist".to_string(),
3500 ];
3501 let json = serde_json::json!({
3503 "reviews": [
3504 {"author": {"login": "chatgpt-codex-connector"}, "state": "COMMENTED",
3505 "submittedAt": "2026-06-05T01:00:00Z"}
3506 ],
3507 "comments": []
3508 });
3509 let info = compute_review_info(&json, &required);
3510 assert!(!info.all_required_passed());
3511 assert_eq!(info.missing_bots, vec!["gemini-code-assist".to_string()]);
3512 assert_eq!(info.latest_ts, "2026-06-05T01:00:00Z");
3513 }
3514
3515 #[test]
3516 fn compute_review_info_empty_state_not_a_pass() {
3517 let required = vec!["chatgpt-codex-connector".to_string()];
3519 let json = serde_json::json!({
3520 "reviews": [
3521 {"author": {"login": "chatgpt-codex-connector"}, "state": "",
3522 "submittedAt": "2026-06-05T01:00:00Z"}
3523 ],
3524 "comments": []
3525 });
3526 let info = compute_review_info(&json, &required);
3527 assert!(!info.all_required_passed());
3528 }
3529
3530 #[test]
3533 fn blocking_severity_codex_p1_both_forms() {
3534 assert_eq!(
3536 blocking_severity(" Bug"),
3537 Some("P1")
3538 );
3539 assert_eq!(blocking_severity("![P1 Badge] something"), Some("P1"));
3541 assert_eq!(
3542 blocking_severity("see https://img.shields.io/badge/P1-orange"),
3543 Some("P1")
3544 );
3545 }
3546
3547 #[test]
3548 fn blocking_severity_codex_p2_p3_advisory() {
3549 assert_eq!(
3550 blocking_severity(" nit"),
3551 None
3552 );
3553 assert_eq!(
3554 blocking_severity(" nit"),
3555 None
3556 );
3557 }
3558
3559 #[test]
3560 fn blocking_severity_gemini_critical_high_blocking() {
3561 assert_eq!(
3562 blocking_severity(
3563 " bad"
3564 ),
3565 Some("critical")
3566 );
3567 assert_eq!(
3568 blocking_severity(
3569 " bad"
3570 ),
3571 Some("high")
3572 );
3573 }
3574
3575 #[test]
3576 fn blocking_severity_gemini_medium_low_advisory() {
3577 assert_eq!(
3578 blocking_severity(
3579 " hmm"
3580 ),
3581 None
3582 );
3583 assert_eq!(
3584 blocking_severity(
3585 " hmm"
3586 ),
3587 None
3588 );
3589 }
3590
3591 #[test]
3594 fn blocking_severity_unparseable_is_advisory() {
3595 assert_eq!(blocking_severity("just a comment with no badge"), None);
3596 assert_eq!(blocking_severity(""), None);
3597 assert_eq!(blocking_severity("P1 mentioned in prose only"), None);
3598 }
3599
3600 #[test]
3601 fn max_ts_none_handling() {
3602 assert_eq!(
3603 max_ts("none", "2026-06-05T01:00:00Z"),
3604 "2026-06-05T01:00:00Z"
3605 );
3606 assert_eq!(
3607 max_ts("2026-06-05T01:00:00Z", "none"),
3608 "2026-06-05T01:00:00Z"
3609 );
3610 assert_eq!(max_ts("none", "none"), "none");
3611 assert_eq!(max_ts("", ""), "none");
3612 assert_eq!(
3613 max_ts("2026-06-05T01:00:00Z", "2026-06-05T02:00:00Z"),
3614 "2026-06-05T02:00:00Z"
3615 );
3616 }
3617
3618 fn finding_comment(id: i64, body: &str, created_at: &str) -> Value {
3619 serde_json::json!({
3620 "id": id,
3621 "in_reply_to_id": null,
3622 "user": {"login": "chatgpt-codex-connector[bot]"},
3623 "body": body,
3624 "path": "src/x.rs",
3625 "line": 42,
3626 "created_at": created_at
3627 })
3628 }
3629
3630 fn reply_comment(id: i64, parent: i64, login: &str, body: &str, created_at: &str) -> Value {
3631 serde_json::json!({
3632 "id": id,
3633 "in_reply_to_id": parent,
3634 "user": {"login": login},
3635 "body": body,
3636 "created_at": created_at
3637 })
3638 }
3639
3640 const REQ: &[&str] = &["chatgpt-codex-connector"];
3641
3642 fn req_vec() -> Vec<String> {
3643 REQ.iter().map(|s| s.to_string()).collect()
3644 }
3645
3646 #[test]
3648 fn finding_no_reply_is_unaddressed() {
3649 let comments = vec bug",
3652 "2026-06-05T01:10:00Z",
3653 )];
3654 let (ts, unaddressed) = compute_unaddressed_findings(&comments, &[], &req_vec(), &[]);
3655 assert_eq!(ts, "2026-06-05T01:10:00Z");
3656 assert_eq!(unaddressed.len(), 1);
3657 assert_eq!(unaddressed[0].path, "src/x.rs");
3658 assert_eq!(unaddressed[0].line, 42);
3659 assert_eq!(unaddressed[0].severity, "P1");
3660 }
3661
3662 #[test]
3664 fn finding_reply_plus_commit_after_is_addressed() {
3665 let comments = vec bug",
3669 "2026-06-05T01:10:00Z",
3670 ),
3671 reply_comment(
3672 101,
3673 100,
3674 "bllshttng",
3675 "fixed in abc123",
3676 "2026-06-05T01:20:00Z",
3677 ),
3678 ];
3679 let commits = vec!["2026-06-05T01:30:00Z".to_string()];
3680 let (_, unaddressed) = compute_unaddressed_findings(&comments, &commits, &req_vec(), &[]);
3681 assert!(unaddressed.is_empty(), "commit-after arm must address");
3682 }
3683
3684 #[test]
3686 fn finding_wontfix_reply_is_addressed_without_commit() {
3687 let comments = vec bug",
3691 "2026-06-05T01:10:00Z",
3692 ),
3693 reply_comment(
3694 101,
3695 100,
3696 "bllshttng",
3697 "wontfix: intentional - documented tradeoff",
3698 "2026-06-05T01:20:00Z",
3699 ),
3700 ];
3701 let commits = vec!["2026-06-05T01:00:00Z".to_string()];
3703 let (_, unaddressed) = compute_unaddressed_findings(&comments, &commits, &req_vec(), &[]);
3704 assert!(unaddressed.is_empty(), "wontfix arm must address alone");
3705 }
3706
3707 #[test]
3710 fn finding_commit_without_reply_is_unaddressed() {
3711 let comments = vec bug",
3714 "2026-06-05T01:10:00Z",
3715 )];
3716 let commits = vec!["2026-06-05T01:30:00Z".to_string()];
3717 let (_, unaddressed) = compute_unaddressed_findings(&comments, &commits, &req_vec(), &[]);
3718 assert_eq!(unaddressed.len(), 1, "commit alone must not address");
3719 }
3720
3721 #[test]
3723 fn finding_bot_reply_only_is_unaddressed() {
3724 let comments = vec bug",
3728 "2026-06-05T01:10:00Z",
3729 ),
3730 reply_comment(
3731 101,
3732 100,
3733 "chatgpt-codex-connector[bot]",
3734 "elaborating on my finding",
3735 "2026-06-05T01:15:00Z",
3736 ),
3737 ];
3738 let commits = vec!["2026-06-05T01:30:00Z".to_string()];
3739 let (_, unaddressed) = compute_unaddressed_findings(&comments, &commits, &req_vec(), &[]);
3740 assert_eq!(unaddressed.len(), 1, "bot self-reply must not count as ack");
3741 }
3742
3743 #[test]
3745 fn finding_reply_without_commit_or_wontfix_is_unaddressed() {
3746 let comments = vec bug",
3750 "2026-06-05T01:10:00Z",
3751 ),
3752 reply_comment(
3753 101,
3754 100,
3755 "bllshttng",
3756 "looking into it",
3757 "2026-06-05T01:20:00Z",
3758 ),
3759 ];
3760 let commits = vec!["2026-06-05T01:00:00Z".to_string()]; let (_, unaddressed) = compute_unaddressed_findings(&comments, &commits, &req_vec(), &[]);
3762 assert_eq!(unaddressed.len(), 1);
3763 }
3764
3765 #[test]
3767 fn finding_from_non_required_bot_ignored() {
3768 let comments = vec![serde_json::json!({
3769 "id": 200,
3770 "in_reply_to_id": null,
3771 "user": {"login": "gemini-code-assist[bot]"},
3772 "body": " eh",
3773 "path": "src/y.rs",
3774 "line": 7,
3775 "created_at": "2026-06-05T01:10:00Z"
3776 })];
3777 let (ts, unaddressed) = compute_unaddressed_findings(&comments, &[], &req_vec(), &[]);
3779 assert!(unaddressed.is_empty());
3780 assert_eq!(ts, "2026-06-05T01:10:00Z");
3782 }
3783
3784 #[test]
3786 fn empty_comments_no_findings() {
3787 let (ts, unaddressed) = compute_unaddressed_findings(&[], &[], &req_vec(), &[]);
3788 assert_eq!(ts, "none");
3789 assert!(unaddressed.is_empty());
3790 }
3791
3792 #[test]
3796 fn finding_missing_id_skipped_not_pooled() {
3797 let no_id = serde_json::json!({
3798 "in_reply_to_id": null,
3799 "user": {"login": "chatgpt-codex-connector[bot]"},
3800 "body": " idless",
3801 "path": "src/z.rs", "line": 3,
3802 "created_at": "2026-06-05T01:05:00Z"
3803 });
3804 let real = finding_comment(
3805 100,
3806 " real",
3807 "2026-06-05T01:10:00Z",
3808 );
3809 let stray = reply_comment(
3811 101,
3812 0,
3813 "bllshttng",
3814 "wontfix: stray",
3815 "2026-06-05T01:20:00Z",
3816 );
3817 let comments = vec![no_id, real, stray];
3818 let (_, unaddressed) = compute_unaddressed_findings(&comments, &[], &req_vec(), &[]);
3819 assert_eq!(unaddressed.len(), 1, "only the real finding remains");
3820 assert_eq!(unaddressed[0].id, 100);
3821 }
3822
3823 #[test]
3828 fn ts_after_parses_offsets_correctly() {
3829 assert!(!ts_after(
3832 "2026-06-05T23:30:00+13:00",
3833 "2026-06-05T11:00:00Z"
3834 ));
3835 assert!(ts_after(
3841 "2026-06-05T23:30:00+10:00", "2026-06-05T11:00:00Z"
3843 ));
3844 assert!(ts_after("2026-06-05T11:00:01Z", "2026-06-05T11:00:00Z"));
3845 assert!(!ts_after("2026-06-05T11:00:00Z", "2026-06-05T11:00:00Z"));
3846 assert!(!ts_after("garbage", "2026-06-05T11:00:00Z"));
3848 assert!(!ts_after("2026-06-05T11:00:00Z", "garbage"));
3849 assert!(!ts_after("2026-06-05T11:00:00Z", ""));
3850 }
3851
3852 #[test]
3856 fn max_ts_chronological_with_offsets() {
3857 assert_eq!(
3859 max_ts("2026-06-05T23:30:00+13:00", "2026-06-05T11:00:00Z"),
3860 "2026-06-05T11:00:00Z"
3861 );
3862 assert_eq!(
3864 max_ts("2026-06-05T23:30:00+10:00", "2026-06-05T11:00:00Z"),
3865 "2026-06-05T23:30:00+10:00"
3866 );
3867 }
3868
3869 #[test]
3873 fn finding_reply_listed_before_finding_still_addressed() {
3874 let comments = vec bug",
3885 "2026-06-05T01:10:00Z",
3886 ),
3887 ];
3888 let (_, unaddressed) = compute_unaddressed_findings(&comments, &[], &req_vec(), &[]);
3889 assert!(
3890 unaddressed.is_empty(),
3891 "reply-before-finding ordering must still ack"
3892 );
3893 }
3894
3895 #[test]
3898 fn no_pr_stderr_detected() {
3899 assert!(is_no_pr_stderr(
3900 b"no pull requests found for branch \"feat\""
3901 ));
3902 assert!(is_no_pr_stderr(b"No pull requests found for branch \"x\""));
3903 assert!(!is_no_pr_stderr(b"connect: network is unreachable"));
3905 assert!(!is_no_pr_stderr(b"API rate limit exceeded"));
3906 assert!(!is_no_pr_stderr(b""));
3907 }
3908}