Skip to main content

plan_issue/
render.rs

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(&notes);
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}