1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use nils_markdown::Engine;
6use serde::Serialize;
7
8use crate::commands::SplitStrategy;
9use crate::issue_body;
10use crate::task_spec::{
11 TaskSpecRow, execution_mode_by_task, runtime_lane_metadata_by_task, state_dir,
12};
13use nils_common::fs as common_fs;
14use nils_common::git as common_git;
15use nils_common::markdown as common_markdown;
16
17const PLAN_ISSUE_BODY_TEMPLATE: &str = include_str!("../templates/render/plan_issue_body.md.tera");
18const PLAN_ISSUE_BODY_TEMPLATE_NAME: &str = "render_plan_issue_body";
19
20const SPRINT_COMMENT_TEMPLATE: &str = include_str!("../templates/render/sprint_comment.md.tera");
21const SPRINT_COMMENT_TEMPLATE_NAME: &str = "render_sprint_comment";
22
23#[derive(Debug, Serialize)]
24struct PlanIssueBodyView<'a> {
25 pre_table: String,
26 task_table_block: String,
27 plan_file_display: &'a str,
28}
29
30#[derive(Debug, Serialize)]
31struct SprintCommentView<'a> {
32 heading: String,
33 sprint: i32,
34 sprint_name: &'a str,
35 task_count: usize,
36 lead: &'static str,
37 mode: &'static str,
38 approval_comment_url: Option<&'a str>,
39 sprint_section: Option<String>,
40 note_text: Option<String>,
41 task_rows: Vec<SprintTaskRowView<'a>>,
42}
43
44#[derive(Debug, Serialize)]
45struct SprintTaskRowView<'a> {
46 task: &'a str,
47 summary: String,
48 third_col: String,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum SprintCommentMode {
53 Start,
54 Ready,
55 Accepted,
56}
57
58#[derive(Debug, Clone)]
59pub struct SprintCommentInput<'a> {
60 pub mode: SprintCommentMode,
61 pub plan_file: &'a Path,
62 pub sprint: i32,
63 pub sprint_name: &'a str,
64 pub rows: &'a [TaskSpecRow],
65 pub strategy: SplitStrategy,
66 pub note_text: Option<&'a str>,
67 pub approval_comment_url: Option<&'a str>,
68 pub issue_body_text: Option<&'a str>,
69}
70
71pub fn default_plan_issue_body_path(plan_file: &Path) -> PathBuf {
72 let plan_stem = plan_file
73 .file_stem()
74 .and_then(|name| name.to_str())
75 .unwrap_or("plan")
76 .to_string();
77 state_dir()
78 .join("out")
79 .join("plan-issue-delivery")
80 .join(format!("{plan_stem}-plan-issue-body.md"))
81}
82
83pub fn default_sprint_comment_path(
84 plan_file: &Path,
85 sprint: i32,
86 mode: SprintCommentMode,
87) -> PathBuf {
88 let plan_stem = plan_file
89 .file_stem()
90 .and_then(|name| name.to_str())
91 .unwrap_or("plan")
92 .to_string();
93 let mode_label = match mode {
94 SprintCommentMode::Start => "start",
95 SprintCommentMode::Ready => "ready",
96 SprintCommentMode::Accepted => "accepted",
97 };
98
99 state_dir()
100 .join("out")
101 .join("plan-issue-delivery")
102 .join(format!(
103 "{plan_stem}-sprint-{sprint}-{mode_label}-comment.md"
104 ))
105}
106
107pub fn render_plan_issue_body(
108 plan_file: &Path,
109 plan_file_display: &str,
110 plan_title: &str,
111 rows: &[TaskSpecRow],
112 strategy: SplitStrategy,
113) -> String {
114 let fallback_title = if plan_title.trim().is_empty() {
115 Path::new(plan_file_display)
116 .file_stem()
117 .and_then(|v| v.to_str())
118 .unwrap_or("Plan")
119 .to_string()
120 } else {
121 plan_title.trim().to_string()
122 };
123
124 let mut header_lines = load_pre_sprint_plan_lines(plan_file)
125 .filter(|lines| !lines.is_empty())
126 .unwrap_or_else(|| vec![format!("# {fallback_title}")]);
127 while header_lines
128 .last()
129 .map(|line| line.trim().is_empty())
130 .unwrap_or(false)
131 {
132 header_lines.pop();
133 }
134 let pre_table = header_lines.join("\n");
135
136 let runtime_lane_metadata = runtime_lane_metadata_by_task(rows, strategy);
137 let task_rows: Vec<issue_body::TaskRow> = rows
138 .iter()
139 .map(|row| {
140 let lane = runtime_lane_metadata.get(&row.task_id);
141 let owner = lane
142 .map(|metadata| metadata.owner.clone())
143 .unwrap_or_else(|| row.owner.clone());
144 let branch = lane
145 .map(|metadata| metadata.branch.clone())
146 .unwrap_or_else(|| row.branch.clone());
147 let worktree = lane
148 .map(|metadata| metadata.worktree.clone())
149 .unwrap_or_else(|| row.worktree.clone());
150 let execution_mode = lane
151 .map(|metadata| metadata.execution_mode.clone())
152 .unwrap_or_else(|| "pr-isolated".to_string());
153 let notes = lane
154 .map(|metadata| metadata.notes.trim().to_string())
155 .unwrap_or_else(|| row.notes.trim().to_string());
156 let notes = common_markdown::canonicalize_table_cell(¬es);
157 let notes = if notes.trim().is_empty() {
158 "-".to_string()
159 } else {
160 notes
161 };
162 issue_body::TaskRow {
163 task: row.task_id.clone(),
164 summary: row.summary.clone(),
165 owner,
166 branch,
167 worktree,
168 execution_mode,
169 pr: "TBD".to_string(),
170 status: "planned".to_string(),
171 notes,
172 line_index: 0,
173 }
174 })
175 .collect();
176
177 let task_table_block = issue_body::render_task_decomposition_block(&task_rows)
178 .expect("task-decomposition block renders");
179
180 let view = PlanIssueBodyView {
181 pre_table,
182 task_table_block,
183 plan_file_display,
184 };
185
186 let mut engine = Engine::builder().build();
187 engine
188 .register_template(PLAN_ISSUE_BODY_TEMPLATE_NAME, PLAN_ISSUE_BODY_TEMPLATE)
189 .expect("plan_issue_body template registers");
190 engine
191 .render(PLAN_ISSUE_BODY_TEMPLATE_NAME, &view)
192 .expect("plan_issue_body template renders")
193}
194
195fn load_pre_sprint_plan_lines(plan_file: &Path) -> Option<Vec<String>> {
196 let repo_root = detect_repo_root();
197 let resolved = resolve_repo_relative(&repo_root, plan_file);
198 let text = fs::read_to_string(&resolved).ok()?;
199 let lines: Vec<String> = text.lines().map(|line| line.to_string()).collect();
200 if lines.is_empty() {
201 return None;
202 }
203
204 let mut preface_end = lines.len();
205 for (idx, line) in lines.iter().enumerate() {
206 if let Some((level, heading)) = parse_heading(line)
207 && level == 2
208 && parse_sprint_heading_number(&heading) == Some(1)
209 {
210 preface_end = idx;
211 break;
212 }
213 }
214
215 Some(lines.into_iter().take(preface_end).collect())
216}
217
218fn parse_sprint_heading_number(heading: &str) -> Option<i32> {
219 let normalized = heading.trim().to_ascii_lowercase();
220 let rest = normalized.strip_prefix("sprint ")?;
221 let digits: String = rest.chars().take_while(|ch| ch.is_ascii_digit()).collect();
222 if digits.is_empty() {
223 return None;
224 }
225 digits.parse::<i32>().ok()
226}
227
228fn parse_heading(line: &str) -> Option<(usize, String)> {
229 let trimmed = line.trim();
230 if !trimmed.starts_with('#') {
231 return None;
232 }
233
234 let level = trimmed.chars().take_while(|ch| *ch == '#').count();
235 if !(1..=6).contains(&level) {
236 return None;
237 }
238
239 let heading = trimmed[level..].trim();
240 if heading.is_empty() {
241 None
242 } else {
243 Some((level, heading.to_string()))
244 }
245}
246
247pub fn render_sprint_comment(input: SprintCommentInput<'_>) -> Result<String, String> {
248 let SprintCommentInput {
249 mode,
250 plan_file,
251 sprint,
252 sprint_name,
253 rows,
254 strategy,
255 note_text,
256 approval_comment_url,
257 issue_body_text,
258 } = input;
259
260 if rows.is_empty() {
261 return Err("task spec contains no rows".to_string());
262 }
263
264 let execution_modes = execution_mode_by_task(rows, strategy);
265
266 let issue_pr_values = issue_body_text
267 .map(parse_issue_pr_values)
268 .unwrap_or_default();
269
270 let (heading, lead, mode_token) = match mode {
271 SprintCommentMode::Start => (
272 format!("## Sprint {sprint} Start"),
273 "Main-agent starts this sprint on the plan issue and dispatches implementation to subagents.",
274 "start",
275 ),
276 SprintCommentMode::Ready => (
277 format!("## Sprint {sprint} Ready for Review"),
278 "Main-agent requests sprint-level review before merge/acceptance on the plan issue (the issue remains open).",
279 "ready",
280 ),
281 SprintCommentMode::Accepted => (
282 format!("## Sprint {sprint} Accepted"),
283 "Main-agent records sprint acceptance after merge gate passes and sprint rows are synced to done (issue remains open for remaining sprints).",
284 "accepted",
285 ),
286 };
287
288 let approval_url = approval_comment_url
289 .map(str::trim)
290 .filter(|trimmed| !trimmed.is_empty());
291
292 let task_rows: Vec<SprintTaskRowView<'_>> = rows
293 .iter()
294 .map(|row| {
295 let summary = if row.summary.is_empty() {
296 "-".to_string()
297 } else {
298 row.summary.clone()
299 };
300 let third_col = match mode {
301 SprintCommentMode::Start => execution_modes
302 .get(&row.task_id)
303 .cloned()
304 .unwrap_or_else(|| "pr-isolated".to_string()),
305 SprintCommentMode::Ready | SprintCommentMode::Accepted => {
306 let mut pr_value = issue_pr_values
307 .get(&row.task_id)
308 .map(|v| normalize_pr_display(v))
309 .unwrap_or_default();
310 if pr_value.is_empty() {
311 let execution_mode = execution_modes
312 .get(&row.task_id)
313 .map(String::as_str)
314 .unwrap_or("pr-isolated");
315 pr_value = if execution_mode == "per-sprint" {
316 "TBD (per-sprint)".to_string()
317 } else {
318 format!("TBD (group:{})", row.pr_group)
319 };
320 }
321 pr_value
322 }
323 };
324 SprintTaskRowView {
325 task: &row.task_id,
326 summary,
327 third_col,
328 }
329 })
330 .collect();
331
332 let sprint_section = if mode == SprintCommentMode::Start {
333 let section = extract_sprint_section(plan_file, sprint)?;
334 if section.is_empty() {
335 None
336 } else {
337 Some(section)
338 }
339 } else {
340 None
341 };
342
343 let note_text_owned = note_text
344 .map(str::trim)
345 .filter(|trimmed| !trimmed.is_empty())
346 .map(str::to_string);
347
348 let view = SprintCommentView {
349 heading,
350 sprint,
351 sprint_name,
352 task_count: rows.len(),
353 lead,
354 mode: mode_token,
355 approval_comment_url: approval_url,
356 sprint_section,
357 note_text: note_text_owned,
358 task_rows,
359 };
360
361 let mut engine = Engine::builder().build();
362 engine
363 .register_template(SPRINT_COMMENT_TEMPLATE_NAME, SPRINT_COMMENT_TEMPLATE)
364 .map_err(|err| format!("sprint_comment template register failed: {err}"))?;
365 engine
366 .render(SPRINT_COMMENT_TEMPLATE_NAME, &view)
367 .map_err(|err| format!("sprint_comment template render failed: {err}"))
368}
369
370pub fn write_rendered(path: &Path, content: &str) -> Result<(), String> {
371 common_fs::write_text(path, content).map_err(|err| match err {
372 common_fs::WriteTextError::CreateParentDir { path, source } => {
373 format!(
374 "failed to create output directory {}: {source}",
375 path.display()
376 )
377 }
378 common_fs::WriteTextError::WriteFile { source, .. } => {
379 format!("failed to write {}: {source}", path.display())
380 }
381 })
382}
383
384fn parse_issue_pr_values(issue_body_text: &str) -> HashMap<String, String> {
385 let mut out = HashMap::new();
386 let lines: Vec<&str> = issue_body_text.lines().collect();
387
388 let Some((start, end)) = section_bounds(&lines, "## Task Decomposition") else {
389 return out;
390 };
391
392 let table_lines: Vec<&str> = lines[start..end]
393 .iter()
394 .copied()
395 .filter(|line| line.trim().starts_with('|'))
396 .collect();
397
398 if table_lines.len() < 3 {
399 return out;
400 }
401
402 let headers = parse_markdown_row(table_lines[0]);
403 let Some(task_idx) = headers.iter().position(|h| h == "Task") else {
404 return out;
405 };
406 let Some(pr_idx) = headers.iter().position(|h| h == "PR") else {
407 return out;
408 };
409
410 for line in table_lines.iter().skip(2) {
411 let cells = parse_markdown_row(line);
412 if cells.len() != headers.len() {
413 continue;
414 }
415 let task = cells[task_idx].trim();
416 let pr = cells[pr_idx].trim();
417 if task.is_empty() {
418 continue;
419 }
420 let normalized = normalize_pr_display(pr);
421 if !normalized.is_empty() {
422 out.insert(task.to_string(), normalized);
423 }
424 }
425
426 out
427}
428
429fn section_bounds(lines: &[&str], heading: &str) -> Option<(usize, usize)> {
430 let mut start = None;
431 for (idx, line) in lines.iter().enumerate() {
432 if line.trim() == heading {
433 start = Some(idx + 1);
434 break;
435 }
436 }
437 let start = start?;
438
439 let mut end = lines.len();
440 for (idx, line) in lines.iter().enumerate().skip(start) {
441 if line.starts_with("## ") {
442 end = idx;
443 break;
444 }
445 }
446
447 Some((start, end))
448}
449
450fn parse_markdown_row(line: &str) -> Vec<String> {
451 let trimmed = line.trim();
452 if !trimmed.starts_with('|') || !trimmed.ends_with('|') {
453 return Vec::new();
454 }
455 trimmed[1..trimmed.len() - 1]
456 .split('|')
457 .map(|cell| cell.trim().to_string())
458 .collect()
459}
460
461fn is_placeholder(value: &str) -> bool {
462 let token = value.trim().to_ascii_lowercase();
463 if matches!(
464 token.as_str(),
465 "" | "-" | "tbd" | "none" | "n/a" | "na" | "..."
466 ) {
467 return true;
468 }
469 if token.starts_with("tbd") {
470 return true;
471 }
472 if token.starts_with('<') && token.ends_with('>') {
473 return true;
474 }
475 token.contains("task ids")
476}
477
478fn parse_digits(token: &str) -> Option<String> {
479 if token.is_empty() || !token.chars().all(|c| c.is_ascii_digit()) {
480 return None;
481 }
482 Some(token.to_string())
483}
484
485fn normalize_pr_display(value: &str) -> String {
486 let token = value.trim();
487 if is_placeholder(token) {
488 return String::new();
489 }
490
491 if let Some(rest) = token.strip_prefix('#')
492 && let Some(num) = parse_digits(rest)
493 {
494 return format!("#{num}");
495 }
496
497 if let Some(rest) = token.to_ascii_lowercase().strip_prefix("pr#")
498 && let Some(num) = parse_digits(rest)
499 {
500 return format!("#{num}");
501 }
502
503 if let Some((_, tail)) = token.rsplit_once('#')
504 && let Some(num) = parse_digits(tail)
505 && token.contains('/')
506 {
507 return format!("#{num}");
508 }
509
510 if let Some(idx) = token.to_ascii_lowercase().find("/pull/") {
511 let after = &token[idx + "/pull/".len()..];
512 let number: String = after.chars().take_while(|ch| ch.is_ascii_digit()).collect();
513 if let Some(num) = parse_digits(&number) {
514 return format!("#{num}");
515 }
516 }
517
518 token.to_string()
519}
520
521fn extract_sprint_section(plan_file: &Path, sprint: i32) -> Result<String, String> {
522 let repo_root = detect_repo_root();
523 let resolved = resolve_repo_relative(&repo_root, plan_file);
524 let text = std::fs::read_to_string(&resolved).map_err(|err| {
525 format!(
526 "failed to read plan file {}: {err}",
527 plan_file.to_string_lossy()
528 )
529 })?;
530 let lines: Vec<&str> = text.lines().collect();
531
532 let target_prefix = format!("## Sprint {sprint}");
533 let mut start = None;
534 for (idx, line) in lines.iter().enumerate() {
535 if line.trim().starts_with(&target_prefix) {
536 start = Some(idx);
537 break;
538 }
539 }
540
541 let Some(start_idx) = start else {
542 return Ok(String::new());
543 };
544
545 let mut end_idx = lines.len();
546 for (idx, line) in lines.iter().enumerate().skip(start_idx + 1) {
547 if line.starts_with("## ") {
548 end_idx = idx;
549 break;
550 }
551 }
552
553 Ok(lines[start_idx..end_idx].join("\n").trim().to_string())
554}
555
556fn detect_repo_root() -> PathBuf {
557 common_git::repo_root_or_cwd()
558}
559
560fn resolve_repo_relative(repo_root: &Path, path: &Path) -> PathBuf {
561 if path.is_absolute() {
562 return path.to_path_buf();
563 }
564 repo_root.join(path)
565}