1use std::collections::BTreeMap;
2
3use nils_markdown::Engine;
4use serde::Serialize;
5use serde_json::Value;
6
7use crate::commands::record::{LifecycleCommentKind, RecordProfile, TaskLedgerDisplay};
8
9const DASHBOARD_TEMPLATE: &str = include_str!("../templates/lifecycle_record/dashboard.md.tera");
10const DASHBOARD_TEMPLATE_NAME: &str = "lifecycle_record_dashboard";
11
12const SNAPSHOT_TEMPLATE: &str = include_str!("../templates/lifecycle_record/snapshot.md.tera");
13const SNAPSHOT_TEMPLATE_NAME: &str = "lifecycle_record_snapshot";
14
15const POST_COMMENT_TEMPLATE: &str =
16 include_str!("../templates/lifecycle_record/post_comment.md.tera");
17const POST_COMMENT_TEMPLATE_NAME: &str = "lifecycle_record_post_comment";
18
19const STATE_VISIBLE_TEMPLATE: &str = include_str!("../templates/lifecycle_record/state.md.tera");
20const STATE_VISIBLE_TEMPLATE_NAME: &str = "lifecycle_record_state";
21
22const SESSION_VISIBLE_TEMPLATE: &str =
23 include_str!("../templates/lifecycle_record/session.md.tera");
24const SESSION_VISIBLE_TEMPLATE_NAME: &str = "lifecycle_record_session";
25
26const VALIDATION_VISIBLE_TEMPLATE: &str =
27 include_str!("../templates/lifecycle_record/validation.md.tera");
28const VALIDATION_VISIBLE_TEMPLATE_NAME: &str = "lifecycle_record_validation";
29
30const REVIEW_VISIBLE_TEMPLATE: &str = include_str!("../templates/lifecycle_record/review.md.tera");
31const REVIEW_VISIBLE_TEMPLATE_NAME: &str = "lifecycle_record_review";
32
33const CLOSEOUT_VISIBLE_TEMPLATE: &str =
34 include_str!("../templates/lifecycle_record/closeout.md.tera");
35const CLOSEOUT_VISIBLE_TEMPLATE_NAME: &str = "lifecycle_record_closeout";
36
37#[derive(Debug, Serialize)]
38struct PostCommentView<'a> {
39 marker: String,
40 heading: &'static str,
41 profile: &'a str,
42 visible_content: String,
43 envelope_carrier: String,
44}
45
46#[derive(Debug, Serialize)]
47struct StateVisibleView<'a> {
48 status: Option<&'static str>,
49 target_scope: Option<&'a str>,
50 current: Option<&'a str>,
51 next_action: Option<&'a str>,
52 tasks: Vec<StateTaskRow>,
53}
54
55#[derive(Debug, Serialize)]
56struct StateTaskRow {
57 id: String,
58 status: &'static str,
59 title: String,
60}
61
62#[derive(Debug, Serialize)]
63struct SessionVisibleView<'a> {
64 summary: &'a str,
65 highlights: Vec<String>,
66 links: Vec<KeyValuePair>,
67 extras: Vec<KeyValuePair>,
68}
69
70#[derive(Debug, Serialize)]
71struct KeyValuePair {
72 key: String,
73 value: String,
74}
75
76#[derive(Debug, Serialize)]
77struct ValidationVisibleView<'a> {
78 overall: &'static str,
79 commands: Vec<ValidationCommandRow<'a>>,
80 waivers: Vec<ValidationWaiverRow<'a>>,
81}
82
83#[derive(Debug, Serialize)]
84struct ValidationCommandRow<'a> {
85 command: String,
86 status: &'static str,
87 evidence: String,
88 _phantom: std::marker::PhantomData<&'a ()>,
89}
90
91#[derive(Debug, Serialize)]
92struct ValidationWaiverRow<'a> {
93 command: &'a str,
94 reason: &'a str,
95}
96
97#[derive(Debug, Serialize)]
98struct ReviewVisibleView<'a> {
99 decision: &'static str,
100 lenses: Option<String>,
101 outcome_comment_url: Option<&'a str>,
102 findings: Vec<ReviewFindingRow>,
103}
104
105#[derive(Debug, Serialize)]
106struct ReviewFindingRow {
107 id: String,
108 severity: &'static str,
109 disposition: &'static str,
110 summary: String,
111}
112
113#[derive(Debug, Serialize)]
114struct CloseoutVisibleView<'a> {
115 final_status: &'a str,
116 approver: Option<&'a str>,
117 approval_url: Option<&'a str>,
118 final_validation_url: Option<&'a str>,
119 notes: Option<&'a str>,
120 has_override: bool,
121 override_reason: Option<String>,
122 override_failures: Option<String>,
123 linked_prs: Vec<CloseoutPrRow>,
124}
125
126#[derive(Debug, Serialize)]
127struct CloseoutPrRow {
128 label: String,
129 merge_sha: String,
130 checks: &'static str,
131 required: String,
132 non_required_failures: String,
133}
134
135#[derive(Debug, Serialize)]
136struct SnapshotView<'a> {
137 marker: String,
138 heading: &'static str,
139 profile: &'a str,
140 path: Option<&'a str>,
141 commit: Option<&'a str>,
142 summary: Option<&'a str>,
143 details_summary: &'static str,
144 content: &'a str,
145 envelope_carrier: String,
146}
147
148#[derive(Debug, Serialize)]
149struct DashboardView<'a> {
150 title: &'static str,
151 status: String,
152 profile: &'a str,
153 target_scope: String,
154 current: String,
155 next_action: String,
156 validation: String,
157 linked_prs: String,
158 blockers: String,
159 approval: String,
160 source_link: String,
161 plan_link: String,
162 state_link: String,
163 session_link: String,
164 validation_link: String,
165 review_link: String,
166 show_review: bool,
167 closeout_link: String,
168 tracker_block: String,
169}
170
171fn render_tracker_block(title: Option<&str>, issue_url: Option<&str>) -> String {
172 let title = title.map(str::trim).filter(|value| !value.is_empty());
173 let issue_url = issue_url.map(str::trim).filter(|value| !value.is_empty());
174 if title.is_none() && issue_url.is_none() {
175 return String::new();
176 }
177 let mut out = vec![
178 String::new(),
179 "## Original Tracker".to_string(),
180 String::new(),
181 ];
182 if let Some(value) = title {
183 out.push(format!("- Title: {value}"));
184 }
185 if let Some(value) = issue_url {
186 out.push(format!("- Issue: {value}"));
187 }
188 out.join("\n")
189}
190
191fn render_dashboard_with_template(view: &DashboardView<'_>) -> String {
192 let mut engine = Engine::builder().build();
193 engine
194 .register_template(DASHBOARD_TEMPLATE_NAME, DASHBOARD_TEMPLATE)
195 .expect("dashboard template registers");
196 engine
197 .render(DASHBOARD_TEMPLATE_NAME, view)
198 .expect("dashboard template renders")
199}
200
201#[derive(Debug, Clone)]
202pub struct DashboardInput {
203 pub profile: RecordProfile,
204 pub status: String,
205 pub target_scope: String,
206 pub current: String,
207 pub next_action: String,
208 pub validation: String,
209 pub linked_prs: Vec<String>,
210 pub blockers: Vec<String>,
211 pub approval: String,
212 pub source_url: Option<String>,
213 pub plan_url: Option<String>,
214 pub state_url: Option<String>,
215 pub session_url: Option<String>,
216 pub validation_url: Option<String>,
217 pub review_url: Option<String>,
218 pub closeout_url: Option<String>,
219 pub title: Option<String>,
220 pub issue_url: Option<String>,
221}
222
223#[derive(Debug, Clone)]
224pub struct CommentInput {
225 pub profile: RecordProfile,
226 pub kind: LifecycleCommentKind,
227 pub path: Option<String>,
228 pub commit: Option<String>,
229 pub content: Option<String>,
230 pub title: Option<String>,
231 pub details_summary: Option<String>,
232}
233
234#[derive(Debug, Clone, Serialize)]
235pub struct LifecycleEvidence {
236 pub role: PayloadRole,
237 pub profile: PayloadProfile,
238 pub url: Option<String>,
239 pub created_at: Option<String>,
240 pub status: Option<String>,
245 #[serde(skip_serializing_if = "Option::is_none")]
250 pub payload: Option<RecordPayload>,
251}
252
253#[derive(Debug, Clone, Serialize)]
254pub struct UnsupportedMarker {
255 pub marker_prefix: String,
256 pub url: Option<String>,
257 pub created_at: Option<String>,
258}
259
260#[derive(Debug, Clone, Serialize)]
261pub struct RecordAudit {
262 pub profile_filter: Option<String>,
263 pub body_sections: BodySections,
264 pub evidence: BTreeMap<String, LifecycleEvidence>,
267 pub missing_required: Vec<String>,
270 pub unsupported_markers: Vec<UnsupportedMarker>,
273 pub recognized_count: usize,
274 #[serde(skip_serializing)]
275 pub evidence_text: String,
276}
277
278#[derive(Debug, Clone, Serialize)]
279pub struct BodySections {
280 pub current_dashboard: bool,
281 pub final_dashboard: bool,
282 pub durable_record: bool,
283 pub closeout_checks: bool,
284 pub task_decomposition: bool,
285}
286
287#[derive(Debug, Clone, Serialize)]
288pub struct CloseoutCheck {
289 pub check: String,
290 pub status: String,
291 pub detail: String,
292}
293
294#[derive(Debug)]
295struct CommentJson {
296 body: Option<String>,
297 url: Option<String>,
298 html_url: Option<String>,
299 created_at: Option<String>,
300}
301
302pub fn render_dashboard(input: DashboardInput) -> String {
303 let title = if input.status.trim().eq_ignore_ascii_case("complete") {
304 "## Final Dashboard"
305 } else {
306 "## Current Dashboard"
307 };
308
309 let show_review = input.profile == RecordProfile::Dispatch || input.review_url.is_some();
310 let tracker_block = render_tracker_block(input.title.as_deref(), input.issue_url.as_deref());
311
312 let view = DashboardView {
313 title,
314 status: input.status.trim().to_string(),
315 profile: input.profile.as_str(),
316 target_scope: input.target_scope.trim().to_string(),
317 current: input.current.trim().to_string(),
318 next_action: input.next_action.trim().to_string(),
319 validation: input.validation.trim().to_string(),
320 linked_prs: non_empty_join(&input.linked_prs, "none yet"),
321 blockers: non_empty_join(&input.blockers, "none"),
322 approval: input.approval.trim().to_string(),
323 source_link: dashboard_link(input.source_url.as_deref(), "source snapshot"),
324 plan_link: dashboard_link(input.plan_url.as_deref(), "plan snapshot"),
325 state_link: dashboard_link(input.state_url.as_deref(), "execution state"),
326 session_link: dashboard_link(input.session_url.as_deref(), "Execution Session"),
327 validation_link: dashboard_link(input.validation_url.as_deref(), "Validation Evidence"),
328 review_link: dashboard_link(input.review_url.as_deref(), "Review Evidence"),
329 show_review,
330 closeout_link: dashboard_link(input.closeout_url.as_deref(), "closeout"),
331 tracker_block,
332 };
333
334 render_dashboard_with_template(&view)
335}
336
337pub fn render_comment(input: CommentInput) -> Result<String, String> {
338 let marker = marker_for(input.profile, input.kind);
339 let heading = input
340 .title
341 .clone()
342 .unwrap_or_else(|| default_heading(input.profile, input.kind).to_string());
343 let content = input.content.unwrap_or_default();
344
345 let mut out = Vec::new();
346 out.push(marker);
347 out.push(String::new());
348 out.push(format!("## {heading}"));
349 out.push(String::new());
350
351 out.push(format!("- Profile: {}", input.profile.as_str()));
352 if let Some(path) = input
353 .path
354 .as_deref()
355 .filter(|value| !value.trim().is_empty())
356 {
357 out.push(format!("- Path: `{}`", path.trim()));
358 }
359 if let Some(commit) = input
360 .commit
361 .as_deref()
362 .filter(|value| !value.trim().is_empty())
363 {
364 out.push(format!("- Commit: `{}`", commit.trim()));
365 }
366
367 if matches!(
368 input.kind,
369 LifecycleCommentKind::Source | LifecycleCommentKind::Plan
370 ) {
371 if !out.last().is_some_and(String::is_empty) {
372 out.push(String::new());
373 }
374 out.push("- Snapshot mode: local committed Markdown".to_string());
375 out.push(String::new());
376 out.push("<details>".to_string());
377 out.push(format!(
378 "<summary>{}</summary>",
379 input
380 .details_summary
381 .as_deref()
382 .unwrap_or_else(|| default_details_summary(input.kind))
383 ));
384 out.push(String::new());
385 out.push(content);
386 out.push(String::new());
387 out.push("</details>".to_string());
388 } else if !content.trim().is_empty() {
389 out.push(String::new());
390 out.push(content);
391 }
392
393 Ok(finalize_markdown(out))
394}
395
396pub fn audit_record(
397 body: Option<&str>,
398 comments_json: &str,
399 profile_filter: Option<RecordProfile>,
400) -> Result<RecordAudit, String> {
401 let mut comments = parse_comments_json(comments_json)?;
402 comments.sort_by(|left, right| {
403 compare_created_at(right.created_at.as_deref(), left.created_at.as_deref())
404 });
405 let mut evidence: BTreeMap<String, LifecycleEvidence> = BTreeMap::new();
406 let mut unsupported_markers = Vec::new();
407 let mut recognized_count = 0usize;
408 let mut evidence_text = String::new();
409 if let Some(body) = body {
410 evidence_text.push_str(body);
411 evidence_text.push('\n');
412 }
413
414 for comment in comments {
415 let Some(comment_body) = comment.body.as_deref() else {
416 continue;
417 };
418 let Some(first_marker) = first_comment_marker(comment_body) else {
419 continue;
420 };
421 let Some(parsed) = parse_marker_line(first_marker) else {
422 continue;
423 };
424 match parsed {
425 MarkerParse::V2 { role, profile } => {
426 if profile_filter.is_some_and(|expected| profile != PayloadProfile::from(expected))
427 {
428 continue;
429 }
430 let url = comment.url.clone().or_else(|| comment.html_url.clone());
431 let created_at = comment.created_at.clone();
432 let key = role.as_str().to_string();
433 if evidence.contains_key(&key) {
434 continue;
435 }
436 let payload = match extract_payload(comment_body) {
437 Ok(payload) => Some(payload),
438 Err(err) if err.kind == PayloadErrorKind::NoFence => None,
439 Err(err) => {
440 return Err(format!(
441 "comment at {} has malformed payload: {}",
442 url.as_deref().unwrap_or("(unknown url)"),
443 err.message
444 ));
445 }
446 };
447 if let Some(payload) = payload.as_ref() {
448 validate_payload_data_for_role(payload.role, &payload.data).map_err(|err| {
449 format!(
450 "comment at {} has malformed payload for role `{}`: {}",
451 url.as_deref().unwrap_or("(unknown url)"),
452 payload.role.as_str(),
453 err
454 )
455 })?;
456 }
457 let status = payload.as_ref().and_then(derive_status_from_payload);
458 let candidate = LifecycleEvidence {
459 role,
460 profile,
461 url,
462 created_at: created_at.clone(),
463 status,
464 payload,
465 };
466 evidence_text.push_str(comment_body);
467 evidence_text.push('\n');
468 recognized_count += 1;
469 evidence.insert(key, candidate);
470 }
471 MarkerParse::Unsupported { prefix } => {
472 unsupported_markers.push(UnsupportedMarker {
473 marker_prefix: prefix,
474 url: comment.url.or(comment.html_url),
475 created_at: comment.created_at,
476 });
477 }
478 }
479 }
480
481 let mut missing_required = Vec::new();
482 for code in ["source-missing", "plan-missing", "state-missing"] {
483 let role_key = code.trim_end_matches("-missing");
484 if !evidence.contains_key(role_key) {
485 missing_required.push(code.to_string());
486 }
487 }
488
489 Ok(RecordAudit {
490 profile_filter: profile_filter.map(|profile| profile.as_str().to_string()),
491 body_sections: inspect_body_sections(body.unwrap_or_default()),
492 evidence,
493 missing_required,
494 unsupported_markers,
495 recognized_count,
496 evidence_text,
497 })
498}
499
500pub fn latest_role_bodies(
508 comments_json: &str,
509 profile_filter: Option<crate::commands::record::RecordProfile>,
510) -> Result<BTreeMap<PayloadRole, String>, String> {
511 let mut comments = parse_comments_json(comments_json)?;
512 comments.sort_by(|left, right| {
513 compare_created_at(right.created_at.as_deref(), left.created_at.as_deref())
514 });
515 let mut bodies: BTreeMap<PayloadRole, String> = BTreeMap::new();
516 for comment in comments {
517 let Some(body) = comment.body.as_deref() else {
518 continue;
519 };
520 let Some(first_marker) = first_comment_marker(body) else {
521 continue;
522 };
523 let Some(parsed) = parse_marker_line(first_marker) else {
524 continue;
525 };
526 if let MarkerParse::V2 { role, profile } = parsed {
527 if profile_filter.is_some_and(|expected| profile != PayloadProfile::from(expected)) {
528 continue;
529 }
530 bodies.entry(role).or_insert_with(|| body.to_string());
531 }
532 }
533 Ok(bodies)
534}
535
536fn compare_created_at(left: Option<&str>, right: Option<&str>) -> std::cmp::Ordering {
540 match (left, right) {
541 (Some(l), Some(r)) => l.cmp(r),
542 (Some(_), None) => std::cmp::Ordering::Greater,
543 (None, Some(_)) => std::cmp::Ordering::Less,
544 (None, None) => std::cmp::Ordering::Equal,
545 }
546}
547
548fn derive_status_from_payload(payload: &RecordPayload) -> Option<String> {
553 match payload.role {
554 PayloadRole::State => payload
555 .parse_state()
556 .ok()
557 .and_then(|data| data.status.map(|s| status_state_label(s).to_string())),
558 PayloadRole::Validation => payload
559 .parse_validation()
560 .ok()
561 .map(|data| validation_overall_label(data.overall).to_string()),
562 PayloadRole::Review => payload
563 .parse_review()
564 .ok()
565 .map(|data| review_decision_label(data.decision).to_string()),
566 PayloadRole::Closeout => payload.parse_closeout().ok().map(|data| data.final_status),
567 PayloadRole::Source | PayloadRole::Plan | PayloadRole::Session => None,
568 }
569}
570
571fn status_state_label(value: StateStatus) -> &'static str {
572 match value {
573 StateStatus::InProgress => "in-progress",
574 StateStatus::Complete => "complete",
575 StateStatus::Blocked => "blocked",
576 }
577}
578
579fn validation_overall_label(value: ValidationOverall) -> &'static str {
580 match value {
581 ValidationOverall::Pass => "pass",
582 ValidationOverall::Fail => "fail",
583 ValidationOverall::Partial => "partial",
584 }
585}
586
587fn review_decision_label(value: ReviewDecision) -> &'static str {
588 match value {
589 ReviewDecision::Approve => "approve",
590 ReviewDecision::RequestChanges => "request-changes",
591 ReviewDecision::CommentsOnly => "comments-only",
592 }
593}
594
595pub fn render_dashboard_from_audit(
602 audit: &RecordAudit,
603 title: Option<&str>,
604 issue_url: Option<&str>,
605) -> String {
606 let state_evidence = audit.evidence.get("state");
607 let state_data = state_evidence
608 .and_then(|hit| hit.payload.as_ref())
609 .and_then(|payload| payload.parse_state().ok());
610 let is_complete = state_evidence
611 .and_then(|hit| hit.status.as_deref())
612 .map(|status| status.eq_ignore_ascii_case("complete"))
613 .unwrap_or(false);
614
615 let dashboard_title = if is_complete {
616 "## Final Dashboard"
617 } else {
618 "## Current Dashboard"
619 };
620
621 let profile_str = state_evidence
622 .map(|hit| hit.profile.as_str().to_string())
623 .or_else(|| {
624 audit
625 .evidence
626 .values()
627 .next()
628 .map(|hit| hit.profile.as_str().to_string())
629 })
630 .unwrap_or_else(|| "tracking".to_string());
631
632 let status_value = state_evidence
633 .and_then(|hit| hit.status.clone())
634 .unwrap_or_else(|| "pending".to_string());
635
636 let target_scope = state_data
637 .as_ref()
638 .and_then(|data| data.target_scope.clone())
639 .unwrap_or_else(|| "pending".to_string());
640 let current = state_data
641 .as_ref()
642 .and_then(|data| data.current.clone())
643 .unwrap_or_else(|| "pending".to_string());
644 let next_action = state_data
645 .as_ref()
646 .and_then(|data| data.next_action.clone())
647 .unwrap_or_else(|| "pending".to_string());
648 let validation_status = audit
649 .evidence
650 .get("validation")
651 .and_then(|hit| hit.status.clone())
652 .unwrap_or_else(|| "pending".to_string());
653 let linked_prs = state_data
654 .as_ref()
655 .map(|data| {
656 data.prs
657 .iter()
658 .map(|pr| pr.url.clone().unwrap_or_else(|| pr.pr_ref.clone()))
659 .collect::<Vec<_>>()
660 })
661 .unwrap_or_default();
662 let blockers = state_data
663 .as_ref()
664 .map(|data| data.blockers.clone())
665 .unwrap_or_default();
666 let approval = audit
667 .evidence
668 .get("closeout")
669 .and_then(|hit| hit.payload.as_ref())
670 .and_then(|payload| payload.parse_closeout().ok())
671 .and_then(|data| data.approval.comment_url)
672 .unwrap_or_else(|| "pending".to_string());
673
674 let is_dispatch_profile = profile_str == "dispatch";
675 let show_review = is_dispatch_profile || audit.evidence.contains_key("review");
676 let tracker_block = render_tracker_block(title, issue_url);
677
678 let view = DashboardView {
679 title: dashboard_title,
680 status: status_value,
681 profile: &profile_str,
682 target_scope,
683 current,
684 next_action,
685 validation: validation_status,
686 linked_prs: non_empty_join(&linked_prs, "none yet"),
687 blockers: non_empty_join(&blockers, "none"),
688 approval,
689 source_link: dashboard_link(evidence_url(audit, "source").as_deref(), "source snapshot"),
690 plan_link: dashboard_link(evidence_url(audit, "plan").as_deref(), "plan snapshot"),
691 state_link: dashboard_link(evidence_url(audit, "state").as_deref(), "execution state"),
692 session_link: dashboard_link(
693 evidence_url(audit, "session").as_deref(),
694 "Execution Session",
695 ),
696 validation_link: dashboard_link(
697 evidence_url(audit, "validation").as_deref(),
698 "Validation Evidence",
699 ),
700 review_link: dashboard_link(evidence_url(audit, "review").as_deref(), "Review Evidence"),
701 show_review,
702 closeout_link: dashboard_link(evidence_url(audit, "closeout").as_deref(), "closeout"),
703 tracker_block,
704 };
705
706 render_dashboard_with_template(&view)
707}
708
709fn evidence_url(audit: &RecordAudit, role: &str) -> Option<String> {
710 audit
711 .evidence
712 .get(role)
713 .and_then(|hit| hit.url.clone())
714 .filter(|value| !value.trim().is_empty())
715}
716
717fn non_empty_join(values: &[String], fallback: &str) -> String {
718 let joined = values
719 .iter()
720 .map(|value| value.trim())
721 .filter(|value| !value.is_empty())
722 .collect::<Vec<_>>()
723 .join(", ");
724 if joined.is_empty() {
725 fallback.to_string()
726 } else {
727 joined
728 }
729}
730
731fn dashboard_link(url: Option<&str>, label: &str) -> String {
732 match url.map(str::trim).filter(|value| !value.is_empty()) {
733 Some(url) if url.starts_with("http://") || url.starts_with("https://") => {
734 format!("[{label}]({url})")
735 }
736 Some(value) => value.to_string(),
737 None => "pending".to_string(),
738 }
739}
740
741fn default_heading(profile: RecordProfile, kind: LifecycleCommentKind) -> &'static str {
742 match kind {
743 LifecycleCommentKind::Source => "Source Snapshot",
744 LifecycleCommentKind::Plan => "Plan Snapshot",
745 LifecycleCommentKind::State => "Execution State",
746 LifecycleCommentKind::Session => "Execution Session",
747 LifecycleCommentKind::Validation => "Validation Evidence",
748 LifecycleCommentKind::Review => "Review Evidence",
749 LifecycleCommentKind::Closeout => match profile {
750 RecordProfile::Tracking => "Tracking Issue Closeout",
751 RecordProfile::Dispatch => "Dispatch Issue Closeout",
752 },
753 }
754}
755
756fn default_details_summary(kind: LifecycleCommentKind) -> &'static str {
757 match kind {
758 LifecycleCommentKind::Source => "Source snapshot",
759 LifecycleCommentKind::Plan => "Plan snapshot",
760 _ => "Details",
761 }
762}
763
764fn marker_for(profile: RecordProfile, kind: LifecycleCommentKind) -> String {
766 format!(
767 "<!-- plan-issue-record:v2 role={} profile={} -->",
768 kind.as_str(),
769 profile.as_str()
770 )
771}
772
773fn parse_comments_json(raw: &str) -> Result<Vec<CommentJson>, String> {
774 let value = serde_json::from_str::<Value>(raw)
775 .map_err(|err| format!("failed to parse comments JSON: {err}"))?;
776 let comments_value = match value {
777 Value::Object(mut object) => object
778 .remove("comments")
779 .ok_or_else(|| "comments JSON object is missing `comments`".to_string())?,
780 Value::Array(items) => Value::Array(items),
781 _ => {
782 return Err(
783 "comments JSON must be an array or an object with a `comments` array".to_string(),
784 );
785 }
786 };
787 let Value::Array(items) = comments_value else {
788 return Err("`comments` must be an array".to_string());
789 };
790
791 Ok(items
792 .into_iter()
793 .filter_map(|item| {
794 let Value::Object(mut object) = item else {
795 return None;
796 };
797 Some(CommentJson {
798 body: string_field(&mut object, "body"),
799 url: string_field(&mut object, "url"),
800 html_url: string_field(&mut object, "html_url"),
801 created_at: string_field(&mut object, "created_at")
802 .or_else(|| string_field(&mut object, "createdAt")),
803 })
804 })
805 .collect())
806}
807
808fn string_field(object: &mut serde_json::Map<String, Value>, key: &str) -> Option<String> {
809 object
810 .remove(key)
811 .and_then(|value| value.as_str().map(ToString::to_string))
812}
813
814fn first_comment_marker(body: &str) -> Option<&str> {
815 for line in body.lines() {
816 let trimmed = line.trim();
817 if trimmed.is_empty() {
818 continue;
819 }
820 if trimmed.starts_with("<!--") && trimmed.ends_with("-->") {
821 return Some(trimmed);
822 }
823 return None;
824 }
825 None
826}
827
828#[derive(Debug, Clone)]
830enum MarkerParse {
831 V2 {
833 role: PayloadRole,
834 profile: PayloadProfile,
835 },
836 Unsupported { prefix: String },
839}
840
841fn parse_marker_line(marker: &str) -> Option<MarkerParse> {
842 let inner = marker.strip_prefix("<!--")?.strip_suffix("-->")?.trim();
843 let attrs = parse_attrs(inner);
844
845 if let Some(rest) = inner.strip_prefix("plan-issue-record:v2") {
846 let _ = rest;
847 let role = attrs.get("role").and_then(|value| parse_role(value))?;
848 let profile = attrs
849 .get("profile")
850 .and_then(|value| parse_profile(value))
851 .unwrap_or(PayloadProfile::Tracking);
852 return Some(MarkerParse::V2 { role, profile });
853 }
854
855 for prefix in [
859 "issue-backed-plan:",
860 "plan-tracking-issue:",
861 "execute-from-tracking-issue:",
862 "execute-plan-tracking-issue:",
863 "tracking-issue-closeout:",
864 "plan-tracking-issue-closeout:",
865 "deliver-dispatch-plan:",
866 "dispatch-plan:",
867 ] {
868 if inner.starts_with(prefix) {
869 return Some(MarkerParse::Unsupported {
870 prefix: prefix.trim_end_matches(':').to_string(),
871 });
872 }
873 }
874
875 None
876}
877
878fn parse_role(value: &str) -> Option<PayloadRole> {
879 Some(match value {
880 "source" => PayloadRole::Source,
881 "plan" => PayloadRole::Plan,
882 "state" => PayloadRole::State,
883 "session" => PayloadRole::Session,
884 "validation" => PayloadRole::Validation,
885 "review" => PayloadRole::Review,
886 "closeout" => PayloadRole::Closeout,
887 _ => return None,
888 })
889}
890
891fn parse_profile(value: &str) -> Option<PayloadProfile> {
892 Some(match value {
893 "tracking" => PayloadProfile::Tracking,
894 "dispatch" => PayloadProfile::Dispatch,
895 _ => return None,
896 })
897}
898
899fn parse_attrs(marker: &str) -> BTreeMap<String, String> {
900 let mut attrs = BTreeMap::new();
901 for token in marker.split_whitespace().skip(1) {
902 let Some((key, value)) = token.split_once('=') else {
903 continue;
904 };
905 attrs.insert(
906 key.trim().to_string(),
907 value
908 .trim()
909 .trim_matches('"')
910 .trim_matches('\'')
911 .to_string(),
912 );
913 }
914 attrs
915}
916
917fn inspect_body_sections(body: &str) -> BodySections {
918 BodySections {
919 current_dashboard: body.contains("## Current Dashboard"),
920 final_dashboard: body.contains("## Final Dashboard"),
921 durable_record: body.contains("## Durable Record"),
922 closeout_checks: body.contains("## Closeout Checks"),
923 task_decomposition: body.contains("## Task Decomposition"),
924 }
925}
926
927fn finalize_markdown(lines: Vec<String>) -> String {
928 let mut rendered = lines.join("\n");
929 if !rendered.ends_with('\n') {
930 rendered.push('\n');
931 }
932 rendered
933}
934
935pub const PAYLOAD_SCHEMA_V2: &str = "plan-issue-record.payload.v2";
950
951pub const PAYLOAD_FENCE_INFO: &str = "plan-issue-record-payload";
953const PAYLOAD_COMMENT_PREFIX: &str = "plan-issue-record-payload:hex:";
954
955#[derive(
956 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, serde::Deserialize,
957)]
958#[serde(rename_all = "lowercase")]
959pub enum PayloadRole {
960 Source,
961 Plan,
962 State,
963 Session,
964 Validation,
965 Review,
966 Closeout,
967}
968
969impl PayloadRole {
970 pub fn as_str(self) -> &'static str {
971 match self {
972 Self::Source => "source",
973 Self::Plan => "plan",
974 Self::State => "state",
975 Self::Session => "session",
976 Self::Validation => "validation",
977 Self::Review => "review",
978 Self::Closeout => "closeout",
979 }
980 }
981}
982
983#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
984#[serde(rename_all = "lowercase")]
985pub enum PayloadProfile {
986 Tracking,
987 Dispatch,
988}
989
990impl PayloadProfile {
991 pub fn as_str(self) -> &'static str {
992 match self {
993 Self::Tracking => "tracking",
994 Self::Dispatch => "dispatch",
995 }
996 }
997}
998
999impl From<RecordProfile> for PayloadProfile {
1000 fn from(value: RecordProfile) -> Self {
1001 match value {
1002 RecordProfile::Tracking => PayloadProfile::Tracking,
1003 RecordProfile::Dispatch => PayloadProfile::Dispatch,
1004 }
1005 }
1006}
1007
1008#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1010pub struct RecordPayload {
1011 pub schema: String,
1012 pub role: PayloadRole,
1013 pub profile: PayloadProfile,
1014 #[serde(default)]
1015 pub updated_at: Option<String>,
1016 #[serde(default)]
1017 pub data: Value,
1018}
1019
1020#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1021pub struct SnapshotData {
1022 pub path: String,
1023 pub commit: String,
1024 #[serde(default)]
1025 pub title: Option<String>,
1026 #[serde(default)]
1027 pub summary: Option<String>,
1028}
1029
1030#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
1031#[serde(rename_all = "kebab-case")]
1032pub enum StateStatus {
1033 InProgress,
1034 Complete,
1035 Blocked,
1036}
1037
1038#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
1039#[serde(rename_all = "kebab-case")]
1040pub enum TaskRowStatus {
1041 Pending,
1042 InProgress,
1043 Done,
1044 Deferred,
1045 Blocked,
1046 Waived,
1047}
1048
1049#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1050pub struct TaskRowPayload {
1051 pub id: String,
1052 pub status: TaskRowStatus,
1053 #[serde(default)]
1054 pub title: Option<String>,
1055}
1056
1057#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
1058#[serde(rename_all = "lowercase")]
1059pub enum PrLifecycleStatus {
1060 Open,
1061 Merged,
1062 Closed,
1063}
1064
1065#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1066pub struct PrRefPayload {
1067 #[serde(rename = "ref")]
1068 pub pr_ref: String,
1069 #[serde(default)]
1070 pub url: Option<String>,
1071 pub status: PrLifecycleStatus,
1072}
1073
1074#[derive(Debug, Clone, Default, Serialize, serde::Deserialize)]
1075pub struct StateData {
1076 pub status: Option<StateStatus>,
1077 #[serde(default)]
1078 pub target_scope: Option<String>,
1079 #[serde(default)]
1080 pub current: Option<String>,
1081 #[serde(default)]
1082 pub next_action: Option<String>,
1083 #[serde(default)]
1084 pub tasks: Vec<TaskRowPayload>,
1085 #[serde(default)]
1086 pub prs: Vec<PrRefPayload>,
1087 #[serde(default)]
1088 pub blockers: Vec<String>,
1089 #[serde(default)]
1090 pub links: BTreeMap<String, String>,
1091}
1092
1093#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1094pub struct SessionData {
1095 pub summary: String,
1096 #[serde(default)]
1097 pub highlights: Vec<String>,
1098 #[serde(default)]
1099 pub links: BTreeMap<String, String>,
1100}
1101
1102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
1103#[serde(rename_all = "lowercase")]
1104pub enum ValidationOverall {
1105 Pass,
1106 Fail,
1107 Partial,
1108}
1109
1110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
1111#[serde(rename_all = "lowercase")]
1112pub enum ValidationCommandStatus {
1113 Pass,
1114 Fail,
1115 Skipped,
1116}
1117
1118#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1119pub struct ValidationCommand {
1120 pub command: String,
1121 pub status: ValidationCommandStatus,
1122 #[serde(default)]
1123 pub evidence: Option<String>,
1124}
1125
1126#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1127pub struct ValidationWaiver {
1128 pub command: String,
1129 pub reason: String,
1130}
1131
1132#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1133pub struct ValidationData {
1134 pub overall: ValidationOverall,
1135 #[serde(default)]
1136 pub commands: Vec<ValidationCommand>,
1137 #[serde(default)]
1138 pub waivers: Vec<ValidationWaiver>,
1139}
1140
1141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
1142#[serde(rename_all = "kebab-case")]
1143pub enum ReviewDecision {
1144 Approve,
1145 RequestChanges,
1146 CommentsOnly,
1147}
1148
1149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
1150#[serde(rename_all = "lowercase")]
1151pub enum FindingSeverity {
1152 Blocker,
1153 Major,
1154 Minor,
1155 Nit,
1156}
1157
1158#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
1159#[serde(rename_all = "kebab-case")]
1160pub enum FindingDisposition {
1161 Fixed,
1162 Residual,
1163 FollowUp,
1164 Deferred,
1165 NoAction,
1166}
1167
1168#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1169pub struct ReviewFinding {
1170 pub id: String,
1171 pub severity: FindingSeverity,
1172 pub disposition: FindingDisposition,
1173 pub summary: String,
1174}
1175
1176#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1177pub struct ReviewData {
1178 pub decision: ReviewDecision,
1179 #[serde(default)]
1180 pub lenses: Vec<String>,
1181 #[serde(default)]
1182 pub findings: Vec<ReviewFinding>,
1183 #[serde(default)]
1184 pub outcome_comment_url: Option<String>,
1185}
1186
1187#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1188pub struct ApprovalEvidence {
1189 #[serde(default)]
1190 pub comment_url: Option<String>,
1191 #[serde(default)]
1192 pub approver: Option<String>,
1193}
1194
1195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
1196#[serde(rename_all = "lowercase")]
1197pub enum CheckStatus {
1198 Pass,
1199 Fail,
1200 None,
1201}
1202
1203#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1204pub struct LinkedPrEvidence {
1205 #[serde(rename = "ref")]
1206 pub pr_ref: String,
1207 #[serde(default)]
1208 pub url: Option<String>,
1209 #[serde(default)]
1210 pub merge_sha: Option<String>,
1211 pub checks: CheckStatus,
1217 #[serde(default)]
1223 pub required_state: Option<CheckStatus>,
1224 #[serde(default)]
1228 pub required_count: Option<u32>,
1229 #[serde(default)]
1233 pub non_required_failures: Vec<String>,
1234}
1235
1236#[derive(Debug, Clone, Serialize, serde::Deserialize)]
1237pub struct CloseoutData {
1238 pub final_status: String,
1239 pub approval: ApprovalEvidence,
1240 #[serde(default)]
1241 pub linked_prs: Vec<LinkedPrEvidence>,
1242 #[serde(default)]
1243 pub non_required_check_override: Option<Value>,
1244 #[serde(default)]
1245 pub final_validation_url: Option<String>,
1246 #[serde(default)]
1247 pub notes: Option<String>,
1248}
1249
1250#[derive(Debug, Clone, PartialEq, Eq)]
1251pub enum PayloadErrorKind {
1252 NoFence,
1253 MultipleFences,
1254 SchemaMismatch,
1255 InvalidJson,
1256}
1257
1258#[derive(Debug, Clone)]
1259pub struct PayloadError {
1260 pub kind: PayloadErrorKind,
1261 pub message: String,
1262}
1263
1264impl PayloadError {
1265 fn new(kind: PayloadErrorKind, message: impl Into<String>) -> Self {
1266 Self {
1267 kind,
1268 message: message.into(),
1269 }
1270 }
1271}
1272
1273impl std::fmt::Display for PayloadError {
1274 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1275 write!(f, "{}", self.message)
1276 }
1277}
1278
1279impl std::error::Error for PayloadError {}
1280
1281pub fn extract_payload(comment_body: &str) -> Result<RecordPayload, PayloadError> {
1296 let carriers = collect_payload_comment_carriers(comment_body)?;
1297 let fences = collect_payload_fences(comment_body);
1298 let payload_count = carriers.len() + fences.len();
1299 if payload_count == 0 {
1300 return Err(PayloadError::new(
1301 PayloadErrorKind::NoFence,
1302 "no plan-issue-record-payload carrier or fence in comment body",
1303 ));
1304 }
1305 if payload_count > 1 {
1306 return Err(PayloadError::new(
1307 PayloadErrorKind::MultipleFences,
1308 "multiple plan-issue-record-payload carriers or fences in comment body",
1309 ));
1310 }
1311
1312 let raw = carriers.first().or_else(|| fences.first()).ok_or_else(|| {
1313 PayloadError::new(
1314 PayloadErrorKind::NoFence,
1315 "no plan-issue-record-payload carrier or fence in comment body",
1316 )
1317 })?;
1318 let payload: RecordPayload = serde_json::from_str(raw)
1319 .map_err(|err| PayloadError::new(PayloadErrorKind::InvalidJson, err.to_string()))?;
1320 if payload.schema != PAYLOAD_SCHEMA_V2 {
1321 return Err(PayloadError::new(
1322 PayloadErrorKind::SchemaMismatch,
1323 format!(
1324 "expected schema `{PAYLOAD_SCHEMA_V2}`, got `{}`",
1325 payload.schema
1326 ),
1327 ));
1328 }
1329 Ok(payload)
1330}
1331
1332fn collect_payload_comment_carriers(body: &str) -> Result<Vec<String>, PayloadError> {
1333 let mut out = Vec::new();
1334 let mut details_depth = 0usize;
1335 for line in body.lines() {
1336 if update_details_depth(line, &mut details_depth) {
1337 continue;
1338 }
1339 if details_depth > 0 {
1340 continue;
1341 }
1342 let trimmed = line.trim();
1343 let Some(inner) = trimmed
1344 .strip_prefix("<!--")
1345 .and_then(|value| value.strip_suffix("-->"))
1346 else {
1347 continue;
1348 };
1349 let inner = inner.trim();
1350 let Some(encoded) = inner.strip_prefix(PAYLOAD_COMMENT_PREFIX) else {
1351 continue;
1352 };
1353 let payload = decode_hex(encoded.trim()).map_err(|err| {
1354 PayloadError::new(
1355 PayloadErrorKind::InvalidJson,
1356 format!("invalid hidden payload carrier: {err}"),
1357 )
1358 })?;
1359 let payload = String::from_utf8(payload).map_err(|err| {
1360 PayloadError::new(
1361 PayloadErrorKind::InvalidJson,
1362 format!("hidden payload carrier is not UTF-8: {err}"),
1363 )
1364 })?;
1365 out.push(payload);
1366 }
1367 Ok(out)
1368}
1369
1370fn collect_payload_fences(body: &str) -> Vec<String> {
1371 let mut out = Vec::new();
1372 let mut current: Option<Vec<String>> = None;
1373 let mut details_depth = 0usize;
1374 for line in body.lines() {
1375 let trimmed = line.trim_start();
1376 if let Some(buf) = current.as_mut() {
1377 if trimmed.starts_with("```") {
1378 let mut block = String::new();
1379 for chunk in buf.iter() {
1380 if !block.is_empty() {
1381 block.push('\n');
1382 }
1383 block.push_str(chunk);
1384 }
1385 out.push(block);
1386 current = None;
1387 } else {
1388 buf.push(line.to_string());
1389 }
1390 } else {
1391 if update_details_depth(line, &mut details_depth) {
1392 continue;
1393 }
1394 if details_depth > 0 {
1395 continue;
1396 }
1397 if let Some(rest) = trimmed.strip_prefix("```")
1398 && rest.trim() == PAYLOAD_FENCE_INFO
1399 {
1400 current = Some(Vec::new());
1401 }
1402 }
1403 }
1404 out
1405}
1406
1407fn update_details_depth(line: &str, depth: &mut usize) -> bool {
1408 let trimmed = line.trim();
1409 if trimmed.starts_with("<details") {
1410 *depth += 1;
1411 return true;
1412 }
1413 if trimmed.starts_with("</details>") {
1414 *depth = depth.saturating_sub(1);
1415 return true;
1416 }
1417 false
1418}
1419
1420fn encode_hex(bytes: &[u8]) -> String {
1421 const HEX: &[u8; 16] = b"0123456789abcdef";
1422 let mut out = String::with_capacity(bytes.len() * 2);
1423 for byte in bytes {
1424 out.push(HEX[(byte >> 4) as usize] as char);
1425 out.push(HEX[(byte & 0x0f) as usize] as char);
1426 }
1427 out
1428}
1429
1430fn decode_hex(input: &str) -> Result<Vec<u8>, String> {
1431 if !input.len().is_multiple_of(2) {
1432 return Err("hex payload has odd length".to_string());
1433 }
1434 let mut out = Vec::with_capacity(input.len() / 2);
1435 let bytes = input.as_bytes();
1436 for pair in bytes.chunks_exact(2) {
1437 let hi = hex_value(pair[0])
1438 .ok_or_else(|| format!("invalid hex digit `{}`", char::from(pair[0])))?;
1439 let lo = hex_value(pair[1])
1440 .ok_or_else(|| format!("invalid hex digit `{}`", char::from(pair[1])))?;
1441 out.push((hi << 4) | lo);
1442 }
1443 Ok(out)
1444}
1445
1446fn hex_value(byte: u8) -> Option<u8> {
1447 match byte {
1448 b'0'..=b'9' => Some(byte - b'0'),
1449 b'a'..=b'f' => Some(byte - b'a' + 10),
1450 b'A'..=b'F' => Some(byte - b'A' + 10),
1451 _ => None,
1452 }
1453}
1454
1455impl RecordPayload {
1456 pub fn parse_state(&self) -> Result<StateData, PayloadError> {
1457 self.decode_data(PayloadRole::State)
1458 }
1459
1460 pub fn parse_session(&self) -> Result<SessionData, PayloadError> {
1461 self.decode_data(PayloadRole::Session)
1462 }
1463
1464 pub fn parse_validation(&self) -> Result<ValidationData, PayloadError> {
1465 self.decode_data(PayloadRole::Validation)
1466 }
1467
1468 pub fn parse_review(&self) -> Result<ReviewData, PayloadError> {
1469 self.decode_data(PayloadRole::Review)
1470 }
1471
1472 pub fn parse_closeout(&self) -> Result<CloseoutData, PayloadError> {
1473 self.decode_data(PayloadRole::Closeout)
1474 }
1475
1476 pub fn parse_snapshot(&self) -> Result<SnapshotData, PayloadError> {
1477 if !matches!(self.role, PayloadRole::Source | PayloadRole::Plan) {
1478 return Err(PayloadError::new(
1479 PayloadErrorKind::SchemaMismatch,
1480 format!(
1481 "expected source or plan payload, got `{}`",
1482 self.role.as_str()
1483 ),
1484 ));
1485 }
1486 serde_json::from_value::<SnapshotData>(self.data.clone())
1487 .map_err(|err| PayloadError::new(PayloadErrorKind::InvalidJson, err.to_string()))
1488 }
1489
1490 fn decode_data<T: serde::de::DeserializeOwned>(
1491 &self,
1492 expected: PayloadRole,
1493 ) -> Result<T, PayloadError> {
1494 if self.role != expected {
1495 return Err(PayloadError::new(
1496 PayloadErrorKind::SchemaMismatch,
1497 format!(
1498 "expected role `{}`, got `{}`",
1499 expected.as_str(),
1500 self.role.as_str()
1501 ),
1502 ));
1503 }
1504 serde_json::from_value::<T>(self.data.clone())
1505 .map_err(|err| PayloadError::new(PayloadErrorKind::InvalidJson, err.to_string()))
1506 }
1507}
1508
1509pub fn validate_payload_data_for_kind(
1510 kind: LifecycleCommentKind,
1511 data: &Value,
1512) -> Result<(), PayloadError> {
1513 validate_payload_data_for_role(payload_role_for_kind(kind), data)
1514}
1515
1516fn validate_payload_data_for_role(role: PayloadRole, data: &Value) -> Result<(), PayloadError> {
1517 let payload = RecordPayload {
1518 schema: PAYLOAD_SCHEMA_V2.to_string(),
1519 role,
1520 profile: PayloadProfile::Tracking,
1521 updated_at: None,
1522 data: data.clone(),
1523 };
1524 validate_payload_data(&payload)
1525}
1526
1527fn validate_payload_data(payload: &RecordPayload) -> Result<(), PayloadError> {
1528 match payload.role {
1529 PayloadRole::Source | PayloadRole::Plan => payload.parse_snapshot().map(|_| ()),
1530 PayloadRole::State => payload.parse_state().map(|_| ()),
1531 PayloadRole::Session => payload.parse_session().map(|_| ()),
1532 PayloadRole::Validation => payload.parse_validation().map(|_| ()),
1533 PayloadRole::Review => payload.parse_review().map(|_| ()),
1534 PayloadRole::Closeout => payload.parse_closeout().map(|_| ()),
1535 }
1536}
1537
1538fn payload_role_for_kind(kind: LifecycleCommentKind) -> PayloadRole {
1549 match kind {
1550 LifecycleCommentKind::Source => PayloadRole::Source,
1551 LifecycleCommentKind::Plan => PayloadRole::Plan,
1552 LifecycleCommentKind::State => PayloadRole::State,
1553 LifecycleCommentKind::Session => PayloadRole::Session,
1554 LifecycleCommentKind::Validation => PayloadRole::Validation,
1555 LifecycleCommentKind::Review => PayloadRole::Review,
1556 LifecycleCommentKind::Closeout => PayloadRole::Closeout,
1557 }
1558}
1559
1560fn render_payload_carrier(envelope: &RecordPayload) -> Result<String, String> {
1561 let envelope_json = serde_json::to_string(envelope).map_err(|err| err.to_string())?;
1562 Ok(format!(
1563 "<!-- {PAYLOAD_COMMENT_PREFIX}{} -->",
1564 encode_hex(envelope_json.as_bytes())
1565 ))
1566}
1567
1568pub fn render_record_snapshot_comment(
1572 profile: RecordProfile,
1573 kind: LifecycleCommentKind,
1574 snapshot: &SnapshotData,
1575 content: &str,
1576 updated_at: Option<&str>,
1577) -> Result<String, String> {
1578 if !matches!(
1579 kind,
1580 LifecycleCommentKind::Source | LifecycleCommentKind::Plan
1581 ) {
1582 return Err(format!(
1583 "render_record_snapshot_comment: expected source or plan kind, got `{}`",
1584 kind.as_str()
1585 ));
1586 }
1587
1588 let envelope = RecordPayload {
1589 schema: PAYLOAD_SCHEMA_V2.to_string(),
1590 role: payload_role_for_kind(kind),
1591 profile: PayloadProfile::from(profile),
1592 updated_at: updated_at.map(str::to_string),
1593 data: serde_json::to_value(snapshot).map_err(|err| err.to_string())?,
1594 };
1595 let envelope_carrier = render_payload_carrier(&envelope)?;
1596
1597 let path = Some(snapshot.path.trim()).filter(|value| !value.is_empty());
1598 let commit = Some(snapshot.commit.trim()).filter(|value| !value.is_empty());
1599 let summary = snapshot
1600 .summary
1601 .as_deref()
1602 .map(str::trim)
1603 .filter(|value| !value.is_empty());
1604
1605 let view = SnapshotView {
1606 marker: marker_for(profile, kind),
1607 heading: default_heading(profile, kind),
1608 profile: profile.as_str(),
1609 path,
1610 commit,
1611 summary,
1612 details_summary: default_details_summary(kind),
1613 content,
1614 envelope_carrier,
1615 };
1616
1617 let mut engine = Engine::builder().build();
1618 engine
1619 .register_template(SNAPSHOT_TEMPLATE_NAME, SNAPSHOT_TEMPLATE)
1620 .map_err(|err| format!("snapshot template register failed: {err}"))?;
1621 engine
1622 .render(SNAPSHOT_TEMPLATE_NAME, &view)
1623 .map_err(|err| format!("snapshot template render failed: {err}"))
1624}
1625
1626pub fn extract_snapshot_content(comment_body: &str) -> Result<String, String> {
1640 let lines: Vec<&str> = comment_body.lines().collect();
1641 let open_idx = lines
1642 .iter()
1643 .position(|line| line.trim().starts_with("<details"))
1644 .ok_or_else(|| "snapshot comment has no <details> block".to_string())?;
1645
1646 let mut start = open_idx + 1;
1649 if start < lines.len() && lines[start].trim_start().starts_with("<summary") {
1650 start += 1;
1651 }
1652 if start < lines.len() && lines[start].trim().is_empty() {
1653 start += 1;
1654 }
1655
1656 let mut depth = 1usize;
1659 let mut content: Vec<&str> = Vec::new();
1660 let mut closed = false;
1661 for line in &lines[start..] {
1662 let trimmed = line.trim();
1663 if trimmed.starts_with("<details") {
1664 depth += 1;
1665 } else if trimmed.starts_with("</details>") {
1666 depth -= 1;
1667 if depth == 0 {
1668 closed = true;
1669 break;
1670 }
1671 }
1672 content.push(line);
1673 }
1674 if !closed {
1675 return Err("snapshot <details> block is not closed".to_string());
1676 }
1677
1678 while content.last().is_some_and(|line| line.trim().is_empty()) {
1681 content.pop();
1682 }
1683 let mut out = content.join("\n");
1684 out.push('\n');
1685 Ok(out)
1686}
1687
1688pub fn render_record_post_comment(
1692 profile: RecordProfile,
1693 kind: LifecycleCommentKind,
1694 payload_data: Value,
1695 summary: Option<&str>,
1696 updated_at: Option<&str>,
1697) -> Result<String, String> {
1698 render_record_post_comment_with_display(
1699 profile,
1700 kind,
1701 payload_data,
1702 summary,
1703 updated_at,
1704 TaskLedgerDisplay::Auto,
1705 )
1706}
1707
1708#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1710pub enum StateHeaderMode {
1711 Authored,
1715 DeriveFromPayload,
1721}
1722
1723pub fn render_record_post_comment_with_display(
1724 profile: RecordProfile,
1725 kind: LifecycleCommentKind,
1726 payload_data: Value,
1727 summary: Option<&str>,
1728 updated_at: Option<&str>,
1729 task_ledger_display: TaskLedgerDisplay,
1730) -> Result<String, String> {
1731 render_record_post_comment_with_display_mode(
1732 profile,
1733 kind,
1734 payload_data,
1735 summary,
1736 updated_at,
1737 task_ledger_display,
1738 StateHeaderMode::Authored,
1739 )
1740}
1741
1742pub fn render_record_post_comment_with_display_mode(
1743 profile: RecordProfile,
1744 kind: LifecycleCommentKind,
1745 payload_data: Value,
1746 summary: Option<&str>,
1747 updated_at: Option<&str>,
1748 task_ledger_display: TaskLedgerDisplay,
1749 header_mode: StateHeaderMode,
1750) -> Result<String, String> {
1751 if matches!(
1752 kind,
1753 LifecycleCommentKind::Source | LifecycleCommentKind::Plan
1754 ) {
1755 return Err(format!(
1756 "render_record_post_comment: source/plan kinds are owned by `record open`, got `{}`",
1757 kind.as_str()
1758 ));
1759 }
1760
1761 let visible_content = render_visible_post_content(
1762 kind,
1763 &payload_data,
1764 summary,
1765 task_ledger_display,
1766 header_mode,
1767 )?;
1768 let envelope = RecordPayload {
1769 schema: PAYLOAD_SCHEMA_V2.to_string(),
1770 role: payload_role_for_kind(kind),
1771 profile: PayloadProfile::from(profile),
1772 updated_at: updated_at.map(str::to_string),
1773 data: payload_data,
1774 };
1775 let envelope_carrier = render_payload_carrier(&envelope)?;
1776
1777 let view = PostCommentView {
1778 marker: marker_for(profile, kind),
1779 heading: default_heading(profile, kind),
1780 profile: profile.as_str(),
1781 visible_content,
1782 envelope_carrier,
1783 };
1784
1785 let mut engine = Engine::builder().build();
1786 engine
1787 .register_template(POST_COMMENT_TEMPLATE_NAME, POST_COMMENT_TEMPLATE)
1788 .map_err(|err| format!("post_comment template register failed: {err}"))?;
1789 engine
1790 .render(POST_COMMENT_TEMPLATE_NAME, &view)
1791 .map_err(|err| format!("post_comment template render failed: {err}"))
1792}
1793
1794fn render_visible_post_content(
1795 kind: LifecycleCommentKind,
1796 payload_data: &Value,
1797 summary: Option<&str>,
1798 task_ledger_display: TaskLedgerDisplay,
1799 header_mode: StateHeaderMode,
1800) -> Result<String, String> {
1801 let summary = summary.map(str::trim).filter(|value| !value.is_empty());
1802 let generated = match kind {
1803 LifecycleCommentKind::State => {
1804 let state = serde_json::from_value::<StateData>(payload_data.clone())
1805 .map_err(|err| format!("state payload invalid for visible rendering: {err}"))?;
1806 match summary {
1807 Some(text) if text.contains("## Task Ledger") => {
1808 render_state_markdown_with_task_ledger_display(
1809 text,
1810 task_ledger_display,
1811 &state,
1812 header_mode,
1813 )?
1814 }
1815 Some(text) => text.to_string(),
1816 None => render_state_payload_visible(&state),
1817 }
1818 }
1819 LifecycleCommentKind::Session => {
1820 let session = serde_json::from_value::<SessionData>(payload_data.clone())
1821 .map_err(|err| format!("session payload invalid for visible rendering: {err}"))?;
1822 combine_summary_and_generated(
1823 summary,
1824 render_session_payload_visible(&session, payload_data),
1825 )
1826 }
1827 LifecycleCommentKind::Validation => {
1828 let validation = serde_json::from_value::<ValidationData>(payload_data.clone())
1829 .map_err(|err| {
1830 format!("validation payload invalid for visible rendering: {err}")
1831 })?;
1832 combine_summary_and_generated(summary, render_validation_payload_visible(&validation))
1833 }
1834 LifecycleCommentKind::Review => {
1835 let review = serde_json::from_value::<ReviewData>(payload_data.clone())
1836 .map_err(|err| format!("review payload invalid for visible rendering: {err}"))?;
1837 combine_summary_and_generated(summary, render_review_payload_visible(&review))
1838 }
1839 LifecycleCommentKind::Closeout => {
1840 let closeout = serde_json::from_value::<CloseoutData>(payload_data.clone())
1841 .map_err(|err| format!("closeout payload invalid for visible rendering: {err}"))?;
1842 combine_summary_and_generated(summary, render_closeout_payload_visible(&closeout))
1843 }
1844 LifecycleCommentKind::Source | LifecycleCommentKind::Plan => unreachable!(),
1845 };
1846
1847 if generated.trim().is_empty() {
1848 return Err(format!(
1849 "`record post --kind {}` would render no visible lifecycle content",
1850 kind.as_str()
1851 ));
1852 }
1853 Ok(generated)
1854}
1855
1856fn combine_summary_and_generated(summary: Option<&str>, generated: String) -> String {
1857 match (summary, generated.trim().is_empty()) {
1858 (Some(text), false) => format!("{}\n\n{}", text.trim(), generated.trim()),
1859 (Some(text), true) => text.trim().to_string(),
1860 (None, _) => generated,
1861 }
1862}
1863
1864fn render_state_markdown_with_task_ledger_display(
1865 markdown: &str,
1866 display: TaskLedgerDisplay,
1867 state: &StateData,
1868 header_mode: StateHeaderMode,
1869) -> Result<String, String> {
1870 let markdown = normalize_state_markdown_for_comment(markdown)?;
1871 let markdown = match header_mode {
1879 StateHeaderMode::DeriveFromPayload => replace_state_header_from_payload(&markdown, state),
1880 StateHeaderMode::Authored => markdown,
1881 };
1882 let effective = match display {
1883 TaskLedgerDisplay::Expanded => TaskLedgerDisplay::Expanded,
1884 TaskLedgerDisplay::Collapsed => TaskLedgerDisplay::Collapsed,
1885 TaskLedgerDisplay::Open => TaskLedgerDisplay::Open,
1886 TaskLedgerDisplay::Auto => {
1887 if is_terminal_state(state) {
1888 TaskLedgerDisplay::Expanded
1889 } else {
1890 TaskLedgerDisplay::Collapsed
1891 }
1892 }
1893 };
1894 if effective == TaskLedgerDisplay::Expanded {
1895 return Ok(markdown);
1896 }
1897 let details_open_tag = match effective {
1900 TaskLedgerDisplay::Open => "<details open>",
1901 _ => "<details>",
1902 };
1903
1904 let lines: Vec<&str> = markdown.lines().collect();
1905 let Some(start) = lines
1906 .iter()
1907 .position(|line| line.trim() == "## Task Ledger")
1908 else {
1909 return Err("execution-state markdown is missing `## Task Ledger`".to_string());
1910 };
1911 let end = lines
1912 .iter()
1913 .enumerate()
1914 .skip(start + 1)
1915 .find_map(|(idx, line)| {
1916 if line.starts_with("## ") {
1917 Some(idx)
1918 } else {
1919 None
1920 }
1921 })
1922 .unwrap_or(lines.len());
1923 let body = lines[start + 1..end].join("\n").trim().to_string();
1924 if body.is_empty() {
1925 return Err("execution-state Task Ledger section is empty".to_string());
1926 }
1927
1928 let mut out = Vec::new();
1929 out.extend(lines[..=start].iter().map(|line| (*line).to_string()));
1930 out.push(String::new());
1931 out.push(details_open_tag.to_string());
1932 out.push("<summary>Show task ledger</summary>".to_string());
1933 out.push(String::new());
1934 out.push(body);
1935 out.push(String::new());
1936 out.push("</details>".to_string());
1937 if end < lines.len() {
1938 out.push(String::new());
1939 out.extend(lines[end..].iter().map(|line| (*line).to_string()));
1940 }
1941 Ok(finalize_markdown(out).trim().to_string())
1942}
1943
1944fn normalize_state_markdown_for_comment(markdown: &str) -> Result<String, String> {
1945 let stripped = markdown
1946 .trim()
1947 .lines()
1948 .filter(|line| {
1949 let trimmed = line.trim();
1950 !trimmed.starts_with("<!-- plan-issue-record:")
1951 && !trimmed.starts_with("<!-- execute-from-tracking-issue:")
1952 })
1953 .map(str::to_string)
1954 .collect::<Vec<_>>();
1955 let Some(execution_heading) = stripped
1956 .iter()
1957 .position(|line| line.trim() == "## Execution State")
1958 else {
1959 return Err("execution-state markdown is missing `## Execution State`".to_string());
1960 };
1961
1962 let mut out = stripped
1963 .into_iter()
1964 .skip(execution_heading + 1)
1965 .filter(|line| !line.trim().starts_with("- Profile:"))
1966 .collect::<Vec<_>>();
1967 while out.first().is_some_and(|line| line.trim().is_empty()) {
1968 out.remove(0);
1969 }
1970 let normalized = finalize_markdown(out).trim().to_string();
1971 if normalized.is_empty() {
1972 return Err("execution-state markdown has no visible state content".to_string());
1973 }
1974 Ok(normalized)
1975}
1976
1977fn is_terminal_state(state: &StateData) -> bool {
1978 state.status == Some(StateStatus::Complete)
1979 && state.tasks.iter().all(|task| {
1980 matches!(
1981 task.status,
1982 TaskRowStatus::Done | TaskRowStatus::Deferred | TaskRowStatus::Waived
1983 )
1984 })
1985}
1986
1987fn render_state_payload_visible(state: &StateData) -> String {
1988 let view = StateVisibleView {
1989 status: state.status.map(status_state_label),
1990 target_scope: state
1991 .target_scope
1992 .as_deref()
1993 .filter(|value| !value.is_empty()),
1994 current: state.current.as_deref().filter(|value| !value.is_empty()),
1995 next_action: state
1996 .next_action
1997 .as_deref()
1998 .filter(|value| !value.is_empty()),
1999 tasks: state
2000 .tasks
2001 .iter()
2002 .map(|task| StateTaskRow {
2003 id: table_cell(&task.id),
2004 status: task_row_status_label(task.status),
2005 title: table_cell(task.title.as_deref().unwrap_or("")),
2006 })
2007 .collect(),
2008 };
2009 let mut engine = Engine::builder().build();
2010 engine
2011 .register_template(STATE_VISIBLE_TEMPLATE_NAME, STATE_VISIBLE_TEMPLATE)
2012 .expect("state template registers");
2013 let rendered = engine
2014 .render(STATE_VISIBLE_TEMPLATE_NAME, &view)
2015 .expect("state template renders");
2016 rendered.trim().to_string()
2017}
2018
2019fn replace_state_header_from_payload(markdown: &str, state: &StateData) -> String {
2026 let header = render_state_header_lines_from_payload(state);
2027 if header.is_empty() {
2028 return markdown.to_string();
2029 }
2030 let lines: Vec<&str> = markdown.lines().collect();
2031 let first_section = lines
2032 .iter()
2033 .position(|line| line.trim_start().starts_with("## "));
2034 let mut out = header;
2035 if let Some(idx) = first_section {
2036 out.push(String::new());
2037 out.extend(lines[idx..].iter().map(|line| (*line).to_string()));
2038 }
2039 finalize_markdown(out).trim().to_string()
2040}
2041
2042fn render_state_header_lines_from_payload(state: &StateData) -> Vec<String> {
2046 let mut lines = Vec::new();
2047 if let Some(status) = state.status.map(status_state_label) {
2048 lines.push(format!("- Status: {status}"));
2049 }
2050 if let Some(scope) = state
2051 .target_scope
2052 .as_deref()
2053 .filter(|value| !value.is_empty())
2054 {
2055 lines.push(format!("- Target scope: {scope}"));
2056 }
2057 if let Some(current) = state.current.as_deref().filter(|value| !value.is_empty()) {
2058 lines.push(format!("- Current task: {current}"));
2059 }
2060 if let Some(next) = state
2061 .next_action
2062 .as_deref()
2063 .filter(|value| !value.is_empty())
2064 {
2065 lines.push(format!("- Next task: {next}"));
2066 }
2067 lines
2068}
2069
2070fn render_session_payload_visible(session: &SessionData, raw: &Value) -> String {
2071 let extras: Vec<KeyValuePair> = raw
2072 .as_object()
2073 .map(|object| {
2074 object
2075 .iter()
2076 .filter(|(key, value)| {
2077 !matches!(key.as_str(), "summary" | "highlights" | "links") && !value.is_null()
2078 })
2079 .map(|(key, value)| KeyValuePair {
2080 key: key.trim().to_string(),
2081 value: visible_value(value),
2082 })
2083 .collect()
2084 })
2085 .unwrap_or_default();
2086
2087 let view = SessionVisibleView {
2088 summary: session.summary.trim(),
2089 highlights: session
2090 .highlights
2091 .iter()
2092 .map(|item| item.trim().to_string())
2093 .collect(),
2094 links: session
2095 .links
2096 .iter()
2097 .map(|(key, value)| KeyValuePair {
2098 key: key.trim().to_string(),
2099 value: value.trim().to_string(),
2100 })
2101 .collect(),
2102 extras,
2103 };
2104 let mut engine = Engine::builder().build();
2105 engine
2106 .register_template(SESSION_VISIBLE_TEMPLATE_NAME, SESSION_VISIBLE_TEMPLATE)
2107 .expect("session template registers");
2108 let rendered = engine
2109 .render(SESSION_VISIBLE_TEMPLATE_NAME, &view)
2110 .expect("session template renders");
2111 rendered.trim().to_string()
2112}
2113
2114fn render_validation_payload_visible(validation: &ValidationData) -> String {
2115 let view = ValidationVisibleView {
2116 overall: validation_overall_label(validation.overall),
2117 commands: validation
2118 .commands
2119 .iter()
2120 .map(|command| ValidationCommandRow {
2121 command: table_cell(&command.command),
2122 status: validation_command_status_label(command.status),
2123 evidence: table_cell(command.evidence.as_deref().unwrap_or("")),
2124 _phantom: std::marker::PhantomData,
2125 })
2126 .collect(),
2127 waivers: validation
2128 .waivers
2129 .iter()
2130 .map(|waiver| ValidationWaiverRow {
2131 command: waiver.command.trim(),
2132 reason: waiver.reason.trim(),
2133 })
2134 .collect(),
2135 };
2136 let mut engine = Engine::builder().build();
2137 engine
2138 .register_template(
2139 VALIDATION_VISIBLE_TEMPLATE_NAME,
2140 VALIDATION_VISIBLE_TEMPLATE,
2141 )
2142 .expect("validation template registers");
2143 let rendered = engine
2144 .render(VALIDATION_VISIBLE_TEMPLATE_NAME, &view)
2145 .expect("validation template renders");
2146 rendered.trim().to_string()
2147}
2148
2149fn render_review_payload_visible(review: &ReviewData) -> String {
2150 let view = ReviewVisibleView {
2151 decision: review_decision_label(review.decision),
2152 lenses: if review.lenses.is_empty() {
2153 None
2154 } else {
2155 Some(review.lenses.join(", "))
2156 },
2157 outcome_comment_url: review
2158 .outcome_comment_url
2159 .as_deref()
2160 .map(str::trim)
2161 .filter(|value| !value.is_empty()),
2162 findings: review
2163 .findings
2164 .iter()
2165 .map(|finding| ReviewFindingRow {
2166 id: table_cell(&finding.id),
2167 severity: finding_severity_label(finding.severity),
2168 disposition: finding_disposition_label(finding.disposition),
2169 summary: table_cell(&finding.summary),
2170 })
2171 .collect(),
2172 };
2173 let mut engine = Engine::builder().build();
2174 engine
2175 .register_template(REVIEW_VISIBLE_TEMPLATE_NAME, REVIEW_VISIBLE_TEMPLATE)
2176 .expect("review template registers");
2177 let rendered = engine
2178 .render(REVIEW_VISIBLE_TEMPLATE_NAME, &view)
2179 .expect("review template renders");
2180 rendered.trim().to_string()
2181}
2182
2183fn render_closeout_payload_visible(closeout: &CloseoutData) -> String {
2184 let override_block = closeout
2185 .non_required_check_override
2186 .as_ref()
2187 .filter(|value| !value.is_null());
2188 let override_reason = override_block.and_then(|block| {
2189 block
2190 .get("reason")
2191 .and_then(Value::as_str)
2192 .map(str::trim)
2193 .filter(|value| !value.is_empty())
2194 .map(str::to_string)
2195 });
2196 let override_failures = override_block.and_then(|block| {
2197 let items = block
2198 .get("observed_non_required_failures")
2199 .and_then(Value::as_array)
2200 .filter(|items| !items.is_empty())?;
2201 Some(
2202 items
2203 .iter()
2204 .map(visible_value)
2205 .filter(|value| !value.trim().is_empty())
2206 .collect::<Vec<_>>()
2207 .join(", "),
2208 )
2209 });
2210 let has_override = override_block.is_some();
2211
2212 let view = CloseoutVisibleView {
2213 final_status: closeout.final_status.trim(),
2214 approver: closeout
2215 .approval
2216 .approver
2217 .as_deref()
2218 .map(str::trim)
2219 .filter(|value| !value.is_empty()),
2220 approval_url: closeout
2221 .approval
2222 .comment_url
2223 .as_deref()
2224 .map(str::trim)
2225 .filter(|value| !value.is_empty()),
2226 final_validation_url: closeout
2227 .final_validation_url
2228 .as_deref()
2229 .map(str::trim)
2230 .filter(|value| !value.is_empty()),
2231 notes: closeout
2232 .notes
2233 .as_deref()
2234 .map(str::trim)
2235 .filter(|value| !value.is_empty()),
2236 has_override,
2237 override_reason,
2238 override_failures,
2239 linked_prs: closeout
2240 .linked_prs
2241 .iter()
2242 .map(|pr| {
2243 let pr_label = pr.url.as_deref().unwrap_or(&pr.pr_ref);
2244 let required_label = required_check_label(pr.required_state, pr.required_count);
2245 CloseoutPrRow {
2246 label: table_cell(pr_label),
2247 merge_sha: table_cell(pr.merge_sha.as_deref().unwrap_or("")),
2248 checks: check_status_label(pr.checks),
2249 required: table_cell(&required_label),
2250 non_required_failures: table_cell(&non_empty_join(
2251 &pr.non_required_failures,
2252 "none",
2253 )),
2254 }
2255 })
2256 .collect(),
2257 };
2258 let mut engine = Engine::builder().build();
2259 engine
2260 .register_template(CLOSEOUT_VISIBLE_TEMPLATE_NAME, CLOSEOUT_VISIBLE_TEMPLATE)
2261 .expect("closeout template registers");
2262 let rendered = engine
2263 .render(CLOSEOUT_VISIBLE_TEMPLATE_NAME, &view)
2264 .expect("closeout template renders");
2265 rendered.trim().to_string()
2266}
2267
2268fn task_row_status_label(status: TaskRowStatus) -> &'static str {
2269 match status {
2270 TaskRowStatus::Pending => "pending",
2271 TaskRowStatus::InProgress => "in-progress",
2272 TaskRowStatus::Done => "done",
2273 TaskRowStatus::Deferred => "deferred",
2274 TaskRowStatus::Blocked => "blocked",
2275 TaskRowStatus::Waived => "waived",
2276 }
2277}
2278
2279fn validation_command_status_label(status: ValidationCommandStatus) -> &'static str {
2280 match status {
2281 ValidationCommandStatus::Pass => "pass",
2282 ValidationCommandStatus::Fail => "fail",
2283 ValidationCommandStatus::Skipped => "skipped",
2284 }
2285}
2286
2287fn finding_severity_label(severity: FindingSeverity) -> &'static str {
2288 match severity {
2289 FindingSeverity::Blocker => "blocker",
2290 FindingSeverity::Major => "major",
2291 FindingSeverity::Minor => "minor",
2292 FindingSeverity::Nit => "nit",
2293 }
2294}
2295
2296fn finding_disposition_label(disposition: FindingDisposition) -> &'static str {
2297 match disposition {
2298 FindingDisposition::Fixed => "fixed",
2299 FindingDisposition::Residual => "residual",
2300 FindingDisposition::FollowUp => "follow-up",
2301 FindingDisposition::Deferred => "deferred",
2302 FindingDisposition::NoAction => "no-action",
2303 }
2304}
2305
2306fn check_status_label(status: CheckStatus) -> &'static str {
2307 match status {
2308 CheckStatus::Pass => "pass",
2309 CheckStatus::Fail => "fail",
2310 CheckStatus::None => "none",
2311 }
2312}
2313
2314fn required_check_label(state: Option<CheckStatus>, count: Option<u32>) -> String {
2339 match (state, count) {
2340 (Some(CheckStatus::Pass), Some(0)) => "none required".to_string(),
2341 (Some(CheckStatus::Pass), Some(n)) => format!("pass ({n})"),
2342 (Some(CheckStatus::Pass), None) => "pass".to_string(),
2343 (Some(CheckStatus::Fail), Some(n)) => format!("fail ({n})"),
2344 (Some(CheckStatus::Fail), None) => "fail".to_string(),
2345 (Some(CheckStatus::None), _) => "none".to_string(),
2346 (None, _) => "unknown".to_string(),
2347 }
2348}
2349
2350fn table_cell(value: &str) -> String {
2351 value.trim().replace('|', "\\|").replace('\n', "<br>")
2352}
2353
2354fn visible_value(value: &Value) -> String {
2355 match value {
2356 Value::String(text) => text.trim().to_string(),
2357 Value::Array(items) => items
2358 .iter()
2359 .map(visible_value)
2360 .collect::<Vec<_>>()
2361 .join(", "),
2362 Value::Object(_) => value.to_string(),
2363 Value::Null => String::new(),
2364 _ => value.to_string(),
2365 }
2366}
2367
2368#[derive(Debug, Clone, Serialize)]
2373pub struct StrictCloseoutGateResult {
2374 pub ready: bool,
2375 pub checks: Vec<CloseoutCheck>,
2376 pub blocked_codes: Vec<String>,
2378}
2379
2380#[derive(Debug, Clone)]
2381pub struct StrictCloseoutGateInput<'a> {
2382 pub profile: RecordProfile,
2383 pub approval: Option<&'a str>,
2384 pub linked_prs: &'a [LinkedPrEvidence],
2387 pub current_body: Option<&'a str>,
2391 pub expected_dashboard: Option<&'a str>,
2392 pub allow_non_required_check_failure: bool,
2398}
2399
2400pub fn evaluate_strict_closeout_gate(
2401 audit: &RecordAudit,
2402 input: StrictCloseoutGateInput<'_>,
2403) -> StrictCloseoutGateResult {
2404 let mut checks = Vec::new();
2405 let mut blocked_codes: Vec<String> = Vec::new();
2406
2407 let push_pass = |checks: &mut Vec<CloseoutCheck>, check: &str, detail: String| {
2408 checks.push(CloseoutCheck {
2409 check: check.to_string(),
2410 status: "pass".to_string(),
2411 detail,
2412 });
2413 };
2414 let push_fail = |checks: &mut Vec<CloseoutCheck>,
2415 blocked: &mut Vec<String>,
2416 check: &str,
2417 detail: String,
2418 code: &str| {
2419 checks.push(CloseoutCheck {
2420 check: check.to_string(),
2421 status: "fail".to_string(),
2422 detail,
2423 });
2424 blocked.push(code.to_string());
2425 };
2426
2427 for (role, label, code) in [
2428 ("source", "source snapshot", "source-missing"),
2429 ("plan", "plan snapshot", "plan-missing"),
2430 ] {
2431 if audit.evidence.contains_key(role) {
2432 push_pass(&mut checks, label, "present".to_string());
2433 } else {
2434 push_fail(
2435 &mut checks,
2436 &mut blocked_codes,
2437 label,
2438 "missing".to_string(),
2439 code,
2440 );
2441 }
2442 }
2443
2444 match audit.evidence.get("state") {
2445 Some(hit) => {
2446 let status = hit.status.as_deref();
2447 let parsed = hit
2448 .payload
2449 .as_ref()
2450 .and_then(|payload| payload.parse_state().ok());
2451 match status {
2452 Some(value) if value.eq_ignore_ascii_case("complete") => {
2453 let tasks_incomplete = parsed
2454 .as_ref()
2455 .map(|data| {
2456 data.tasks.iter().any(|task| {
2457 !matches!(
2458 task.status,
2459 TaskRowStatus::Done | TaskRowStatus::Deferred
2460 )
2461 })
2462 })
2463 .unwrap_or(false);
2464 if tasks_incomplete {
2465 push_fail(
2466 &mut checks,
2467 &mut blocked_codes,
2468 "execution state",
2469 "complete but tasks are not all done/deferred".to_string(),
2470 "state-tasks-incomplete",
2471 );
2472 } else {
2473 push_pass(&mut checks, "execution state", "complete".to_string());
2474 }
2475 }
2476 Some(value) => push_fail(
2477 &mut checks,
2478 &mut blocked_codes,
2479 "execution state",
2480 format!("latest state status is `{value}`"),
2481 "state-not-complete",
2482 ),
2483 None => push_fail(
2484 &mut checks,
2485 &mut blocked_codes,
2486 "execution state",
2487 "missing payload status".to_string(),
2488 "state-not-complete",
2489 ),
2490 }
2491 }
2492 None => push_fail(
2493 &mut checks,
2494 &mut blocked_codes,
2495 "execution state",
2496 "missing".to_string(),
2497 "state-missing",
2498 ),
2499 }
2500
2501 match audit.evidence.get("session") {
2502 Some(hit) => push_pass(
2503 &mut checks,
2504 "execution session",
2505 hit.url.as_deref().unwrap_or("present").to_string(),
2506 ),
2507 None => push_fail(
2508 &mut checks,
2509 &mut blocked_codes,
2510 "execution session",
2511 "missing role=session lifecycle record".to_string(),
2512 "session-missing",
2513 ),
2514 }
2515
2516 match audit.evidence.get("validation") {
2517 Some(hit) => match hit.status.as_deref() {
2518 Some("pass") => push_pass(&mut checks, "validation", "pass".to_string()),
2519 Some(value) => push_fail(
2520 &mut checks,
2521 &mut blocked_codes,
2522 "validation",
2523 format!("latest validation overall = `{value}`"),
2524 "validation-failed",
2525 ),
2526 None => push_fail(
2527 &mut checks,
2528 &mut blocked_codes,
2529 "validation",
2530 "missing payload status".to_string(),
2531 "validation-failed",
2532 ),
2533 },
2534 None => push_fail(
2535 &mut checks,
2536 &mut blocked_codes,
2537 "validation",
2538 "missing".to_string(),
2539 "validation-missing",
2540 ),
2541 }
2542
2543 match audit.evidence.get("review") {
2544 Some(hit) => {
2545 let parsed = hit.payload.as_ref().map(|payload| payload.parse_review());
2546 match parsed {
2547 Some(Ok(data)) => match data.decision {
2548 ReviewDecision::RequestChanges => push_fail(
2549 &mut checks,
2550 &mut blocked_codes,
2551 "review",
2552 "decision = request-changes".to_string(),
2553 "review-rejected",
2554 ),
2555 decision => {
2556 let unresolved = data.findings.iter().any(|finding| {
2557 matches!(finding.disposition, FindingDisposition::Residual)
2558 && matches!(
2559 finding.severity,
2560 FindingSeverity::Blocker | FindingSeverity::Major
2561 )
2562 });
2563 if unresolved {
2564 push_fail(
2565 &mut checks,
2566 &mut blocked_codes,
2567 "review",
2568 "unresolved blocker/major findings".to_string(),
2569 "review-unresolved-findings",
2570 );
2571 } else {
2572 let label = match decision {
2573 ReviewDecision::Approve => "approve",
2574 ReviewDecision::CommentsOnly => "comments-only",
2575 ReviewDecision::RequestChanges => unreachable!(),
2576 };
2577 push_pass(&mut checks, "review", format!("decision = {label}"));
2578 }
2579 }
2580 },
2581 Some(Err(err)) => push_fail(
2582 &mut checks,
2583 &mut blocked_codes,
2584 "review",
2585 format!("malformed review payload: {}", err.message),
2586 "review-rejected",
2587 ),
2588 None => push_fail(
2589 &mut checks,
2590 &mut blocked_codes,
2591 "review",
2592 "missing payload".to_string(),
2593 "review-missing",
2594 ),
2595 }
2596 }
2597 None => push_fail(
2598 &mut checks,
2599 &mut blocked_codes,
2600 "review",
2601 "missing".to_string(),
2602 "review-missing",
2603 ),
2604 }
2605
2606 let approval_text = input.approval.unwrap_or("").trim();
2607 if approval_text.is_empty() {
2608 push_fail(
2609 &mut checks,
2610 &mut blocked_codes,
2611 "close approval",
2612 "missing explicit approval".to_string(),
2613 "approval-missing",
2614 );
2615 } else {
2616 push_pass(&mut checks, "close approval", approval_text.to_string());
2617 }
2618
2619 if input.linked_prs.is_empty() {
2620 push_pass(&mut checks, "linked PRs", "none provided".to_string());
2621 } else {
2622 let mut unmerged: Vec<String> = Vec::new();
2623 let mut required_failed: Vec<String> = Vec::new();
2624 for pr in input.linked_prs {
2625 let sha = pr.merge_sha.as_deref().map(str::trim).unwrap_or("");
2626 if sha.is_empty() {
2627 unmerged.push(format!("{} (no merge_sha)", pr.pr_ref));
2628 continue;
2629 }
2630 match pr.required_state {
2631 Some(CheckStatus::Fail) => {
2632 required_failed.push(format!("{} (required checks failed)", pr.pr_ref));
2633 }
2634 Some(CheckStatus::Pass | CheckStatus::None) => {
2635 }
2639 None => {
2640 if matches!(pr.checks, CheckStatus::Fail)
2645 && !input.allow_non_required_check_failure
2646 {
2647 required_failed.push(format!(
2648 "{} (checks={:?}; required-state unknown)",
2649 pr.pr_ref, pr.checks
2650 ));
2651 }
2652 }
2653 }
2654 }
2655 if !unmerged.is_empty() {
2656 push_fail(
2657 &mut checks,
2658 &mut blocked_codes,
2659 "linked PRs",
2660 unmerged.join(", "),
2661 "linked-pr-not-merged",
2662 );
2663 }
2664 if !required_failed.is_empty() {
2665 push_fail(
2666 &mut checks,
2667 &mut blocked_codes,
2668 "linked PRs required checks",
2669 required_failed.join(", "),
2670 "linked-pr-checks-failed",
2671 );
2672 }
2673 if unmerged.is_empty() && required_failed.is_empty() {
2674 push_pass(
2675 &mut checks,
2676 "linked PRs",
2677 format!("{} merged", input.linked_prs.len()),
2678 );
2679 }
2680 }
2681
2682 if let (Some(current), Some(expected)) = (input.current_body, input.expected_dashboard) {
2683 let current_norm = normalize_for_dashboard_compare(current);
2684 let expected_norm = normalize_for_dashboard_compare(expected);
2685 if current_norm.contains(&expected_norm) {
2686 push_pass(&mut checks, "dashboard", "matches canonical".to_string());
2687 } else {
2688 push_fail(
2689 &mut checks,
2690 &mut blocked_codes,
2691 "dashboard",
2692 "dashboard differs from recomputed canonical".to_string(),
2693 "dashboard-out-of-date",
2694 );
2695 }
2696 }
2697
2698 let ready = checks.iter().all(|check| check.status == "pass");
2699 StrictCloseoutGateResult {
2700 ready,
2701 checks,
2702 blocked_codes,
2703 }
2704}
2705
2706fn normalize_for_dashboard_compare(text: &str) -> String {
2707 text.lines()
2708 .map(str::trim_end)
2709 .collect::<Vec<_>>()
2710 .join("\n")
2711}
2712
2713#[cfg(test)]
2714mod sprint3_tests {
2715 use super::*;
2716 use serde_json::json;
2717
2718 fn build_audit_with_evidence(comments: Vec<(serde_json::Value, &str)>) -> RecordAudit {
2719 let payload = json!({
2720 "comments": comments
2721 .into_iter()
2722 .map(|(body, url)| json!({"body": body, "url": url, "created_at": "2026-05-23T08:00:00Z"}))
2723 .collect::<Vec<_>>()
2724 });
2725 audit_record(None, &payload.to_string(), None).expect("audit ok")
2726 }
2727
2728 fn v2_body(role: &str, data: Value) -> Value {
2729 let envelope = json!({
2730 "schema": PAYLOAD_SCHEMA_V2,
2731 "role": role,
2732 "profile": "tracking",
2733 "data": data,
2734 });
2735 let payload_json = serde_json::to_string(&envelope).expect("serialize");
2736 json!(format!(
2737 "<!-- plan-issue-record:v2 role={role} profile=tracking -->\n\n```{PAYLOAD_FENCE_INFO}\n{payload_json}\n```\n",
2738 ))
2739 }
2740
2741 #[test]
2742 fn audit_treats_v2_marker_without_payload_fence_as_payload_none() {
2743 let body_only_marker = json!(
2746 "<!-- plan-issue-record:v2 role=session profile=tracking -->\n\n## Execution Session\n\nfreeform notes, no payload\n"
2747 );
2748 let audit = build_audit_with_evidence(vec![(
2749 body_only_marker,
2750 "https://github.com/owner/repo/issues/1#issuecomment-session",
2751 )]);
2752 let session = audit
2753 .evidence
2754 .get("session")
2755 .expect("session evidence registered");
2756 assert!(session.payload.is_none(), "payload should be None");
2757 assert_eq!(audit.recognized_count, 1);
2758 }
2759
2760 #[test]
2761 fn audit_strict_fails_on_malformed_payload() {
2762 let body = json!(
2765 "<!-- plan-issue-record:v2 role=state profile=tracking -->\n\n```plan-issue-record-payload\n{not valid json\n```\n"
2766 );
2767 let payload = json!({
2768 "comments": [{
2769 "body": body,
2770 "url": "https://github.com/owner/repo/issues/1#issuecomment-bad",
2771 "created_at": "2026-05-23T08:00:00Z",
2772 }]
2773 });
2774 let err = audit_record(None, &payload.to_string(), None)
2775 .expect_err("malformed payload should fail audit");
2776 assert!(
2777 err.contains("malformed payload"),
2778 "error should mention malformed payload: {err}"
2779 );
2780 }
2781
2782 #[test]
2783 fn strict_gate_passes_when_all_v2_evidence_complete_and_merged() {
2784 let state = v2_body(
2785 "state",
2786 json!({
2787 "status": "complete",
2788 "target_scope": "scope",
2789 "tasks": [
2790 {"id": "1.1", "status": "done", "title": "x"},
2791 {"id": "1.2", "status": "deferred", "title": "y"},
2792 ],
2793 "prs": [{"ref": "owner/repo#1", "url": "u", "status": "merged"}],
2794 "blockers": [],
2795 "links": {},
2796 }),
2797 );
2798 let validation = v2_body(
2799 "validation",
2800 json!({"overall": "pass", "commands": [], "waivers": []}),
2801 );
2802 let review = v2_body(
2803 "review",
2804 json!({
2805 "decision": "approve",
2806 "lenses": ["testing"],
2807 "findings": [],
2808 }),
2809 );
2810 let source = v2_body("source", json!({"path": "p", "commit": "c"}));
2811 let plan = v2_body("plan", json!({"path": "p", "commit": "c"}));
2812 let session = v2_body("session", json!({"summary": "session complete"}));
2813 let audit = build_audit_with_evidence(vec![
2814 (source, "u-src"),
2815 (plan, "u-plan"),
2816 (state, "u-state"),
2817 (session, "u-session"),
2818 (validation, "u-val"),
2819 (review, "u-rev"),
2820 ]);
2821
2822 let linked_prs = vec![LinkedPrEvidence {
2823 pr_ref: "owner/repo#1".to_string(),
2824 url: Some("https://github.com/owner/repo/pull/1".to_string()),
2825 merge_sha: Some("abcdef1234567890".to_string()),
2826 checks: CheckStatus::Pass,
2827 required_state: Some(CheckStatus::Pass),
2828 required_count: Some(1),
2829 non_required_failures: Vec::new(),
2830 }];
2831 let result = evaluate_strict_closeout_gate(
2832 &audit,
2833 StrictCloseoutGateInput {
2834 profile: RecordProfile::Tracking,
2835 approval: Some("https://github.com/owner/repo/issues/1#issuecomment-9"),
2836 linked_prs: &linked_prs,
2837 current_body: None,
2838 expected_dashboard: None,
2839 allow_non_required_check_failure: false,
2840 },
2841 );
2842 assert!(result.ready, "gate should pass: {:?}", result.checks);
2843 assert!(result.blocked_codes.is_empty());
2844 }
2845
2846 #[test]
2847 fn strict_gate_blocks_when_state_not_complete() {
2848 let state = v2_body(
2849 "state",
2850 json!({"status": "in-progress", "target_scope": "s", "tasks": [], "prs": [], "blockers": [], "links": {}}),
2851 );
2852 let source = v2_body("source", json!({"path": "p", "commit": "c"}));
2853 let plan = v2_body("plan", json!({"path": "p", "commit": "c"}));
2854 let session = v2_body("session", json!({"summary": "session complete"}));
2855 let validation = v2_body("validation", json!({"overall": "pass"}));
2856 let review = v2_body("review", json!({"decision": "approve"}));
2857 let audit = build_audit_with_evidence(vec![
2858 (source, "a"),
2859 (plan, "b"),
2860 (state, "c"),
2861 (session, "d"),
2862 (validation, "d"),
2863 (review, "e"),
2864 ]);
2865 let result = evaluate_strict_closeout_gate(
2866 &audit,
2867 StrictCloseoutGateInput {
2868 profile: RecordProfile::Tracking,
2869 approval: Some("ok"),
2870 linked_prs: &[],
2871 current_body: None,
2872 expected_dashboard: None,
2873 allow_non_required_check_failure: false,
2874 },
2875 );
2876 assert!(!result.ready);
2877 assert!(
2878 result
2879 .blocked_codes
2880 .iter()
2881 .any(|c| c == "state-not-complete"),
2882 "{:?}",
2883 result.blocked_codes
2884 );
2885 }
2886
2887 #[test]
2888 fn strict_gate_blocks_when_review_rejected_or_unresolved() {
2889 let source = v2_body("source", json!({"path": "p", "commit": "c"}));
2890 let plan = v2_body("plan", json!({"path": "p", "commit": "c"}));
2891 let state = v2_body(
2892 "state",
2893 json!({"status": "complete", "tasks": [], "prs": [], "blockers": [], "links": {}}),
2894 );
2895 let session = v2_body("session", json!({"summary": "session complete"}));
2896 let validation = v2_body("validation", json!({"overall": "pass"}));
2897 let review_rejected = v2_body("review", json!({"decision": "request-changes"}));
2898 let audit_rej = build_audit_with_evidence(vec![
2899 (source.clone(), "a"),
2900 (plan.clone(), "b"),
2901 (state.clone(), "c"),
2902 (session.clone(), "d"),
2903 (validation.clone(), "d"),
2904 (review_rejected, "e"),
2905 ]);
2906 let res_rej = evaluate_strict_closeout_gate(
2907 &audit_rej,
2908 StrictCloseoutGateInput {
2909 profile: RecordProfile::Tracking,
2910 approval: Some("ok"),
2911 linked_prs: &[],
2912 current_body: None,
2913 expected_dashboard: None,
2914 allow_non_required_check_failure: false,
2915 },
2916 );
2917 assert!(res_rej.blocked_codes.iter().any(|c| c == "review-rejected"));
2918
2919 let review_unresolved = v2_body(
2920 "review",
2921 json!({
2922 "decision": "approve",
2923 "findings": [
2924 {"id": "F1", "severity": "blocker", "disposition": "residual", "summary": "x"}
2925 ]
2926 }),
2927 );
2928 let audit_un = build_audit_with_evidence(vec![
2929 (source, "a"),
2930 (plan, "b"),
2931 (state, "c"),
2932 (session, "d"),
2933 (validation, "d"),
2934 (review_unresolved, "e"),
2935 ]);
2936 let res_un = evaluate_strict_closeout_gate(
2937 &audit_un,
2938 StrictCloseoutGateInput {
2939 profile: RecordProfile::Tracking,
2940 approval: Some("ok"),
2941 linked_prs: &[],
2942 current_body: None,
2943 expected_dashboard: None,
2944 allow_non_required_check_failure: false,
2945 },
2946 );
2947 assert!(
2948 res_un
2949 .blocked_codes
2950 .iter()
2951 .any(|c| c == "review-unresolved-findings")
2952 );
2953 }
2954
2955 #[test]
2956 fn strict_gate_blocks_when_linked_pr_missing_merge_sha() {
2957 let source = v2_body("source", json!({"path": "p", "commit": "c"}));
2958 let plan = v2_body("plan", json!({"path": "p", "commit": "c"}));
2959 let state = v2_body(
2960 "state",
2961 json!({"status": "complete", "tasks": [], "prs": [], "blockers": [], "links": {}}),
2962 );
2963 let session = v2_body("session", json!({"summary": "session complete"}));
2964 let validation = v2_body("validation", json!({"overall": "pass"}));
2965 let review = v2_body("review", json!({"decision": "approve"}));
2966 let audit = build_audit_with_evidence(vec![
2967 (source, "a"),
2968 (plan, "b"),
2969 (state, "c"),
2970 (session, "d"),
2971 (validation, "d"),
2972 (review, "e"),
2973 ]);
2974 let linked = vec![LinkedPrEvidence {
2975 pr_ref: "owner/repo#1".to_string(),
2976 url: None,
2977 merge_sha: None,
2978 checks: CheckStatus::Pass,
2979 required_state: Some(CheckStatus::Pass),
2980 required_count: Some(0),
2981 non_required_failures: Vec::new(),
2982 }];
2983 let res = evaluate_strict_closeout_gate(
2984 &audit,
2985 StrictCloseoutGateInput {
2986 profile: RecordProfile::Tracking,
2987 approval: Some("ok"),
2988 linked_prs: &linked,
2989 current_body: None,
2990 expected_dashboard: None,
2991 allow_non_required_check_failure: false,
2992 },
2993 );
2994 assert!(
2995 res.blocked_codes
2996 .iter()
2997 .any(|c| c == "linked-pr-not-merged")
2998 );
2999 }
3000
3001 #[test]
3002 fn strict_gate_passes_with_non_required_failure_when_required_pass() {
3003 let audit = build_audit_with_evidence(vec![
3006 (v2_body("source", json!({"path": "p", "commit": "c"})), "a"),
3007 (v2_body("plan", json!({"path": "p", "commit": "c"})), "b"),
3008 (
3009 v2_body(
3010 "state",
3011 json!({"status": "complete", "tasks": [], "prs": [], "blockers": [], "links": {}}),
3012 ),
3013 "c",
3014 ),
3015 (
3016 v2_body("session", json!({"summary": "session complete"})),
3017 "d",
3018 ),
3019 (v2_body("validation", json!({"overall": "pass"})), "d"),
3020 (v2_body("review", json!({"decision": "approve"})), "e"),
3021 ]);
3022 let linked = vec![LinkedPrEvidence {
3023 pr_ref: "owner/repo#1".to_string(),
3024 url: None,
3025 merge_sha: Some("abc".to_string()),
3026 checks: CheckStatus::Fail,
3027 required_state: Some(CheckStatus::Pass),
3028 required_count: Some(0),
3029 non_required_failures: vec!["scripts/ci/all.sh".to_string()],
3030 }];
3031 let res = evaluate_strict_closeout_gate(
3032 &audit,
3033 StrictCloseoutGateInput {
3034 profile: RecordProfile::Tracking,
3035 approval: Some("ok"),
3036 linked_prs: &linked,
3037 current_body: None,
3038 expected_dashboard: None,
3039 allow_non_required_check_failure: false,
3040 },
3041 );
3042 assert!(res.ready, "blocked: {:?}", res.blocked_codes);
3043 assert!(res.blocked_codes.is_empty(), "{:?}", res.blocked_codes);
3044 }
3045
3046 #[test]
3047 fn strict_gate_emits_linked_pr_checks_failed_when_required_fail() {
3048 let audit = build_audit_with_evidence(vec![
3049 (v2_body("source", json!({"path": "p", "commit": "c"})), "a"),
3050 (v2_body("plan", json!({"path": "p", "commit": "c"})), "b"),
3051 (
3052 v2_body(
3053 "state",
3054 json!({"status": "complete", "tasks": [], "prs": [], "blockers": [], "links": {}}),
3055 ),
3056 "c",
3057 ),
3058 (
3059 v2_body("session", json!({"summary": "session complete"})),
3060 "d",
3061 ),
3062 (v2_body("validation", json!({"overall": "pass"})), "d"),
3063 (v2_body("review", json!({"decision": "approve"})), "e"),
3064 ]);
3065 let linked = vec![LinkedPrEvidence {
3066 pr_ref: "owner/repo#1".to_string(),
3067 url: None,
3068 merge_sha: Some("abc".to_string()),
3069 checks: CheckStatus::Fail,
3070 required_state: Some(CheckStatus::Fail),
3071 required_count: Some(2),
3072 non_required_failures: Vec::new(),
3073 }];
3074 let res = evaluate_strict_closeout_gate(
3075 &audit,
3076 StrictCloseoutGateInput {
3077 profile: RecordProfile::Tracking,
3078 approval: Some("ok"),
3079 linked_prs: &linked,
3080 current_body: None,
3081 expected_dashboard: None,
3082 allow_non_required_check_failure: false,
3083 },
3084 );
3085 assert!(
3086 res.blocked_codes
3087 .iter()
3088 .any(|c| c == "linked-pr-checks-failed"),
3089 "expected linked-pr-checks-failed, got {:?}",
3090 res.blocked_codes
3091 );
3092 assert!(
3093 !res.blocked_codes
3094 .iter()
3095 .any(|c| c == "linked-pr-not-merged"),
3096 "must not collapse into linked-pr-not-merged"
3097 );
3098 }
3099
3100 #[test]
3101 fn strict_gate_override_unblocks_unknown_required_state_aggregate_fail() {
3102 let audit = build_audit_with_evidence(vec![
3103 (v2_body("source", json!({"path": "p", "commit": "c"})), "a"),
3104 (v2_body("plan", json!({"path": "p", "commit": "c"})), "b"),
3105 (
3106 v2_body(
3107 "state",
3108 json!({"status": "complete", "tasks": [], "prs": [], "blockers": [], "links": {}}),
3109 ),
3110 "c",
3111 ),
3112 (
3113 v2_body("session", json!({"summary": "session complete"})),
3114 "d",
3115 ),
3116 (v2_body("validation", json!({"overall": "pass"})), "d"),
3117 (v2_body("review", json!({"decision": "approve"})), "e"),
3118 ]);
3119 let linked = vec![LinkedPrEvidence {
3120 pr_ref: "owner/repo#1".to_string(),
3121 url: None,
3122 merge_sha: Some("abc".to_string()),
3123 checks: CheckStatus::Fail,
3124 required_state: None,
3125 required_count: None,
3126 non_required_failures: vec!["opt-in/lint".to_string()],
3127 }];
3128
3129 let blocked = evaluate_strict_closeout_gate(
3130 &audit,
3131 StrictCloseoutGateInput {
3132 profile: RecordProfile::Tracking,
3133 approval: Some("ok"),
3134 linked_prs: &linked,
3135 current_body: None,
3136 expected_dashboard: None,
3137 allow_non_required_check_failure: false,
3138 },
3139 );
3140 assert!(
3141 blocked
3142 .blocked_codes
3143 .iter()
3144 .any(|c| c == "linked-pr-checks-failed"),
3145 "conservative path blocks: {:?}",
3146 blocked.blocked_codes
3147 );
3148
3149 let unblocked = evaluate_strict_closeout_gate(
3150 &audit,
3151 StrictCloseoutGateInput {
3152 profile: RecordProfile::Tracking,
3153 approval: Some("ok"),
3154 linked_prs: &linked,
3155 current_body: None,
3156 expected_dashboard: None,
3157 allow_non_required_check_failure: true,
3158 },
3159 );
3160 assert!(unblocked.ready, "{:?}", unblocked.blocked_codes);
3161 }
3162
3163 #[test]
3164 fn strict_gate_blocks_when_approval_empty() {
3165 let source = v2_body("source", json!({"path": "p", "commit": "c"}));
3166 let plan = v2_body("plan", json!({"path": "p", "commit": "c"}));
3167 let state = v2_body(
3168 "state",
3169 json!({"status": "complete", "tasks": [], "prs": [], "blockers": [], "links": {}}),
3170 );
3171 let session = v2_body("session", json!({"summary": "session complete"}));
3172 let validation = v2_body("validation", json!({"overall": "pass"}));
3173 let review = v2_body("review", json!({"decision": "approve"}));
3174 let audit = build_audit_with_evidence(vec![
3175 (source, "a"),
3176 (plan, "b"),
3177 (state, "c"),
3178 (session, "d"),
3179 (validation, "d"),
3180 (review, "e"),
3181 ]);
3182 let res = evaluate_strict_closeout_gate(
3183 &audit,
3184 StrictCloseoutGateInput {
3185 profile: RecordProfile::Tracking,
3186 approval: Some(" "),
3187 linked_prs: &[],
3188 current_body: None,
3189 expected_dashboard: None,
3190 allow_non_required_check_failure: false,
3191 },
3192 );
3193 assert!(res.blocked_codes.iter().any(|c| c == "approval-missing"));
3194 }
3195
3196 #[test]
3197 fn render_record_post_comment_emits_marker_and_hidden_payload_carrier() {
3198 let body = render_record_post_comment(
3199 RecordProfile::Tracking,
3200 LifecycleCommentKind::State,
3201 json!({"status": "complete", "tasks": [], "prs": []}),
3202 Some("session summary"),
3203 Some("2026-05-23T08:42:11Z"),
3204 )
3205 .expect("render");
3206 assert!(
3207 body.starts_with("<!-- plan-issue-record:v2 role=state profile=tracking -->"),
3208 "{body}"
3209 );
3210 assert!(
3211 !body.contains(&format!("```{PAYLOAD_FENCE_INFO}")),
3212 "{body}"
3213 );
3214 assert!(body.contains(PAYLOAD_COMMENT_PREFIX), "{body}");
3215 let payload = extract_payload(&body).expect("payload");
3216 assert_eq!(payload.schema, PAYLOAD_SCHEMA_V2);
3217 assert_eq!(payload.role, PayloadRole::State);
3218 assert!(body.contains("session summary"), "{body}");
3219 }
3220
3221 fn state_summary_with_task_ledger() -> &'static str {
3222 "## Execution State\n\n\
3223 - Status: in-progress\n\n\
3224 ## Task Ledger\n\n\
3225 | ID | Status | Task |\n\
3226 | --- | --- | --- |\n\
3227 | 1.1 | pending | Demo task |\n"
3228 }
3229
3230 fn render_state_with_display(display: TaskLedgerDisplay) -> String {
3231 render_record_post_comment_with_display(
3232 RecordProfile::Tracking,
3233 LifecycleCommentKind::State,
3234 json!({
3235 "status": "in-progress",
3236 "tasks": [{"id": "1.1", "status": "pending", "title": "Demo task"}],
3237 "prs": [],
3238 "blockers": [],
3239 "links": {}
3240 }),
3241 Some(state_summary_with_task_ledger()),
3242 None,
3243 display,
3244 )
3245 .expect("render")
3246 }
3247
3248 #[test]
3249 fn task_ledger_display_open_emits_open_fold() {
3250 let body = render_state_with_display(TaskLedgerDisplay::Open);
3251 assert!(body.contains("<details open>"), "{body}");
3252 assert!(
3253 body.contains("<summary>Show task ledger</summary>"),
3254 "{body}"
3255 );
3256 assert!(body.contains("| 1.1 | pending | Demo task |"), "{body}");
3257 }
3258
3259 #[test]
3260 fn task_ledger_display_collapsed_emits_closed_fold() {
3261 let body = render_state_with_display(TaskLedgerDisplay::Collapsed);
3262 assert!(body.contains("<details>"), "{body}");
3263 assert!(!body.contains("<details open>"), "{body}");
3264 }
3265
3266 #[test]
3267 fn task_ledger_display_expanded_emits_no_fold() {
3268 let body = render_state_with_display(TaskLedgerDisplay::Expanded);
3269 assert!(!body.contains("<details"), "{body}");
3270 assert!(body.contains("| 1.1 | pending | Demo task |"), "{body}");
3271 }
3272
3273 #[test]
3274 fn render_record_post_comment_synthesizes_validation_review_and_closeout() {
3275 let validation = render_record_post_comment(
3276 RecordProfile::Tracking,
3277 LifecycleCommentKind::Validation,
3278 json!({
3279 "overall": "pass",
3280 "commands": [{"command": "cargo test", "status": "pass", "evidence": "ok"}],
3281 "waivers": []
3282 }),
3283 None,
3284 None,
3285 )
3286 .expect("validation render");
3287 assert!(validation.contains("- Overall: pass"), "{validation}");
3288 assert!(
3289 validation.contains("| cargo test | pass | ok |"),
3290 "{validation}"
3291 );
3292 assert!(validation.contains(PAYLOAD_COMMENT_PREFIX), "{validation}");
3293
3294 let review = render_record_post_comment(
3295 RecordProfile::Tracking,
3296 LifecycleCommentKind::Review,
3297 json!({
3298 "decision": "approve",
3299 "lenses": ["testing", "maintainability"],
3300 "findings": [{
3301 "id": "F1",
3302 "severity": "minor",
3303 "disposition": "fixed",
3304 "summary": "covered"
3305 }],
3306 "outcome_comment_url": "https://example.test/review"
3307 }),
3308 None,
3309 None,
3310 )
3311 .expect("review render");
3312 assert!(review.contains("- Decision: approve"), "{review}");
3313 assert!(
3314 review.contains("- Lenses: testing, maintainability"),
3315 "{review}"
3316 );
3317 assert!(
3318 review.contains("| F1 | minor | fixed | covered |"),
3319 "{review}"
3320 );
3321
3322 let closeout = render_record_post_comment(
3323 RecordProfile::Tracking,
3324 LifecycleCommentKind::Closeout,
3325 json!({
3326 "final_status": "complete",
3327 "approval": {"comment_url": "https://example.test/approval"},
3328 "linked_prs": [{
3329 "ref": "owner/repo#1",
3330 "url": "https://example.test/pr/1",
3331 "merge_sha": "abc123",
3332 "checks": "pass",
3333 "required_state": "pass",
3334 "required_count": 2,
3335 "non_required_failures": []
3336 }],
3337 "non_required_check_override": {
3338 "reason": "operator accepted non-required lint",
3339 "observed_non_required_failures": ["owner/repo#1: opt-in/lint"]
3340 },
3341 "notes": "closed"
3342 }),
3343 Some("Closeout summary."),
3344 None,
3345 )
3346 .expect("closeout render");
3347 assert!(closeout.contains("Closeout summary."), "{closeout}");
3348 assert!(closeout.contains("- Final status: complete"), "{closeout}");
3349 assert!(
3350 closeout.contains("| https://example.test/pr/1 | abc123 | pass | pass (2) | none |"),
3351 "{closeout}"
3352 );
3353 assert!(
3354 closeout.contains("- Reason: operator accepted non-required lint"),
3355 "{closeout}"
3356 );
3357 assert!(
3358 closeout.contains("- Observed failures: owner/repo#1: opt-in/lint"),
3359 "{closeout}"
3360 );
3361
3362 let no_pr_closeout = render_record_post_comment(
3363 RecordProfile::Tracking,
3364 LifecycleCommentKind::Closeout,
3365 json!({
3366 "final_status": "complete",
3367 "approval": {"comment_url": "https://example.test/approval"},
3368 "linked_prs": [],
3369 "notes": "closed without linked PR"
3370 }),
3371 Some("Closeout summary."),
3372 None,
3373 )
3374 .expect("closeout render");
3375 assert!(
3376 no_pr_closeout.contains("- Linked PRs: none"),
3377 "{no_pr_closeout}"
3378 );
3379 }
3380
3381 #[test]
3382 fn render_record_post_comment_rejects_source_or_plan() {
3383 let err = render_record_post_comment(
3384 RecordProfile::Tracking,
3385 LifecycleCommentKind::Source,
3386 json!({}),
3387 None,
3388 None,
3389 )
3390 .expect_err("must reject source");
3391 assert!(err.contains("source"), "{err}");
3392 }
3393
3394 #[test]
3395 fn render_record_snapshot_comment_includes_details_and_hidden_payload() {
3396 let snapshot = SnapshotData {
3397 path: "docs/plans/sample/sample-plan.md".to_string(),
3398 commit: "abc1234".to_string(),
3399 title: Some("Sample Plan".to_string()),
3400 summary: Some("One-liner".to_string()),
3401 };
3402 let body = render_record_snapshot_comment(
3403 RecordProfile::Tracking,
3404 LifecycleCommentKind::Plan,
3405 &snapshot,
3406 "# Sample Plan\n\nbody...\n",
3407 Some("2026-05-23T08:42:11Z"),
3408 )
3409 .expect("render");
3410 assert!(
3411 body.contains("- Path: `docs/plans/sample/sample-plan.md`"),
3412 "{body}"
3413 );
3414 assert!(body.contains("- Commit: `abc1234`"), "{body}");
3415 assert!(body.contains("- Summary: One-liner"), "{body}");
3416 assert!(body.contains("<details>"), "{body}");
3417 assert!(
3418 !body.contains(&format!("```{PAYLOAD_FENCE_INFO}")),
3419 "{body}"
3420 );
3421 assert!(body.contains(PAYLOAD_COMMENT_PREFIX), "{body}");
3422 let payload = extract_payload(&body).expect("payload");
3423 assert_eq!(payload.schema, PAYLOAD_SCHEMA_V2);
3424 assert_eq!(payload.role, PayloadRole::Plan);
3425 }
3426
3427 #[test]
3428 fn extract_payload_ignores_payload_markers_inside_snapshot_details() {
3429 let nested_payload = RecordPayload {
3430 schema: PAYLOAD_SCHEMA_V2.to_string(),
3431 role: PayloadRole::State,
3432 profile: PayloadProfile::Tracking,
3433 updated_at: None,
3434 data: json!({"status": "complete"}),
3435 };
3436 let nested_carrier = render_payload_carrier(&nested_payload).expect("nested carrier");
3437 let snapshot = SnapshotData {
3438 path: "docs/plans/sample/sample-discussion-source.md".to_string(),
3439 commit: "abc1234".to_string(),
3440 title: None,
3441 summary: None,
3442 };
3443 let body = render_record_snapshot_comment(
3444 RecordProfile::Tracking,
3445 LifecycleCommentKind::Source,
3446 &snapshot,
3447 &format!(
3448 "# Source\n\n{nested_carrier}\n\n```{PAYLOAD_FENCE_INFO}\n{{not valid json}}\n```\n"
3449 ),
3450 None,
3451 )
3452 .expect("render");
3453
3454 let payload = extract_payload(&body).expect("payload");
3455 assert_eq!(payload.role, PayloadRole::Source);
3456 }
3457
3458 #[test]
3459 fn required_check_label_emits_five_distinct_branches() {
3460 assert_eq!(
3464 required_check_label(Some(CheckStatus::Pass), Some(0)),
3465 "none required"
3466 );
3467
3468 assert_eq!(
3470 required_check_label(Some(CheckStatus::Pass), Some(3)),
3471 "pass (3)"
3472 );
3473
3474 assert_eq!(required_check_label(Some(CheckStatus::Pass), None), "pass");
3477
3478 assert_eq!(
3480 required_check_label(Some(CheckStatus::Fail), Some(2)),
3481 "fail (2)"
3482 );
3483 assert_eq!(required_check_label(Some(CheckStatus::Fail), None), "fail");
3484
3485 assert_eq!(
3488 required_check_label(Some(CheckStatus::None), Some(0)),
3489 "none"
3490 );
3491 assert_eq!(required_check_label(Some(CheckStatus::None), None), "none");
3492
3493 assert_eq!(required_check_label(None, None), "unknown");
3495 assert_eq!(required_check_label(None, Some(0)), "unknown");
3496 }
3497
3498 fn golden_dump(label: &str, body: &str) {
3505 if std::env::var("LIFECYCLE_RECORD_GOLDEN_DUMP").is_ok() {
3506 eprintln!("--- BEGIN {label} ---\n{body}\n--- END {label} ---");
3507 }
3508 }
3509
3510 #[test]
3511 fn golden_state_post_comment_locks_full_wire_shape() {
3512 let body = render_record_post_comment(
3513 RecordProfile::Tracking,
3514 LifecycleCommentKind::State,
3515 json!({
3516 "status": "complete",
3517 "target_scope": "PR #599 follow-ups",
3518 "current": "delivering snapshot tests",
3519 "next_action": "open closeout comment",
3520 "tasks": [
3521 {"id": "1.1", "status": "done", "title": "ship URL parser"},
3522 {"id": "1.2", "status": "in-progress", "title": "ship snapshots"},
3523 ],
3524 "prs": [{"ref": "owner/repo#1", "url": "https://example.test/pr/1", "status": "merged"}],
3525 "blockers": [],
3526 "links": {},
3527 }),
3528 None,
3529 Some("2026-05-23T08:42:11Z"),
3530 )
3531 .expect("state render");
3532 golden_dump("state", &body);
3533 assert_eq!(
3534 body,
3535 include_str!("snapshots/state_post_comment.md"),
3536 "state post-comment shape drifted; run with LIFECYCLE_RECORD_GOLDEN_DUMP=1 to dump"
3537 );
3538 }
3539
3540 #[test]
3541 fn golden_validation_post_comment_locks_full_wire_shape() {
3542 let body = render_record_post_comment(
3543 RecordProfile::Tracking,
3544 LifecycleCommentKind::Validation,
3545 json!({
3546 "overall": "pass",
3547 "commands": [
3548 {"command": "cargo test --workspace", "status": "pass", "evidence": "all green"},
3549 {"command": "scripts/ci/local-fast.sh", "status": "pass", "evidence": "ok"},
3550 ],
3551 "waivers": [],
3552 }),
3553 None,
3554 Some("2026-05-23T08:42:11Z"),
3555 )
3556 .expect("validation render");
3557 golden_dump("validation", &body);
3558 assert_eq!(
3559 body,
3560 include_str!("snapshots/validation_post_comment.md"),
3561 "validation post-comment shape drifted"
3562 );
3563 }
3564
3565 #[test]
3566 fn golden_review_post_comment_locks_full_wire_shape() {
3567 let body = render_record_post_comment(
3568 RecordProfile::Tracking,
3569 LifecycleCommentKind::Review,
3570 json!({
3571 "decision": "approve",
3572 "lenses": ["testing", "maintainability"],
3573 "findings": [
3574 {"id": "F1", "severity": "minor", "disposition": "fixed", "summary": "covered"},
3575 ],
3576 "outcome_comment_url": "https://example.test/review",
3577 }),
3578 None,
3579 Some("2026-05-23T08:42:11Z"),
3580 )
3581 .expect("review render");
3582 golden_dump("review", &body);
3583 assert_eq!(
3584 body,
3585 include_str!("snapshots/review_post_comment.md"),
3586 "review post-comment shape drifted"
3587 );
3588 }
3589
3590 #[test]
3591 fn golden_closeout_post_comment_locks_full_wire_shape() {
3592 let body = render_record_post_comment(
3593 RecordProfile::Tracking,
3594 LifecycleCommentKind::Closeout,
3595 json!({
3596 "final_status": "complete",
3597 "approval": {"comment_url": "https://example.test/approval"},
3598 "linked_prs": [{
3599 "ref": "owner/repo#1",
3600 "url": "https://example.test/pr/1",
3601 "merge_sha": "abc1234",
3602 "checks": "pass",
3603 "required_state": "pass",
3604 "required_count": 2,
3605 "non_required_failures": []
3606 }],
3607 "notes": "shipped"
3608 }),
3609 Some("Closeout summary."),
3610 Some("2026-05-23T08:42:11Z"),
3611 )
3612 .expect("closeout render");
3613 golden_dump("closeout", &body);
3614 assert_eq!(
3615 body,
3616 include_str!("snapshots/closeout_post_comment.md"),
3617 "closeout post-comment shape drifted"
3618 );
3619 }
3620}