|
| 1 | +//! Deterministic close-time artifact harvest. |
| 2 | +//! |
| 3 | +//! When a task closes we want its resume pack to carry the *real* refs of what |
| 4 | +//! shipped — the commit, the branch, the PR — as structured [`Artifacts`], not |
| 5 | +//! as hopeful regex scrapes of free-form prose. This module shells out to |
| 6 | +//! `git`/`gh` in the task's repo and returns whatever it can find. |
| 7 | +//! |
| 8 | +//! Strictly best-effort and side-effect free: a missing repo, a detached HEAD, |
| 9 | +//! an absent `gh`, or no PR for the branch simply yields fewer artifacts. It |
| 10 | +//! NEVER errors and NEVER runs a model — this is the cheap, deterministic |
| 11 | +//! Layer-2 of the "perfect pack at close" design. The pure [`build`] decides |
| 12 | +//! what to keep so the filtering is unit-testable without a live repo. |
| 13 | +
|
| 14 | +use crate::artifacts::Artifacts; |
| 15 | +use std::path::Path; |
| 16 | +use std::process::Command; |
| 17 | + |
| 18 | +/// Pure assembler: turn the raw `(branch, commit, pr_url)` git/gh outputs into |
| 19 | +/// a clean [`Artifacts`], dropping the values that aren't real refs — a |
| 20 | +/// detached HEAD (`"HEAD"`), empty strings, or a non-http PR line. Separated |
| 21 | +/// from the IO so the keep/drop rules can be tested without spawning git. |
| 22 | +pub fn build(branch: Option<String>, commit: Option<String>, pr_url: Option<String>) -> Artifacts { |
| 23 | + let mut a = Artifacts::default(); |
| 24 | + if let Some(b) = branch { |
| 25 | + let b = b.trim(); |
| 26 | + // "HEAD" means detached — not a branch name worth recording. |
| 27 | + if !b.is_empty() && b != "HEAD" { |
| 28 | + a.branch_names.push(b.to_string()); |
| 29 | + } |
| 30 | + } |
| 31 | + if let Some(c) = commit { |
| 32 | + let c = c.trim(); |
| 33 | + if !c.is_empty() { |
| 34 | + a.commit_hashes.push(c.to_string()); |
| 35 | + } |
| 36 | + } |
| 37 | + if let Some(u) = pr_url { |
| 38 | + let u = u.trim(); |
| 39 | + if u.starts_with("http") { |
| 40 | + a.pr_urls.push(u.to_string()); |
| 41 | + } |
| 42 | + } |
| 43 | + a |
| 44 | +} |
| 45 | + |
| 46 | +/// Harvest commit/branch/PR refs from the git repo at `dir`. Best-effort; |
| 47 | +/// returns an empty [`Artifacts`] when `dir` is not a repo or the tools are |
| 48 | +/// absent. Used at task close to stamp deterministic refs onto the close event. |
| 49 | +pub fn harvest(dir: &Path) -> Artifacts { |
| 50 | + let branch = git(dir, &["rev-parse", "--abbrev-ref", "HEAD"]); |
| 51 | + let commit = git(dir, &["rev-parse", "--short", "HEAD"]); |
| 52 | + let pr_url = gh_pr_url(dir); |
| 53 | + build(branch, commit, pr_url) |
| 54 | +} |
| 55 | + |
| 56 | +/// Run `git -C <dir> <args>` and return trimmed stdout, or `None` on any |
| 57 | +/// failure (missing git, not a repo, non-zero exit). |
| 58 | +fn git(dir: &Path, args: &[&str]) -> Option<String> { |
| 59 | + let out = Command::new("git") |
| 60 | + .arg("-C") |
| 61 | + .arg(dir) |
| 62 | + .args(args) |
| 63 | + .output() |
| 64 | + .ok()?; |
| 65 | + if !out.status.success() { |
| 66 | + return None; |
| 67 | + } |
| 68 | + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); |
| 69 | + if s.is_empty() { |
| 70 | + None |
| 71 | + } else { |
| 72 | + Some(s) |
| 73 | + } |
| 74 | +} |
| 75 | + |
| 76 | +/// Best-effort PR URL for the repo's current branch via `gh`. `None` when `gh` |
| 77 | +/// is absent, unauthenticated, or the branch has no PR. May make a network |
| 78 | +/// call, so it is the slowest part of the harvest — still bounded to one |
| 79 | +/// short-lived child and never blocks the close on failure. |
| 80 | +fn gh_pr_url(dir: &Path) -> Option<String> { |
| 81 | + let out = Command::new("gh") |
| 82 | + .args(["pr", "view", "--json", "url", "-q", ".url"]) |
| 83 | + .current_dir(dir) |
| 84 | + .output() |
| 85 | + .ok()?; |
| 86 | + if !out.status.success() { |
| 87 | + return None; |
| 88 | + } |
| 89 | + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); |
| 90 | + if s.starts_with("http") { |
| 91 | + Some(s) |
| 92 | + } else { |
| 93 | + None |
| 94 | + } |
| 95 | +} |
| 96 | + |
| 97 | +#[cfg(test)] |
| 98 | +mod tests { |
| 99 | + use super::*; |
| 100 | + |
| 101 | + #[test] |
| 102 | + fn build_keeps_real_refs() { |
| 103 | + let a = build( |
| 104 | + Some("feat/clean-pack".into()), |
| 105 | + Some("75f65e2".into()), |
| 106 | + Some("https://github.com/o/r/pull/51".into()), |
| 107 | + ); |
| 108 | + assert_eq!(a.branch_names, vec!["feat/clean-pack"]); |
| 109 | + assert_eq!(a.commit_hashes, vec!["75f65e2"]); |
| 110 | + assert_eq!(a.pr_urls, vec!["https://github.com/o/r/pull/51"]); |
| 111 | + } |
| 112 | + |
| 113 | + #[test] |
| 114 | + fn build_drops_detached_head_empty_and_non_http() { |
| 115 | + let a = build( |
| 116 | + Some("HEAD".into()), |
| 117 | + Some(" ".into()), |
| 118 | + Some("no pull request".into()), |
| 119 | + ); |
| 120 | + assert!( |
| 121 | + a.is_empty(), |
| 122 | + "detached HEAD + empty commit + non-url PR all dropped" |
| 123 | + ); |
| 124 | + } |
| 125 | + |
| 126 | + #[test] |
| 127 | + fn build_tolerates_all_absent() { |
| 128 | + assert!(build(None, None, None).is_empty()); |
| 129 | + } |
| 130 | +} |
0 commit comments