Skip to main content

harn_vm/orchestration/playground/
step.rs

1//! Step actions — the levers a brute-forcing agent pulls between captain
2//! sweeps in a Merge Captain mock-repos playground (#1020).
3//!
4//! Step actions are split into two flavors:
5//!
6//! * **State-only**: mutations that only touch `state.json` (e.g.
7//!   `set_check`, `set_labels`, `add_comment`).
8//! * **Git-native**: mutations that produce real commits on the bare
9//!   remote (e.g. `merge_pull_request`, `force_push_author`,
10//!   `advance_base`). The captain's subsequent rebase/fetch sees the
11//!   real diverged history.
12
13use std::path::Path;
14
15use serde_json::json;
16
17use crate::value::VmError;
18
19use super::git::GitOps;
20use super::manifest::{ScenarioAction, ScenarioManifest, ScenarioStep};
21use super::state::{PlaygroundPullRequest, PlaygroundState};
22
23pub struct StepReport {
24    /// Number of state mutations applied (may be more than 1 per step).
25    pub actions_applied: usize,
26    /// Names of PRs whose state was updated. Useful for `mock status`.
27    pub prs_touched: Vec<String>,
28    /// Optional human-readable summary lines.
29    pub summary: Vec<String>,
30}
31
32pub fn run_named_step(
33    dir: &Path,
34    state: &mut PlaygroundState,
35    manifest: &ScenarioManifest,
36    step_name: &str,
37) -> Result<StepReport, VmError> {
38    let step = manifest
39        .steps
40        .iter()
41        .find(|s| s.name == step_name)
42        .ok_or_else(|| {
43            let available: Vec<String> = manifest.steps.iter().map(|s| s.name.clone()).collect();
44            VmError::Runtime(format!(
45                "scenario {} has no step named {step_name:?} (available: {})",
46                manifest.scenario,
47                if available.is_empty() {
48                    "<none>".to_string()
49                } else {
50                    available.join(", ")
51                }
52            ))
53        })?
54        .clone();
55    apply_step(dir, state, manifest, &step)
56}
57
58pub fn apply_step(
59    dir: &Path,
60    state: &mut PlaygroundState,
61    manifest: &ScenarioManifest,
62    step: &ScenarioStep,
63) -> Result<StepReport, VmError> {
64    let mut report = StepReport {
65        actions_applied: 0,
66        prs_touched: Vec::new(),
67        summary: Vec::new(),
68    };
69    for action in &step.actions {
70        let summary = apply_action(dir, state, manifest, action)?;
71        report.actions_applied += 1;
72        if let Some((pr, line)) = summary {
73            if !report.prs_touched.contains(&pr) {
74                report.prs_touched.push(pr);
75            }
76            report.summary.push(line);
77        }
78    }
79    state.record(
80        &format!("step:{}", step.name),
81        json!({"actions": step.actions.len()}),
82    );
83    Ok(report)
84}
85
86/// Apply a single ad-hoc action without going through a named step. Used by
87/// the `mock step --action <json>` CLI fallback for one-off mutations.
88pub fn apply_one_action(
89    dir: &Path,
90    state: &mut PlaygroundState,
91    manifest: &ScenarioManifest,
92    action: &ScenarioAction,
93) -> Result<StepReport, VmError> {
94    let summary = apply_action(dir, state, manifest, action)?;
95    state.record(
96        &format!("action:{}", action_kind(action)),
97        serde_json::to_value(action).unwrap_or(serde_json::Value::Null),
98    );
99    let mut report = StepReport {
100        actions_applied: 1,
101        prs_touched: Vec::new(),
102        summary: Vec::new(),
103    };
104    if let Some((pr, line)) = summary {
105        report.prs_touched.push(pr);
106        report.summary.push(line);
107    }
108    Ok(report)
109}
110
111fn apply_action(
112    dir: &Path,
113    state: &mut PlaygroundState,
114    manifest: &ScenarioManifest,
115    action: &ScenarioAction,
116) -> Result<Option<(String, String)>, VmError> {
117    let git = GitOps::default();
118    match action {
119        ScenarioAction::SetCheck {
120            repo,
121            pr_number,
122            name,
123            status,
124            conclusion,
125            details_url,
126        } => {
127            let now_ms = state.now_ms;
128            let pr = require_pr_mut(state, repo, *pr_number)?;
129            let existing = pr.checks.iter_mut().find(|c| &c.name == name);
130            let conclusion_value = conclusion.clone();
131            let started = Some(format_clock(now_ms));
132            let completed = if status == "completed" {
133                Some(format_clock(now_ms + 1))
134            } else {
135                None
136            };
137            match existing {
138                Some(check) => {
139                    check.status = status.clone();
140                    check.conclusion = conclusion_value;
141                    if check.started_at.is_none() {
142                        check.started_at = started;
143                    }
144                    check.completed_at = completed;
145                    check.details_url = details_url.clone();
146                }
147                None => {
148                    pr.checks.push(super::manifest::ScenarioCheck {
149                        name: name.clone(),
150                        status: status.clone(),
151                        conclusion: conclusion_value,
152                        details_url: details_url.clone(),
153                        started_at: started,
154                        completed_at: completed,
155                    });
156                }
157            }
158            Ok(Some((
159                pr.key(),
160                format!("set check {name}={status} on {}#{}", repo, pr_number),
161            )))
162        }
163        ScenarioAction::AddPullRequest { pr } => {
164            if !state.repos.contains_key(&pr.repo) {
165                return Err(VmError::Runtime(format!("unknown repo {}", pr.repo)));
166            }
167            let pr_state = PlaygroundPullRequest::from_manifest_pr(pr);
168            let key = pr_state.key();
169            if state.pull_requests.contains_key(&key) {
170                return Err(VmError::Runtime(format!("PR {key} already exists",)));
171            }
172            // Resolve head_sha if the head branch exists on the remote.
173            let working = working_path(dir, &pr.repo);
174            let head_sha = git
175                .rev_parse(&working, &format!("origin/{}", pr.head_branch))
176                .ok();
177            let mut pr_state = pr_state;
178            pr_state.head_sha = head_sha;
179            state.pull_requests.insert(key.clone(), pr_state);
180            Ok(Some((key, format!("opened PR {}#{}", pr.repo, pr.number))))
181        }
182        ScenarioAction::ClosePullRequest { repo, pr_number } => {
183            let now_ms = state.now_ms;
184            let pr = require_pr_mut(state, repo, *pr_number)?;
185            pr.state = "closed".to_string();
186            pr.closed_at = Some(format_clock(now_ms));
187            Ok(Some((pr.key(), format!("closed PR {repo}#{pr_number}"))))
188        }
189        ScenarioAction::MergePullRequest {
190            repo,
191            pr_number,
192            merge_method,
193        } => {
194            let (head_branch, base_branch, title) = {
195                let pr = require_pr(state, repo, *pr_number)?;
196                (
197                    pr.head_branch.clone(),
198                    pr.base_branch.clone(),
199                    pr.title.clone(),
200                )
201            };
202            let working = working_path(dir, repo);
203            git.fetch(&working)?;
204            let merge_message =
205                format!("Merge pull request #{pr_number} from {repo}/{head_branch}\n\n{title}");
206            let merge_sha =
207                git.merge_branch(&working, &head_branch, &base_branch, &merge_message)?;
208            let now_ms = state.now_ms;
209            let pr = require_pr_mut(state, repo, *pr_number)?;
210            pr.state = "merged".to_string();
211            pr.merged_at = Some(format_clock(now_ms));
212            pr.head_sha = Some(merge_sha.clone());
213            pr.mergeable_state = "clean".to_string();
214            pr.mergeable = Some(true);
215            pr.merge_queue_status = match merge_method.as_deref() {
216                Some(method) if method.eq_ignore_ascii_case("queue") => Some("merged".to_string()),
217                _ => pr.merge_queue_status.clone(),
218            };
219            Ok(Some((
220                pr.key(),
221                format!("merged PR {repo}#{pr_number} into {base_branch}"),
222            )))
223        }
224        ScenarioAction::AddComment {
225            repo,
226            pr_number,
227            user,
228            body,
229        } => {
230            let now_ms = state.now_ms;
231            let pr = require_pr_mut(state, repo, *pr_number)?;
232            pr.comments.push(super::manifest::ScenarioComment {
233                user: user.clone(),
234                body: body.clone(),
235                created_at: Some(format_clock(now_ms)),
236            });
237            Ok(Some((
238                pr.key(),
239                format!("comment by {user} on {repo}#{pr_number}"),
240            )))
241        }
242        ScenarioAction::SetLabels {
243            repo,
244            pr_number,
245            labels,
246        } => {
247            let pr = require_pr_mut(state, repo, *pr_number)?;
248            pr.labels = labels.clone();
249            Ok(Some((
250                pr.key(),
251                format!("labels={:?} on {repo}#{pr_number}", labels),
252            )))
253        }
254        ScenarioAction::SetMergeQueueStatus {
255            repo,
256            pr_number,
257            status,
258        } => {
259            let pr = require_pr_mut(state, repo, *pr_number)?;
260            pr.merge_queue_status = Some(status.clone());
261            Ok(Some((
262                pr.key(),
263                format!("merge_queue_status={status} on {repo}#{pr_number}"),
264            )))
265        }
266        ScenarioAction::ForcePushAuthor {
267            repo,
268            branch,
269            files_set,
270            files_delete,
271            commit_message,
272        } => {
273            let working = working_path(dir, repo);
274            // Refresh local refs so origin/<base> is current before
275            // we reset the head branch onto it.
276            git.fetch(&working)?;
277            let base = manifest_base_for_branch(manifest, repo, branch);
278            let message = commit_message
279                .clone()
280                .unwrap_or_else(|| format!("Force-pushed rewrite on {branch}"));
281            let new_sha = git.force_rewrite_branch(
282                &working,
283                branch,
284                &base,
285                files_set,
286                files_delete,
287                &message,
288            )?;
289            // Update head_sha on any PRs that point at this branch.
290            let mut keys = Vec::new();
291            for pr in state
292                .pull_requests
293                .values_mut()
294                .filter(|pr| pr.repo == *repo && pr.head_branch == *branch)
295            {
296                pr.head_sha = Some(new_sha.clone());
297                pr.mergeable_state = "unknown".to_string();
298                pr.mergeable = None;
299                keys.push(pr.key());
300            }
301            let summary_key = keys.first().cloned().unwrap_or_else(|| repo.clone());
302            Ok(Some((
303                summary_key,
304                format!("force-push by author on {repo}/{branch}"),
305            )))
306        }
307        ScenarioAction::AdvanceBase {
308            repo,
309            files_set,
310            files_delete,
311            commit_message,
312        } => {
313            let working = working_path(dir, repo);
314            git.fetch(&working)?;
315            let default_branch = state
316                .repos
317                .get(repo)
318                .map(|r| r.default_branch.clone())
319                .ok_or_else(|| VmError::Runtime(format!("unknown repo {repo}")))?;
320            git.checkout(&working, &default_branch)?;
321            git.run(
322                &working,
323                &["pull", "--quiet", "--ff-only", "origin", &default_branch],
324            )?;
325            let message = commit_message
326                .clone()
327                .unwrap_or_else(|| format!("Advance {default_branch}"));
328            let _new_sha = git.commit_overlay(
329                &working,
330                files_set,
331                files_delete,
332                &message,
333                Some(&default_branch),
334            )?;
335            // Mark all open PRs with this base as `behind`.
336            let mut prs_touched = Vec::new();
337            for pr in state.pull_requests.values_mut().filter(|pr| {
338                pr.repo == *repo && pr.base_branch == default_branch && pr.state == "open"
339            }) {
340                pr.mergeable_state = "behind".to_string();
341                pr.mergeable = Some(false);
342                prs_touched.push(pr.key());
343            }
344            Ok(Some((
345                prs_touched.first().cloned().unwrap_or_else(|| repo.clone()),
346                format!("advanced base {repo}/{default_branch}"),
347            )))
348        }
349        ScenarioAction::SetMergeability {
350            repo,
351            pr_number,
352            mergeable,
353            mergeable_state,
354        } => {
355            let pr = require_pr_mut(state, repo, *pr_number)?;
356            pr.mergeable = *mergeable;
357            pr.mergeable_state = mergeable_state.clone();
358            Ok(Some((
359                pr.key(),
360                format!("mergeable_state={mergeable_state} on {repo}#{pr_number}"),
361            )))
362        }
363        ScenarioAction::AdvanceTimeMs { ms } => {
364            state.now_ms = state.now_ms.saturating_add(*ms as i64);
365            Ok(None)
366        }
367    }
368}
369
370fn require_pr_mut<'a>(
371    state: &'a mut PlaygroundState,
372    repo: &str,
373    number: u64,
374) -> Result<&'a mut PlaygroundPullRequest, VmError> {
375    let key = PlaygroundPullRequest::compose_key(repo, number);
376    state
377        .pull_requests
378        .get_mut(&key)
379        .ok_or_else(|| VmError::Runtime(format!("unknown PR {key}")))
380}
381
382fn require_pr<'a>(
383    state: &'a PlaygroundState,
384    repo: &str,
385    number: u64,
386) -> Result<&'a PlaygroundPullRequest, VmError> {
387    let key = PlaygroundPullRequest::compose_key(repo, number);
388    state
389        .pull_requests
390        .get(&key)
391        .ok_or_else(|| VmError::Runtime(format!("unknown PR {key}")))
392}
393
394fn working_path(dir: &Path, repo: &str) -> std::path::PathBuf {
395    dir.join("working").join(repo)
396}
397
398fn manifest_base_for_branch(manifest: &ScenarioManifest, repo: &str, branch: &str) -> String {
399    if let Some(repo_def) = manifest.repos.iter().find(|r| r.name == repo) {
400        if let Some(b) = repo_def.branches.iter().find(|b| b.name == branch) {
401            return b
402                .base
403                .clone()
404                .unwrap_or_else(|| repo_def.default_branch.clone());
405        }
406        return repo_def.default_branch.clone();
407    }
408    "main".to_string()
409}
410
411fn action_kind(action: &ScenarioAction) -> &'static str {
412    match action {
413        ScenarioAction::SetCheck { .. } => "set_check",
414        ScenarioAction::AddPullRequest { .. } => "add_pull_request",
415        ScenarioAction::ClosePullRequest { .. } => "close_pull_request",
416        ScenarioAction::MergePullRequest { .. } => "merge_pull_request",
417        ScenarioAction::AddComment { .. } => "add_comment",
418        ScenarioAction::SetLabels { .. } => "set_labels",
419        ScenarioAction::SetMergeQueueStatus { .. } => "set_merge_queue_status",
420        ScenarioAction::ForcePushAuthor { .. } => "force_push_author",
421        ScenarioAction::AdvanceBase { .. } => "advance_base",
422        ScenarioAction::SetMergeability { .. } => "set_mergeability",
423        ScenarioAction::AdvanceTimeMs { .. } => "advance_time_ms",
424    }
425}
426
427fn format_clock(now_ms: i64) -> String {
428    use chrono::TimeZone;
429    let utc = chrono::Utc
430        .timestamp_millis_opt(now_ms)
431        .single()
432        .unwrap_or_else(chrono::Utc::now);
433    utc.format("%Y-%m-%dT%H:%M:%SZ").to_string()
434}