From 3286a253381798f679145ca95be4e309e7bd8d63 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:44:46 +0000 Subject: [PATCH 1/2] feat(cli): support explicit GitHub repo target --- README.md | 3 + changelog.d/20260617_explicit_repo_context.md | 3 + src/capture.rs | 23 +++- src/git.rs | 5 + src/github.rs | 55 +++++--- src/main.rs | 38 ++++-- src/normalize.rs | 11 +- tests/integration/cli.rs | 124 +++++++++++++++++- 8 files changed, 217 insertions(+), 45 deletions(-) create mode 100644 changelog.d/20260617_explicit_repo_context.md diff --git a/README.md b/README.md index 496e5d0..aff1789 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ plan-to-git show plan-to-git render plan-to-git sync plan-to-git sync --pr 7 +plan-to-git --repo owner/repo sync --pr 7 plan-to-git import-codex --dry-run plan-to-git import-codex plan-to-git import-claude --dry-run @@ -103,6 +104,8 @@ When `gh pr view` finds an open, non-draft PR for the current branch, `plan-to-g Use `plan-to-git sync --pr 7` to post queued current-branch items to a specific pull request instead of relying on branch-based PR discovery. `sync` is source-agnostic: one run posts all unposted current-branch items in the state file, whether they came from Codex, Claude Code, or another supported agent. +Use `--repo owner/repo` or `PLAN_TO_GIT_REPO=owner/repo` when the local `origin` remote is not the pull request target repository, for example in fork-origin workflows. The explicit repository only selects the GitHub PR/comment target; local state and history matching remain tied to the current checkout. + The PR description is not edited. Closed, merged, or still-draft pull requests are not commented on; new items stay queued until the PR is valid (open and marked ready for review). After a comment is created, the local state file records the posted item hashes and GitHub comment id so repeated `sync`, `hook`, `import-codex`, or `import-claude` runs do not post the same plan again, including on a later PR. ## Safety diff --git a/changelog.d/20260617_explicit_repo_context.md b/changelog.d/20260617_explicit_repo_context.md new file mode 100644 index 0000000..9027049 --- /dev/null +++ b/changelog.d/20260617_explicit_repo_context.md @@ -0,0 +1,3 @@ +### Added + +- Added `--repo` / `PLAN_TO_GIT_REPO` explicit GitHub repository context for hook, import, sync, show, render, and clear commands so fork-origin workflows can post plan comments to the upstream repository without mutating git remote configuration. diff --git a/src/capture.rs b/src/capture.rs index 1164084..65a006b 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -54,6 +54,13 @@ struct ClaudeHookInput { } pub fn process_codex_hook(input: &str) -> AppResult { + process_codex_hook_with_repo(input, None) +} + +pub fn process_codex_hook_with_repo( + input: &str, + target_repo: Option<&str>, +) -> AppResult { let hook_input: CodexHookInput = serde_json::from_str(input)?; process_agent_hook(&AgentHookInput { source: AgentSource::Codex, @@ -64,10 +71,18 @@ pub fn process_codex_hook(input: &str) -> AppResult { prompt: hook_input.prompt, last_assistant_message: hook_input.last_assistant_message, transcript_path: None, + target_repo, }) } pub fn process_claude_hook(input: &str) -> AppResult { + process_claude_hook_with_repo(input, None) +} + +pub fn process_claude_hook_with_repo( + input: &str, + target_repo: Option<&str>, +) -> AppResult { let hook_input: ClaudeHookInput = serde_json::from_str(input)?; process_agent_hook(&AgentHookInput { source: AgentSource::Claude, @@ -78,10 +93,11 @@ pub fn process_claude_hook(input: &str) -> AppResult { prompt: hook_input.prompt, last_assistant_message: hook_input.last_assistant_message, transcript_path: hook_input.transcript_path, + target_repo, }) } -struct AgentHookInput { +struct AgentHookInput<'a> { source: AgentSource, session_id: Option, cwd: Option, @@ -90,9 +106,10 @@ struct AgentHookInput { prompt: Option, last_assistant_message: Option, transcript_path: Option, + target_repo: Option<&'a str>, } -fn process_agent_hook(hook_input: &AgentHookInput) -> AppResult { +fn process_agent_hook(hook_input: &AgentHookInput<'_>) -> AppResult { let start_dir = hook_input.cwd.as_deref().unwrap_or_else(|| Path::new(".")); let context = git::discover(start_dir)?; let state_path = state_path::state_path(&context); @@ -200,7 +217,7 @@ fn process_agent_hook(hook_input: &AgentHookInput) -> AppResult { save_state(&state_path, &state)?; } - let sync_status = github::sync_state(&context, &mut state)?; + let sync_status = github::sync_state(&context, &mut state, hook_input.target_repo)?; if changed || !state.items.is_empty() || !state.pending_questions.is_empty() { save_state(&state_path, &state)?; } diff --git a/src/git.rs b/src/git.rs index f7de0a5..f2c9174 100644 --- a/src/git.rs +++ b/src/git.rs @@ -45,6 +45,11 @@ pub fn parse_github_slug(remote: &str) -> Option { None } +#[must_use] +pub fn parse_github_slug_or_slug(value: &str) -> Option { + parse_github_slug(value).or_else(|| normalize_slug(value.trim())) +} + fn normalize_slug(path: &str) -> Option { let mut parts = path.split('/'); let owner = parts.next()?; diff --git a/src/github.rs b/src/github.rs index 157072e..183bada 100644 --- a/src/github.rs +++ b/src/github.rs @@ -44,35 +44,41 @@ struct IssueComment { id: u64, } -pub fn sync_state(context: &GitContext, state: &mut AgentPlanState) -> AppResult { +pub fn sync_state( + context: &GitContext, + state: &mut AgentPlanState, + target_repo: Option<&str>, +) -> AppResult { if !state.has_current_branch_items() { return Ok(SyncStatus::NoItems); } - let Some(pull_request) = view_current_pr(&context.repo_root)? else { + let Some(pull_request) = view_current_pr(&context.repo_root, target_repo)? else { return Ok(SyncStatus::NoPullRequest); }; - sync_to_pull_request(context, state, pull_request) + sync_to_pull_request(context, state, pull_request, target_repo) } pub fn sync_state_to_pr( context: &GitContext, state: &mut AgentPlanState, number: u64, + target_repo: Option<&str>, ) -> AppResult { if !state.has_current_branch_items() { return Ok(SyncStatus::NoItems); } - let pull_request = view_pr(&context.repo_root, number)?; - sync_to_pull_request(context, state, pull_request) + let pull_request = view_pr(&context.repo_root, number, target_repo)?; + sync_to_pull_request(context, state, pull_request, target_repo) } fn sync_to_pull_request( context: &GitContext, state: &mut AgentPlanState, pull_request: PullRequest, + target_repo: Option<&str>, ) -> AppResult { if !pull_request.state.eq_ignore_ascii_case("OPEN") { return Ok(SyncStatus::ClosedPullRequest { @@ -97,7 +103,8 @@ fn sync_to_pull_request( (render_plan_comment(state, &items), item_ids, items.len()) }; - let comment_id = create_issue_comment(context, pull_request.number, &comment_body)?; + let comment_id = + create_issue_comment(context, pull_request.number, &comment_body, target_repo)?; state.mark_items_commented(pull_request.number, &item_ids, Some(comment_id)); Ok(SyncStatus::Commented { @@ -107,11 +114,15 @@ fn sync_to_pull_request( }) } -fn view_current_pr(repo_root: &Path) -> AppResult> { - let output = Command::new("gh") +fn view_current_pr(repo_root: &Path, target_repo: Option<&str>) -> AppResult> { + let mut command = Command::new("gh"); + command .current_dir(repo_root) - .args(["pr", "view", "--json", "number,state,url,isDraft"]) - .output()?; + .args(["pr", "view", "--json", "number,state,url,isDraft"]); + if let Some(target_repo) = target_repo { + command.args(["--repo", target_repo]); + } + let output = command.output()?; if output.status.success() { return Ok(Some(serde_json::from_slice(&output.stdout)?)); @@ -125,13 +136,17 @@ fn view_current_pr(repo_root: &Path) -> AppResult> { Err(AppError::new(format!("gh pr view failed: {stderr}")).into()) } -fn view_pr(repo_root: &Path, number: u64) -> AppResult { - let output = Command::new("gh") +fn view_pr(repo_root: &Path, number: u64, target_repo: Option<&str>) -> AppResult { + let mut command = Command::new("gh"); + command .current_dir(repo_root) .args(["pr", "view"]) .arg(number.to_string()) - .args(["--json", "number,state,url,isDraft"]) - .output()?; + .args(["--json", "number,state,url,isDraft"]); + if let Some(target_repo) = target_repo { + command.args(["--repo", target_repo]); + } + let output = command.output()?; if output.status.success() { return Ok(serde_json::from_slice(&output.stdout)?); @@ -141,10 +156,14 @@ fn view_pr(repo_root: &Path, number: u64) -> AppResult { Err(AppError::new(format!("gh pr view {number} failed: {stderr}")).into()) } -fn create_issue_comment(context: &GitContext, number: u64, body: &str) -> AppResult { - let repo_slug = context - .repo_slug - .as_deref() +fn create_issue_comment( + context: &GitContext, + number: u64, + body: &str, + target_repo: Option<&str>, +) -> AppResult { + let repo_slug = target_repo + .or(context.repo_slug.as_deref()) .ok_or_else(|| AppError::new("cannot sync PR comments without a GitHub origin remote"))?; let request_file = temp_request_path(); let request = serde_json::json!({ "body": body }); diff --git a/src/main.rs b/src/main.rs index 9e8ef14..6b37ca8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ use plan_to_git::capture; use plan_to_git::claude_history; use plan_to_git::codex_history; use plan_to_git::error::{AppError, AppResult}; -use plan_to_git::git; +use plan_to_git::git::{self, parse_github_slug_or_slug}; use plan_to_git::github::{self, SyncStatus}; use plan_to_git::history::HistoryImportOutcome; use plan_to_git::render::render_plan_comment; @@ -20,6 +20,9 @@ use plan_to_git::store::{load_state, save_state, STATE_FILE_NAME}; about = "Capture agent plans and sync them to GitHub pull requests" )] struct Cli { + /// Target GitHub repository for state and PR comments. Accepts owner/repo or a GitHub URL. + #[arg(long, env = "PLAN_TO_GIT_REPO", global = true)] + repo: Option, #[command(subcommand)] command: Commands, } @@ -85,12 +88,12 @@ fn main() { match &cli.command { Commands::Hook { source } => { - if let Err(error) = run_hook(*source) { + if let Err(error) = run_hook(*source, cli.repo.as_deref()) { eprintln!("plan-to-git hook error: {error}"); } } command => { - if let Err(error) = run(command) { + if let Err(error) = run(command, cli.repo.as_deref()) { eprintln!("plan-to-git error: {error}"); std::process::exit(1); } @@ -98,7 +101,8 @@ fn main() { } } -fn run(command: &Commands) -> AppResult<()> { +fn run(command: &Commands, repo_slug_override: Option<&str>) -> AppResult<()> { + let target_repo = target_repo(repo_slug_override)?; match command { Commands::Hook { .. } => Ok(()), Commands::ImportCodex { @@ -130,7 +134,7 @@ fn run(command: &Commands) -> AppResult<()> { return Ok(()); } - let sync_status = github::sync_state(&context, &mut state)?; + let sync_status = github::sync_state(&context, &mut state, target_repo.as_deref())?; save_state(&state_path, &state)?; print_sync_status(&sync_status); Ok(()) @@ -165,7 +169,7 @@ fn run(command: &Commands) -> AppResult<()> { return Ok(()); } - let sync_status = github::sync_state(&context, &mut state)?; + let sync_status = github::sync_state(&context, &mut state, target_repo.as_deref())?; save_state(&state_path, &state)?; print_sync_status(&sync_status); Ok(()) @@ -179,9 +183,9 @@ fn run(command: &Commands) -> AppResult<()> { context.head_sha.clone(), ); let sync_status = if let Some(pr_number) = pr { - github::sync_state_to_pr(&context, &mut state, *pr_number)? + github::sync_state_to_pr(&context, &mut state, *pr_number, target_repo.as_deref())? } else { - github::sync_state(&context, &mut state)? + github::sync_state(&context, &mut state, target_repo.as_deref())? }; save_state(&state_path, &state)?; print_sync_status(&sync_status); @@ -216,13 +220,14 @@ fn run(command: &Commands) -> AppResult<()> { } } -fn run_hook(source: HookSource) -> AppResult<()> { +fn run_hook(source: HookSource, repo_slug_override: Option<&str>) -> AppResult<()> { + let target_repo = target_repo(repo_slug_override)?; let mut input = String::new(); io::stdin().read_to_string(&mut input)?; match source { HookSource::Codex => { - let outcome = capture::process_codex_hook(&input)?; + let outcome = capture::process_codex_hook_with_repo(&input, target_repo.as_deref())?; eprintln!( "plan-to-git: captured {} plan(s), {} decision(s), {} pending question set(s), sync={:?}", outcome.captured_plans, @@ -232,7 +237,7 @@ fn run_hook(source: HookSource) -> AppResult<()> { ); } HookSource::Claude => { - let outcome = capture::process_claude_hook(&input)?; + let outcome = capture::process_claude_hook_with_repo(&input, target_repo.as_deref())?; eprintln!( "plan-to-git: captured {} plan(s), {} decision(s), {} pending question set(s), sync={:?}", outcome.captured_plans, @@ -253,6 +258,17 @@ fn state_context() -> AppResult<(git::GitContext, std::path::PathBuf)> { Ok((context, state_path)) } +fn target_repo(repo_slug_override: Option<&str>) -> AppResult> { + repo_slug_override.map_or(Ok(None), |repo| { + let repo_slug = parse_github_slug_or_slug(repo).ok_or_else(|| { + AppError::new(format!( + "expected GitHub repository as owner/repo or GitHub URL, got {repo}" + )) + })?; + Ok(Some(repo_slug)) + }) +} + fn print_sync_status(status: &SyncStatus) { match status { SyncStatus::NoItems => println!("no captured plan items to sync"), diff --git a/src/normalize.rs b/src/normalize.rs index 431cc70..c8134e2 100644 --- a/src/normalize.rs +++ b/src/normalize.rs @@ -223,17 +223,12 @@ fn find_ascii_case_insensitive(haystack: &str, needle: &str, from: usize) -> Opt 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()] + (from..=haystack.len().saturating_sub(needle.len())).find(|&index| { + 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 { diff --git a/tests/integration/cli.rs b/tests/integration/cli.rs index 342fa42..aa2cb9f 100644 --- a/tests/integration/cli.rs +++ b/tests/integration/cli.rs @@ -525,6 +525,68 @@ mod unix { assert!(state.contains("\"comment_id\": 12345")); } + #[test] + fn sync_explicit_pr_uses_explicit_repo_context() { + 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 state_dir = temp_dir.path().join("state"); + 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_no_pr(&bin_dir); + + run_hook_with_env( + &repo_dir, + &bin_dir, + "codex", + &format!( + r#"{{ + "session_id":"codex-session", + "cwd":"{}", + "hook_event_name":"Stop", + "turn_id":"codex-turn", + "last_assistant_message":"\n# Upstream Plan\n\n- Sync to upstream\n" + }}"#, + repo_dir.display() + ), + &[ + ("PLAN_TO_GIT_REPO", "upstream/repo"), + ( + "PLAN_TO_GIT_STATE_DIR", + state_dir.to_str().expect("state dir"), + ), + ], + ); + + write_fake_gh_explicit_open_pr_for_repo(&bin_dir, "upstream/repo", &captured_request); + + let output = Command::new(env!("CARGO_BIN_EXE_plan-to-git")) + .arg("--repo") + .arg("upstream/repo") + .arg("sync") + .arg("--pr") + .arg("17") + .current_dir(&repo_dir) + .env("PATH", path_with_fake_bin(&bin_dir)) + .env("PLAN_TO_GIT_STATE_DIR", &state_dir) + .output() + .expect("run sync"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout"); + assert!(stdout.contains("posted 1 plan item(s) to pull request #17 comment #12345")); + + let request = fs::read_to_string(captured_request).expect("captured request"); + assert!(request.contains("Sync to upstream")); + + let state_files = find_state_files(&state_dir); + assert_eq!(state_files.len(), 1); + let state_path = state_files[0].to_string_lossy(); + assert!(state_path.contains("example-repo")); + } + #[test] fn hook_leaves_plans_queued_when_pr_is_merged() { let temp_dir = tempfile::tempdir().expect("temp dir"); @@ -777,17 +839,39 @@ mod unix { } fn run_hook_source(repo_dir: &Path, bin_dir: &Path, source: &str, payload: &str) { - let mut child = Command::new(env!("CARGO_BIN_EXE_plan-to-git")) + run_hook_with_env(repo_dir, bin_dir, source, payload, &[]); + } + + fn run_hook_with_env( + repo_dir: &Path, + bin_dir: &Path, + source: &str, + payload: &str, + envs: &[(&str, &str)], + ) { + let has_state_override = envs + .iter() + .any(|(key, _)| matches!(*key, "PLAN_TO_GIT_STATE_PATH" | "PLAN_TO_GIT_STATE_DIR")); + + let mut command = Command::new(env!("CARGO_BIN_EXE_plan-to-git")); + command .arg("hook") .arg("--source") .arg(source) .current_dir(repo_dir) .env("PATH", path_with_fake_bin(bin_dir)) - .env("PLAN_TO_GIT_STATE_PATH", state_path(repo_dir)) .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .spawn() - .expect("spawn plan-to-git"); + .stdout(Stdio::piped()); + + if !has_state_override { + command.env("PLAN_TO_GIT_STATE_PATH", state_path(repo_dir)); + } + + for (key, value) in envs { + command.env(key, value); + } + + let mut child = command.spawn().expect("spawn plan-to-git"); child .stdin @@ -888,6 +972,10 @@ if [[ "$*" == "pr view --json number,state,url,isDraft" ]]; then echo 'no pull requests found for branch "feature/test"' >&2 exit 1 fi +if [[ "$*" == pr\ view\ --json\ number,state,url,isDraft\ --repo\ * ]]; then + echo 'no pull requests found for branch "feature/test"' >&2 + exit 1 +fi echo "unexpected gh args: $*" >&2 exit 1 "#; @@ -936,6 +1024,32 @@ exit 1 write_executable(&bin_dir.join("gh"), &script); } + fn write_fake_gh_explicit_open_pr_for_repo( + bin_dir: &Path, + repo_slug: &str, + captured_request: &Path, + ) { + let script = format!( + r#"#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" == "pr view 17 --json number,state,url,isDraft --repo {repo_slug}" ]]; then + printf '%s\n' '{{"number":17,"state":"OPEN","url":"https://github.com/{repo_slug}/pull/17"}}' + exit 0 +fi +if [[ "$1 $2 $3" == "api --method POST" && "$4" == "repos/{repo_slug}/issues/17/comments" && "$5" == "--input" ]]; then + cp "$6" "{captured_request}" + printf '%s\n' '{{"id":12345}}' + exit 0 +fi +echo "unexpected gh args: $*" >&2 +exit 1 +"#, + repo_slug = repo_slug, + captured_request = captured_request.display() + ); + 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 From debc5f5b08a02f9038d7df8938cf72a6ec24a22a Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:57:01 +0000 Subject: [PATCH 2/2] test(cli): split integration helpers --- tests/integration/cli.rs | 283 +---------------------------------- tests/integration/mod.rs | 4 + tests/integration/support.rs | 281 ++++++++++++++++++++++++++++++++++ 3 files changed, 292 insertions(+), 276 deletions(-) create mode 100644 tests/integration/support.rs diff --git a/tests/integration/cli.rs b/tests/integration/cli.rs index aa2cb9f..b8faa36 100644 --- a/tests/integration/cli.rs +++ b/tests/integration/cli.rs @@ -2,12 +2,16 @@ mod unix { use std::fs; use std::io::Write; - use std::os::unix::fs::PermissionsExt; - use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; + use crate::support::{ + find_state_files, path_with_fake_bin, run_hook, run_hook_source, run_hook_with_env, + run_import_claude_from_default, run_import_codex, state_path, write_fake_gh_closed_pr, + write_fake_gh_draft_pr, write_fake_gh_explicit_open_pr, + write_fake_gh_explicit_open_pr_for_repo, write_fake_gh_no_pr, write_fake_gh_open_pr, + write_fake_git, + }; use plan_to_git::store::STATE_FILE_NAME; - use walkdir::WalkDir; #[test] fn hook_captures_plan_and_handles_missing_pr() { @@ -833,277 +837,4 @@ mod unix { assert!(second.contains("found 1 plan(s), added 0")); assert!(second.contains("skipped 1 duplicate(s)")); } - - fn run_hook(repo_dir: &Path, bin_dir: &Path, payload: &str) { - run_hook_source(repo_dir, bin_dir, "codex", payload); - } - - fn run_hook_source(repo_dir: &Path, bin_dir: &Path, source: &str, payload: &str) { - run_hook_with_env(repo_dir, bin_dir, source, payload, &[]); - } - - fn run_hook_with_env( - repo_dir: &Path, - bin_dir: &Path, - source: &str, - payload: &str, - envs: &[(&str, &str)], - ) { - let has_state_override = envs - .iter() - .any(|(key, _)| matches!(*key, "PLAN_TO_GIT_STATE_PATH" | "PLAN_TO_GIT_STATE_DIR")); - - let mut command = Command::new(env!("CARGO_BIN_EXE_plan-to-git")); - command - .arg("hook") - .arg("--source") - .arg(source) - .current_dir(repo_dir) - .env("PATH", path_with_fake_bin(bin_dir)) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()); - - if !has_state_override { - command.env("PLAN_TO_GIT_STATE_PATH", state_path(repo_dir)); - } - - for (key, value) in envs { - command.env(key, value); - } - - let mut child = command.spawn().expect("spawn plan-to-git"); - - child - .stdin - .as_mut() - .expect("stdin") - .write_all(payload.as_bytes()) - .expect("write payload"); - - let output = child.wait_with_output().expect("wait"); - assert!(output.status.success()); - assert!(output.stdout.is_empty()); - } - - fn run_import_codex(repo_dir: &Path, bin_dir: &Path, codex_home: &Path) -> String { - let output = Command::new(env!("CARGO_BIN_EXE_plan-to-git")) - .arg("import-codex") - .arg("--codex-home") - .arg(codex_home) - .arg("--no-sync") - .current_dir(repo_dir) - .env("PATH", path_with_fake_bin(bin_dir)) - .env("PLAN_TO_GIT_STATE_PATH", state_path(repo_dir)) - .output() - .expect("run import-codex"); - - assert!(output.status.success()); - String::from_utf8(output.stdout).expect("stdout") - } - - fn run_import_claude_from_default( - repo_dir: &Path, - bin_dir: &Path, - claude_home: &Path, - ) -> String { - let output = Command::new(env!("CARGO_BIN_EXE_plan-to-git")) - .arg("import-claude") - .arg("--no-sync") - .current_dir(repo_dir) - .env("PATH", path_with_fake_bin(bin_dir)) - .env("CLAUDE_CONFIG_DIR", claude_home) - .env("PLAN_TO_GIT_STATE_PATH", state_path(repo_dir)) - .output() - .expect("run import-claude"); - - assert!(output.status.success()); - String::from_utf8(output.stdout).expect("stdout") - } - - fn state_path(repo_dir: &Path) -> PathBuf { - repo_dir.join(STATE_FILE_NAME) - } - - fn find_state_files(dir: &Path) -> Vec { - WalkDir::new(dir) - .into_iter() - .filter_map(Result::ok) - .filter(|entry| entry.file_type().is_file()) - .map(walkdir::DirEntry::into_path) - .filter(|path| path.file_name().and_then(|name| name.to_str()) == Some(STATE_FILE_NAME)) - .collect() - } - - fn write_fake_git(bin_dir: &Path, repo_dir: &Path) { - let script = format!( - r#"#!/usr/bin/env bash -set -euo pipefail -if [[ "$1" == "-C" ]]; then - shift 2 -fi -case "$*" in - "rev-parse --show-toplevel") - printf '%s\n' "{}" - ;; - "rev-parse --abbrev-ref HEAD") - printf '%s\n' "feature/test" - ;; - "rev-parse HEAD") - printf '%s\n' "abcdef1234567890" - ;; - "remote get-url origin") - printf '%s\n' "https://github.com/example/repo.git" - ;; - *) - echo "unexpected git args: $*" >&2 - exit 1 - ;; -esac -"#, - repo_dir.display() - ); - write_executable(&bin_dir.join("git"), &script); - } - - fn write_fake_gh_no_pr(bin_dir: &Path) { - let script = r#"#!/usr/bin/env bash -set -euo pipefail -if [[ "$*" == "pr view --json number,state,url,isDraft" ]]; then - echo 'no pull requests found for branch "feature/test"' >&2 - exit 1 -fi -if [[ "$*" == pr\ view\ --json\ number,state,url,isDraft\ --repo\ * ]]; then - echo 'no pull requests found for branch "feature/test"' >&2 - exit 1 -fi -echo "unexpected gh args: $*" >&2 -exit 1 -"#; - write_executable(&bin_dir.join("gh"), script); - } - - fn write_fake_gh_open_pr(bin_dir: &Path, captured_request: &Path) { - let script = format!( - r#"#!/usr/bin/env bash -set -euo pipefail -if [[ "$*" == "pr view --json number,state,url,isDraft" ]]; 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 - cp "$6" "{}" - printf '%s\n' '{{"id":12345}}' - exit 0 -fi -echo "unexpected gh args: $*" >&2 -exit 1 -"#, - captured_request.display() - ); - write_executable(&bin_dir.join("gh"), &script); - } - - fn write_fake_gh_explicit_open_pr(bin_dir: &Path, captured_request: &Path) { - let script = format!( - r#"#!/usr/bin/env bash -set -euo pipefail -if [[ "$*" == "pr view 17 --json number,state,url,isDraft" ]]; 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 - cp "$6" "{}" - printf '%s\n' '{{"id":12345}}' - exit 0 -fi -echo "unexpected gh args: $*" >&2 -exit 1 -"#, - captured_request.display() - ); - write_executable(&bin_dir.join("gh"), &script); - } - - fn write_fake_gh_explicit_open_pr_for_repo( - bin_dir: &Path, - repo_slug: &str, - captured_request: &Path, - ) { - let script = format!( - r#"#!/usr/bin/env bash -set -euo pipefail -if [[ "$*" == "pr view 17 --json number,state,url,isDraft --repo {repo_slug}" ]]; then - printf '%s\n' '{{"number":17,"state":"OPEN","url":"https://github.com/{repo_slug}/pull/17"}}' - exit 0 -fi -if [[ "$1 $2 $3" == "api --method POST" && "$4" == "repos/{repo_slug}/issues/17/comments" && "$5" == "--input" ]]; then - cp "$6" "{captured_request}" - printf '%s\n' '{{"id":12345}}' - exit 0 -fi -echo "unexpected gh args: $*" >&2 -exit 1 -"#, - repo_slug = repo_slug, - captured_request = captured_request.display() - ); - 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,isDraft" ]]; 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_fake_gh_draft_pr(bin_dir: &Path, captured_request: &Path) { - let script = format!( - r#"#!/usr/bin/env bash -set -euo pipefail -if [[ "$*" == "pr view --json number,state,url,isDraft" ]]; then - printf '%s\n' '{{"number":17,"state":"OPEN","url":"https://github.com/example/repo/pull/17","isDraft":true}}' - exit 0 -fi -if [[ "$1" == "api" ]]; then - printf '%s\n' "$*" > "{}" - echo "comment API should not be called for draft 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(); - permissions.set_mode(0o755); - fs::set_permissions(path, permissions).expect("permissions"); - } - - fn path_with_fake_bin(bin_dir: &Path) -> String { - format!( - "{}:{}", - bin_dir.display(), - std::env::var("PATH").unwrap_or_default() - ) - } } diff --git a/tests/integration/mod.rs b/tests/integration/mod.rs index 26710c1..91f86be 100644 --- a/tests/integration/mod.rs +++ b/tests/integration/mod.rs @@ -1 +1,5 @@ +#[cfg(unix)] +mod support; + +#[cfg(unix)] mod cli; diff --git a/tests/integration/support.rs b/tests/integration/support.rs new file mode 100644 index 0000000..34698d5 --- /dev/null +++ b/tests/integration/support.rs @@ -0,0 +1,281 @@ +use std::fs; +use std::io::Write; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use plan_to_git::store::STATE_FILE_NAME; +use walkdir::WalkDir; + +pub fn run_hook(repo_dir: &Path, bin_dir: &Path, payload: &str) { + run_hook_source(repo_dir, bin_dir, "codex", payload); +} + +pub fn run_hook_source(repo_dir: &Path, bin_dir: &Path, source: &str, payload: &str) { + run_hook_with_env(repo_dir, bin_dir, source, payload, &[]); +} + +pub fn run_hook_with_env( + repo_dir: &Path, + bin_dir: &Path, + source: &str, + payload: &str, + envs: &[(&str, &str)], +) { + let has_state_override = envs + .iter() + .any(|(key, _)| matches!(*key, "PLAN_TO_GIT_STATE_PATH" | "PLAN_TO_GIT_STATE_DIR")); + + let mut command = Command::new(env!("CARGO_BIN_EXE_plan-to-git")); + command + .arg("hook") + .arg("--source") + .arg(source) + .current_dir(repo_dir) + .env("PATH", path_with_fake_bin(bin_dir)) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()); + + if !has_state_override { + command.env("PLAN_TO_GIT_STATE_PATH", state_path(repo_dir)); + } + + for (key, value) in envs { + command.env(key, value); + } + + let mut child = command.spawn().expect("spawn plan-to-git"); + + child + .stdin + .as_mut() + .expect("stdin") + .write_all(payload.as_bytes()) + .expect("write payload"); + + let output = child.wait_with_output().expect("wait"); + assert!(output.status.success()); + assert!(output.stdout.is_empty()); +} + +pub fn run_import_codex(repo_dir: &Path, bin_dir: &Path, codex_home: &Path) -> String { + let output = Command::new(env!("CARGO_BIN_EXE_plan-to-git")) + .arg("import-codex") + .arg("--codex-home") + .arg(codex_home) + .arg("--no-sync") + .current_dir(repo_dir) + .env("PATH", path_with_fake_bin(bin_dir)) + .env("PLAN_TO_GIT_STATE_PATH", state_path(repo_dir)) + .output() + .expect("run import-codex"); + + assert!(output.status.success()); + String::from_utf8(output.stdout).expect("stdout") +} + +pub fn run_import_claude_from_default( + repo_dir: &Path, + bin_dir: &Path, + claude_home: &Path, +) -> String { + let output = Command::new(env!("CARGO_BIN_EXE_plan-to-git")) + .arg("import-claude") + .arg("--no-sync") + .current_dir(repo_dir) + .env("PATH", path_with_fake_bin(bin_dir)) + .env("CLAUDE_CONFIG_DIR", claude_home) + .env("PLAN_TO_GIT_STATE_PATH", state_path(repo_dir)) + .output() + .expect("run import-claude"); + + assert!(output.status.success()); + String::from_utf8(output.stdout).expect("stdout") +} + +pub fn state_path(repo_dir: &Path) -> PathBuf { + repo_dir.join(STATE_FILE_NAME) +} + +pub fn find_state_files(dir: &Path) -> Vec { + WalkDir::new(dir) + .into_iter() + .filter_map(Result::ok) + .filter(|entry| entry.file_type().is_file()) + .map(walkdir::DirEntry::into_path) + .filter(|path| path.file_name().and_then(|name| name.to_str()) == Some(STATE_FILE_NAME)) + .collect() +} + +pub fn write_fake_git(bin_dir: &Path, repo_dir: &Path) { + let script = format!( + r#"#!/usr/bin/env bash +set -euo pipefail +if [[ "$1" == "-C" ]]; then + shift 2 +fi +case "$*" in + "rev-parse --show-toplevel") + printf '%s\n' "{}" + ;; + "rev-parse --abbrev-ref HEAD") + printf '%s\n' "feature/test" + ;; + "rev-parse HEAD") + printf '%s\n' "abcdef1234567890" + ;; + "remote get-url origin") + printf '%s\n' "https://github.com/example/repo.git" + ;; + *) + echo "unexpected git args: $*" >&2 + exit 1 + ;; +esac +"#, + repo_dir.display() + ); + write_executable(&bin_dir.join("git"), &script); +} + +pub fn write_fake_gh_no_pr(bin_dir: &Path) { + let script = r#"#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" == "pr view --json number,state,url,isDraft" ]]; then + echo 'no pull requests found for branch "feature/test"' >&2 + exit 1 +fi +if [[ "$*" == pr\ view\ --json\ number,state,url,isDraft\ --repo\ * ]]; then + echo 'no pull requests found for branch "feature/test"' >&2 + exit 1 +fi +echo "unexpected gh args: $*" >&2 +exit 1 +"#; + write_executable(&bin_dir.join("gh"), script); +} + +pub fn write_fake_gh_open_pr(bin_dir: &Path, captured_request: &Path) { + let script = format!( + r#"#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" == "pr view --json number,state,url,isDraft" ]]; 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 + cp "$6" "{}" + printf '%s\n' '{{"id":12345}}' + exit 0 +fi +echo "unexpected gh args: $*" >&2 +exit 1 +"#, + captured_request.display() + ); + write_executable(&bin_dir.join("gh"), &script); +} + +pub fn write_fake_gh_explicit_open_pr(bin_dir: &Path, captured_request: &Path) { + let script = format!( + r#"#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" == "pr view 17 --json number,state,url,isDraft" ]]; 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 + cp "$6" "{}" + printf '%s\n' '{{"id":12345}}' + exit 0 +fi +echo "unexpected gh args: $*" >&2 +exit 1 +"#, + captured_request.display() + ); + write_executable(&bin_dir.join("gh"), &script); +} + +pub fn write_fake_gh_explicit_open_pr_for_repo( + bin_dir: &Path, + repo_slug: &str, + captured_request: &Path, +) { + let script = format!( + r#"#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" == "pr view 17 --json number,state,url,isDraft --repo {repo_slug}" ]]; then + printf '%s\n' '{{"number":17,"state":"OPEN","url":"https://github.com/{repo_slug}/pull/17"}}' + exit 0 +fi +if [[ "$1 $2 $3" == "api --method POST" && "$4" == "repos/{repo_slug}/issues/17/comments" && "$5" == "--input" ]]; then + cp "$6" "{captured_request}" + printf '%s\n' '{{"id":12345}}' + exit 0 +fi +echo "unexpected gh args: $*" >&2 +exit 1 +"#, + repo_slug = repo_slug, + captured_request = captured_request.display() + ); + write_executable(&bin_dir.join("gh"), &script); +} + +pub 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,isDraft" ]]; 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); +} + +pub fn write_fake_gh_draft_pr(bin_dir: &Path, captured_request: &Path) { + let script = format!( + r#"#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" == "pr view --json number,state,url,isDraft" ]]; then + printf '%s\n' '{{"number":17,"state":"OPEN","url":"https://github.com/example/repo/pull/17","isDraft":true}}' + exit 0 +fi +if [[ "$1" == "api" ]]; then + printf '%s\n' "$*" > "{}" + echo "comment API should not be called for draft 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(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).expect("permissions"); +} + +pub fn path_with_fake_bin(bin_dir: &Path) -> String { + format!( + "{}:{}", + bin_dir.display(), + std::env::var("PATH").unwrap_or_default() + ) +}