1use crate::error::{EngineError, Result};
11use crate::git_ops::GitRepo;
12use crate::scrub;
13use crate::types::{Mission, MissionStatus};
14use serde::{Deserialize, Serialize};
15use std::path::{Path, PathBuf};
16use std::process::Command;
17
18const TITLE_MAX: usize = 120;
19const BODY_MAX: usize = 8_000;
20const DEFAULT_REMOTE: &str = "origin";
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "camelCase", tag = "kind")]
25pub enum PrHandoff {
26 NeedsPush {
28 command: String,
29 remote: String,
30 branch: String,
31 },
32 ReadyToCreate {
34 command: String,
35 title: String,
36 body: String,
37 remote: String,
38 branch: String,
39 base: String,
40 },
41 Unavailable { reason: String },
43}
44
45#[derive(Debug, Clone)]
47pub struct PrHandoffInputs<'a> {
48 pub mission: &'a Mission,
49 pub report_md: Option<&'a str>,
50 pub plan_md: Option<&'a str>,
51 pub remote: &'a str,
52 pub probe_remote: bool,
55}
56
57pub fn assess(repo_root: &Path, inputs: &PrHandoffInputs<'_>) -> PrHandoff {
60 if inputs.mission.status != MissionStatus::Complete {
61 return PrHandoff::Unavailable {
62 reason: format!(
63 "mission is {:?}; PR handoff is only for COMPLETE missions",
64 inputs.mission.status
65 ),
66 };
67 }
68 let branch = &inputs.mission.mission_branch;
69 if !branch.starts_with("kranz/") {
70 return PrHandoff::Unavailable {
71 reason: format!("mission branch {branch:?} is not a kranz/* ref"),
72 };
73 }
74
75 let repo = match GitRepo::open(repo_root) {
76 Ok(r) => r,
77 Err(e) => {
78 return PrHandoff::Unavailable {
79 reason: format!("cannot open git repo: {e}"),
80 };
81 }
82 };
83
84 let remote = inputs.remote;
85 let remote_url = match repo.remote_url(remote) {
86 Ok(Some(url)) => url,
87 Ok(None) => {
88 return PrHandoff::Unavailable {
89 reason: format!("no git remote named {remote:?}"),
90 };
91 }
92 Err(e) => {
93 return PrHandoff::Unavailable {
94 reason: format!("could not read remote {remote:?}: {e}"),
95 };
96 }
97 };
98 if !looks_like_github_remote(&remote_url) {
99 return PrHandoff::Unavailable {
100 reason: format!(
101 "remote {remote:?} ({remote_url}) does not look like GitHub; \
102 PR handoff uses `gh`"
103 ),
104 };
105 }
106
107 if !inputs.probe_remote {
108 return PrHandoff::Unavailable {
111 reason: "open the dashboard Delivered panel for PR handoff \
112 (Slack skips the remote-branch network probe)"
113 .into(),
114 };
115 }
116
117 let on_remote = match repo.remote_has_branch(remote, branch) {
118 Ok(v) => v,
119 Err(e) => {
120 return PrHandoff::Unavailable {
121 reason: format!("could not probe remote branch {branch:?}: {e}"),
122 };
123 }
124 };
125
126 if !on_remote {
127 return PrHandoff::NeedsPush {
128 command: format!("git push {remote} {branch}"),
129 remote: remote.to_string(),
130 branch: branch.clone(),
131 };
132 }
133
134 if which_gh().is_none() {
135 return PrHandoff::Unavailable {
136 reason: "`gh` CLI not found on PATH (install GitHub CLI to open a PR)".to_string(),
137 };
138 }
139
140 let (title, body) = build_title_body(inputs);
141 let base = &inputs.mission.base_branch;
142 let command = format_gh_pr_create_command(base, branch, &title, &body);
143 PrHandoff::ReadyToCreate {
144 command,
145 title,
146 body,
147 remote: remote.to_string(),
148 branch: branch.clone(),
149 base: base.clone(),
150 }
151}
152
153fn read_mission_doc(path: &Path) -> Option<String> {
164 use std::io::Read;
165 let mut text = String::new();
166 crate::paths::open_read_nofollow(path)
167 .and_then(|mut file| {
168 file.read_to_string(&mut text)?;
169 Ok(())
170 })
171 .ok()?;
172 Some(text)
173}
174
175pub fn assess_mission(repo_root: &Path, mission_id: &str) -> Result<PrHandoff> {
177 let paths = crate::paths::MissionPaths::new(repo_root, mission_id);
178 if !paths.events_file().is_file() {
179 return Err(EngineError::Config(format!(
180 "unknown mission '{mission_id}'"
181 )));
182 }
183 let state: crate::types::MissionState = {
184 let text = std::fs::read_to_string(paths.state_file()).map_err(|e| {
185 EngineError::Config(format!("cannot read state for '{mission_id}': {e}"))
186 })?;
187 serde_json::from_str(&text).map_err(|e| {
188 EngineError::Config(format!("invalid state.json for '{mission_id}': {e}"))
189 })?
190 };
191 let report = read_mission_doc(&paths.report_file());
192 let plan = read_mission_doc(&paths.plan_md_file());
193 Ok(assess(
194 repo_root,
195 &PrHandoffInputs {
196 mission: &state.mission,
197 report_md: report.as_deref(),
198 plan_md: plan.as_deref(),
199 remote: DEFAULT_REMOTE,
200 probe_remote: true,
201 },
202 ))
203}
204
205pub fn create_pull_request(repo_root: &Path, handoff: &PrHandoff) -> Result<String> {
207 let PrHandoff::ReadyToCreate {
208 title,
209 body,
210 branch,
211 base,
212 ..
213 } = handoff
214 else {
215 return Err(EngineError::Config(
216 "create_pull_request requires ReadyToCreate handoff (remote branch present)".into(),
217 ));
218 };
219 let gh = which_gh().ok_or_else(|| EngineError::Config("`gh` CLI not found on PATH".into()))?;
220 let output = Command::new(&gh)
222 .current_dir(repo_root)
223 .args([
224 "pr", "create", "--base", base, "--head", branch, "--title", title, "--body", body,
225 ])
226 .output()
227 .map_err(|e| EngineError::Backend(format!("failed to spawn gh: {e}")))?;
228 if !output.status.success() {
229 let stderr = String::from_utf8_lossy(&output.stderr);
230 return Err(EngineError::Backend(format!(
231 "gh pr create failed ({}): {}",
232 output.status,
233 stderr.trim()
234 )));
235 }
236 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
237}
238
239fn looks_like_github_remote(url: &str) -> bool {
240 let lower = url.to_ascii_lowercase();
241 lower.contains("github.com") || lower.contains("github.")
242}
243
244fn which_gh() -> Option<PathBuf> {
245 which_binary("gh")
246}
247
248fn which_binary(name: &str) -> Option<PathBuf> {
249 let Ok(path_var) = std::env::var("PATH") else {
250 return None;
251 };
252 for dir in std::env::split_paths(&path_var) {
253 let candidate = dir.join(name);
254 if candidate.is_file() {
255 return Some(candidate);
256 }
257 #[cfg(windows)]
258 {
259 let exe = dir.join(format!("{name}.exe"));
260 if exe.is_file() {
261 return Some(exe);
262 }
263 }
264 }
265 None
266}
267
268fn build_title_body(inputs: &PrHandoffInputs<'_>) -> (String, String) {
269 let goal = scrub::scrub_and_truncate(inputs.mission.goal.trim(), TITLE_MAX);
270 let title = if goal.is_empty() {
271 format!("kranz mission {}", inputs.mission.id)
272 } else {
273 goal
274 };
275
276 let mut body = String::new();
277 body.push_str(&format!(
278 "## Mission `{}`\n\n",
279 scrub::scrub(&inputs.mission.id)
280 ));
281 body.push_str(&format!(
282 "**Branch:** `{}` → `{}`\n\n",
283 inputs.mission.mission_branch, inputs.mission.base_branch
284 ));
285 body.push_str(
286 "**Merge gates:** not yet run. Merge gates run at merge time \
287 (`kranz` / dashboard Merge) and are still pending. This PR does \
288 **not** mean the mission has landed on the base branch.\n\n",
289 );
290 body.push_str(&format!(
291 "**Artifacts:** `.kranz/missions/{}/plan.md`, \
292 `.kranz/missions/{}/report.md`\n\n",
293 inputs.mission.id, inputs.mission.id
294 ));
295
296 if let Some(report) = inputs.report_md.map(str::trim).filter(|s| !s.is_empty()) {
297 body.push_str("## Report excerpt\n\n");
298 body.push_str(&scrub::scrub_and_truncate(report, 3_000));
299 body.push_str("\n\n");
300 } else if let Some(plan) = inputs.plan_md.map(str::trim).filter(|s| !s.is_empty()) {
301 body.push_str("## Plan excerpt\n\n");
302 body.push_str(&scrub::scrub_and_truncate(plan, 2_000));
303 body.push_str("\n\n");
304 }
305
306 if !inputs.mission.validation_contract.is_empty() {
307 body.push_str("## Validation contract (from mission)\n\n");
308 for a in inputs.mission.validation_contract.iter().take(12) {
309 body.push_str(&format!(
310 "- `{}`: {}\n",
311 scrub::scrub(&a.id),
312 scrub::scrub_and_truncate(&a.statement, 160)
313 ));
314 }
315 body.push('\n');
316 }
317
318 let body = scrub::scrub_and_truncate(&body, BODY_MAX);
319 (title, body)
320}
321
322fn format_gh_pr_create_command(base: &str, head: &str, title: &str, body: &str) -> String {
323 let end = floor_char_boundary(body, 400);
325 let body_short = if body.len() > end {
326 format!("{}…", &body[..end])
327 } else {
328 body.to_string()
329 };
330 format!("gh pr create --base {base} --head {head} --title {title:?} --body {body_short:?}")
331}
332
333fn floor_char_boundary(s: &str, index: usize) -> usize {
334 if index >= s.len() {
335 return s.len();
336 }
337 let mut i = index;
338 while i > 0 && !s.is_char_boundary(i) {
339 i -= 1;
340 }
341 i
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347 use crate::types::{Assertion, AssertionCheck, MissionStatus};
348 use chrono::Utc;
349
350 fn sample_mission(status: MissionStatus) -> Mission {
351 Mission {
352 id: "m-test".into(),
353 goal: "Ship PR handoff without auto-push".into(),
354 validation_contract: vec![Assertion {
355 id: "a1".into(),
356 statement: "tests pass".into(),
357 check: AssertionCheck::Command,
358 command: Some("cargo test".into()),
359 negative_control: None,
360 pty_script: None,
361 }],
362 milestones: vec![],
363 status,
364 created_at: Utc::now(),
365 base_branch: "main".into(),
366 base_sha: Some("abc".into()),
367 mission_branch: "kranz/mission-m-test".into(),
368 command_grants: vec![],
369 touch_set: vec![],
370 deny_exceptions: vec![],
371 egress_grants: vec![],
372 executor_route: None,
373 standards_manifest: None,
374 reviewer_independence: None,
375 }
376 }
377
378 #[cfg(unix)]
383 #[test]
384 fn a_symlinked_report_yields_no_body_not_the_targets_content() {
385 let tmp = tempfile::tempdir().unwrap();
386 let root = tmp.path();
387 std::fs::write(root.join("serve.token"), "SUPER-SECRET-MUTATION-TOKEN").unwrap();
388
389 let paths = crate::paths::MissionPaths::new(root, "m-test");
390 std::fs::create_dir_all(paths.mission_dir()).unwrap();
391
392 std::fs::write(paths.report_file(), "# Report\n\nAll good.").unwrap();
394 assert_eq!(
395 read_mission_doc(&paths.report_file()).as_deref(),
396 Some("# Report\n\nAll good.")
397 );
398
399 std::fs::remove_file(paths.report_file()).unwrap();
401 std::os::unix::fs::symlink(root.join("serve.token"), paths.report_file()).unwrap();
402 assert_eq!(
403 read_mission_doc(&paths.report_file()),
404 None,
405 "a symlinked report.md must not be read through"
406 );
407 }
408
409 #[test]
410 fn non_complete_is_unavailable() {
411 let tmp = tempfile::tempdir().unwrap();
412 let m = sample_mission(MissionStatus::Running);
413 let h = assess(
414 tmp.path(),
415 &PrHandoffInputs {
416 mission: &m,
417 report_md: None,
418 plan_md: None,
419 remote: "origin",
420 probe_remote: true,
421 },
422 );
423 assert!(matches!(h, PrHandoff::Unavailable { .. }));
424 }
425
426 #[test]
427 fn body_states_merge_gates_pending() {
428 let m = sample_mission(MissionStatus::Complete);
429 let (title, body) = build_title_body(&PrHandoffInputs {
430 mission: &m,
431 report_md: Some("# Report\n\nAll good."),
432 plan_md: None,
433 remote: "origin",
434 probe_remote: true,
435 });
436 assert!(title.contains("Ship PR handoff"));
437 assert!(body.contains("still pending"));
438 assert!(!body.to_ascii_lowercase().contains("merge gates passed"));
439 assert!(body.contains("m-test"));
440 }
441
442 #[test]
443 fn pr_handoff_source_never_invokes_git_push() {
444 let src = include_str!("pr_handoff.rs");
446 let prod = src.split("#[cfg(test)]").next().unwrap_or(src);
447 let code: String = prod
448 .lines()
449 .filter(|l| {
450 let t = l.trim_start();
451 !t.starts_with("//") && !t.starts_with("//!") && !t.starts_with('*')
452 })
453 .collect::<Vec<_>>()
454 .join("\n");
455 for needle in [
456 "push_mission_branch",
457 ".args([\"push\"",
458 ".arg(\"push\")",
459 "[\"push\",",
460 "\"git\", \"push\"",
461 "Command::new(\"git\")",
462 ] {
463 assert!(
464 !code.contains(needle),
465 "pr_handoff.rs must not contain {needle:?}"
466 );
467 }
468 assert!(code.contains("git push {remote} {branch}"));
470 }
471
472 #[test]
473 fn needs_push_command_is_copyable_not_executed() {
474 let handoff = PrHandoff::NeedsPush {
475 command: "git push origin kranz/mission-m-test".into(),
476 remote: "origin".into(),
477 branch: "kranz/mission-m-test".into(),
478 };
479 let err = create_pull_request(Path::new("/tmp"), &handoff).unwrap_err();
480 assert!(
481 err.to_string().contains("ReadyToCreate"),
482 "must refuse create on NeedsPush: {err}"
483 );
484 }
485
486 #[test]
487 fn skip_remote_probe_does_not_call_network() {
488 let tmp = tempfile::tempdir().unwrap();
489 let git = |args: &[&str]| {
491 let out = std::process::Command::new("git")
492 .args(args)
493 .current_dir(tmp.path())
494 .output()
495 .unwrap();
496 assert!(out.status.success(), "{args:?} {:?}", out);
497 };
498 git(&["init"]);
499 git(&["config", "user.email", "t@t"]);
500 git(&["config", "user.name", "t"]);
501 std::fs::write(tmp.path().join("f"), "x").unwrap();
502 git(&["add", "f"]);
503 git(&["commit", "-m", "i"]);
504 git(&[
505 "remote",
506 "add",
507 "origin",
508 "https://github.com/example/repo.git",
509 ]);
510
511 let m = sample_mission(MissionStatus::Complete);
512 let h = assess(
513 tmp.path(),
514 &PrHandoffInputs {
515 mission: &m,
516 report_md: None,
517 plan_md: None,
518 remote: "origin",
519 probe_remote: false,
520 },
521 );
522 match h {
523 PrHandoff::Unavailable { reason } => {
524 assert!(reason.contains("dashboard"), "{reason}");
525 }
526 other => panic!("expected Unavailable dashboard pointer, got {other:?}"),
527 }
528 }
529}