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
1332pub(crate) fn raw_payload_marker_count(body: &str) -> usize {
1333 raw_payload_comment_marker_count(body) + raw_payload_fence_marker_count(body)
1334}
1335
1336fn raw_payload_comment_marker_count(body: &str) -> usize {
1337 body.lines()
1338 .filter(|line| {
1339 line.trim()
1340 .strip_prefix("<!--")
1341 .and_then(|value| value.strip_suffix("-->"))
1342 .is_some_and(|inner| inner.trim().starts_with(PAYLOAD_COMMENT_PREFIX))
1343 })
1344 .count()
1345}
1346
1347fn raw_payload_fence_marker_count(body: &str) -> usize {
1348 body.lines()
1349 .filter(|line| {
1350 line.trim_start()
1351 .strip_prefix("```")
1352 .is_some_and(|rest| rest.trim() == PAYLOAD_FENCE_INFO)
1353 })
1354 .count()
1355}
1356
1357fn collect_payload_comment_carriers(body: &str) -> Result<Vec<String>, PayloadError> {
1358 let mut out = Vec::new();
1359 let mut details_depth = 0usize;
1360 for line in body.lines() {
1361 if update_details_depth(line, &mut details_depth) {
1362 continue;
1363 }
1364 if details_depth > 0 {
1365 continue;
1366 }
1367 let trimmed = line.trim();
1368 let Some(inner) = trimmed
1369 .strip_prefix("<!--")
1370 .and_then(|value| value.strip_suffix("-->"))
1371 else {
1372 continue;
1373 };
1374 let inner = inner.trim();
1375 let Some(encoded) = inner.strip_prefix(PAYLOAD_COMMENT_PREFIX) else {
1376 continue;
1377 };
1378 let payload = decode_hex(encoded.trim()).map_err(|err| {
1379 PayloadError::new(
1380 PayloadErrorKind::InvalidJson,
1381 format!("invalid hidden payload carrier: {err}"),
1382 )
1383 })?;
1384 let payload = String::from_utf8(payload).map_err(|err| {
1385 PayloadError::new(
1386 PayloadErrorKind::InvalidJson,
1387 format!("hidden payload carrier is not UTF-8: {err}"),
1388 )
1389 })?;
1390 out.push(payload);
1391 }
1392 Ok(out)
1393}
1394
1395fn collect_payload_fences(body: &str) -> Vec<String> {
1396 let mut out = Vec::new();
1397 let mut current: Option<Vec<String>> = None;
1398 let mut details_depth = 0usize;
1399 for line in body.lines() {
1400 let trimmed = line.trim_start();
1401 if let Some(buf) = current.as_mut() {
1402 if trimmed.starts_with("```") {
1403 let mut block = String::new();
1404 for chunk in buf.iter() {
1405 if !block.is_empty() {
1406 block.push('\n');
1407 }
1408 block.push_str(chunk);
1409 }
1410 out.push(block);
1411 current = None;
1412 } else {
1413 buf.push(line.to_string());
1414 }
1415 } else {
1416 if update_details_depth(line, &mut details_depth) {
1417 continue;
1418 }
1419 if details_depth > 0 {
1420 continue;
1421 }
1422 if let Some(rest) = trimmed.strip_prefix("```")
1423 && rest.trim() == PAYLOAD_FENCE_INFO
1424 {
1425 current = Some(Vec::new());
1426 }
1427 }
1428 }
1429 out
1430}
1431
1432fn update_details_depth(line: &str, depth: &mut usize) -> bool {
1433 let trimmed = line.trim();
1434 if trimmed.starts_with("<details") {
1435 *depth += 1;
1436 return true;
1437 }
1438 if trimmed.starts_with("</details>") {
1439 *depth = depth.saturating_sub(1);
1440 return true;
1441 }
1442 false
1443}
1444
1445fn encode_hex(bytes: &[u8]) -> String {
1446 const HEX: &[u8; 16] = b"0123456789abcdef";
1447 let mut out = String::with_capacity(bytes.len() * 2);
1448 for byte in bytes {
1449 out.push(HEX[(byte >> 4) as usize] as char);
1450 out.push(HEX[(byte & 0x0f) as usize] as char);
1451 }
1452 out
1453}
1454
1455fn decode_hex(input: &str) -> Result<Vec<u8>, String> {
1456 if !input.len().is_multiple_of(2) {
1457 return Err("hex payload has odd length".to_string());
1458 }
1459 let mut out = Vec::with_capacity(input.len() / 2);
1460 let bytes = input.as_bytes();
1461 for pair in bytes.chunks_exact(2) {
1462 let hi = hex_value(pair[0])
1463 .ok_or_else(|| format!("invalid hex digit `{}`", char::from(pair[0])))?;
1464 let lo = hex_value(pair[1])
1465 .ok_or_else(|| format!("invalid hex digit `{}`", char::from(pair[1])))?;
1466 out.push((hi << 4) | lo);
1467 }
1468 Ok(out)
1469}
1470
1471fn hex_value(byte: u8) -> Option<u8> {
1472 match byte {
1473 b'0'..=b'9' => Some(byte - b'0'),
1474 b'a'..=b'f' => Some(byte - b'a' + 10),
1475 b'A'..=b'F' => Some(byte - b'A' + 10),
1476 _ => None,
1477 }
1478}
1479
1480impl RecordPayload {
1481 pub fn parse_state(&self) -> Result<StateData, PayloadError> {
1482 self.decode_data(PayloadRole::State)
1483 }
1484
1485 pub fn parse_session(&self) -> Result<SessionData, PayloadError> {
1486 self.decode_data(PayloadRole::Session)
1487 }
1488
1489 pub fn parse_validation(&self) -> Result<ValidationData, PayloadError> {
1490 self.decode_data(PayloadRole::Validation)
1491 }
1492
1493 pub fn parse_review(&self) -> Result<ReviewData, PayloadError> {
1494 self.decode_data(PayloadRole::Review)
1495 }
1496
1497 pub fn parse_closeout(&self) -> Result<CloseoutData, PayloadError> {
1498 self.decode_data(PayloadRole::Closeout)
1499 }
1500
1501 pub fn parse_snapshot(&self) -> Result<SnapshotData, PayloadError> {
1502 if !matches!(self.role, PayloadRole::Source | PayloadRole::Plan) {
1503 return Err(PayloadError::new(
1504 PayloadErrorKind::SchemaMismatch,
1505 format!(
1506 "expected source or plan payload, got `{}`",
1507 self.role.as_str()
1508 ),
1509 ));
1510 }
1511 serde_json::from_value::<SnapshotData>(self.data.clone())
1512 .map_err(|err| PayloadError::new(PayloadErrorKind::InvalidJson, err.to_string()))
1513 }
1514
1515 fn decode_data<T: serde::de::DeserializeOwned>(
1516 &self,
1517 expected: PayloadRole,
1518 ) -> Result<T, PayloadError> {
1519 if self.role != expected {
1520 return Err(PayloadError::new(
1521 PayloadErrorKind::SchemaMismatch,
1522 format!(
1523 "expected role `{}`, got `{}`",
1524 expected.as_str(),
1525 self.role.as_str()
1526 ),
1527 ));
1528 }
1529 serde_json::from_value::<T>(self.data.clone())
1530 .map_err(|err| PayloadError::new(PayloadErrorKind::InvalidJson, err.to_string()))
1531 }
1532}
1533
1534pub fn validate_payload_data_for_kind(
1535 kind: LifecycleCommentKind,
1536 data: &Value,
1537) -> Result<(), PayloadError> {
1538 validate_payload_data_for_role(payload_role_for_kind(kind), data)
1539}
1540
1541fn validate_payload_data_for_role(role: PayloadRole, data: &Value) -> Result<(), PayloadError> {
1542 let payload = RecordPayload {
1543 schema: PAYLOAD_SCHEMA_V2.to_string(),
1544 role,
1545 profile: PayloadProfile::Tracking,
1546 updated_at: None,
1547 data: data.clone(),
1548 };
1549 validate_payload_data(&payload)
1550}
1551
1552fn validate_payload_data(payload: &RecordPayload) -> Result<(), PayloadError> {
1553 match payload.role {
1554 PayloadRole::Source | PayloadRole::Plan => payload.parse_snapshot().map(|_| ()),
1555 PayloadRole::State => payload.parse_state().map(|_| ()),
1556 PayloadRole::Session => payload.parse_session().map(|_| ()),
1557 PayloadRole::Validation => payload.parse_validation().map(|_| ()),
1558 PayloadRole::Review => payload.parse_review().map(|_| ()),
1559 PayloadRole::Closeout => payload.parse_closeout().map(|_| ()),
1560 }
1561}
1562
1563fn payload_role_for_kind(kind: LifecycleCommentKind) -> PayloadRole {
1574 match kind {
1575 LifecycleCommentKind::Source => PayloadRole::Source,
1576 LifecycleCommentKind::Plan => PayloadRole::Plan,
1577 LifecycleCommentKind::State => PayloadRole::State,
1578 LifecycleCommentKind::Session => PayloadRole::Session,
1579 LifecycleCommentKind::Validation => PayloadRole::Validation,
1580 LifecycleCommentKind::Review => PayloadRole::Review,
1581 LifecycleCommentKind::Closeout => PayloadRole::Closeout,
1582 }
1583}
1584
1585fn render_payload_carrier(envelope: &RecordPayload) -> Result<String, String> {
1586 let envelope_json = serde_json::to_string(envelope).map_err(|err| err.to_string())?;
1587 Ok(format!(
1588 "<!-- {PAYLOAD_COMMENT_PREFIX}{} -->",
1589 encode_hex(envelope_json.as_bytes())
1590 ))
1591}
1592
1593pub fn render_record_snapshot_comment(
1597 profile: RecordProfile,
1598 kind: LifecycleCommentKind,
1599 snapshot: &SnapshotData,
1600 content: &str,
1601 updated_at: Option<&str>,
1602) -> Result<String, String> {
1603 if !matches!(
1604 kind,
1605 LifecycleCommentKind::Source | LifecycleCommentKind::Plan
1606 ) {
1607 return Err(format!(
1608 "render_record_snapshot_comment: expected source or plan kind, got `{}`",
1609 kind.as_str()
1610 ));
1611 }
1612
1613 let envelope = RecordPayload {
1614 schema: PAYLOAD_SCHEMA_V2.to_string(),
1615 role: payload_role_for_kind(kind),
1616 profile: PayloadProfile::from(profile),
1617 updated_at: updated_at.map(str::to_string),
1618 data: serde_json::to_value(snapshot).map_err(|err| err.to_string())?,
1619 };
1620 let envelope_carrier = render_payload_carrier(&envelope)?;
1621
1622 let path = Some(snapshot.path.trim()).filter(|value| !value.is_empty());
1623 let commit = Some(snapshot.commit.trim()).filter(|value| !value.is_empty());
1624 let summary = snapshot
1625 .summary
1626 .as_deref()
1627 .map(str::trim)
1628 .filter(|value| !value.is_empty());
1629
1630 let view = SnapshotView {
1631 marker: marker_for(profile, kind),
1632 heading: default_heading(profile, kind),
1633 profile: profile.as_str(),
1634 path,
1635 commit,
1636 summary,
1637 details_summary: default_details_summary(kind),
1638 content,
1639 envelope_carrier,
1640 };
1641
1642 let mut engine = Engine::builder().build();
1643 engine
1644 .register_template(SNAPSHOT_TEMPLATE_NAME, SNAPSHOT_TEMPLATE)
1645 .map_err(|err| format!("snapshot template register failed: {err}"))?;
1646 engine
1647 .render(SNAPSHOT_TEMPLATE_NAME, &view)
1648 .map_err(|err| format!("snapshot template render failed: {err}"))
1649}
1650
1651pub fn extract_snapshot_content(comment_body: &str) -> Result<String, String> {
1665 let lines: Vec<&str> = comment_body.lines().collect();
1666 let open_idx = lines
1667 .iter()
1668 .position(|line| line.trim().starts_with("<details"))
1669 .ok_or_else(|| "snapshot comment has no <details> block".to_string())?;
1670
1671 let mut start = open_idx + 1;
1674 if start < lines.len() && lines[start].trim_start().starts_with("<summary") {
1675 start += 1;
1676 }
1677 if start < lines.len() && lines[start].trim().is_empty() {
1678 start += 1;
1679 }
1680
1681 let mut depth = 1usize;
1684 let mut content: Vec<&str> = Vec::new();
1685 let mut closed = false;
1686 for line in &lines[start..] {
1687 let trimmed = line.trim();
1688 if trimmed.starts_with("<details") {
1689 depth += 1;
1690 } else if trimmed.starts_with("</details>") {
1691 depth -= 1;
1692 if depth == 0 {
1693 closed = true;
1694 break;
1695 }
1696 }
1697 content.push(line);
1698 }
1699 if !closed {
1700 return Err("snapshot <details> block is not closed".to_string());
1701 }
1702
1703 while content.last().is_some_and(|line| line.trim().is_empty()) {
1706 content.pop();
1707 }
1708 let mut out = content.join("\n");
1709 out.push('\n');
1710 Ok(out)
1711}
1712
1713pub fn render_record_post_comment(
1717 profile: RecordProfile,
1718 kind: LifecycleCommentKind,
1719 payload_data: Value,
1720 summary: Option<&str>,
1721 updated_at: Option<&str>,
1722) -> Result<String, String> {
1723 render_record_post_comment_with_display(
1724 profile,
1725 kind,
1726 payload_data,
1727 None,
1728 summary,
1729 updated_at,
1730 TaskLedgerDisplay::Auto,
1731 )
1732}
1733
1734#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1736pub enum StateHeaderMode {
1737 Authored,
1741 DeriveFromPayload,
1747}
1748
1749pub fn render_record_post_comment_with_display(
1754 profile: RecordProfile,
1755 kind: LifecycleCommentKind,
1756 payload_data: Value,
1757 execution_state: Option<&str>,
1758 summary: Option<&str>,
1759 updated_at: Option<&str>,
1760 task_ledger_display: TaskLedgerDisplay,
1761) -> Result<String, String> {
1762 render_record_post_comment_with_display_mode(
1763 profile,
1764 kind,
1765 payload_data,
1766 execution_state,
1767 summary,
1768 updated_at,
1769 task_ledger_display,
1770 StateHeaderMode::Authored,
1771 )
1772}
1773
1774#[allow(clippy::too_many_arguments)]
1775pub fn render_record_post_comment_with_display_mode(
1776 profile: RecordProfile,
1777 kind: LifecycleCommentKind,
1778 payload_data: Value,
1779 execution_state: Option<&str>,
1780 summary: Option<&str>,
1781 updated_at: Option<&str>,
1782 task_ledger_display: TaskLedgerDisplay,
1783 header_mode: StateHeaderMode,
1784) -> Result<String, String> {
1785 if matches!(
1786 kind,
1787 LifecycleCommentKind::Source | LifecycleCommentKind::Plan
1788 ) {
1789 return Err(format!(
1790 "render_record_post_comment: source/plan kinds are owned by `record open`, got `{}`",
1791 kind.as_str()
1792 ));
1793 }
1794 if execution_state.is_some() && kind != LifecycleCommentKind::State {
1795 return Err(format!(
1796 "render_record_post_comment: execution-state markdown is only valid for `state`, got `{}`",
1797 kind.as_str()
1798 ));
1799 }
1800
1801 let visible_content = render_visible_post_content(
1802 kind,
1803 &payload_data,
1804 execution_state,
1805 summary,
1806 task_ledger_display,
1807 header_mode,
1808 )?;
1809 let envelope = RecordPayload {
1810 schema: PAYLOAD_SCHEMA_V2.to_string(),
1811 role: payload_role_for_kind(kind),
1812 profile: PayloadProfile::from(profile),
1813 updated_at: updated_at.map(str::to_string),
1814 data: payload_data,
1815 };
1816 let envelope_carrier = render_payload_carrier(&envelope)?;
1817
1818 let view = PostCommentView {
1819 marker: marker_for(profile, kind),
1820 heading: default_heading(profile, kind),
1821 profile: profile.as_str(),
1822 visible_content,
1823 envelope_carrier,
1824 };
1825
1826 let mut engine = Engine::builder().build();
1827 engine
1828 .register_template(POST_COMMENT_TEMPLATE_NAME, POST_COMMENT_TEMPLATE)
1829 .map_err(|err| format!("post_comment template register failed: {err}"))?;
1830 engine
1831 .render(POST_COMMENT_TEMPLATE_NAME, &view)
1832 .map_err(|err| format!("post_comment template render failed: {err}"))
1833}
1834
1835fn render_visible_post_content(
1836 kind: LifecycleCommentKind,
1837 payload_data: &Value,
1838 execution_state: Option<&str>,
1839 summary: Option<&str>,
1840 task_ledger_display: TaskLedgerDisplay,
1841 header_mode: StateHeaderMode,
1842) -> Result<String, String> {
1843 let execution_state = execution_state
1844 .map(str::trim)
1845 .filter(|value| !value.is_empty());
1846 let summary = summary.map(str::trim).filter(|value| !value.is_empty());
1847 let generated = match kind {
1848 LifecycleCommentKind::State => {
1849 let state = serde_json::from_value::<StateData>(payload_data.clone())
1850 .map_err(|err| format!("state payload invalid for visible rendering: {err}"))?;
1851 match (execution_state, summary) {
1852 (Some(document), summary) => {
1853 let rendered = render_state_markdown_with_task_ledger_display(
1854 document,
1855 task_ledger_display,
1856 &state,
1857 header_mode,
1858 )?;
1859 combine_summary_and_generated(summary, rendered)
1860 }
1861 (None, Some(text)) if text.contains("## Task Ledger") => {
1866 render_state_markdown_with_task_ledger_display(
1867 text,
1868 task_ledger_display,
1869 &state,
1870 header_mode,
1871 )?
1872 }
1873 (None, Some(text)) => text.to_string(),
1874 (None, None) => render_state_payload_visible(&state),
1875 }
1876 }
1877 LifecycleCommentKind::Session => {
1878 let session = serde_json::from_value::<SessionData>(payload_data.clone())
1879 .map_err(|err| format!("session payload invalid for visible rendering: {err}"))?;
1880 combine_summary_and_generated(
1881 summary,
1882 render_session_payload_visible(&session, payload_data),
1883 )
1884 }
1885 LifecycleCommentKind::Validation => {
1886 let validation = serde_json::from_value::<ValidationData>(payload_data.clone())
1887 .map_err(|err| {
1888 format!("validation payload invalid for visible rendering: {err}")
1889 })?;
1890 combine_summary_and_generated(summary, render_validation_payload_visible(&validation))
1891 }
1892 LifecycleCommentKind::Review => {
1893 let review = serde_json::from_value::<ReviewData>(payload_data.clone())
1894 .map_err(|err| format!("review payload invalid for visible rendering: {err}"))?;
1895 combine_summary_and_generated(summary, render_review_payload_visible(&review))
1896 }
1897 LifecycleCommentKind::Closeout => {
1898 let closeout = serde_json::from_value::<CloseoutData>(payload_data.clone())
1899 .map_err(|err| format!("closeout payload invalid for visible rendering: {err}"))?;
1900 combine_summary_and_generated(summary, render_closeout_payload_visible(&closeout))
1901 }
1902 LifecycleCommentKind::Source | LifecycleCommentKind::Plan => unreachable!(),
1903 };
1904
1905 if generated.trim().is_empty() {
1906 return Err(format!(
1907 "`record post --kind {}` would render no visible lifecycle content",
1908 kind.as_str()
1909 ));
1910 }
1911 Ok(generated)
1912}
1913
1914fn combine_summary_and_generated(summary: Option<&str>, generated: String) -> String {
1915 match (summary, generated.trim().is_empty()) {
1916 (Some(text), false) => format!("{}\n\n{}", text.trim(), generated.trim()),
1917 (Some(text), true) => text.trim().to_string(),
1918 (None, _) => generated,
1919 }
1920}
1921
1922fn render_state_markdown_with_task_ledger_display(
1923 markdown: &str,
1924 display: TaskLedgerDisplay,
1925 state: &StateData,
1926 header_mode: StateHeaderMode,
1927) -> Result<String, String> {
1928 let markdown = normalize_state_markdown_for_comment(markdown)?;
1929 let markdown = match header_mode {
1937 StateHeaderMode::DeriveFromPayload => replace_state_header_from_payload(&markdown, state),
1938 StateHeaderMode::Authored => markdown,
1939 };
1940 let effective = match display {
1941 TaskLedgerDisplay::Expanded => TaskLedgerDisplay::Expanded,
1942 TaskLedgerDisplay::Collapsed => TaskLedgerDisplay::Collapsed,
1943 TaskLedgerDisplay::Open => TaskLedgerDisplay::Open,
1944 TaskLedgerDisplay::Auto => {
1945 if is_terminal_state(state) {
1946 TaskLedgerDisplay::Expanded
1947 } else {
1948 TaskLedgerDisplay::Collapsed
1949 }
1950 }
1951 };
1952 if effective == TaskLedgerDisplay::Expanded {
1953 return Ok(markdown);
1954 }
1955 let details_open_tag = match effective {
1958 TaskLedgerDisplay::Open => "<details open>",
1959 _ => "<details>",
1960 };
1961
1962 let lines: Vec<&str> = markdown.lines().collect();
1963 let Some(start) = lines
1964 .iter()
1965 .position(|line| line.trim() == "## Task Ledger")
1966 else {
1967 return Err("execution-state markdown is missing `## Task Ledger`".to_string());
1968 };
1969 let end = lines
1970 .iter()
1971 .enumerate()
1972 .skip(start + 1)
1973 .find_map(|(idx, line)| {
1974 if line.starts_with("## ") {
1975 Some(idx)
1976 } else {
1977 None
1978 }
1979 })
1980 .unwrap_or(lines.len());
1981 let body = lines[start + 1..end].join("\n").trim().to_string();
1982 if body.is_empty() {
1983 return Err("execution-state Task Ledger section is empty".to_string());
1984 }
1985
1986 let mut out = Vec::new();
1987 out.extend(lines[..=start].iter().map(|line| (*line).to_string()));
1988 out.push(String::new());
1989 out.push(details_open_tag.to_string());
1990 out.push("<summary>Show task ledger</summary>".to_string());
1991 out.push(String::new());
1992 out.push(body);
1993 out.push(String::new());
1994 out.push("</details>".to_string());
1995 if end < lines.len() {
1996 out.push(String::new());
1997 out.extend(lines[end..].iter().map(|line| (*line).to_string()));
1998 }
1999 Ok(finalize_markdown(out).trim().to_string())
2000}
2001
2002fn normalize_state_markdown_for_comment(markdown: &str) -> Result<String, String> {
2003 let stripped = markdown
2004 .trim()
2005 .lines()
2006 .filter(|line| {
2007 let trimmed = line.trim();
2008 !trimmed.starts_with("<!-- plan-issue-record:")
2009 && !trimmed.starts_with("<!-- execute-from-tracking-issue:")
2010 })
2011 .map(str::to_string)
2012 .collect::<Vec<_>>();
2013 let Some(execution_heading) = stripped
2014 .iter()
2015 .position(|line| line.trim() == "## Execution State")
2016 else {
2017 return Err("execution-state markdown is missing `## Execution State`".to_string());
2018 };
2019
2020 let mut out = stripped
2021 .into_iter()
2022 .skip(execution_heading + 1)
2023 .filter(|line| !line.trim().starts_with("- Profile:"))
2024 .collect::<Vec<_>>();
2025 while out.first().is_some_and(|line| line.trim().is_empty()) {
2026 out.remove(0);
2027 }
2028 let normalized = finalize_markdown(out).trim().to_string();
2029 if normalized.is_empty() {
2030 return Err("execution-state markdown has no visible state content".to_string());
2031 }
2032 Ok(normalized)
2033}
2034
2035fn is_terminal_task_status(status: TaskRowStatus) -> bool {
2038 matches!(
2039 status,
2040 TaskRowStatus::Done | TaskRowStatus::Deferred | TaskRowStatus::Waived
2041 )
2042}
2043
2044fn is_terminal_state(state: &StateData) -> bool {
2045 state.status == Some(StateStatus::Complete)
2046 && state
2047 .tasks
2048 .iter()
2049 .all(|task| is_terminal_task_status(task.status))
2050}
2051
2052fn render_state_payload_visible(state: &StateData) -> String {
2053 let view = StateVisibleView {
2054 status: state.status.map(status_state_label),
2055 target_scope: state
2056 .target_scope
2057 .as_deref()
2058 .filter(|value| !value.is_empty()),
2059 current: state.current.as_deref().filter(|value| !value.is_empty()),
2060 next_action: state
2061 .next_action
2062 .as_deref()
2063 .filter(|value| !value.is_empty()),
2064 tasks: state
2065 .tasks
2066 .iter()
2067 .map(|task| StateTaskRow {
2068 id: table_cell(&task.id),
2069 status: task_row_status_label(task.status),
2070 title: table_cell(task.title.as_deref().unwrap_or("")),
2071 })
2072 .collect(),
2073 };
2074 let mut engine = Engine::builder().build();
2075 engine
2076 .register_template(STATE_VISIBLE_TEMPLATE_NAME, STATE_VISIBLE_TEMPLATE)
2077 .expect("state template registers");
2078 let rendered = engine
2079 .render(STATE_VISIBLE_TEMPLATE_NAME, &view)
2080 .expect("state template renders");
2081 rendered.trim().to_string()
2082}
2083
2084fn replace_state_header_from_payload(markdown: &str, state: &StateData) -> String {
2091 let header = render_state_header_lines_from_payload(state);
2092 if header.is_empty() {
2093 return markdown.to_string();
2094 }
2095 let lines: Vec<&str> = markdown.lines().collect();
2096 let first_section = lines
2097 .iter()
2098 .position(|line| line.trim_start().starts_with("## "));
2099 let mut out = header;
2100 if let Some(idx) = first_section {
2101 out.push(String::new());
2102 out.extend(lines[idx..].iter().map(|line| (*line).to_string()));
2103 }
2104 finalize_markdown(out).trim().to_string()
2105}
2106
2107fn render_state_header_lines_from_payload(state: &StateData) -> Vec<String> {
2111 let mut lines = Vec::new();
2112 if let Some(status) = state.status.map(status_state_label) {
2113 lines.push(format!("- Status: {status}"));
2114 }
2115 if let Some(scope) = state
2116 .target_scope
2117 .as_deref()
2118 .filter(|value| !value.is_empty())
2119 {
2120 lines.push(format!("- Target scope: {scope}"));
2121 }
2122 if let Some(current) = state.current.as_deref().filter(|value| !value.is_empty()) {
2123 lines.push(format!("- Current task: {current}"));
2124 }
2125 if let Some(next) = state
2126 .next_action
2127 .as_deref()
2128 .filter(|value| !value.is_empty())
2129 {
2130 lines.push(format!("- Next task: {next}"));
2131 }
2132 lines
2133}
2134
2135fn render_session_payload_visible(session: &SessionData, raw: &Value) -> String {
2136 let extras: Vec<KeyValuePair> = raw
2137 .as_object()
2138 .map(|object| {
2139 object
2140 .iter()
2141 .filter(|(key, value)| {
2142 !matches!(key.as_str(), "summary" | "highlights" | "links") && !value.is_null()
2143 })
2144 .map(|(key, value)| KeyValuePair {
2145 key: key.trim().to_string(),
2146 value: visible_value(value),
2147 })
2148 .collect()
2149 })
2150 .unwrap_or_default();
2151
2152 let view = SessionVisibleView {
2153 summary: session.summary.trim(),
2154 highlights: session
2155 .highlights
2156 .iter()
2157 .map(|item| item.trim().to_string())
2158 .collect(),
2159 links: session
2160 .links
2161 .iter()
2162 .map(|(key, value)| KeyValuePair {
2163 key: key.trim().to_string(),
2164 value: value.trim().to_string(),
2165 })
2166 .collect(),
2167 extras,
2168 };
2169 let mut engine = Engine::builder().build();
2170 engine
2171 .register_template(SESSION_VISIBLE_TEMPLATE_NAME, SESSION_VISIBLE_TEMPLATE)
2172 .expect("session template registers");
2173 let rendered = engine
2174 .render(SESSION_VISIBLE_TEMPLATE_NAME, &view)
2175 .expect("session template renders");
2176 rendered.trim().to_string()
2177}
2178
2179fn render_validation_payload_visible(validation: &ValidationData) -> String {
2180 let view = ValidationVisibleView {
2181 overall: validation_overall_label(validation.overall),
2182 commands: validation
2183 .commands
2184 .iter()
2185 .map(|command| ValidationCommandRow {
2186 command: table_cell(&command.command),
2187 status: validation_command_status_label(command.status),
2188 evidence: table_cell(command.evidence.as_deref().unwrap_or("")),
2189 _phantom: std::marker::PhantomData,
2190 })
2191 .collect(),
2192 waivers: validation
2193 .waivers
2194 .iter()
2195 .map(|waiver| ValidationWaiverRow {
2196 command: waiver.command.trim(),
2197 reason: waiver.reason.trim(),
2198 })
2199 .collect(),
2200 };
2201 let mut engine = Engine::builder().build();
2202 engine
2203 .register_template(
2204 VALIDATION_VISIBLE_TEMPLATE_NAME,
2205 VALIDATION_VISIBLE_TEMPLATE,
2206 )
2207 .expect("validation template registers");
2208 let rendered = engine
2209 .render(VALIDATION_VISIBLE_TEMPLATE_NAME, &view)
2210 .expect("validation template renders");
2211 rendered.trim().to_string()
2212}
2213
2214fn render_review_payload_visible(review: &ReviewData) -> String {
2215 let view = ReviewVisibleView {
2216 decision: review_decision_label(review.decision),
2217 lenses: if review.lenses.is_empty() {
2218 None
2219 } else {
2220 Some(review.lenses.join(", "))
2221 },
2222 outcome_comment_url: review
2223 .outcome_comment_url
2224 .as_deref()
2225 .map(str::trim)
2226 .filter(|value| !value.is_empty()),
2227 findings: review
2228 .findings
2229 .iter()
2230 .map(|finding| ReviewFindingRow {
2231 id: table_cell(&finding.id),
2232 severity: finding_severity_label(finding.severity),
2233 disposition: finding_disposition_label(finding.disposition),
2234 summary: table_cell(&finding.summary),
2235 })
2236 .collect(),
2237 };
2238 let mut engine = Engine::builder().build();
2239 engine
2240 .register_template(REVIEW_VISIBLE_TEMPLATE_NAME, REVIEW_VISIBLE_TEMPLATE)
2241 .expect("review template registers");
2242 let rendered = engine
2243 .render(REVIEW_VISIBLE_TEMPLATE_NAME, &view)
2244 .expect("review template renders");
2245 rendered.trim().to_string()
2246}
2247
2248fn render_closeout_payload_visible(closeout: &CloseoutData) -> String {
2249 let override_block = closeout
2250 .non_required_check_override
2251 .as_ref()
2252 .filter(|value| !value.is_null());
2253 let override_reason = override_block.and_then(|block| {
2254 block
2255 .get("reason")
2256 .and_then(Value::as_str)
2257 .map(str::trim)
2258 .filter(|value| !value.is_empty())
2259 .map(str::to_string)
2260 });
2261 let override_failures = override_block.and_then(|block| {
2262 let items = block
2263 .get("observed_non_required_failures")
2264 .and_then(Value::as_array)
2265 .filter(|items| !items.is_empty())?;
2266 Some(
2267 items
2268 .iter()
2269 .map(visible_value)
2270 .filter(|value| !value.trim().is_empty())
2271 .collect::<Vec<_>>()
2272 .join(", "),
2273 )
2274 });
2275 let has_override = override_block.is_some();
2276
2277 let view = CloseoutVisibleView {
2278 final_status: closeout.final_status.trim(),
2279 approver: closeout
2280 .approval
2281 .approver
2282 .as_deref()
2283 .map(str::trim)
2284 .filter(|value| !value.is_empty()),
2285 approval_url: closeout
2286 .approval
2287 .comment_url
2288 .as_deref()
2289 .map(str::trim)
2290 .filter(|value| !value.is_empty()),
2291 final_validation_url: closeout
2292 .final_validation_url
2293 .as_deref()
2294 .map(str::trim)
2295 .filter(|value| !value.is_empty()),
2296 notes: closeout
2297 .notes
2298 .as_deref()
2299 .map(str::trim)
2300 .filter(|value| !value.is_empty()),
2301 has_override,
2302 override_reason,
2303 override_failures,
2304 linked_prs: closeout
2305 .linked_prs
2306 .iter()
2307 .map(|pr| {
2308 let pr_label = pr.url.as_deref().unwrap_or(&pr.pr_ref);
2309 let required_label = required_check_label(pr.required_state, pr.required_count);
2310 CloseoutPrRow {
2311 label: table_cell(pr_label),
2312 merge_sha: table_cell(pr.merge_sha.as_deref().unwrap_or("")),
2313 checks: check_status_label(pr.checks),
2314 required: table_cell(&required_label),
2315 non_required_failures: table_cell(&non_empty_join(
2316 &pr.non_required_failures,
2317 "none",
2318 )),
2319 }
2320 })
2321 .collect(),
2322 };
2323 let mut engine = Engine::builder().build();
2324 engine
2325 .register_template(CLOSEOUT_VISIBLE_TEMPLATE_NAME, CLOSEOUT_VISIBLE_TEMPLATE)
2326 .expect("closeout template registers");
2327 let rendered = engine
2328 .render(CLOSEOUT_VISIBLE_TEMPLATE_NAME, &view)
2329 .expect("closeout template renders");
2330 rendered.trim().to_string()
2331}
2332
2333fn task_row_status_label(status: TaskRowStatus) -> &'static str {
2334 match status {
2335 TaskRowStatus::Pending => "pending",
2336 TaskRowStatus::InProgress => "in-progress",
2337 TaskRowStatus::Done => "done",
2338 TaskRowStatus::Deferred => "deferred",
2339 TaskRowStatus::Blocked => "blocked",
2340 TaskRowStatus::Waived => "waived",
2341 }
2342}
2343
2344fn validation_command_status_label(status: ValidationCommandStatus) -> &'static str {
2345 match status {
2346 ValidationCommandStatus::Pass => "pass",
2347 ValidationCommandStatus::Fail => "fail",
2348 ValidationCommandStatus::Skipped => "skipped",
2349 }
2350}
2351
2352fn finding_severity_label(severity: FindingSeverity) -> &'static str {
2353 match severity {
2354 FindingSeverity::Blocker => "blocker",
2355 FindingSeverity::Major => "major",
2356 FindingSeverity::Minor => "minor",
2357 FindingSeverity::Nit => "nit",
2358 }
2359}
2360
2361fn finding_disposition_label(disposition: FindingDisposition) -> &'static str {
2362 match disposition {
2363 FindingDisposition::Fixed => "fixed",
2364 FindingDisposition::Residual => "residual",
2365 FindingDisposition::FollowUp => "follow-up",
2366 FindingDisposition::Deferred => "deferred",
2367 FindingDisposition::NoAction => "no-action",
2368 }
2369}
2370
2371fn check_status_label(status: CheckStatus) -> &'static str {
2372 match status {
2373 CheckStatus::Pass => "pass",
2374 CheckStatus::Fail => "fail",
2375 CheckStatus::None => "none",
2376 }
2377}
2378
2379fn required_check_label(state: Option<CheckStatus>, count: Option<u32>) -> String {
2404 match (state, count) {
2405 (Some(CheckStatus::Pass), Some(0)) => "none required".to_string(),
2406 (Some(CheckStatus::Pass), Some(n)) => format!("pass ({n})"),
2407 (Some(CheckStatus::Pass), None) => "pass".to_string(),
2408 (Some(CheckStatus::Fail), Some(n)) => format!("fail ({n})"),
2409 (Some(CheckStatus::Fail), None) => "fail".to_string(),
2410 (Some(CheckStatus::None), _) => "none".to_string(),
2411 (None, _) => "unknown".to_string(),
2412 }
2413}
2414
2415fn table_cell(value: &str) -> String {
2416 value.trim().replace('|', "\\|").replace('\n', "<br>")
2417}
2418
2419fn visible_value(value: &Value) -> String {
2420 match value {
2421 Value::String(text) => text.trim().to_string(),
2422 Value::Array(items) => items
2423 .iter()
2424 .map(visible_value)
2425 .collect::<Vec<_>>()
2426 .join(", "),
2427 Value::Object(_) => value.to_string(),
2428 Value::Null => String::new(),
2429 _ => value.to_string(),
2430 }
2431}
2432
2433#[derive(Debug, Clone, Serialize)]
2438pub struct StrictCloseoutGateResult {
2439 pub ready: bool,
2440 pub checks: Vec<CloseoutCheck>,
2441 pub blocked_codes: Vec<String>,
2443}
2444
2445#[derive(Debug, Clone)]
2446pub struct StrictCloseoutGateInput<'a> {
2447 pub profile: RecordProfile,
2448 pub approval: Option<&'a str>,
2449 pub linked_prs: &'a [LinkedPrEvidence],
2452 pub current_body: Option<&'a str>,
2456 pub expected_dashboard: Option<&'a str>,
2457 pub allow_non_required_check_failure: bool,
2463}
2464
2465pub fn evaluate_strict_closeout_gate(
2466 audit: &RecordAudit,
2467 input: StrictCloseoutGateInput<'_>,
2468) -> StrictCloseoutGateResult {
2469 let mut checks = Vec::new();
2470 let mut blocked_codes: Vec<String> = Vec::new();
2471
2472 let push_pass = |checks: &mut Vec<CloseoutCheck>, check: &str, detail: String| {
2473 checks.push(CloseoutCheck {
2474 check: check.to_string(),
2475 status: "pass".to_string(),
2476 detail,
2477 });
2478 };
2479 let push_fail = |checks: &mut Vec<CloseoutCheck>,
2480 blocked: &mut Vec<String>,
2481 check: &str,
2482 detail: String,
2483 code: &str| {
2484 checks.push(CloseoutCheck {
2485 check: check.to_string(),
2486 status: "fail".to_string(),
2487 detail,
2488 });
2489 blocked.push(code.to_string());
2490 };
2491
2492 for (role, label, code) in [
2493 ("source", "source snapshot", "source-missing"),
2494 ("plan", "plan snapshot", "plan-missing"),
2495 ] {
2496 if audit.evidence.contains_key(role) {
2497 push_pass(&mut checks, label, "present".to_string());
2498 } else {
2499 push_fail(
2500 &mut checks,
2501 &mut blocked_codes,
2502 label,
2503 "missing".to_string(),
2504 code,
2505 );
2506 }
2507 }
2508
2509 match audit.evidence.get("state") {
2510 Some(hit) => {
2511 let status = hit.status.as_deref();
2512 let parsed = hit
2513 .payload
2514 .as_ref()
2515 .and_then(|payload| payload.parse_state().ok());
2516 match status {
2517 Some(value) if value.eq_ignore_ascii_case("complete") => {
2518 let tasks_incomplete = parsed
2519 .as_ref()
2520 .map(|data| {
2521 data.tasks
2522 .iter()
2523 .any(|task| !is_terminal_task_status(task.status))
2524 })
2525 .unwrap_or(false);
2526 if tasks_incomplete {
2527 push_fail(
2528 &mut checks,
2529 &mut blocked_codes,
2530 "execution state",
2531 "complete but tasks are not all done/deferred/waived".to_string(),
2532 "state-tasks-incomplete",
2533 );
2534 } else {
2535 push_pass(&mut checks, "execution state", "complete".to_string());
2536 }
2537 }
2538 Some(value) => push_fail(
2539 &mut checks,
2540 &mut blocked_codes,
2541 "execution state",
2542 format!("latest state status is `{value}`"),
2543 "state-not-complete",
2544 ),
2545 None => push_fail(
2546 &mut checks,
2547 &mut blocked_codes,
2548 "execution state",
2549 "missing payload status".to_string(),
2550 "state-not-complete",
2551 ),
2552 }
2553 }
2554 None => push_fail(
2555 &mut checks,
2556 &mut blocked_codes,
2557 "execution state",
2558 "missing".to_string(),
2559 "state-missing",
2560 ),
2561 }
2562
2563 match audit.evidence.get("session") {
2564 Some(hit) => push_pass(
2565 &mut checks,
2566 "execution session",
2567 hit.url.as_deref().unwrap_or("present").to_string(),
2568 ),
2569 None => push_fail(
2570 &mut checks,
2571 &mut blocked_codes,
2572 "execution session",
2573 "missing role=session lifecycle record".to_string(),
2574 "session-missing",
2575 ),
2576 }
2577
2578 match audit.evidence.get("validation") {
2579 Some(hit) => match hit.status.as_deref() {
2580 Some("pass") => push_pass(&mut checks, "validation", "pass".to_string()),
2581 Some(value) => push_fail(
2582 &mut checks,
2583 &mut blocked_codes,
2584 "validation",
2585 format!("latest validation overall = `{value}`"),
2586 "validation-failed",
2587 ),
2588 None => push_fail(
2589 &mut checks,
2590 &mut blocked_codes,
2591 "validation",
2592 "missing payload status".to_string(),
2593 "validation-failed",
2594 ),
2595 },
2596 None => push_fail(
2597 &mut checks,
2598 &mut blocked_codes,
2599 "validation",
2600 "missing".to_string(),
2601 "validation-missing",
2602 ),
2603 }
2604
2605 match audit.evidence.get("review") {
2606 Some(hit) => {
2607 let parsed = hit.payload.as_ref().map(|payload| payload.parse_review());
2608 match parsed {
2609 Some(Ok(data)) => match data.decision {
2610 ReviewDecision::RequestChanges => push_fail(
2611 &mut checks,
2612 &mut blocked_codes,
2613 "review",
2614 "decision = request-changes".to_string(),
2615 "review-rejected",
2616 ),
2617 decision => {
2618 let unresolved = data.findings.iter().any(|finding| {
2619 matches!(finding.disposition, FindingDisposition::Residual)
2620 && matches!(
2621 finding.severity,
2622 FindingSeverity::Blocker | FindingSeverity::Major
2623 )
2624 });
2625 if unresolved {
2626 push_fail(
2627 &mut checks,
2628 &mut blocked_codes,
2629 "review",
2630 "unresolved blocker/major findings".to_string(),
2631 "review-unresolved-findings",
2632 );
2633 } else {
2634 let label = match decision {
2635 ReviewDecision::Approve => "approve",
2636 ReviewDecision::CommentsOnly => "comments-only",
2637 ReviewDecision::RequestChanges => unreachable!(),
2638 };
2639 push_pass(&mut checks, "review", format!("decision = {label}"));
2640 }
2641 }
2642 },
2643 Some(Err(err)) => push_fail(
2644 &mut checks,
2645 &mut blocked_codes,
2646 "review",
2647 format!("malformed review payload: {}", err.message),
2648 "review-rejected",
2649 ),
2650 None => push_fail(
2651 &mut checks,
2652 &mut blocked_codes,
2653 "review",
2654 "missing payload".to_string(),
2655 "review-missing",
2656 ),
2657 }
2658 }
2659 None => push_fail(
2660 &mut checks,
2661 &mut blocked_codes,
2662 "review",
2663 "missing".to_string(),
2664 "review-missing",
2665 ),
2666 }
2667
2668 let approval_text = input.approval.unwrap_or("").trim();
2669 if approval_text.is_empty() {
2670 push_fail(
2671 &mut checks,
2672 &mut blocked_codes,
2673 "close approval",
2674 "missing explicit approval".to_string(),
2675 "approval-missing",
2676 );
2677 } else {
2678 push_pass(&mut checks, "close approval", approval_text.to_string());
2679 }
2680
2681 if input.linked_prs.is_empty() {
2682 push_pass(&mut checks, "linked PRs", "none provided".to_string());
2683 } else {
2684 let mut unmerged: Vec<String> = Vec::new();
2685 let mut required_failed: Vec<String> = Vec::new();
2686 for pr in input.linked_prs {
2687 let sha = pr.merge_sha.as_deref().map(str::trim).unwrap_or("");
2688 if sha.is_empty() {
2689 unmerged.push(format!("{} (no merge_sha)", pr.pr_ref));
2690 continue;
2691 }
2692 match pr.required_state {
2693 Some(CheckStatus::Fail) => {
2694 required_failed.push(format!("{} (required checks failed)", pr.pr_ref));
2695 }
2696 Some(CheckStatus::Pass | CheckStatus::None) => {
2697 }
2701 None => {
2702 if matches!(pr.checks, CheckStatus::Fail)
2707 && !input.allow_non_required_check_failure
2708 {
2709 required_failed.push(format!(
2710 "{} (checks={:?}; required-state unknown)",
2711 pr.pr_ref, pr.checks
2712 ));
2713 }
2714 }
2715 }
2716 }
2717 if !unmerged.is_empty() {
2718 push_fail(
2719 &mut checks,
2720 &mut blocked_codes,
2721 "linked PRs",
2722 unmerged.join(", "),
2723 "linked-pr-not-merged",
2724 );
2725 }
2726 if !required_failed.is_empty() {
2727 push_fail(
2728 &mut checks,
2729 &mut blocked_codes,
2730 "linked PRs required checks",
2731 required_failed.join(", "),
2732 "linked-pr-checks-failed",
2733 );
2734 }
2735 if unmerged.is_empty() && required_failed.is_empty() {
2736 push_pass(
2737 &mut checks,
2738 "linked PRs",
2739 format!("{} merged", input.linked_prs.len()),
2740 );
2741 }
2742 }
2743
2744 if let (Some(current), Some(expected)) = (input.current_body, input.expected_dashboard) {
2745 let current_norm = normalize_for_dashboard_compare(current);
2746 let expected_norm = normalize_for_dashboard_compare(expected);
2747 if current_norm.contains(&expected_norm) {
2748 push_pass(&mut checks, "dashboard", "matches canonical".to_string());
2749 } else {
2750 push_fail(
2751 &mut checks,
2752 &mut blocked_codes,
2753 "dashboard",
2754 "dashboard differs from recomputed canonical".to_string(),
2755 "dashboard-out-of-date",
2756 );
2757 }
2758 }
2759
2760 let ready = checks.iter().all(|check| check.status == "pass");
2761 StrictCloseoutGateResult {
2762 ready,
2763 checks,
2764 blocked_codes,
2765 }
2766}
2767
2768fn normalize_for_dashboard_compare(text: &str) -> String {
2769 text.lines()
2770 .map(str::trim_end)
2771 .collect::<Vec<_>>()
2772 .join("\n")
2773}
2774
2775#[cfg(test)]
2776mod sprint3_tests {
2777 use super::*;
2778 use serde_json::json;
2779
2780 fn build_audit_with_evidence(comments: Vec<(serde_json::Value, &str)>) -> RecordAudit {
2781 let payload = json!({
2782 "comments": comments
2783 .into_iter()
2784 .map(|(body, url)| json!({"body": body, "url": url, "created_at": "2026-05-23T08:00:00Z"}))
2785 .collect::<Vec<_>>()
2786 });
2787 audit_record(None, &payload.to_string(), None).expect("audit ok")
2788 }
2789
2790 fn v2_body(role: &str, data: Value) -> Value {
2791 let envelope = json!({
2792 "schema": PAYLOAD_SCHEMA_V2,
2793 "role": role,
2794 "profile": "tracking",
2795 "data": data,
2796 });
2797 let payload_json = serde_json::to_string(&envelope).expect("serialize");
2798 json!(format!(
2799 "<!-- plan-issue-record:v2 role={role} profile=tracking -->\n\n```{PAYLOAD_FENCE_INFO}\n{payload_json}\n```\n",
2800 ))
2801 }
2802
2803 #[test]
2804 fn audit_treats_v2_marker_without_payload_fence_as_payload_none() {
2805 let body_only_marker = json!(
2808 "<!-- plan-issue-record:v2 role=session profile=tracking -->\n\n## Execution Session\n\nfreeform notes, no payload\n"
2809 );
2810 let audit = build_audit_with_evidence(vec![(
2811 body_only_marker,
2812 "https://github.com/owner/repo/issues/1#issuecomment-session",
2813 )]);
2814 let session = audit
2815 .evidence
2816 .get("session")
2817 .expect("session evidence registered");
2818 assert!(session.payload.is_none(), "payload should be None");
2819 assert_eq!(audit.recognized_count, 1);
2820 }
2821
2822 #[test]
2823 fn audit_strict_fails_on_malformed_payload() {
2824 let body = json!(
2827 "<!-- plan-issue-record:v2 role=state profile=tracking -->\n\n```plan-issue-record-payload\n{not valid json\n```\n"
2828 );
2829 let payload = json!({
2830 "comments": [{
2831 "body": body,
2832 "url": "https://github.com/owner/repo/issues/1#issuecomment-bad",
2833 "created_at": "2026-05-23T08:00:00Z",
2834 }]
2835 });
2836 let err = audit_record(None, &payload.to_string(), None)
2837 .expect_err("malformed payload should fail audit");
2838 assert!(
2839 err.contains("malformed payload"),
2840 "error should mention malformed payload: {err}"
2841 );
2842 }
2843
2844 #[test]
2845 fn strict_gate_passes_when_all_v2_evidence_complete_and_merged() {
2846 let state = v2_body(
2847 "state",
2848 json!({
2849 "status": "complete",
2850 "target_scope": "scope",
2851 "tasks": [
2852 {"id": "1.1", "status": "done", "title": "x"},
2853 {"id": "1.2", "status": "deferred", "title": "y"},
2854 ],
2855 "prs": [{"ref": "owner/repo#1", "url": "u", "status": "merged"}],
2856 "blockers": [],
2857 "links": {},
2858 }),
2859 );
2860 let validation = v2_body(
2861 "validation",
2862 json!({"overall": "pass", "commands": [], "waivers": []}),
2863 );
2864 let review = v2_body(
2865 "review",
2866 json!({
2867 "decision": "approve",
2868 "lenses": ["testing"],
2869 "findings": [],
2870 }),
2871 );
2872 let source = v2_body("source", json!({"path": "p", "commit": "c"}));
2873 let plan = v2_body("plan", json!({"path": "p", "commit": "c"}));
2874 let session = v2_body("session", json!({"summary": "session complete"}));
2875 let audit = build_audit_with_evidence(vec![
2876 (source, "u-src"),
2877 (plan, "u-plan"),
2878 (state, "u-state"),
2879 (session, "u-session"),
2880 (validation, "u-val"),
2881 (review, "u-rev"),
2882 ]);
2883
2884 let linked_prs = vec![LinkedPrEvidence {
2885 pr_ref: "owner/repo#1".to_string(),
2886 url: Some("https://github.com/owner/repo/pull/1".to_string()),
2887 merge_sha: Some("abcdef1234567890".to_string()),
2888 checks: CheckStatus::Pass,
2889 required_state: Some(CheckStatus::Pass),
2890 required_count: Some(1),
2891 non_required_failures: Vec::new(),
2892 }];
2893 let result = evaluate_strict_closeout_gate(
2894 &audit,
2895 StrictCloseoutGateInput {
2896 profile: RecordProfile::Tracking,
2897 approval: Some("https://github.com/owner/repo/issues/1#issuecomment-9"),
2898 linked_prs: &linked_prs,
2899 current_body: None,
2900 expected_dashboard: None,
2901 allow_non_required_check_failure: false,
2902 },
2903 );
2904 assert!(result.ready, "gate should pass: {:?}", result.checks);
2905 assert!(result.blocked_codes.is_empty());
2906 }
2907
2908 #[test]
2909 fn strict_gate_passes_when_state_tasks_include_waived() {
2910 let state = v2_body(
2915 "state",
2916 json!({
2917 "status": "complete",
2918 "target_scope": "scope",
2919 "tasks": [
2920 {"id": "1.1", "status": "done", "title": "x"},
2921 {"id": "1.2", "status": "waived", "title": "y"},
2922 ],
2923 "prs": [{"ref": "owner/repo#1", "url": "u", "status": "merged"}],
2924 "blockers": [],
2925 "links": {},
2926 }),
2927 );
2928 let validation = v2_body(
2929 "validation",
2930 json!({"overall": "pass", "commands": [], "waivers": []}),
2931 );
2932 let review = v2_body(
2933 "review",
2934 json!({
2935 "decision": "approve",
2936 "lenses": ["testing"],
2937 "findings": [],
2938 }),
2939 );
2940 let source = v2_body("source", json!({"path": "p", "commit": "c"}));
2941 let plan = v2_body("plan", json!({"path": "p", "commit": "c"}));
2942 let session = v2_body("session", json!({"summary": "session complete"}));
2943 let audit = build_audit_with_evidence(vec![
2944 (source, "u-src"),
2945 (plan, "u-plan"),
2946 (state, "u-state"),
2947 (session, "u-session"),
2948 (validation, "u-val"),
2949 (review, "u-rev"),
2950 ]);
2951
2952 let linked_prs = vec![LinkedPrEvidence {
2953 pr_ref: "owner/repo#1".to_string(),
2954 url: Some("https://github.com/owner/repo/pull/1".to_string()),
2955 merge_sha: Some("abcdef1234567890".to_string()),
2956 checks: CheckStatus::Pass,
2957 required_state: Some(CheckStatus::Pass),
2958 required_count: Some(1),
2959 non_required_failures: Vec::new(),
2960 }];
2961 let result = evaluate_strict_closeout_gate(
2962 &audit,
2963 StrictCloseoutGateInput {
2964 profile: RecordProfile::Tracking,
2965 approval: Some("https://github.com/owner/repo/issues/1#issuecomment-9"),
2966 linked_prs: &linked_prs,
2967 current_body: None,
2968 expected_dashboard: None,
2969 allow_non_required_check_failure: false,
2970 },
2971 );
2972 assert!(
2973 result.ready,
2974 "waived task row should be terminal: {:?}",
2975 result.checks
2976 );
2977 assert!(result.blocked_codes.is_empty());
2978 }
2979
2980 #[test]
2981 fn strict_gate_blocks_when_state_not_complete() {
2982 let state = v2_body(
2983 "state",
2984 json!({"status": "in-progress", "target_scope": "s", "tasks": [], "prs": [], "blockers": [], "links": {}}),
2985 );
2986 let source = v2_body("source", json!({"path": "p", "commit": "c"}));
2987 let plan = v2_body("plan", json!({"path": "p", "commit": "c"}));
2988 let session = v2_body("session", json!({"summary": "session complete"}));
2989 let validation = v2_body("validation", json!({"overall": "pass"}));
2990 let review = v2_body("review", json!({"decision": "approve"}));
2991 let audit = build_audit_with_evidence(vec![
2992 (source, "a"),
2993 (plan, "b"),
2994 (state, "c"),
2995 (session, "d"),
2996 (validation, "d"),
2997 (review, "e"),
2998 ]);
2999 let result = evaluate_strict_closeout_gate(
3000 &audit,
3001 StrictCloseoutGateInput {
3002 profile: RecordProfile::Tracking,
3003 approval: Some("ok"),
3004 linked_prs: &[],
3005 current_body: None,
3006 expected_dashboard: None,
3007 allow_non_required_check_failure: false,
3008 },
3009 );
3010 assert!(!result.ready);
3011 assert!(
3012 result
3013 .blocked_codes
3014 .iter()
3015 .any(|c| c == "state-not-complete"),
3016 "{:?}",
3017 result.blocked_codes
3018 );
3019 }
3020
3021 #[test]
3022 fn strict_gate_blocks_when_review_rejected_or_unresolved() {
3023 let source = v2_body("source", json!({"path": "p", "commit": "c"}));
3024 let plan = v2_body("plan", json!({"path": "p", "commit": "c"}));
3025 let state = v2_body(
3026 "state",
3027 json!({"status": "complete", "tasks": [], "prs": [], "blockers": [], "links": {}}),
3028 );
3029 let session = v2_body("session", json!({"summary": "session complete"}));
3030 let validation = v2_body("validation", json!({"overall": "pass"}));
3031 let review_rejected = v2_body("review", json!({"decision": "request-changes"}));
3032 let audit_rej = build_audit_with_evidence(vec![
3033 (source.clone(), "a"),
3034 (plan.clone(), "b"),
3035 (state.clone(), "c"),
3036 (session.clone(), "d"),
3037 (validation.clone(), "d"),
3038 (review_rejected, "e"),
3039 ]);
3040 let res_rej = evaluate_strict_closeout_gate(
3041 &audit_rej,
3042 StrictCloseoutGateInput {
3043 profile: RecordProfile::Tracking,
3044 approval: Some("ok"),
3045 linked_prs: &[],
3046 current_body: None,
3047 expected_dashboard: None,
3048 allow_non_required_check_failure: false,
3049 },
3050 );
3051 assert!(res_rej.blocked_codes.iter().any(|c| c == "review-rejected"));
3052
3053 let review_unresolved = v2_body(
3054 "review",
3055 json!({
3056 "decision": "approve",
3057 "findings": [
3058 {"id": "F1", "severity": "blocker", "disposition": "residual", "summary": "x"}
3059 ]
3060 }),
3061 );
3062 let audit_un = build_audit_with_evidence(vec![
3063 (source, "a"),
3064 (plan, "b"),
3065 (state, "c"),
3066 (session, "d"),
3067 (validation, "d"),
3068 (review_unresolved, "e"),
3069 ]);
3070 let res_un = evaluate_strict_closeout_gate(
3071 &audit_un,
3072 StrictCloseoutGateInput {
3073 profile: RecordProfile::Tracking,
3074 approval: Some("ok"),
3075 linked_prs: &[],
3076 current_body: None,
3077 expected_dashboard: None,
3078 allow_non_required_check_failure: false,
3079 },
3080 );
3081 assert!(
3082 res_un
3083 .blocked_codes
3084 .iter()
3085 .any(|c| c == "review-unresolved-findings")
3086 );
3087 }
3088
3089 #[test]
3090 fn strict_gate_blocks_when_linked_pr_missing_merge_sha() {
3091 let source = v2_body("source", json!({"path": "p", "commit": "c"}));
3092 let plan = v2_body("plan", json!({"path": "p", "commit": "c"}));
3093 let state = v2_body(
3094 "state",
3095 json!({"status": "complete", "tasks": [], "prs": [], "blockers": [], "links": {}}),
3096 );
3097 let session = v2_body("session", json!({"summary": "session complete"}));
3098 let validation = v2_body("validation", json!({"overall": "pass"}));
3099 let review = v2_body("review", json!({"decision": "approve"}));
3100 let audit = build_audit_with_evidence(vec![
3101 (source, "a"),
3102 (plan, "b"),
3103 (state, "c"),
3104 (session, "d"),
3105 (validation, "d"),
3106 (review, "e"),
3107 ]);
3108 let linked = vec![LinkedPrEvidence {
3109 pr_ref: "owner/repo#1".to_string(),
3110 url: None,
3111 merge_sha: None,
3112 checks: CheckStatus::Pass,
3113 required_state: Some(CheckStatus::Pass),
3114 required_count: Some(0),
3115 non_required_failures: Vec::new(),
3116 }];
3117 let res = evaluate_strict_closeout_gate(
3118 &audit,
3119 StrictCloseoutGateInput {
3120 profile: RecordProfile::Tracking,
3121 approval: Some("ok"),
3122 linked_prs: &linked,
3123 current_body: None,
3124 expected_dashboard: None,
3125 allow_non_required_check_failure: false,
3126 },
3127 );
3128 assert!(
3129 res.blocked_codes
3130 .iter()
3131 .any(|c| c == "linked-pr-not-merged")
3132 );
3133 }
3134
3135 #[test]
3136 fn strict_gate_passes_with_non_required_failure_when_required_pass() {
3137 let audit = build_audit_with_evidence(vec![
3140 (v2_body("source", json!({"path": "p", "commit": "c"})), "a"),
3141 (v2_body("plan", json!({"path": "p", "commit": "c"})), "b"),
3142 (
3143 v2_body(
3144 "state",
3145 json!({"status": "complete", "tasks": [], "prs": [], "blockers": [], "links": {}}),
3146 ),
3147 "c",
3148 ),
3149 (
3150 v2_body("session", json!({"summary": "session complete"})),
3151 "d",
3152 ),
3153 (v2_body("validation", json!({"overall": "pass"})), "d"),
3154 (v2_body("review", json!({"decision": "approve"})), "e"),
3155 ]);
3156 let linked = vec![LinkedPrEvidence {
3157 pr_ref: "owner/repo#1".to_string(),
3158 url: None,
3159 merge_sha: Some("abc".to_string()),
3160 checks: CheckStatus::Fail,
3161 required_state: Some(CheckStatus::Pass),
3162 required_count: Some(0),
3163 non_required_failures: vec!["scripts/ci/all.sh".to_string()],
3164 }];
3165 let res = evaluate_strict_closeout_gate(
3166 &audit,
3167 StrictCloseoutGateInput {
3168 profile: RecordProfile::Tracking,
3169 approval: Some("ok"),
3170 linked_prs: &linked,
3171 current_body: None,
3172 expected_dashboard: None,
3173 allow_non_required_check_failure: false,
3174 },
3175 );
3176 assert!(res.ready, "blocked: {:?}", res.blocked_codes);
3177 assert!(res.blocked_codes.is_empty(), "{:?}", res.blocked_codes);
3178 }
3179
3180 #[test]
3181 fn strict_gate_emits_linked_pr_checks_failed_when_required_fail() {
3182 let audit = build_audit_with_evidence(vec![
3183 (v2_body("source", json!({"path": "p", "commit": "c"})), "a"),
3184 (v2_body("plan", json!({"path": "p", "commit": "c"})), "b"),
3185 (
3186 v2_body(
3187 "state",
3188 json!({"status": "complete", "tasks": [], "prs": [], "blockers": [], "links": {}}),
3189 ),
3190 "c",
3191 ),
3192 (
3193 v2_body("session", json!({"summary": "session complete"})),
3194 "d",
3195 ),
3196 (v2_body("validation", json!({"overall": "pass"})), "d"),
3197 (v2_body("review", json!({"decision": "approve"})), "e"),
3198 ]);
3199 let linked = vec![LinkedPrEvidence {
3200 pr_ref: "owner/repo#1".to_string(),
3201 url: None,
3202 merge_sha: Some("abc".to_string()),
3203 checks: CheckStatus::Fail,
3204 required_state: Some(CheckStatus::Fail),
3205 required_count: Some(2),
3206 non_required_failures: Vec::new(),
3207 }];
3208 let res = evaluate_strict_closeout_gate(
3209 &audit,
3210 StrictCloseoutGateInput {
3211 profile: RecordProfile::Tracking,
3212 approval: Some("ok"),
3213 linked_prs: &linked,
3214 current_body: None,
3215 expected_dashboard: None,
3216 allow_non_required_check_failure: false,
3217 },
3218 );
3219 assert!(
3220 res.blocked_codes
3221 .iter()
3222 .any(|c| c == "linked-pr-checks-failed"),
3223 "expected linked-pr-checks-failed, got {:?}",
3224 res.blocked_codes
3225 );
3226 assert!(
3227 !res.blocked_codes
3228 .iter()
3229 .any(|c| c == "linked-pr-not-merged"),
3230 "must not collapse into linked-pr-not-merged"
3231 );
3232 }
3233
3234 #[test]
3235 fn strict_gate_override_unblocks_unknown_required_state_aggregate_fail() {
3236 let audit = build_audit_with_evidence(vec![
3237 (v2_body("source", json!({"path": "p", "commit": "c"})), "a"),
3238 (v2_body("plan", json!({"path": "p", "commit": "c"})), "b"),
3239 (
3240 v2_body(
3241 "state",
3242 json!({"status": "complete", "tasks": [], "prs": [], "blockers": [], "links": {}}),
3243 ),
3244 "c",
3245 ),
3246 (
3247 v2_body("session", json!({"summary": "session complete"})),
3248 "d",
3249 ),
3250 (v2_body("validation", json!({"overall": "pass"})), "d"),
3251 (v2_body("review", json!({"decision": "approve"})), "e"),
3252 ]);
3253 let linked = vec![LinkedPrEvidence {
3254 pr_ref: "owner/repo#1".to_string(),
3255 url: None,
3256 merge_sha: Some("abc".to_string()),
3257 checks: CheckStatus::Fail,
3258 required_state: None,
3259 required_count: None,
3260 non_required_failures: vec!["opt-in/lint".to_string()],
3261 }];
3262
3263 let blocked = evaluate_strict_closeout_gate(
3264 &audit,
3265 StrictCloseoutGateInput {
3266 profile: RecordProfile::Tracking,
3267 approval: Some("ok"),
3268 linked_prs: &linked,
3269 current_body: None,
3270 expected_dashboard: None,
3271 allow_non_required_check_failure: false,
3272 },
3273 );
3274 assert!(
3275 blocked
3276 .blocked_codes
3277 .iter()
3278 .any(|c| c == "linked-pr-checks-failed"),
3279 "conservative path blocks: {:?}",
3280 blocked.blocked_codes
3281 );
3282
3283 let unblocked = evaluate_strict_closeout_gate(
3284 &audit,
3285 StrictCloseoutGateInput {
3286 profile: RecordProfile::Tracking,
3287 approval: Some("ok"),
3288 linked_prs: &linked,
3289 current_body: None,
3290 expected_dashboard: None,
3291 allow_non_required_check_failure: true,
3292 },
3293 );
3294 assert!(unblocked.ready, "{:?}", unblocked.blocked_codes);
3295 }
3296
3297 #[test]
3298 fn strict_gate_blocks_when_approval_empty() {
3299 let source = v2_body("source", json!({"path": "p", "commit": "c"}));
3300 let plan = v2_body("plan", json!({"path": "p", "commit": "c"}));
3301 let state = v2_body(
3302 "state",
3303 json!({"status": "complete", "tasks": [], "prs": [], "blockers": [], "links": {}}),
3304 );
3305 let session = v2_body("session", json!({"summary": "session complete"}));
3306 let validation = v2_body("validation", json!({"overall": "pass"}));
3307 let review = v2_body("review", json!({"decision": "approve"}));
3308 let audit = build_audit_with_evidence(vec![
3309 (source, "a"),
3310 (plan, "b"),
3311 (state, "c"),
3312 (session, "d"),
3313 (validation, "d"),
3314 (review, "e"),
3315 ]);
3316 let res = evaluate_strict_closeout_gate(
3317 &audit,
3318 StrictCloseoutGateInput {
3319 profile: RecordProfile::Tracking,
3320 approval: Some(" "),
3321 linked_prs: &[],
3322 current_body: None,
3323 expected_dashboard: None,
3324 allow_non_required_check_failure: false,
3325 },
3326 );
3327 assert!(res.blocked_codes.iter().any(|c| c == "approval-missing"));
3328 }
3329
3330 #[test]
3331 fn render_record_post_comment_emits_marker_and_hidden_payload_carrier() {
3332 let body = render_record_post_comment(
3333 RecordProfile::Tracking,
3334 LifecycleCommentKind::State,
3335 json!({"status": "complete", "tasks": [], "prs": []}),
3336 Some("session summary"),
3337 Some("2026-05-23T08:42:11Z"),
3338 )
3339 .expect("render");
3340 assert!(
3341 body.starts_with("<!-- plan-issue-record:v2 role=state profile=tracking -->"),
3342 "{body}"
3343 );
3344 assert!(
3345 !body.contains(&format!("```{PAYLOAD_FENCE_INFO}")),
3346 "{body}"
3347 );
3348 assert!(body.contains(PAYLOAD_COMMENT_PREFIX), "{body}");
3349 let payload = extract_payload(&body).expect("payload");
3350 assert_eq!(payload.schema, PAYLOAD_SCHEMA_V2);
3351 assert_eq!(payload.role, PayloadRole::State);
3352 assert!(body.contains("session summary"), "{body}");
3353 }
3354
3355 fn state_summary_with_task_ledger() -> &'static str {
3356 "## Execution State\n\n\
3357 - Status: in-progress\n\n\
3358 ## Task Ledger\n\n\
3359 | ID | Status | Task |\n\
3360 | --- | --- | --- |\n\
3361 | 1.1 | pending | Demo task |\n"
3362 }
3363
3364 fn render_state_with_display(display: TaskLedgerDisplay) -> String {
3365 render_record_post_comment_with_display(
3366 RecordProfile::Tracking,
3367 LifecycleCommentKind::State,
3368 json!({
3369 "status": "in-progress",
3370 "tasks": [{"id": "1.1", "status": "pending", "title": "Demo task"}],
3371 "prs": [],
3372 "blockers": [],
3373 "links": {}
3374 }),
3375 None,
3376 Some(state_summary_with_task_ledger()),
3377 None,
3378 display,
3379 )
3380 .expect("render")
3381 }
3382
3383 #[test]
3384 fn task_ledger_display_open_emits_open_fold() {
3385 let body = render_state_with_display(TaskLedgerDisplay::Open);
3386 assert!(body.contains("<details open>"), "{body}");
3387 assert!(
3388 body.contains("<summary>Show task ledger</summary>"),
3389 "{body}"
3390 );
3391 assert!(body.contains("| 1.1 | pending | Demo task |"), "{body}");
3392 }
3393
3394 #[test]
3395 fn task_ledger_display_collapsed_emits_closed_fold() {
3396 let body = render_state_with_display(TaskLedgerDisplay::Collapsed);
3397 assert!(body.contains("<details>"), "{body}");
3398 assert!(!body.contains("<details open>"), "{body}");
3399 }
3400
3401 #[test]
3402 fn task_ledger_display_expanded_emits_no_fold() {
3403 let body = render_state_with_display(TaskLedgerDisplay::Expanded);
3404 assert!(!body.contains("<details"), "{body}");
3405 assert!(body.contains("| 1.1 | pending | Demo task |"), "{body}");
3406 }
3407
3408 #[test]
3409 fn render_record_post_comment_synthesizes_validation_review_and_closeout() {
3410 let validation = render_record_post_comment(
3411 RecordProfile::Tracking,
3412 LifecycleCommentKind::Validation,
3413 json!({
3414 "overall": "pass",
3415 "commands": [{"command": "cargo test", "status": "pass", "evidence": "ok"}],
3416 "waivers": []
3417 }),
3418 None,
3419 None,
3420 )
3421 .expect("validation render");
3422 assert!(validation.contains("- Overall: pass"), "{validation}");
3423 assert!(
3424 validation.contains("| cargo test | pass | ok |"),
3425 "{validation}"
3426 );
3427 assert!(validation.contains(PAYLOAD_COMMENT_PREFIX), "{validation}");
3428
3429 let review = render_record_post_comment(
3430 RecordProfile::Tracking,
3431 LifecycleCommentKind::Review,
3432 json!({
3433 "decision": "approve",
3434 "lenses": ["testing", "maintainability"],
3435 "findings": [{
3436 "id": "F1",
3437 "severity": "minor",
3438 "disposition": "fixed",
3439 "summary": "covered"
3440 }],
3441 "outcome_comment_url": "https://example.test/review"
3442 }),
3443 None,
3444 None,
3445 )
3446 .expect("review render");
3447 assert!(review.contains("- Decision: approve"), "{review}");
3448 assert!(
3449 review.contains("- Lenses: testing, maintainability"),
3450 "{review}"
3451 );
3452 assert!(
3453 review.contains("| F1 | minor | fixed | covered |"),
3454 "{review}"
3455 );
3456
3457 let closeout = render_record_post_comment(
3458 RecordProfile::Tracking,
3459 LifecycleCommentKind::Closeout,
3460 json!({
3461 "final_status": "complete",
3462 "approval": {"comment_url": "https://example.test/approval"},
3463 "linked_prs": [{
3464 "ref": "owner/repo#1",
3465 "url": "https://example.test/pr/1",
3466 "merge_sha": "abc123",
3467 "checks": "pass",
3468 "required_state": "pass",
3469 "required_count": 2,
3470 "non_required_failures": []
3471 }],
3472 "non_required_check_override": {
3473 "reason": "operator accepted non-required lint",
3474 "observed_non_required_failures": ["owner/repo#1: opt-in/lint"]
3475 },
3476 "notes": "closed"
3477 }),
3478 Some("Closeout summary."),
3479 None,
3480 )
3481 .expect("closeout render");
3482 assert!(closeout.contains("Closeout summary."), "{closeout}");
3483 assert!(closeout.contains("- Final status: complete"), "{closeout}");
3484 assert!(
3485 closeout.contains("| https://example.test/pr/1 | abc123 | pass | pass (2) | none |"),
3486 "{closeout}"
3487 );
3488 assert!(
3489 closeout.contains("- Reason: operator accepted non-required lint"),
3490 "{closeout}"
3491 );
3492 assert!(
3493 closeout.contains("- Observed failures: owner/repo#1: opt-in/lint"),
3494 "{closeout}"
3495 );
3496
3497 let no_pr_closeout = render_record_post_comment(
3498 RecordProfile::Tracking,
3499 LifecycleCommentKind::Closeout,
3500 json!({
3501 "final_status": "complete",
3502 "approval": {"comment_url": "https://example.test/approval"},
3503 "linked_prs": [],
3504 "notes": "closed without linked PR"
3505 }),
3506 Some("Closeout summary."),
3507 None,
3508 )
3509 .expect("closeout render");
3510 assert!(
3511 no_pr_closeout.contains("- Linked PRs: none"),
3512 "{no_pr_closeout}"
3513 );
3514 }
3515
3516 #[test]
3517 fn render_record_post_comment_rejects_source_or_plan() {
3518 let err = render_record_post_comment(
3519 RecordProfile::Tracking,
3520 LifecycleCommentKind::Source,
3521 json!({}),
3522 None,
3523 None,
3524 )
3525 .expect_err("must reject source");
3526 assert!(err.contains("source"), "{err}");
3527 }
3528
3529 #[test]
3530 fn render_record_snapshot_comment_includes_details_and_hidden_payload() {
3531 let snapshot = SnapshotData {
3532 path: "docs/plans/sample/sample-plan.md".to_string(),
3533 commit: "abc1234".to_string(),
3534 title: Some("Sample Plan".to_string()),
3535 summary: Some("One-liner".to_string()),
3536 };
3537 let body = render_record_snapshot_comment(
3538 RecordProfile::Tracking,
3539 LifecycleCommentKind::Plan,
3540 &snapshot,
3541 "# Sample Plan\n\nbody...\n",
3542 Some("2026-05-23T08:42:11Z"),
3543 )
3544 .expect("render");
3545 assert!(
3546 body.contains("- Path: `docs/plans/sample/sample-plan.md`"),
3547 "{body}"
3548 );
3549 assert!(body.contains("- Commit: `abc1234`"), "{body}");
3550 assert!(body.contains("- Summary: One-liner"), "{body}");
3551 assert!(body.contains("<details>"), "{body}");
3552 assert!(
3553 !body.contains(&format!("```{PAYLOAD_FENCE_INFO}")),
3554 "{body}"
3555 );
3556 assert!(body.contains(PAYLOAD_COMMENT_PREFIX), "{body}");
3557 let payload = extract_payload(&body).expect("payload");
3558 assert_eq!(payload.schema, PAYLOAD_SCHEMA_V2);
3559 assert_eq!(payload.role, PayloadRole::Plan);
3560 }
3561
3562 #[test]
3563 fn extract_payload_ignores_payload_markers_inside_snapshot_details() {
3564 let nested_payload = RecordPayload {
3565 schema: PAYLOAD_SCHEMA_V2.to_string(),
3566 role: PayloadRole::State,
3567 profile: PayloadProfile::Tracking,
3568 updated_at: None,
3569 data: json!({"status": "complete"}),
3570 };
3571 let nested_carrier = render_payload_carrier(&nested_payload).expect("nested carrier");
3572 let snapshot = SnapshotData {
3573 path: "docs/plans/sample/sample-discussion-source.md".to_string(),
3574 commit: "abc1234".to_string(),
3575 title: None,
3576 summary: None,
3577 };
3578 let body = render_record_snapshot_comment(
3579 RecordProfile::Tracking,
3580 LifecycleCommentKind::Source,
3581 &snapshot,
3582 &format!(
3583 "# Source\n\n{nested_carrier}\n\n```{PAYLOAD_FENCE_INFO}\n{{not valid json}}\n```\n"
3584 ),
3585 None,
3586 )
3587 .expect("render");
3588
3589 let payload = extract_payload(&body).expect("payload");
3590 assert_eq!(payload.role, PayloadRole::Source);
3591 }
3592
3593 #[test]
3594 fn required_check_label_emits_five_distinct_branches() {
3595 assert_eq!(
3599 required_check_label(Some(CheckStatus::Pass), Some(0)),
3600 "none required"
3601 );
3602
3603 assert_eq!(
3605 required_check_label(Some(CheckStatus::Pass), Some(3)),
3606 "pass (3)"
3607 );
3608
3609 assert_eq!(required_check_label(Some(CheckStatus::Pass), None), "pass");
3612
3613 assert_eq!(
3615 required_check_label(Some(CheckStatus::Fail), Some(2)),
3616 "fail (2)"
3617 );
3618 assert_eq!(required_check_label(Some(CheckStatus::Fail), None), "fail");
3619
3620 assert_eq!(
3623 required_check_label(Some(CheckStatus::None), Some(0)),
3624 "none"
3625 );
3626 assert_eq!(required_check_label(Some(CheckStatus::None), None), "none");
3627
3628 assert_eq!(required_check_label(None, None), "unknown");
3630 assert_eq!(required_check_label(None, Some(0)), "unknown");
3631 }
3632
3633 fn golden_dump(label: &str, body: &str) {
3640 if std::env::var("LIFECYCLE_RECORD_GOLDEN_DUMP").is_ok() {
3641 eprintln!("--- BEGIN {label} ---\n{body}\n--- END {label} ---");
3642 }
3643 }
3644
3645 #[test]
3646 fn golden_state_post_comment_locks_full_wire_shape() {
3647 let body = render_record_post_comment(
3648 RecordProfile::Tracking,
3649 LifecycleCommentKind::State,
3650 json!({
3651 "status": "complete",
3652 "target_scope": "PR #599 follow-ups",
3653 "current": "delivering snapshot tests",
3654 "next_action": "open closeout comment",
3655 "tasks": [
3656 {"id": "1.1", "status": "done", "title": "ship URL parser"},
3657 {"id": "1.2", "status": "in-progress", "title": "ship snapshots"},
3658 ],
3659 "prs": [{"ref": "owner/repo#1", "url": "https://example.test/pr/1", "status": "merged"}],
3660 "blockers": [],
3661 "links": {},
3662 }),
3663 None,
3664 Some("2026-05-23T08:42:11Z"),
3665 )
3666 .expect("state render");
3667 golden_dump("state", &body);
3668 assert_eq!(
3669 body,
3670 include_str!("snapshots/state_post_comment.md"),
3671 "state post-comment shape drifted; run with LIFECYCLE_RECORD_GOLDEN_DUMP=1 to dump"
3672 );
3673 }
3674
3675 #[test]
3676 fn golden_validation_post_comment_locks_full_wire_shape() {
3677 let body = render_record_post_comment(
3678 RecordProfile::Tracking,
3679 LifecycleCommentKind::Validation,
3680 json!({
3681 "overall": "pass",
3682 "commands": [
3683 {"command": "cargo test --workspace", "status": "pass", "evidence": "all green"},
3684 {"command": "scripts/ci/local-fast.sh", "status": "pass", "evidence": "ok"},
3685 ],
3686 "waivers": [],
3687 }),
3688 None,
3689 Some("2026-05-23T08:42:11Z"),
3690 )
3691 .expect("validation render");
3692 golden_dump("validation", &body);
3693 assert_eq!(
3694 body,
3695 include_str!("snapshots/validation_post_comment.md"),
3696 "validation post-comment shape drifted"
3697 );
3698 }
3699
3700 #[test]
3701 fn golden_review_post_comment_locks_full_wire_shape() {
3702 let body = render_record_post_comment(
3703 RecordProfile::Tracking,
3704 LifecycleCommentKind::Review,
3705 json!({
3706 "decision": "approve",
3707 "lenses": ["testing", "maintainability"],
3708 "findings": [
3709 {"id": "F1", "severity": "minor", "disposition": "fixed", "summary": "covered"},
3710 ],
3711 "outcome_comment_url": "https://example.test/review",
3712 }),
3713 None,
3714 Some("2026-05-23T08:42:11Z"),
3715 )
3716 .expect("review render");
3717 golden_dump("review", &body);
3718 assert_eq!(
3719 body,
3720 include_str!("snapshots/review_post_comment.md"),
3721 "review post-comment shape drifted"
3722 );
3723 }
3724
3725 #[test]
3726 fn golden_closeout_post_comment_locks_full_wire_shape() {
3727 let body = render_record_post_comment(
3728 RecordProfile::Tracking,
3729 LifecycleCommentKind::Closeout,
3730 json!({
3731 "final_status": "complete",
3732 "approval": {"comment_url": "https://example.test/approval"},
3733 "linked_prs": [{
3734 "ref": "owner/repo#1",
3735 "url": "https://example.test/pr/1",
3736 "merge_sha": "abc1234",
3737 "checks": "pass",
3738 "required_state": "pass",
3739 "required_count": 2,
3740 "non_required_failures": []
3741 }],
3742 "notes": "shipped"
3743 }),
3744 Some("Closeout summary."),
3745 Some("2026-05-23T08:42:11Z"),
3746 )
3747 .expect("closeout render");
3748 golden_dump("closeout", &body);
3749 assert_eq!(
3750 body,
3751 include_str!("snapshots/closeout_post_comment.md"),
3752 "closeout post-comment shape drifted"
3753 );
3754 }
3755}