Skip to main content

kranz_engine/
pr_handoff.rs

1//! Optional GitHub PR handoff for COMPLETE-but-unmerged missions.
2//!
3//! Local invariant: kranz **never pushes**. This module may:
4//! - probe remotes / `ls-remote` (read-only git),
5//! - run `gh pr create` when the mission branch already exists on the remote,
6//! - or surface a copyable human push command for the operator.
7//!
8//! It must never invoke a mutating git push or the engine cloud-push helper.
9
10use 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/// What the operator should do next for a completed mission branch.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "camelCase", tag = "kind")]
25pub enum PrHandoff {
26    /// Branch is not on the remote yet — show a copyable push; never run it.
27    NeedsPush {
28        command: String,
29        remote: String,
30        branch: String,
31    },
32    /// Remote branch exists — ready for `gh pr create` (or the prefilled cmd).
33    ReadyToCreate {
34        command: String,
35        title: String,
36        body: String,
37        remote: String,
38        branch: String,
39        base: String,
40    },
41    /// Missing GitHub remote, `gh`, auth, etc.
42    Unavailable { reason: String },
43}
44
45/// Inputs gathered from mission artifacts (report/plan optional).
46#[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    /// When false, skip `git ls-remote` (no network). Used by Slack notify
53    /// so a hung remote cannot stall the bridge; the dashboard still probes.
54    pub probe_remote: bool,
55}
56
57/// Assess PR handoff for a COMPLETE mission. Pure side-effect surface is
58/// limited to read-only git probes + `which`-style `gh` discovery.
59pub 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        // No network: point the operator at the dashboard Delivered panel,
109        // which performs the remote-branch probe.
110        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
153/// Read one mission document (report/plan) with the whole `.kranz` chain and
154/// the leaf pinned no-follow.
155///
156/// Threat (follow-up review H-4): `report.md` becomes the BODY of
157/// `PrHandoff::ReadyToCreate`, which `GET /api/missions/:id/pr-handoff`
158/// serves and `POST .../pr-handoff/create` publishes into a real GitHub PR
159/// description. A worker under checkout isolation can replace that leaf with
160/// a symlink (`ln -sf ../../serve.token .kranz/missions/m-1/report.md`), and
161/// the route's `require_no_follow()` pins the mission DIRECTORY chain only.
162/// A refused read yields no body, which the caller already renders honestly.
163fn 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
175/// Convenience: assess from on-disk mission state + report/plan files.
176pub 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
205/// Run `gh pr create` for a [`PrHandoff::ReadyToCreate`] only. Never pushes.
206pub 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    // Title/body via flags — never shell-interpolated. No `git` argv here.
221    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    // Prefilled copyable command; body truncated for shell pasteability.
324    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    /// H-4 (follow-up review): `report.md` becomes the PR body a click
379    /// publishes to GitHub, so a symlinked leaf must yield NO body rather
380    /// than the target's content. The route's directory-chain pin does not
381    /// cover the leaf; this read does.
382    #[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        // A real report reads back verbatim.
393        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        // The same leaf, swapped for a symlink out of the mission tree.
400        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        // String/command deny on production code only (exclude this test module).
445        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        // The copyable human command is allowed as a string template only:
469        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        // Minimal git repo with a github remote — assess must not need ls-remote.
490        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}