Skip to main content

git_policy_cli/
standards_review_run.rs

1//! What:
2//!  The standards-review shadow run, end to end and in-process: classify the landed commit's names, read the commit,
3//!  build the capped input, call the gateway, and append the one log entry. Also a bounded runner that gives a caller a
4//!  hard time limit over all of it.
5//! Why:
6//!  This replaces the root `mise.toml` task `shadow-review` (its Luna route; the `SHADOW_REVIEW_ALLOW=1` route starts
7//!  `pi` and is not ported). The review must never delay or fail a landing, so nothing here panics or returns a failure
8//!  the landing would act on, and `run_within` abandons the work when the time is up. Nothing calls this yet.
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! const outcome = await reviewLanded({ names, sha, subject, read, options, clock });
13//! ```
14
15use super::iso_time::format_iso_milliseconds;
16use super::standards_review_client::{Gateway, Reply, SCRIPT_DEADLINE, post_decision};
17use super::standards_review_append::{deliver, names_commit};
18use super::standards_review_entry::{ReviewEntry, render_review, render_skipped};
19use super::standards_review_input::{LOG_RELATIVE, LandedCommit, Plan, ReviewInput, build_input, plan_for};
20use super::standards_review_request::{ENDPOINT, Judged, read_answer, request_body};
21use super::standards_review_text::seconds_with_tenth;
22use std::path::{Path, PathBuf};
23use std::sync::mpsc;
24use std::time::Duration;
25
26/// The environment variable that points the log at a disposable fixture.
27pub const LOG_VARIABLE: &str = "SHADOW_REVIEW_LOG";
28
29/// The variable that replays a commit other than `HEAD`.
30pub const SHA_VARIABLE: &str = "SHADOW_REVIEW_SHA";
31
32/// The variable that asks for the full review route, which this port does not run.
33pub const ALLOW_VARIABLE: &str = "SHADOW_REVIEW_ALLOW";
34
35/// The credential variable, and the stop-hook credential used when it is unset.
36pub const KEY_VARIABLES: [&str; 2] = ["SHADOW_REVIEW_LLMGATEWAY_API_KEY", "STOP_REMINDER_LLMGATEWAY_API_KEY"];
37
38/// The failure line written when no credential is set.
39pub const NO_KEY_MESSAGE: &str = "no SHADOW_REVIEW_LLMGATEWAY_API_KEY or STOP_REMINDER_LLMGATEWAY_API_KEY in the environment";
40
41/// What:
42///  What the run was told: the log, the credential, and where and how long to call.
43/// Why:
44///   Production passes `ENDPOINT` and `SCRIPT_DEADLINE`; tests pass a loopback URL and a short deadline.
45///
46/// In TS you'd write (pseudocode):
47/// ```ts
48/// type ReviewOptions = { logPath: string; apiKey?: string; endpoint: string; deadline: number };
49/// ```
50#[derive(Clone, Debug)]
51pub struct ReviewOptions {
52    /// Where entries are appended.
53    pub log_path: PathBuf,
54    /// The bearer credential; `None` writes the no-credential failure entry.
55    pub api_key: Option<String>,
56    /// The decisions endpoint.
57    pub endpoint: String,
58    /// The gateway call's total time.
59    pub deadline: Duration,
60    /// A PEM authority replacing the embedded roots (tests only).
61    pub trusted_pem: Option<Vec<u8>>,
62    /// Where each delivered entry is also kept, so a later delivery can put back one another writer overwrote.
63    pub spool: Option<PathBuf>,
64}
65
66/// What:
67///  The options the script's environment variables give for a repository: the log under `repo_root` unless
68///  `SHADOW_REVIEW_LOG` names another, and the first credential variable that is set, even to an empty text.
69/// Why:
70///   `??` in the script falls back only for an unset variable, so an empty first key does not fall back.
71///
72/// In TS you'd write (pseudocode):
73/// ```ts
74/// const logPath = process.env.SHADOW_REVIEW_LOG ?? `${repo}/doc/audit/standards-review-gate-shadow.md`;
75/// ```
76pub fn options_from_environment(repo_root: &Path, variable: &dyn Fn(&str) -> Option<String>) -> ReviewOptions {
77    let log_path: PathBuf = variable(LOG_VARIABLE).map_or_else(|| return repo_root.join(LOG_RELATIVE), PathBuf::from);
78    let api_key: Option<String> = variable(KEY_VARIABLES[0]).or_else(|| return variable(KEY_VARIABLES[1]));
79    return ReviewOptions { log_path, api_key, endpoint: String::from(ENDPOINT), deadline: SCRIPT_DEADLINE, trusted_pem: None, spool: None };
80}
81
82/// What:
83///  Whether the environment asks for the full-review route, which this port leaves out.
84/// Why:
85///   The caller says so in a notice instead of silently running the cheap route.
86///
87/// In TS you'd write (pseudocode):
88/// ```ts
89/// process.env.SHADOW_REVIEW_ALLOW === '1'
90/// ```
91pub fn full_review_requested(variable: &dyn Fn(&str) -> Option<String>) -> bool {
92    return variable(ALLOW_VARIABLE).as_deref() == Some("1");
93}
94
95/// What:
96///  How a run ended.
97/// Why:
98///   Callers log it; none of it fails a landing.
99///
100/// In TS you'd write (pseudocode):
101/// ```ts
102/// type ReviewOutcome = 'no-entry' | 'skipped' | { reviewed: Judged } | { unread: string } | { unwritable: string };
103/// ```
104#[derive(Clone, Debug, Eq, PartialEq)]
105pub enum ReviewOutcome {
106    /// No names, or only the log: no entry, as the script exits silently.
107    NoEntry,
108    /// The no-code-files entry was appended.
109    Skipped,
110    /// A review entry was appended; the gateway's verdict, or its failure in `outcome` with an empty `top`.
111    Reviewed(Judged),
112    /// The commit could not be read; no entry.
113    Unread(String),
114    /// The entry could not be appended.
115    Unwritable(String),
116    /// The log already names this commit (a repeated item): nothing was asked and nothing was added.
117    AlreadyLogged,
118}
119
120/// What:
121///  The gateway step: the verdict, or the failure message, and the elapsed milliseconds.
122/// Why:
123///   A missing credential fails before any connection, as in the script.
124///
125/// In TS you'd write (pseudocode):
126/// ```ts
127/// try { ...fetch... } catch (error) { lunaOutcome = 'Luna failed: ' + message }
128/// ```
129fn ask_gateway(input: &ReviewInput, options: &ReviewOptions) -> Judged {
130    let failed = |message: String| return Judged { top: String::new(), outcome: format!("Luna failed: {message}") };
131    let key: &str = options.api_key.as_deref().unwrap_or("");
132    if key.is_empty() {
133        return failed(String::from(NO_KEY_MESSAGE));
134    }
135    let gateway: Gateway = Gateway { url: options.endpoint.as_str(), api_key: key, deadline: options.deadline, trusted_pem: options.trusted_pem.as_deref() };
136    let reply: Result<Reply, String> = post_decision(&gateway, request_body(input.model_input.as_str()).as_str());
137    return match reply.and_then(|answer| return read_answer(answer.status, answer.body.as_str())) {
138        Ok(judged) => judged,
139        Err(message) => failed(message),
140    };
141}
142
143/// What:
144///  Review one landed commit: `names` are the changed names the landing knows, `read` fetches the commit's patch and
145///  files (called only for a commit with code files), `now_millis` is the clock.
146/// Why:
147///   One function is the script's whole body, with the reads and the clock as parameters so tests fix both.
148///
149/// In TS you'd write (pseudocode):
150/// ```ts
151/// async function reviewLanded({ names, sha, subject, read, options, nowMillis }): Promise<ReviewOutcome>;
152/// ```
153pub fn review_landed(
154    names: &[String],
155    sha: &str,
156    subject: &str,
157    read: &dyn Fn(&[String]) -> Result<LandedCommit, String>,
158    options: &ReviewOptions,
159    now_millis: &dyn Fn() -> u64,
160) -> ReviewOutcome {
161    let code_files: Vec<String> = match plan_for(names) {
162        Plan::Silent => return ReviewOutcome::NoEntry,
163        Plan::NoCode => {
164            return match deliver(options.log_path.as_path(), options.spool.as_deref(), sha, render_skipped(sha, subject).as_str()) {
165                Ok(_) => ReviewOutcome::Skipped,
166                Err(message) => ReviewOutcome::Unwritable(message),
167            };
168        }
169        Plan::Review(files) => files,
170    };
171    if names_commit(options.log_path.as_path(), sha) {
172        return ReviewOutcome::AlreadyLogged;
173    }
174    let commit: LandedCommit = match read(code_files.as_slice()) {
175        Ok(read_commit) => read_commit,
176        Err(message) => return ReviewOutcome::Unread(message),
177    };
178    let input: ReviewInput = build_input(&commit, code_files.as_slice());
179    let started: u64 = now_millis();
180    let judged: Judged = ask_gateway(&input, options);
181    let finished: u64 = now_millis();
182    let entry: ReviewEntry = ReviewEntry {
183        date: format_iso_milliseconds(i64::try_from(finished).unwrap_or(i64::MAX)),
184        sha: String::from(sha),
185        subject: String::from(subject),
186        code_files: input.code_file_count,
187        diff_lines: input.diff_line_count,
188        truncated: input.truncated,
189        input_units: input.model_input_units,
190        seconds: seconds_with_tenth(finished.saturating_sub(started)),
191        top: judged.top.clone(),
192        outcome: judged.outcome.clone(),
193    };
194    return match deliver(options.log_path.as_path(), options.spool.as_deref(), sha, render_review(&entry).as_str()) {
195        Ok(_) => ReviewOutcome::Reviewed(judged),
196        Err(message) => ReviewOutcome::Unwritable(message),
197    };
198}
199
200/// What:
201///  Run `job` on a helper thread and wait at most `limit`; `None` means the time ran out and the job was abandoned.
202///  An abandoned job keeps running until the process exits, which is the end of the wrapper command.
203/// Why:
204///   This is the hard bound over the Git reads, the call and the log write together: the landing's caller waits `limit`
205///   at most. A job that panics also gives `None`.
206///
207/// In TS you'd write (pseudocode):
208/// ```ts
209/// await Promise.race([job(), wait(limit).then(() => undefined)])
210/// ```
211pub fn run_within<T: Send + 'static>(limit: Duration, job: impl FnOnce() -> T + Send + 'static) -> Option<T> {
212    let (sender, receiver) = mpsc::channel::<T>();
213    let worker = std::thread::Builder::new().name(String::from("standards-review")).spawn(move || {
214        let _ = sender.send(job());
215    });
216    if worker.is_err() {
217        return None;
218    }
219    return receiver.recv_timeout(limit).ok();
220}
221
222/// Controls stay out of the release executable.
223#[cfg(test)]
224#[path = "standards_review_run_tests.rs"]
225mod tests;