diff --git a/README.md b/README.md index 05af6f4..b2bf34c 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,10 @@ The MVP is Codex-first: - reads Codex hook JSON from stdin; -- captures only explicit plan blocks such as `...` or `## Accepted Plan`; +- captures only explicit plan blocks such as `...`, `...`, or `## Accepted Plan`; - stores captured plans and planning Q/A decisions in `.agent-plan.json`; -- posts a new PR comment with newly captured current-branch items when a PR exists; -- leaves the local stack queued when no PR exists yet. +- posts a new PR comment with newly captured current-branch items when an open PR exists; +- leaves the local stack queued when no open PR exists yet. ## CLI @@ -40,11 +40,13 @@ type = "command" command = "plan-to-git hook --source codex" ``` -Exact hook configuration shape can vary by Codex release. The hook command itself expects the release behavior documented by Codex hooks: `Stop` includes `last_assistant_message`, and `UserPromptSubmit` includes `prompt`. +Exact hook configuration shape can vary by Codex release. The hook command itself expects the release behavior documented by Codex hooks: `Stop` includes the final agent message (`last_agent_message`, with `last_assistant_message` still accepted for older payloads), and `UserPromptSubmit` includes `prompt`. + +If an agent emits known XML-style plan sections (`summary`, `flow`, `test_plan`, or `assumptions`) inside a proposed plan, `plan-to-git` normalizes them to Markdown headings before storage and PR sync. ## Pull Request Comments -When `gh pr view` finds a PR for the current branch, `plan-to-git` creates a new issue comment on that PR containing items that have not been posted before: +When `gh pr view` finds an open PR for the current branch, `plan-to-git` creates a new issue comment on that PR containing items that have not been posted before: ```markdown ## Agent Plan Update @@ -52,10 +54,10 @@ When `gh pr view` finds a PR for the current branch, `plan-to-git` creates a new ... ``` -The PR description is not edited. After a comment is created, `.agent-plan.json` records the posted item hashes and GitHub comment id so repeated `sync`, `hook`, or `import-codex` runs do not post the same plan again, including on a later PR. +The PR description is not edited. Closed or merged pull requests are not commented on; new items stay queued until an open PR exists. After a comment is created, `.agent-plan.json` records the posted item hashes and GitHub comment id so repeated `sync`, `hook`, or `import-codex` runs do not post the same plan again, including on a later PR. ## Safety -The hook path only uses stable hook payload fields and explicitly marked plan text. `import-codex` can backfill previous plans from `~/.codex/sessions`, but it only reads assistant message events from sessions that match the current repository and branch, and it still imports only explicit markers such as `...` or `## Accepted Plan`. +The hook path only uses stable hook payload fields and explicitly marked plan text. `import-codex` can backfill previous plans from `~/.codex/sessions`, but it only reads assistant message events from sessions that match the current repository and branch, and it still imports only explicit markers such as `...`, `...`, or `## Accepted Plan`. Captured content is redacted before local storage and PR sync. `.agent-plan.json` also acts as the sent-plan registry: content hashes prevent the same plan from being added and commented again. diff --git a/changelog.d/20260603_195500_guard_plan_sync_closed_prs.md b/changelog.d/20260603_195500_guard_plan_sync_closed_prs.md new file mode 100644 index 0000000..1029ee3 --- /dev/null +++ b/changelog.d/20260603_195500_guard_plan_sync_closed_prs.md @@ -0,0 +1,9 @@ +--- +bump: patch +--- + +### Fixed +- Kept captured plan updates queued instead of posting comments to closed or merged pull requests. + +### Changed +- Accepted current Codex `last_agent_message` hook payloads and normalized known XML-style plan sections before PR sync. diff --git a/src/capture.rs b/src/capture.rs index 61f6088..e0a5205 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -30,7 +30,7 @@ struct CodexHookInput { turn_id: Option, #[serde(default)] prompt: Option, - #[serde(default)] + #[serde(default, alias = "last_agent_message")] last_assistant_message: Option, } diff --git a/src/github.rs b/src/github.rs index 9df1617..bcda595 100644 --- a/src/github.rs +++ b/src/github.rs @@ -14,6 +14,10 @@ use crate::store::AgentPlanState; pub enum SyncStatus { NoItems, NoPullRequest, + ClosedPullRequest { + number: u64, + state: String, + }, Unchanged { number: u64, }, @@ -27,6 +31,7 @@ pub enum SyncStatus { #[derive(Debug, Deserialize)] struct PullRequest { number: u64, + state: String, } #[derive(Debug, Deserialize)] @@ -42,6 +47,12 @@ pub fn sync_state(context: &GitContext, state: &mut AgentPlanState) -> AppResult let Some(pull_request) = view_current_pr(&context.repo_root)? else { return Ok(SyncStatus::NoPullRequest); }; + if !pull_request.state.eq_ignore_ascii_case("OPEN") { + return Ok(SyncStatus::ClosedPullRequest { + number: pull_request.number, + state: pull_request.state, + }); + } let (comment_body, item_ids, item_count) = { let items = state.unposted_items_for_pr(pull_request.number); @@ -67,7 +78,7 @@ pub fn sync_state(context: &GitContext, state: &mut AgentPlanState) -> AppResult fn view_current_pr(repo_root: &Path) -> AppResult> { let output = Command::new("gh") .current_dir(repo_root) - .args(["pr", "view", "--json", "number"]) + .args(["pr", "view", "--json", "number,state,url"]) .output()?; if output.status.success() { diff --git a/src/main.rs b/src/main.rs index c4d75a0..dceeed7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -187,6 +187,9 @@ fn print_sync_status(status: &SyncStatus) { match status { SyncStatus::NoItems => println!("no captured plan items to sync"), SyncStatus::NoPullRequest => println!("no pull request found for the current branch"), + SyncStatus::ClosedPullRequest { number, state } => { + println!("pull request #{number} is {state}; leaving plan items queued"); + } SyncStatus::Unchanged { number } => { println!("no new plan items to comment on pull request #{number}"); } diff --git a/src/normalize.rs b/src/normalize.rs index 4a10e24..431cc70 100644 --- a/src/normalize.rs +++ b/src/normalize.rs @@ -40,21 +40,20 @@ pub fn extract_questions(message: &str) -> Vec { } fn extract_tagged_plans(message: &str) -> Vec { - let lower = message.to_lowercase(); - let open_tag = ""; let close_tag = ""; let mut cursor = 0; let mut plans = Vec::new(); - while let Some(relative_start) = lower[cursor..].find(open_tag) { - let content_start = cursor + relative_start + open_tag.len(); + while let Some(open_tag) = find_proposed_plan_open_tag(message, cursor) { + let content_start = open_tag.end; let mut close_cursor = content_start; let Some(content_end) = (loop { - let Some(relative_end) = lower[close_cursor..].find(close_tag) else { + let Some(candidate_start) = + find_ascii_case_insensitive(message, close_tag, close_cursor) + else { break None; }; - let candidate_start = close_cursor + relative_end; let candidate_end = candidate_start + close_tag.len(); if closes_plan_block(message, candidate_end) { break Some(candidate_start); @@ -66,10 +65,19 @@ fn extract_tagged_plans(message: &str) -> Vec { let content = message[content_start..content_end].trim(); if !content.is_empty() { - plans.push(CapturedPlan { - title: first_heading(content), - content: content.to_owned(), - }); + let content_heading = first_heading(content); + let has_content_heading = content_heading.is_some(); + let title = content_heading.or(open_tag.title); + let content = if has_content_heading { + content.to_owned() + } else { + title.as_deref().map_or_else( + || content.to_owned(), + |title| format!("# {title}\n\n{content}"), + ) + }; + let content = normalize_xml_plan_sections(&content); + plans.push(CapturedPlan { title, content }); } cursor = content_end + close_tag.len(); } @@ -77,6 +85,157 @@ fn extract_tagged_plans(message: &str) -> Vec { plans } +struct ProposedPlanOpenTag { + end: usize, + title: Option, +} + +fn find_proposed_plan_open_tag(message: &str, cursor: usize) -> Option { + const OPEN_TAG_START: &str = "')? + 1; + let tag = &message[tag_start..tag_end]; + let title = attribute_value(tag, "title") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + + return Some(ProposedPlanOpenTag { + end: tag_end, + title, + }); + } + + None +} + +fn attribute_value<'a>(tag: &'a str, name: &str) -> Option<&'a str> { + let mut index = "' { + break; + } + + if character.is_ascii_whitespace() { + index += character.len_utf8(); + continue; + } + + let attribute_name_start = index; + while index < tag.len() { + let Some(character) = tag[index..].chars().next() else { + break; + }; + if !matches!(character, '-' | '_' | ':') && !character.is_ascii_alphanumeric() { + break; + } + index += character.len_utf8(); + } + if attribute_name_start == index { + if let Some(character) = tag[index..].chars().next() { + index += character.len_utf8(); + } + continue; + } + + let attribute_name = &tag[attribute_name_start..index]; + while index < tag.len() { + let Some(character) = tag[index..].chars().next() else { + break; + }; + if !character.is_ascii_whitespace() { + break; + } + index += character.len_utf8(); + } + + if !tag[index..].starts_with('=') { + continue; + } + index += 1; + + while index < tag.len() { + let Some(character) = tag[index..].chars().next() else { + break; + }; + if !character.is_ascii_whitespace() { + break; + } + index += character.len_utf8(); + } + + let Some(quote) = tag[index..].chars().next() else { + break; + }; + if quote != '"' && quote != '\'' { + let value_start = index; + while index < tag.len() { + let Some(character) = tag[index..].chars().next() else { + break; + }; + if character == '>' || character.is_ascii_whitespace() { + break; + } + index += character.len_utf8(); + } + if attribute_name.eq_ignore_ascii_case(name) { + return Some(&tag[value_start..index]); + } + continue; + } + + index += quote.len_utf8(); + let value_start = index; + let Some(value_end_relative) = tag[index..].find(quote) else { + break; + }; + let value_end = index + value_end_relative; + index = value_end + quote.len_utf8(); + + if attribute_name.eq_ignore_ascii_case(name) { + return Some(&tag[value_start..value_end]); + } + } + + None +} + +fn find_ascii_case_insensitive(haystack: &str, needle: &str, from: usize) -> Option { + if needle.is_empty() || from > haystack.len() || needle.len() > haystack.len() { + return None; + } + + let haystack = haystack.as_bytes(); + let needle = needle.as_bytes(); + for index in from..=haystack.len().saturating_sub(needle.len()) { + if haystack[index..index + needle.len()] + .iter() + .zip(needle.iter()) + .all(|(candidate, expected)| candidate.eq_ignore_ascii_case(expected)) + { + return Some(index); + } + } + + None +} + fn closes_plan_block(message: &str, close_tag_end: usize) -> bool { message[close_tag_end..] .lines() @@ -84,6 +243,178 @@ fn closes_plan_block(message: &str, close_tag_end: usize) -> bool { .is_none_or(|rest_of_line| rest_of_line.trim().is_empty()) } +fn normalize_xml_plan_sections(content: &str) -> String { + let lines = content.lines().collect::>(); + let mut output = Vec::new(); + let mut index = 0; + let mut changed = false; + let mut in_code_fence = false; + let mut details_depth = 0usize; + + while index < lines.len() { + if is_code_fence_line(lines[index]) { + in_code_fence = !in_code_fence; + output.push(lines[index].to_owned()); + index += 1; + continue; + } + + if in_code_fence { + output.push(lines[index].to_owned()); + index += 1; + continue; + } + + if is_open_details_line(lines[index]) { + details_depth += 1; + output.push(lines[index].to_owned()); + index += 1; + continue; + } + + if is_close_details_line(lines[index]) { + details_depth = details_depth.saturating_sub(1); + output.push(lines[index].to_owned()); + index += 1; + continue; + } + + if details_depth > 0 { + output.push(lines[index].to_owned()); + index += 1; + continue; + } + + let Some(section) = xml_plan_section_opening(lines[index]) else { + output.push(lines[index].to_owned()); + index += 1; + continue; + }; + + let Some(close_index) = lines[index + 1..] + .iter() + .position(|line| xml_plan_section_closing(line, section.tag)) + .map(|relative_index| index + 1 + relative_index) + else { + output.push(lines[index].to_owned()); + index += 1; + continue; + }; + + trim_trailing_blank_lines(&mut output); + if !output.is_empty() { + output.push(String::new()); + } + output.push(format!("## {}", section.heading)); + + let body = dedent_lines(&lines[index + 1..close_index]); + if !body.is_empty() { + output.push(String::new()); + output.extend(body); + } + + index = close_index + 1; + changed = true; + } + + if changed { + trim_trailing_blank_lines(&mut output); + output.join("\n") + } else { + content.to_owned() + } +} + +struct XmlPlanSection { + tag: &'static str, + heading: &'static str, +} + +fn xml_plan_section_opening(line: &str) -> Option { + const SECTIONS: &[XmlPlanSection] = &[ + XmlPlanSection { + tag: "summary", + heading: "Summary", + }, + XmlPlanSection { + tag: "flow", + heading: "Flow", + }, + XmlPlanSection { + tag: "test_plan", + heading: "Test Plan", + }, + XmlPlanSection { + tag: "assumptions", + heading: "Assumptions", + }, + ]; + + let trimmed = line.trim(); + SECTIONS + .iter() + .find(|section| trimmed.eq_ignore_ascii_case(&format!("<{}>", section.tag))) + .map(|section| XmlPlanSection { + tag: section.tag, + heading: section.heading, + }) +} + +fn xml_plan_section_closing(line: &str, tag: &str) -> bool { + line.trim().eq_ignore_ascii_case(&format!("")) +} + +fn dedent_lines(lines: &[&str]) -> Vec { + let minimum_indent = lines + .iter() + .filter(|line| !line.trim().is_empty()) + .map(|line| line.len() - line.trim_start().len()) + .min() + .unwrap_or(0); + + let mut dedented = lines + .iter() + .map(|line| { + if line.trim().is_empty() { + String::new() + } else { + line.get(minimum_indent..).unwrap_or(line).to_owned() + } + }) + .collect::>(); + + while dedented.first().is_some_and(String::is_empty) { + dedented.remove(0); + } + trim_trailing_blank_lines(&mut dedented); + dedented +} + +fn trim_trailing_blank_lines(lines: &mut Vec) { + while lines.last().is_some_and(|line| line.trim().is_empty()) { + lines.pop(); + } +} + +fn is_code_fence_line(line: &str) -> bool { + let trimmed = line.trim_start(); + trimmed.starts_with("```") || trimmed.starts_with("~~~") +} + +fn is_open_details_line(line: &str) -> bool { + let trimmed = line.trim(); + trimmed.eq_ignore_ascii_case("
") + || (trimmed.len() > "
".len() + && trimmed + .get(.."
')) +} + +fn is_close_details_line(line: &str) -> bool { + line.trim().eq_ignore_ascii_case("
") +} + fn extract_accepted_plan_headings(message: &str) -> Vec { let lines: Vec<&str> = message.lines().collect(); let mut plans = Vec::new(); diff --git a/tests/integration/cli.rs b/tests/integration/cli.rs index ee15256..1e32d4a 100644 --- a/tests/integration/cli.rs +++ b/tests/integration/cli.rs @@ -55,6 +55,101 @@ mod unix { assert!(state.contains("Capture plan")); } + #[test] + fn hook_accepts_current_codex_last_agent_message() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let bin_dir = temp_dir.path().join("bin"); + let repo_dir = temp_dir.path().join("repo"); + fs::create_dir_all(&bin_dir).expect("bin dir"); + fs::create_dir_all(&repo_dir).expect("repo dir"); + write_fake_git(&bin_dir, &repo_dir); + write_fake_gh_no_pr(&bin_dir); + + run_hook( + &repo_dir, + &bin_dir, + &format!( + r#"{{ + "session_id":"session", + "cwd":"{}", + "hook_event_name":"Stop", + "turn_id":"turn", + "last_agent_message":"\n# Current Codex\n\n- Capture current payload\n" + }}"#, + repo_dir.display() + ), + ); + + let state = fs::read_to_string(repo_dir.join(STATE_FILE_NAME)).expect("state file"); + assert!(state.contains("Capture current payload")); + } + + #[test] + fn hook_accepts_proposed_plan_title_attribute() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let bin_dir = temp_dir.path().join("bin"); + let repo_dir = temp_dir.path().join("repo"); + fs::create_dir_all(&bin_dir).expect("bin dir"); + fs::create_dir_all(&repo_dir).expect("repo dir"); + write_fake_git(&bin_dir, &repo_dir); + write_fake_gh_no_pr(&bin_dir); + + run_hook( + &repo_dir, + &bin_dir, + &format!( + r#"{{ + "session_id":"session", + "cwd":"{}", + "hook_event_name":"Stop", + "turn_id":"turn", + "last_agent_message":"\n- Capture title attribute\n" + }}"#, + repo_dir.display() + ), + ); + + let state = fs::read_to_string(repo_dir.join(STATE_FILE_NAME)).expect("state file"); + assert!(state.contains("Attribute Hook Plan")); + assert!(state.contains("# Attribute Hook Plan")); + assert!(state.contains("Capture title attribute")); + } + + #[test] + fn hook_normalizes_xml_plan_sections() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let bin_dir = temp_dir.path().join("bin"); + let repo_dir = temp_dir.path().join("repo"); + fs::create_dir_all(&bin_dir).expect("bin dir"); + fs::create_dir_all(&repo_dir).expect("repo dir"); + write_fake_git(&bin_dir, &repo_dir); + write_fake_gh_no_pr(&bin_dir); + + run_hook( + &repo_dir, + &bin_dir, + &format!( + r#"{{ + "session_id":"session", + "cwd":"{}", + "hook_event_name":"Stop", + "turn_id":"turn", + "last_agent_message":"\n \n Verify production capture.\n \n\n \n 1. Check GitHub comment.\n \n" + }}"#, + repo_dir.display() + ), + ); + + let state = fs::read_to_string(repo_dir.join(STATE_FILE_NAME)).expect("state file"); + assert!(state.contains("# XML Plan")); + assert!(state.contains("## Summary")); + assert!(state.contains("Verify production capture.")); + assert!(state.contains("## Test Plan")); + assert!(state.contains("1. Check GitHub comment.")); + assert!(!state.contains("")); + assert!(!state.contains("")); + } + #[test] fn hook_records_question_answer_decision() { let temp_dir = tempfile::tempdir().expect("temp dir"); @@ -137,6 +232,109 @@ mod unix { assert!(state.contains("\"comment_id\": 12345")); } + #[test] + fn hook_leaves_plans_queued_when_pr_is_merged() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let bin_dir = temp_dir.path().join("bin"); + let repo_dir = temp_dir.path().join("repo"); + let captured_request = temp_dir.path().join("request.json"); + fs::create_dir_all(&bin_dir).expect("bin dir"); + fs::create_dir_all(&repo_dir).expect("repo dir"); + write_fake_git(&bin_dir, &repo_dir); + write_fake_gh_closed_pr(&bin_dir, "MERGED", &captured_request); + + run_hook( + &repo_dir, + &bin_dir, + &format!( + r#"{{ + "session_id":"session", + "cwd":"{}", + "hook_event_name":"Stop", + "turn_id":"turn", + "last_assistant_message":"\n# Queued\n\n- Wait for an open PR\n" + }}"#, + repo_dir.display() + ), + ); + + let state = fs::read_to_string(repo_dir.join(STATE_FILE_NAME)).expect("state file"); + assert!(state.contains("Wait for an open PR")); + assert!(state.contains("\"posted_comments\": []")); + assert!(!captured_request.exists()); + } + + #[test] + fn hook_leaves_plans_queued_when_pr_is_closed() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let bin_dir = temp_dir.path().join("bin"); + let repo_dir = temp_dir.path().join("repo"); + let captured_request = temp_dir.path().join("request.json"); + fs::create_dir_all(&bin_dir).expect("bin dir"); + fs::create_dir_all(&repo_dir).expect("repo dir"); + write_fake_git(&bin_dir, &repo_dir); + write_fake_gh_closed_pr(&bin_dir, "CLOSED", &captured_request); + + run_hook( + &repo_dir, + &bin_dir, + &format!( + r#"{{ + "session_id":"session", + "cwd":"{}", + "hook_event_name":"Stop", + "turn_id":"turn", + "last_assistant_message":"\n# Queued\n\n- Wait for an open PR\n" + }}"#, + repo_dir.display() + ), + ); + + let state = fs::read_to_string(repo_dir.join(STATE_FILE_NAME)).expect("state file"); + assert!(state.contains("Wait for an open PR")); + assert!(state.contains("\"posted_comments\": []")); + assert!(!captured_request.exists()); + } + + #[test] + fn sync_reports_merged_pr_and_does_not_comment() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let bin_dir = temp_dir.path().join("bin"); + let repo_dir = temp_dir.path().join("repo"); + let captured_request = temp_dir.path().join("request.json"); + fs::create_dir_all(&bin_dir).expect("bin dir"); + fs::create_dir_all(&repo_dir).expect("repo dir"); + write_fake_git(&bin_dir, &repo_dir); + write_fake_gh_closed_pr(&bin_dir, "MERGED", &captured_request); + + run_hook( + &repo_dir, + &bin_dir, + &format!( + r#"{{ + "session_id":"session", + "cwd":"{}", + "hook_event_name":"Stop", + "turn_id":"turn", + "last_assistant_message":"\n# Queued\n\n- Wait for an open PR\n" + }}"#, + repo_dir.display() + ), + ); + + let output = Command::new(env!("CARGO_BIN_EXE_plan-to-git")) + .arg("sync") + .current_dir(&repo_dir) + .env("PATH", path_with_fake_bin(&bin_dir)) + .output() + .expect("run sync"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout"); + assert!(stdout.contains("pull request #17 is MERGED; leaving plan items queued")); + assert!(!captured_request.exists()); + } + #[test] fn import_codex_backfills_history_once_without_syncing() { let temp_dir = tempfile::tempdir().expect("temp dir"); @@ -243,7 +441,7 @@ esac fn write_fake_gh_no_pr(bin_dir: &Path) { let script = r#"#!/usr/bin/env bash set -euo pipefail -if [[ "$*" == "pr view --json number" ]]; then +if [[ "$*" == "pr view --json number,state,url" ]]; then echo 'no pull requests found for branch "feature/test"' >&2 exit 1 fi @@ -257,8 +455,8 @@ exit 1 let script = format!( r#"#!/usr/bin/env bash set -euo pipefail -if [[ "$*" == "pr view --json number" ]]; then - printf '%s\n' '{{"number":17}}' +if [[ "$*" == "pr view --json number,state,url" ]]; then + printf '%s\n' '{{"number":17,"state":"OPEN","url":"https://github.com/example/repo/pull/17"}}' exit 0 fi if [[ "$1 $2 $3" == "api --method POST" && "$4" == "repos/example/repo/issues/17/comments" && "$5" == "--input" ]]; then @@ -274,6 +472,27 @@ exit 1 write_executable(&bin_dir.join("gh"), &script); } + fn write_fake_gh_closed_pr(bin_dir: &Path, state: &str, captured_request: &Path) { + let script = format!( + r#"#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" == "pr view --json number,state,url" ]]; then + printf '%s\n' '{{"number":17,"state":"{state}","url":"https://github.com/example/repo/pull/17"}}' + exit 0 +fi +if [[ "$1" == "api" ]]; then + printf '%s\n' "$*" > "{}" + echo "comment API should not be called for closed PR" >&2 + exit 1 +fi +echo "unexpected gh args: $*" >&2 +exit 1 +"#, + captured_request.display() + ); + write_executable(&bin_dir.join("gh"), &script); + } + fn write_executable(path: &Path, content: &str) { fs::write(path, content).expect("write script"); let mut permissions = fs::metadata(path).expect("metadata").permissions(); diff --git a/tests/unit/plan_capture.rs b/tests/unit/plan_capture.rs index cb693ac..f00bd8b 100644 --- a/tests/unit/plan_capture.rs +++ b/tests/unit/plan_capture.rs @@ -26,6 +26,239 @@ after assert!(plans[0].content.contains("- Add capture.")); } +#[test] +fn extracts_tagged_plan_with_title_attribute() { + let message = r#" + +- Capture attribute title. + +"#; + + let plans = extract_marked_plans(message); + + assert_eq!(plans.len(), 1); + assert_eq!(plans[0].title.as_deref(), Some("Attribute Plan")); + assert!(plans[0].content.starts_with("# Attribute Plan")); + assert!(plans[0].content.contains("- Capture attribute title.")); +} + +#[test] +fn normalizes_xml_plan_sections_to_markdown() { + let message = r#" + + + Verify end-to-end capture. + + + + 1. Inspect hooks. + 2. Confirm GitHub comment. + + + + 1. Run import. + 2. Check sync. + + + + 1. gh is authenticated. + + +"#; + + let plans = extract_marked_plans(message); + + assert_eq!(plans.len(), 1); + assert_eq!( + plans[0].content, + r"# Production Hook Verification Plan + +## Summary + +Verify end-to-end capture. + +## Flow + +1. Inspect hooks. +2. Confirm GitHub comment. + +## Test Plan + +1. Run import. +2. Check sync. + +## Assumptions + +1. gh is authenticated." + ); +} + +#[test] +fn leaves_unknown_xml_plan_content_unchanged() { + let message = r#" + + + Keep unknown tags literal. + + +"#; + + let plans = extract_marked_plans(message); + + assert_eq!(plans.len(), 1); + assert!(plans[0].content.contains("")); + assert!(plans[0].content.contains("")); +} + +#[test] +fn leaves_xml_section_examples_in_code_fences_unchanged() { + let message = r#" + +```xml + + Keep this example literal. + +``` + +"#; + + let plans = extract_marked_plans(message); + + assert_eq!(plans.len(), 1); + assert!(plans[0].content.contains("```xml\n")); + assert!(plans[0].content.contains("\n```")); + assert!(!plans[0].content.contains("## Summary")); +} + +#[test] +fn leaves_details_summary_html_unchanged() { + let message = r#" + +
+ +Click to expand. + + +Body. +
+
+"#; + + let plans = extract_marked_plans(message); + + assert_eq!(plans.len(), 1); + assert!(plans[0].content.contains("
")); + assert!(plans[0].content.contains("")); + assert!(plans[0].content.contains("")); + assert!(!plans[0].content.contains("## Summary")); +} + +#[test] +fn extracts_tagged_plan_with_multiple_attributes() { + let message = r#" + +- Keep the exact title attribute. + +"#; + + let plans = extract_marked_plans(message); + + assert_eq!(plans.len(), 1); + assert_eq!(plans[0].title.as_deref(), Some("Right Plan")); + assert!(plans[0].content.starts_with("# Right Plan")); + assert!(plans[0] + .content + .contains("- Keep the exact title attribute.")); +} + +#[test] +fn extracts_multiple_attributed_plans() { + let message = r#" + +- First body. + + +- Second body. + +"#; + + let plans = extract_marked_plans(message); + + assert_eq!(plans.len(), 2); + assert_eq!(plans[0].title.as_deref(), Some("First Plan")); + assert_eq!(plans[1].title.as_deref(), Some("Second Plan")); + assert!(plans[0].content.contains("- First body.")); + assert!(plans[1].content.contains("- Second body.")); +} + +#[test] +fn does_not_confuse_data_title_with_title() { + let message = r#" + +- No visible attribute title. + +"#; + + let plans = extract_marked_plans(message); + + assert_eq!(plans.len(), 1); + assert_eq!(plans[0].title, None); + assert!(!plans[0].content.contains("Wrong")); + assert!(plans[0] + .content + .starts_with("- No visible attribute title.")); +} + +#[test] +fn ignores_blank_title_attribute() { + let message = r#" + +- No empty heading. + +"#; + + let plans = extract_marked_plans(message); + + assert_eq!(plans.len(), 1); + assert_eq!(plans[0].title, None); + assert!(!plans[0].content.starts_with("# ")); + assert!(plans[0].content.starts_with("- No empty heading.")); +} + +#[test] +fn content_heading_takes_precedence_over_title_attribute() { + let message = r#" + +# Content Plan + +- Keep content heading. + +"#; + + let plans = extract_marked_plans(message); + + assert_eq!(plans.len(), 1); + assert_eq!(plans[0].title.as_deref(), Some("Content Plan")); + assert!(plans[0].content.starts_with("# Content Plan")); + assert!(!plans[0].content.contains("# Attribute Plan")); +} + +#[test] +fn rejects_proposed_plan_prefix_tags() { + let message = r#" + +- Do not capture. + + +- Do not capture either. + +"#; + + let plans = extract_marked_plans(message); + + assert!(plans.is_empty()); +} + #[test] fn tagged_plan_ignores_inline_tag_examples() { let message = r"