1use anyhow::Result;
2use std::path::{Path, PathBuf};
3
4#[derive(Debug, Clone, Default)]
9pub struct SessionMetadata {
10 pub pr_number: Option<u64>,
11 pub pr_url: Option<String>,
12 pub branch: Option<String>,
13 pub pr_reports: Vec<PrReport>,
17}
18
19#[derive(Debug, Clone, PartialEq)]
20pub struct PrReport {
21 pub number: u64,
22 pub url: Option<String>,
23}
24
25#[derive(Debug, Clone, PartialEq)]
30pub struct WorkRequest {
31 pub id: String,
32 pub description: String,
33 pub requested_at: i64,
34}
35
36const GH_WRAPPER: &str = r#"#!/usr/bin/env bash
47# Ninox gh wrapper — intercepts gh pr create to record PR metadata.
48set -euo pipefail
49
50# Locate the real gh binary (skip ourselves).
51_real_gh=""
52IFS=: read -ra _path_parts <<< "$PATH"
53for _dir in "${_path_parts[@]}"; do
54 _candidate="$_dir/gh"
55 if [[ "$_candidate" != "$0" && -x "$_candidate" ]]; then
56 _real_gh="$_candidate"
57 break
58 fi
59done
60if [[ -z "$_real_gh" ]]; then
61 echo "ninox: gh not found in PATH (excluding wrapper)" >&2
62 exit 1
63fi
64
65# Detect `pr create` regardless of preceding global flags (`-R`, `--repo`,
66# `--hostname`, with or without `=`) — argv-position matching alone
67# (`$1==pr && $2==create`) misses `gh -R owner/repo pr create` entirely,
68# silently falling through to the real gh with no metadata capture.
69_is_pr_create=false
70_skip_next=false
71_positional=()
72for _arg in "$@"; do
73 if $_skip_next; then
74 _skip_next=false
75 continue
76 fi
77 case "$_arg" in
78 -R|--repo|--hostname)
79 _skip_next=true
80 continue
81 ;;
82 --repo=*|--hostname=*)
83 continue
84 ;;
85 -R?*)
86 # Cuddled short-flag form gh's parser also accepts, e.g.
87 # `-Rowner/repo` (no space) — value is embedded, nothing to skip.
88 continue
89 ;;
90 esac
91 _positional+=("$_arg")
92done
93if [[ "${_positional[0]:-}" == "pr" && "${_positional[1]:-}" == "create" ]]; then
94 _is_pr_create=true
95fi
96
97# Run the real gh and tee output so we can parse it.
98if $_is_pr_create; then
99 _output=$("$_real_gh" "$@" 2>&1)
100 _exit=$?
101 echo "$_output"
102 _nx_session="${NINOX_SESSION:-}"
103 _nx_data_dir="${NINOX_DATA_DIR:-}"
104 if [[ $_exit -eq 0 && -n "$_nx_session" && -n "$_nx_data_dir" ]]; then
105 _pr_url=$(echo "$_output" | grep -oE 'https?://[^/]+/[^/]+/[^/]+/pull/[0-9]+' | head -1)
106 if [[ -n "$_pr_url" ]]; then
107 _pr_num=$(echo "$_pr_url" | grep -oE '[0-9]+$')
108 _meta_file="${_nx_data_dir}/${_nx_session}.json"
109 mkdir -p "$(dirname "$_meta_file")"
110 _tmp="${_meta_file}.tmp.$$"
111 if [[ -f "$_meta_file" ]]; then
112 _existing=$(cat "$_meta_file")
113 else
114 _existing="{}"
115 fi
116 if command -v jq &>/dev/null; then
117 echo "$_existing" | jq \
118 --arg url "$_pr_url" \
119 --arg num "$_pr_num" \
120 '. + {"agentReportedPrUrl": $url, "agentReportedPrNumber": $num, "agentReportedState": "pr_created"}
121 | .agentReportedPrs = ((.agentReportedPrs // []) + [{"number": $num, "url": $url}])' \
122 > "$_tmp" && mv "$_tmp" "$_meta_file"
123 elif command -v node &>/dev/null; then
124 # PR URL and number are passed via env vars, not interpolated into the
125 # script string, to avoid shell injection from external GitHub output.
126 NINOX_PR_URL="$_pr_url" NINOX_PR_NUM="$_pr_num" node -e "
127 const fs = require('fs');
128 const url = process.env.NINOX_PR_URL;
129 const num = process.env.NINOX_PR_NUM;
130 const f = '${_meta_file}';
131 const m = JSON.parse(fs.existsSync(f) ? fs.readFileSync(f,'utf8') : '{}');
132 m.agentReportedPrUrl = url;
133 m.agentReportedPrNumber = num;
134 m.agentReportedState = 'pr_created';
135 m.agentReportedPrs = (Array.isArray(m.agentReportedPrs) ? m.agentReportedPrs : [])
136 .concat([{ number: num, url: url }]);
137 fs.writeFileSync(f + '.tmp.\$\$', JSON.stringify(m,null,2));
138 fs.renameSync(f + '.tmp.\$\$', f);
139 " 2>/dev/null || echo "ninox: warning: node failed to record PR metadata for $_pr_url" >&2
140 else
141 echo "ninox: warning: neither jq nor node found — PR metadata for $_pr_url was not recorded" >&2
142 fi
143 fi
144 fi
145 exit $_exit
146else
147 exec "$_real_gh" "$@"
148fi
149"#;
150
151const GIT_WRAPPER: &str = r#"#!/usr/bin/env bash
153# Ninox git wrapper — records branch name on checkout -b / switch -c.
154set -euo pipefail
155
156_real_git=""
157IFS=: read -ra _path_parts <<< "$PATH"
158for _dir in "${_path_parts[@]}"; do
159 _candidate="$_dir/git"
160 if [[ "$_candidate" != "$0" && -x "$_candidate" ]]; then
161 _real_git="$_candidate"
162 break
163 fi
164done
165if [[ -z "$_real_git" ]]; then
166 echo "ninox: git not found in PATH (excluding wrapper)" >&2
167 exit 1
168fi
169
170# Run the real git command first.
171"$_real_git" "$@"
172_exit=$?
173
174# On success, capture branch name for checkout -b / switch -c.
175_nx_session="${NINOX_SESSION:-}"
176_nx_data_dir="${NINOX_DATA_DIR:-}"
177if [[ $_exit -eq 0 && -n "$_nx_session" && -n "$_nx_data_dir" ]]; then
178 _branch=""
179 if [[ "${1:-}" == "checkout" && "${2:-}" == "-b" && -n "${3:-}" ]]; then
180 _branch="${3}"
181 elif [[ "${1:-}" == "switch" && "${2:-}" == "-c" && -n "${3:-}" ]]; then
182 _branch="${3}"
183 fi
184 if [[ -n "$_branch" ]]; then
185 _meta_file="${_nx_data_dir}/${_nx_session}.json"
186 mkdir -p "$(dirname "$_meta_file")"
187 if command -v jq &>/dev/null; then
188 _tmp="${_meta_file}.tmp.$$"
189 _existing=$([ -f "$_meta_file" ] && cat "$_meta_file" || echo "{}")
190 echo "$_existing" | jq --arg b "$_branch" '. + {"branch": $b}' \
191 > "$_tmp" && mv "$_tmp" "$_meta_file"
192 fi
193 fi
194fi
195
196exit $_exit
197"#;
198
199pub fn install_wrappers_to(bin_dir: &Path) -> Result<()> {
206 std::fs::create_dir_all(bin_dir)?;
207 write_executable(bin_dir.join("gh"), GH_WRAPPER)?;
208 write_executable(bin_dir.join("git"), GIT_WRAPPER)?;
209 Ok(())
210}
211
212pub fn install_wrappers() -> Result<()> {
214 install_wrappers_to(&crate::config::AppConfig::ninox_bin_dir())
215}
216
217pub fn install_self_shim(current_exe: &Path) -> Result<()> {
222 let bin_dir = crate::config::AppConfig::ninox_bin_dir();
223 std::fs::create_dir_all(&bin_dir)?;
224 let exe = current_exe.to_string_lossy().replace('\'', "'\\''");
225 let script = format!(
226 "#!/usr/bin/env bash\nexec '{}' \"$@\"\n",
227 exe
228 );
229 write_executable(bin_dir.join("ninox"), &script)?;
230 Ok(())
231}
232
233pub fn read_session_metadata(dir: &Path, session_id: &str) -> Result<SessionMetadata> {
236 let map = match read_metadata_map(&metadata_path(dir, session_id))? {
237 Some(m) => m,
238 None => return Ok(SessionMetadata::default()),
239 };
240 let pr_number = map.get("agentReportedPrNumber").and_then(as_u64);
241 let pr_url = map.get("agentReportedPrUrl")
242 .and_then(|v| v.as_str())
243 .map(|s| s.to_string());
244 let branch = map.get("branch")
245 .and_then(|v| v.as_str())
246 .map(|s| s.to_string());
247
248 let mut pr_reports: Vec<PrReport> = map.get("agentReportedPrs")
249 .and_then(|v| v.as_array())
250 .map(|prs| {
251 prs.iter()
252 .filter_map(|p| {
253 Some(PrReport {
254 number: as_u64(p.get("number")?)?,
255 url: p.get("url").and_then(|u| u.as_str()).map(String::from),
256 })
257 })
258 .collect()
259 })
260 .unwrap_or_default();
261 if pr_reports.is_empty() {
264 if let Some(number) = pr_number {
265 pr_reports.push(PrReport { number, url: pr_url.clone() });
266 }
267 }
268
269 Ok(SessionMetadata { pr_number, pr_url, branch, pr_reports })
270}
271
272pub fn append_work_request(dir: &Path, session_id: &str, description: &str) -> Result<WorkRequest> {
277 use std::sync::atomic::{AtomicU32, Ordering};
278 static SEQ: AtomicU32 = AtomicU32::new(0);
279
280 let requested_at = std::time::SystemTime::now()
281 .duration_since(std::time::UNIX_EPOCH)
282 .map(|d| d.as_millis() as i64)
283 .unwrap_or(0);
284 let request = WorkRequest {
287 id: format!(
288 "wr-{requested_at}-{}-{}",
289 std::process::id(),
290 SEQ.fetch_add(1, Ordering::Relaxed),
291 ),
292 description: description.to_string(),
293 requested_at,
294 };
295
296 let requests_dir = work_requests_dir(dir, session_id);
297 std::fs::create_dir_all(&requests_dir)?;
298 let path = requests_dir.join(format!("{}.json", request.id));
299 let body = serde_json::json!({
300 "id": request.id,
301 "description": request.description,
302 "requestedAt": request.requested_at,
303 });
304 let tmp = path.with_extension("tmp");
305 std::fs::write(&tmp, serde_json::to_string_pretty(&body)?)?;
306 std::fs::rename(&tmp, &path)?;
307 Ok(request)
308}
309
310pub fn read_pending_work_requests(dir: &Path, session_id: &str) -> Result<Vec<WorkRequest>> {
312 let requests_dir = work_requests_dir(dir, session_id);
313 let entries = match std::fs::read_dir(&requests_dir) {
314 Ok(e) => e,
315 Err(_) => return Ok(Vec::new()), };
317 let mut requests: Vec<WorkRequest> = entries
318 .filter_map(|e| e.ok())
319 .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
320 .filter_map(|e| {
321 let raw = std::fs::read_to_string(e.path()).ok()?;
322 let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
323 Some(WorkRequest {
324 id: v.get("id")?.as_str()?.to_string(),
325 description: v.get("description")?.as_str()?.to_string(),
326 requested_at: v.get("requestedAt").and_then(|t| t.as_i64()).unwrap_or(0),
327 })
328 })
329 .collect();
330 requests.sort_by(|a, b| a.requested_at.cmp(&b.requested_at).then(a.id.cmp(&b.id)));
331 Ok(requests)
332}
333
334pub fn mark_work_requests_delivered(dir: &Path, session_id: &str, ids: &[String]) -> Result<()> {
340 let requests_dir = work_requests_dir(dir, session_id);
341 let mut first_err: Option<std::io::Error> = None;
342 for id in ids {
343 let path = requests_dir.join(format!("{id}.json"));
344 if path.exists() {
345 let delivered = requests_dir.join(format!("{id}.json.delivered"));
346 if let Err(e) = std::fs::rename(&path, &delivered) {
347 first_err.get_or_insert(e);
348 }
349 }
350 }
351 match first_err {
352 Some(e) => Err(e.into()),
353 None => Ok(()),
354 }
355}
356
357pub fn remove_session_artifacts(dir: &Path, session_id: &str) {
362 let _ = std::fs::remove_file(metadata_path(dir, session_id));
363 let _ = std::fs::remove_dir_all(work_requests_dir(dir, session_id));
364 let _ = std::fs::remove_file(notified_prs_path(dir, session_id));
365}
366
367pub fn read_notified_extra_prs(dir: &Path, session_id: &str) -> Vec<u64> {
372 let path = notified_prs_path(dir, session_id);
373 std::fs::read_to_string(&path)
374 .ok()
375 .and_then(|raw| serde_json::from_str::<Vec<u64>>(&raw).ok())
376 .unwrap_or_default()
377}
378
379pub fn mark_extra_prs_notified(dir: &Path, session_id: &str, numbers: &[u64]) -> Result<()> {
382 let path = notified_prs_path(dir, session_id);
383 let mut notified = read_notified_extra_prs(dir, session_id);
384 for n in numbers {
385 if !notified.contains(n) {
386 notified.push(*n);
387 }
388 }
389 if let Some(parent) = path.parent() {
390 std::fs::create_dir_all(parent)?;
391 }
392 let tmp = path.with_extension("tmp");
393 std::fs::write(&tmp, serde_json::to_string(¬ified)?)?;
394 std::fs::rename(&tmp, &path)?;
395 Ok(())
396}
397
398fn metadata_path(dir: &Path, session_id: &str) -> PathBuf {
403 dir.join(format!("{session_id}.json"))
404}
405
406fn work_requests_dir(dir: &Path, session_id: &str) -> PathBuf {
407 dir.join(format!("{session_id}.requests"))
408}
409
410fn notified_prs_path(dir: &Path, session_id: &str) -> PathBuf {
411 dir.join(format!("{session_id}.notified-prs.json"))
412}
413
414fn read_metadata_map(path: &Path) -> Result<Option<serde_json::Map<String, serde_json::Value>>> {
417 if !path.exists() {
418 return Ok(None);
419 }
420 let raw = std::fs::read_to_string(path)?;
421 Ok(serde_json::from_str(&raw).ok())
422}
423
424fn as_u64(v: &serde_json::Value) -> Option<u64> {
426 v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))
427}
428
429fn write_executable(path: PathBuf, content: &str) -> Result<()> {
430 let tmp = path.with_extension("tmp");
432 std::fs::write(&tmp, content)?;
433 #[cfg(unix)]
434 {
435 use std::os::unix::fs::PermissionsExt;
436 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))?;
437 }
438 std::fs::rename(&tmp, &path)?;
439 Ok(())
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445 use tempfile::tempdir;
446
447 #[test]
448 fn install_wrappers_creates_executables() {
449 let dir = tempdir().unwrap();
450 install_wrappers_to(dir.path()).unwrap();
451 assert!(dir.path().join("gh").exists());
452 assert!(dir.path().join("git").exists());
453 #[cfg(unix)]
454 {
455 use std::os::unix::fs::PermissionsExt;
456 let gh_mode = std::fs::metadata(dir.path().join("gh"))
457 .unwrap().permissions().mode();
458 assert!(gh_mode & 0o111 != 0, "gh wrapper should be executable");
459 }
460 }
461
462 #[test]
463 fn read_session_metadata_parses_pr_number() {
464 let dir = tempdir().unwrap();
465 let metadata = serde_json::json!({
466 "agentReportedPrNumber": "42",
467 "agentReportedPrUrl": "https://github.com/org/repo/pull/42",
468 "branch": "feat/my-fix"
469 });
470 std::fs::write(
471 dir.path().join("s1.json"),
472 serde_json::to_string(&metadata).unwrap(),
473 ).unwrap();
474 let m = read_session_metadata(dir.path(), "s1").unwrap();
475 assert_eq!(m.pr_number, Some(42));
476 assert_eq!(m.branch.as_deref(), Some("feat/my-fix"));
477 }
478
479 #[test]
480 fn read_session_metadata_returns_default_on_missing_file() {
481 let dir = tempdir().unwrap();
482 let m = read_session_metadata(dir.path(), "nonexistent").unwrap();
483 assert_eq!(m.pr_number, None);
484 assert_eq!(m.branch, None);
485 }
486
487 #[test]
488 fn read_session_metadata_handles_malformed_json() {
489 let dir = tempdir().unwrap();
490 std::fs::write(dir.path().join("bad.json"), "not json").unwrap();
491 let m = read_session_metadata(dir.path(), "bad").unwrap();
492 assert_eq!(m.pr_number, None);
493 }
494
495 #[test]
496 fn append_work_request_accumulates_pending_requests() {
497 let dir = tempdir().unwrap();
498 let first = append_work_request(dir.path(), "s1", "Fix flaky auth test").unwrap();
499 let second = append_work_request(dir.path(), "s1", "Migrate config loader").unwrap();
500 assert_ne!(first.id, second.id, "each request needs a distinct id");
501
502 let pending = read_pending_work_requests(dir.path(), "s1").unwrap();
503 assert_eq!(pending.len(), 2);
504 assert_eq!(pending[0].description, "Fix flaky auth test");
505 assert_eq!(pending[1].description, "Migrate config loader");
506 }
507
508 #[test]
511 fn wrapper_scripts_read_only_ninox_env() {
512 for wrapper in [GH_WRAPPER, GIT_WRAPPER] {
513 assert!(wrapper.contains("${NINOX_SESSION:-}"));
514 assert!(wrapper.contains("${NINOX_DATA_DIR:-}"));
515 assert!(!wrapper.contains("ATHENE"));
516 }
517 }
518
519 #[test]
523 fn append_work_request_leaves_shared_metadata_file_alone() {
524 let dir = tempdir().unwrap();
525 let shared = r#"{"agentReportedPrNumber": "42", "branch": "feat/x"}"#;
526 std::fs::write(dir.path().join("s1.json"), shared).unwrap();
527 append_work_request(dir.path(), "s1", "New task").unwrap();
528 assert_eq!(
529 std::fs::read_to_string(dir.path().join("s1.json")).unwrap(),
530 shared,
531 "shared metadata JSON must be byte-identical after a work request",
532 );
533 let m = read_session_metadata(dir.path(), "s1").unwrap();
534 assert_eq!(m.pr_number, Some(42));
535 assert_eq!(read_pending_work_requests(dir.path(), "s1").unwrap().len(), 1);
536 }
537
538 #[test]
539 fn mark_work_requests_delivered_removes_only_named_ids_from_pending() {
540 let dir = tempdir().unwrap();
541 let first = append_work_request(dir.path(), "s1", "task a").unwrap();
542 let second = append_work_request(dir.path(), "s1", "task b").unwrap();
543
544 mark_work_requests_delivered(dir.path(), "s1", std::slice::from_ref(&first.id)).unwrap();
545
546 let pending = read_pending_work_requests(dir.path(), "s1").unwrap();
547 assert_eq!(pending.len(), 1);
548 assert_eq!(pending[0].id, second.id);
549 }
550
551 #[test]
552 fn pending_work_requests_empty_when_none_recorded() {
553 let dir = tempdir().unwrap();
554 assert!(read_pending_work_requests(dir.path(), "s1").unwrap().is_empty());
555 }
556
557 #[test]
561 fn remove_session_artifacts_clears_all_files_for_that_session_only() {
562 let dir = tempdir().unwrap();
563 std::fs::write(dir.path().join("s1.json"), "{}").unwrap();
564 append_work_request(dir.path(), "s1", "task").unwrap();
565 mark_extra_prs_notified(dir.path(), "s1", &[9]).unwrap();
566 std::fs::write(dir.path().join("s2.json"), "{}").unwrap();
567 append_work_request(dir.path(), "s2", "other").unwrap();
568
569 remove_session_artifacts(dir.path(), "s1");
570
571 assert!(!dir.path().join("s1.json").exists());
572 assert!(!dir.path().join("s1.requests").exists());
573 assert!(!dir.path().join("s1.notified-prs.json").exists());
574 assert!(dir.path().join("s2.json").exists(), "other sessions untouched");
575 assert_eq!(read_pending_work_requests(dir.path(), "s2").unwrap().len(), 1);
576 remove_session_artifacts(dir.path(), "missing");
578 }
579
580 #[test]
584 fn notified_extra_prs_round_trip_and_accumulate() {
585 let dir = tempdir().unwrap();
586 assert!(read_notified_extra_prs(dir.path(), "s1").is_empty());
587 mark_extra_prs_notified(dir.path(), "s1", &[43, 44]).unwrap();
588 mark_extra_prs_notified(dir.path(), "s1", &[45]).unwrap();
589 let notified = read_notified_extra_prs(dir.path(), "s1");
590 assert_eq!(notified, vec![43, 44, 45]);
591 assert!(read_notified_extra_prs(dir.path(), "s2").is_empty());
593 }
594
595 #[test]
596 fn read_session_metadata_parses_reported_pr_list() {
597 let dir = tempdir().unwrap();
598 let metadata = serde_json::json!({
599 "agentReportedPrNumber": "44",
600 "agentReportedPrUrl": "https://github.com/org/repo/pull/44",
601 "agentReportedPrs": [
602 {"number": "42", "url": "https://github.com/org/repo/pull/42"},
603 {"number": "43", "url": "https://github.com/org/repo/pull/43"},
604 {"number": "44", "url": "https://github.com/org/repo/pull/44"},
605 ],
606 });
607 std::fs::write(
608 dir.path().join("s1.json"),
609 serde_json::to_string(&metadata).unwrap(),
610 ).unwrap();
611 let m = read_session_metadata(dir.path(), "s1").unwrap();
612 let numbers: Vec<u64> = m.pr_reports.iter().map(|p| p.number).collect();
613 assert_eq!(numbers, vec![42, 43, 44]);
614 assert_eq!(
615 m.pr_reports[0].url.as_deref(),
616 Some("https://github.com/org/repo/pull/42"),
617 );
618 }
619
620 #[test]
624 fn read_session_metadata_synthesizes_pr_list_from_legacy_scalars() {
625 let dir = tempdir().unwrap();
626 let metadata = serde_json::json!({
627 "agentReportedPrNumber": "42",
628 "agentReportedPrUrl": "https://github.com/org/repo/pull/42",
629 });
630 std::fs::write(
631 dir.path().join("s1.json"),
632 serde_json::to_string(&metadata).unwrap(),
633 ).unwrap();
634 let m = read_session_metadata(dir.path(), "s1").unwrap();
635 assert_eq!(m.pr_reports.len(), 1);
636 assert_eq!(m.pr_reports[0].number, 42);
637 }
638
639 #[test]
643 fn gh_wrapper_appends_to_pr_list() {
644 assert!(GH_WRAPPER.contains("agentReportedPrs"));
645 }
646
647 #[test]
650 fn gh_wrapper_warns_when_neither_jq_nor_node_available() {
651 assert!(GH_WRAPPER.contains("neither jq nor node"));
652 }
653
654 fn run_gh_wrapper(args: &[&str], path_extra: &std::path::Path, session: &str, data_dir: &std::path::Path)
659 -> std::process::Output
660 {
661 let wrapper_dir = tempdir().unwrap();
662 let wrapper_path = wrapper_dir.path().join("gh");
663 write_executable(wrapper_path.clone(), GH_WRAPPER).unwrap();
664 let path_env = format!(
665 "{}:{}",
666 path_extra.display(),
667 std::env::var("PATH").unwrap_or_default(),
668 );
669 std::process::Command::new(&wrapper_path)
670 .args(args)
671 .env("PATH", path_env)
672 .env("NINOX_SESSION", session)
673 .env("NINOX_DATA_DIR", data_dir.to_string_lossy().to_string())
674 .output()
675 .expect("failed to run gh wrapper")
676 }
677
678 fn fake_real_gh(pr_url: &str) -> tempfile::TempDir {
679 let dir = tempdir().unwrap();
680 write_executable(
681 dir.path().join("gh"),
682 &format!("#!/usr/bin/env bash\necho 'Created pull request {pr_url}'\n"),
683 ).unwrap();
684 dir
685 }
686
687 #[test]
692 fn gh_wrapper_detects_pr_create_behind_global_repo_flags() {
693 for extra_args in [
694 vec!["-R", "org/repo"],
695 vec!["--repo", "org/repo"],
696 vec!["--repo=org/repo"],
697 vec!["-Rorg/repo"],
698 ] {
699 let real_gh_dir = fake_real_gh("https://github.com/org/repo/pull/99");
700 let data_dir = tempdir().unwrap();
701
702 let mut args = extra_args.clone();
703 args.extend(["pr", "create", "--title", "t", "--body", "b"]);
704
705 let output = run_gh_wrapper(&args, real_gh_dir.path(), "s1", data_dir.path());
706 assert!(
707 output.status.success(),
708 "wrapper failed for {extra_args:?}: {}",
709 String::from_utf8_lossy(&output.stderr),
710 );
711 let meta = read_session_metadata(data_dir.path(), "s1").unwrap();
712 assert_eq!(
713 meta.pr_number, Some(99),
714 "flags {extra_args:?} should still be detected as pr create",
715 );
716 }
717 }
718
719 #[test]
723 fn gh_wrapper_does_not_intercept_non_pr_create_invocations() {
724 let real_gh_dir = fake_real_gh("https://github.com/org/repo/pull/99");
725 let data_dir = tempdir().unwrap();
726
727 let output = run_gh_wrapper(
728 &["-R", "org/repo", "pr", "list"],
729 real_gh_dir.path(), "s1", data_dir.path(),
730 );
731 assert!(output.status.success());
732 assert!(!data_dir.path().join("s1.json").exists());
733 }
734}