Skip to main content

harn_vm/orchestration/playground/
manifest.rs

1//! Scenario manifest types for the Merge Captain mock-repos playground (#1020).
2//!
3//! A manifest is checked-in YAML/JSON describing the *seed* state of a
4//! playground: the repos, branches, pull requests, checks, and the
5//! deterministic step actions an agent can use to advance the scenario.
6//! `init_playground` consumes a manifest to materialize real on-disk git
7//! repos plus a mutable `state.json`; the fake GitHub HTTP server reads
8//! that state.
9
10use std::collections::BTreeMap;
11use std::path::Path;
12
13use serde::{Deserialize, Serialize};
14
15use crate::value::VmError;
16
17/// Manifest envelope `_type` field — used to refuse stray JSON files.
18pub const SCENARIO_TYPE: &str = "merge_captain_playground_scenario";
19
20/// Top-level scenario manifest.
21#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
22#[serde(default)]
23pub struct ScenarioManifest {
24    #[serde(rename = "_type")]
25    pub type_name: String,
26    pub scenario: String,
27    pub description: String,
28    pub owner: String,
29    pub repos: Vec<ScenarioRepo>,
30    pub pull_requests: Vec<ScenarioPullRequest>,
31    pub steps: Vec<ScenarioStep>,
32}
33
34/// A repo definition. The default branch always has a single seed commit;
35/// each `branch` entry is a feature branch authored from that seed plus an
36/// overlay of files.
37#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
38#[serde(default)]
39pub struct ScenarioRepo {
40    pub name: String,
41    pub default_branch: String,
42    /// File overlay applied to the default-branch seed commit. Path → content.
43    pub files: BTreeMap<String, String>,
44    /// Extra commits to apply on top of the default branch *before* feature
45    /// branches are forked. Used to simulate "behind-base" scenarios where the
46    /// feature branch was forked at an earlier point.
47    pub default_branch_extra_commits: Vec<ScenarioCommit>,
48    pub branches: Vec<ScenarioBranch>,
49}
50
51#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(default)]
53pub struct ScenarioBranch {
54    pub name: String,
55    /// Optional base branch. Defaults to the repo's `default_branch`.
56    pub base: Option<String>,
57    /// Whether to fork *before* the default-branch extra commits. When
58    /// true, the branch is created at the seed commit (i.e. behind base).
59    pub fork_before_extra_commits: bool,
60    /// File overlay applied as a single commit on top of the base.
61    pub files_set: BTreeMap<String, String>,
62    /// File deletions applied as part of the same commit.
63    pub files_delete: Vec<String>,
64    /// Optional commit message override.
65    pub commit_message: Option<String>,
66}
67
68#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
69#[serde(default)]
70pub struct ScenarioCommit {
71    pub message: String,
72    pub files_set: BTreeMap<String, String>,
73    pub files_delete: Vec<String>,
74}
75
76#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
77#[serde(default)]
78pub struct ScenarioPullRequest {
79    pub repo: String,
80    pub number: u64,
81    pub title: String,
82    pub body: String,
83    /// `open`, `closed`, `merged`.
84    pub state: String,
85    pub head_branch: String,
86    pub base_branch: String,
87    pub user: String,
88    pub draft: bool,
89    pub labels: Vec<String>,
90    pub checks: Vec<ScenarioCheck>,
91    pub mergeable: Option<bool>,
92    /// `clean`, `dirty`, `behind`, `blocked`, `unstable`.
93    pub mergeable_state: String,
94    /// `none`, `queued`, `merged`, `failed`.
95    pub merge_queue_status: Option<String>,
96    pub comments: Vec<ScenarioComment>,
97}
98
99#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
100#[serde(default)]
101pub struct ScenarioCheck {
102    pub name: String,
103    /// `queued`, `in_progress`, `completed`.
104    pub status: String,
105    /// When `status == "completed"`: `success`, `failure`, `cancelled`,
106    /// `timed_out`, `neutral`, `skipped`.
107    pub conclusion: Option<String>,
108    pub details_url: Option<String>,
109    pub started_at: Option<String>,
110    pub completed_at: Option<String>,
111}
112
113#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
114#[serde(default)]
115pub struct ScenarioComment {
116    pub user: String,
117    pub body: String,
118    pub created_at: Option<String>,
119}
120
121/// A named, declarative step the agent can run via `harn merge-captain mock
122/// step <dir> <name>`. Steps are pure state mutations applied to
123/// `state.json`; nothing inside a step touches the bare git remote.
124/// (Force-push-by-author and similar git-native mutations are first-class
125/// `ScenarioAction` variants so the underlying command is deterministic.)
126#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
127#[serde(default)]
128pub struct ScenarioStep {
129    pub name: String,
130    pub description: String,
131    pub actions: Vec<ScenarioAction>,
132}
133
134/// A single mutation applied to the playground state. Adding a variant here
135/// is a backwards-compatible change because `serde(other)` rejects unknown
136/// tags loudly so old binaries can't silently no-op a newer manifest.
137#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
138#[serde(tag = "kind", rename_all = "snake_case")]
139pub enum ScenarioAction {
140    /// Update or insert a check run for a PR.
141    SetCheck {
142        repo: String,
143        pr_number: u64,
144        name: String,
145        status: String,
146        #[serde(default)]
147        conclusion: Option<String>,
148        #[serde(default)]
149        details_url: Option<String>,
150    },
151    /// Append a new PR. The branch must already exist on the bare remote.
152    AddPullRequest {
153        #[serde(flatten)]
154        pr: ScenarioPullRequest,
155    },
156    /// Mark a PR closed without merging.
157    ClosePullRequest { repo: String, pr_number: u64 },
158    /// Mark a PR merged. Produces a real merge commit on the bare remote
159    /// (so subsequent rebases see a real diverged history).
160    MergePullRequest {
161        repo: String,
162        pr_number: u64,
163        #[serde(default)]
164        merge_method: Option<String>,
165    },
166    /// Append a comment.
167    AddComment {
168        repo: String,
169        pr_number: u64,
170        user: String,
171        body: String,
172    },
173    /// Set the PR's labels (replaces).
174    SetLabels {
175        repo: String,
176        pr_number: u64,
177        labels: Vec<String>,
178    },
179    /// Update the merge-queue status for a PR.
180    SetMergeQueueStatus {
181        repo: String,
182        pr_number: u64,
183        /// `none`, `queued`, `merged`, `failed`.
184        status: String,
185    },
186    /// Simulate a force-push by the author: rewrite the head branch to a new
187    /// snapshot of files. Updates the bare remote.
188    ForcePushAuthor {
189        repo: String,
190        branch: String,
191        files_set: BTreeMap<String, String>,
192        #[serde(default)]
193        files_delete: Vec<String>,
194        #[serde(default)]
195        commit_message: Option<String>,
196    },
197    /// Append a commit on the default branch of a repo (simulates someone
198    /// else landing work, putting open PRs behind base).
199    AdvanceBase {
200        repo: String,
201        #[serde(default)]
202        files_set: BTreeMap<String, String>,
203        #[serde(default)]
204        files_delete: Vec<String>,
205        #[serde(default)]
206        commit_message: Option<String>,
207    },
208    /// Update mergeable / mergeable_state without git mutation.
209    SetMergeability {
210        repo: String,
211        pr_number: u64,
212        mergeable: Option<bool>,
213        mergeable_state: String,
214    },
215    /// Advance the playground clock by N milliseconds. Steps that timestamp
216    /// events use the playground clock so transcripts remain deterministic.
217    AdvanceTimeMs { ms: u64 },
218}
219
220impl ScenarioManifest {
221    /// Parse a manifest from JSON or YAML based on file extension.
222    pub fn load(path: &Path) -> Result<Self, VmError> {
223        let bytes = std::fs::read(path).map_err(|error| {
224            VmError::Runtime(format!(
225                "failed to read scenario manifest {}: {error}",
226                path.display()
227            ))
228        })?;
229        Self::parse(&bytes, path)
230    }
231
232    pub fn parse(bytes: &[u8], path: &Path) -> Result<Self, VmError> {
233        let is_yaml = path
234            .extension()
235            .and_then(|ext| ext.to_str())
236            .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
237            .unwrap_or(false);
238        let manifest: ScenarioManifest = if is_yaml {
239            serde_yml::from_slice(bytes).map_err(|error| {
240                VmError::Runtime(format!(
241                    "failed to parse YAML scenario manifest {}: {error}",
242                    path.display()
243                ))
244            })?
245        } else {
246            serde_json::from_slice(bytes).map_err(|error| {
247                VmError::Runtime(format!(
248                    "failed to parse JSON scenario manifest {}: {error}",
249                    path.display()
250                ))
251            })?
252        };
253        manifest.validate(path)?;
254        Ok(manifest)
255    }
256
257    pub fn validate(&self, path: &Path) -> Result<(), VmError> {
258        if self.type_name != SCENARIO_TYPE {
259            return Err(VmError::Runtime(format!(
260                "scenario manifest {} has _type {:?}, expected {SCENARIO_TYPE}",
261                path.display(),
262                self.type_name
263            )));
264        }
265        if self.scenario.is_empty() {
266            return Err(VmError::Runtime(format!(
267                "scenario manifest {} is missing required field 'scenario'",
268                path.display()
269            )));
270        }
271        if self.owner.is_empty() {
272            return Err(VmError::Runtime(format!(
273                "scenario manifest {} is missing required field 'owner'",
274                path.display()
275            )));
276        }
277        if self.repos.is_empty() {
278            return Err(VmError::Runtime(format!(
279                "scenario manifest {} must declare at least one repo",
280                path.display()
281            )));
282        }
283        let mut repo_names = std::collections::HashSet::new();
284        for repo in &self.repos {
285            if repo.name.is_empty() {
286                return Err(VmError::Runtime(format!(
287                    "scenario manifest {} has a repo with no name",
288                    path.display()
289                )));
290            }
291            if !is_safe_segment(&repo.name) {
292                return Err(VmError::Runtime(format!(
293                    "scenario manifest {} repo name {:?} contains characters that aren't safe for filesystem paths (use [A-Za-z0-9._-])",
294                    path.display(),
295                    repo.name
296                )));
297            }
298            if repo.default_branch.is_empty() {
299                return Err(VmError::Runtime(format!(
300                    "scenario manifest {} repo {} is missing default_branch",
301                    path.display(),
302                    repo.name
303                )));
304            }
305            if !is_safe_ref(&repo.default_branch) {
306                return Err(VmError::Runtime(format!(
307                    "scenario manifest {} repo {} default_branch {:?} is not a valid git ref",
308                    path.display(),
309                    repo.name,
310                    repo.default_branch
311                )));
312            }
313            if !repo_names.insert(repo.name.clone()) {
314                return Err(VmError::Runtime(format!(
315                    "scenario manifest {} declares repo {} twice",
316                    path.display(),
317                    repo.name
318                )));
319            }
320            let mut branch_names = std::collections::HashSet::new();
321            branch_names.insert(repo.default_branch.clone());
322            for branch in &repo.branches {
323                if branch.name.is_empty() {
324                    return Err(VmError::Runtime(format!(
325                        "scenario manifest {} repo {} has a branch with no name",
326                        path.display(),
327                        repo.name
328                    )));
329                }
330                if !is_safe_ref(&branch.name) {
331                    return Err(VmError::Runtime(format!(
332                        "scenario manifest {} repo {} branch {:?} is not a valid git ref",
333                        path.display(),
334                        repo.name,
335                        branch.name
336                    )));
337                }
338                if !branch_names.insert(branch.name.clone()) {
339                    return Err(VmError::Runtime(format!(
340                        "scenario manifest {} repo {} declares branch {} twice",
341                        path.display(),
342                        repo.name,
343                        branch.name
344                    )));
345                }
346            }
347        }
348        let repo_index: std::collections::HashMap<&str, &ScenarioRepo> =
349            self.repos.iter().map(|r| (r.name.as_str(), r)).collect();
350        let mut pr_keys = std::collections::HashSet::new();
351        for pr in &self.pull_requests {
352            let repo = repo_index.get(pr.repo.as_str()).ok_or_else(|| {
353                VmError::Runtime(format!(
354                    "scenario manifest {} pull_request #{} references unknown repo {}",
355                    path.display(),
356                    pr.number,
357                    pr.repo
358                ))
359            })?;
360            if !pr_keys.insert((pr.repo.clone(), pr.number)) {
361                return Err(VmError::Runtime(format!(
362                    "scenario manifest {} declares PR {}/{} twice",
363                    path.display(),
364                    pr.repo,
365                    pr.number
366                )));
367            }
368            if pr.head_branch.is_empty() {
369                return Err(VmError::Runtime(format!(
370                    "scenario manifest {} PR {}/{} is missing head_branch",
371                    path.display(),
372                    pr.repo,
373                    pr.number
374                )));
375            }
376            if pr.base_branch.is_empty() {
377                return Err(VmError::Runtime(format!(
378                    "scenario manifest {} PR {}/{} is missing base_branch",
379                    path.display(),
380                    pr.repo,
381                    pr.number
382                )));
383            }
384            // head_branch must exist in the repo (or equal default_branch).
385            let head_exists = pr.head_branch == repo.default_branch
386                || repo.branches.iter().any(|b| b.name == pr.head_branch);
387            if !head_exists {
388                return Err(VmError::Runtime(format!(
389                    "scenario manifest {} PR {}/{} head_branch {} is not declared on repo {}",
390                    path.display(),
391                    pr.repo,
392                    pr.number,
393                    pr.head_branch,
394                    pr.repo
395                )));
396            }
397        }
398        let mut step_names = std::collections::HashSet::new();
399        for step in &self.steps {
400            if step.name.is_empty() {
401                return Err(VmError::Runtime(format!(
402                    "scenario manifest {} has an unnamed step",
403                    path.display()
404                )));
405            }
406            if !step_names.insert(step.name.clone()) {
407                return Err(VmError::Runtime(format!(
408                    "scenario manifest {} declares step {} twice",
409                    path.display(),
410                    step.name
411                )));
412            }
413        }
414        Ok(())
415    }
416}
417
418/// Repos materialize as a directory under `<playground>/{remotes,working}/`,
419/// so reject anything with path-separator-like characters or leading dots.
420fn is_safe_segment(s: &str) -> bool {
421    !s.is_empty()
422        && !s.starts_with('.')
423        && s.chars()
424            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
425}
426
427/// Git refs allow `/` (e.g. `feature/foo`) but disallow control chars,
428/// `..`, leading/trailing slashes, and `@{`. We use a conservative subset
429/// rather than re-implementing `git check-ref-format`.
430fn is_safe_ref(s: &str) -> bool {
431    if s.is_empty() || s.starts_with('/') || s.ends_with('/') || s.contains("..") {
432        return false;
433    }
434    s.chars().all(|c| {
435        c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' || c == '/' || c == '+'
436    })
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use std::path::PathBuf;
443
444    fn json(s: &str) -> ScenarioManifest {
445        ScenarioManifest::parse(s.as_bytes(), &PathBuf::from("test.json")).unwrap()
446    }
447
448    fn yaml(s: &str) -> ScenarioManifest {
449        ScenarioManifest::parse(s.as_bytes(), &PathBuf::from("test.yaml")).unwrap()
450    }
451
452    #[test]
453    fn parses_minimal_manifest() {
454        let m = json(
455            r#"{
456  "_type": "merge_captain_playground_scenario",
457  "scenario": "x",
458  "owner": "burin-labs",
459  "repos": [{"name": "alpha", "default_branch": "main"}]
460}"#,
461        );
462        assert_eq!(m.scenario, "x");
463        assert_eq!(m.repos.len(), 1);
464    }
465
466    #[test]
467    fn parses_yaml_manifest() {
468        let m = yaml(
469            r"_type: merge_captain_playground_scenario
470scenario: x
471owner: burin-labs
472repos:
473  - name: alpha
474    default_branch: main
475",
476        );
477        assert_eq!(m.scenario, "x");
478        assert_eq!(m.repos[0].name, "alpha");
479    }
480
481    #[test]
482    fn rejects_wrong_type() {
483        let err = ScenarioManifest::parse(
484            br#"{"_type": "wrong", "scenario": "x", "owner": "burin-labs", "repos": [{"name": "a", "default_branch": "main"}]}"#,
485            &PathBuf::from("test.json"),
486        )
487        .unwrap_err();
488        assert!(format!("{err}").contains("_type"));
489    }
490
491    #[test]
492    fn rejects_unknown_pr_repo() {
493        let err = ScenarioManifest::parse(
494            br#"{"_type": "merge_captain_playground_scenario", "scenario": "x", "owner": "burin-labs",
495              "repos": [{"name": "alpha", "default_branch": "main"}],
496              "pull_requests": [{"repo": "ghost", "number": 1, "head_branch": "main", "base_branch": "main", "mergeable_state": "clean"}]}"#,
497            &PathBuf::from("test.json"),
498        )
499        .unwrap_err();
500        assert!(format!("{err}").contains("unknown repo"));
501    }
502
503    #[test]
504    fn rejects_path_traversal_repo_name() {
505        let err = ScenarioManifest::parse(
506            br#"{"_type": "merge_captain_playground_scenario", "scenario": "x", "owner": "burin-labs",
507              "repos": [{"name": "../../etc", "default_branch": "main"}]}"#,
508            &PathBuf::from("test.json"),
509        )
510        .unwrap_err();
511        assert!(format!("{err}").contains("safe for filesystem paths"));
512    }
513
514    #[test]
515    fn rejects_invalid_branch_ref() {
516        let err = ScenarioManifest::parse(
517            br#"{"_type": "merge_captain_playground_scenario", "scenario": "x", "owner": "burin-labs",
518              "repos": [{"name": "alpha", "default_branch": "main",
519                         "branches": [{"name": "..feature"}]}]}"#,
520            &PathBuf::from("test.json"),
521        )
522        .unwrap_err();
523        assert!(format!("{err}").contains("not a valid git ref"));
524    }
525
526    #[test]
527    fn rejects_duplicate_step_name() {
528        let err = ScenarioManifest::parse(
529            br#"{"_type": "merge_captain_playground_scenario", "scenario": "x", "owner": "burin-labs",
530              "repos": [{"name": "alpha", "default_branch": "main"}],
531              "steps": [{"name": "go"}, {"name": "go"}]}"#,
532            &PathBuf::from("test.json"),
533        )
534        .unwrap_err();
535        assert!(format!("{err}").contains("step"));
536    }
537
538    #[test]
539    fn parses_all_action_kinds() {
540        let m = json(
541            r#"{
542  "_type": "merge_captain_playground_scenario",
543  "scenario": "x",
544  "owner": "burin-labs",
545  "repos": [{"name": "alpha", "default_branch": "main",
546             "branches": [{"name": "feature/a", "files_set": {"a.txt": "1"}}]}],
547  "pull_requests": [{"repo": "alpha", "number": 1, "head_branch": "feature/a", "base_branch": "main", "mergeable_state": "clean"}],
548  "steps": [
549    {"name": "all", "actions": [
550      {"kind": "set_check", "repo": "alpha", "pr_number": 1, "name": "ci", "status": "completed", "conclusion": "success"},
551      {"kind": "close_pull_request", "repo": "alpha", "pr_number": 1},
552      {"kind": "merge_pull_request", "repo": "alpha", "pr_number": 1},
553      {"kind": "add_comment", "repo": "alpha", "pr_number": 1, "user": "alice", "body": "lgtm"},
554      {"kind": "set_labels", "repo": "alpha", "pr_number": 1, "labels": ["ready"]},
555      {"kind": "set_merge_queue_status", "repo": "alpha", "pr_number": 1, "status": "queued"},
556      {"kind": "force_push_author", "repo": "alpha", "branch": "feature/a", "files_set": {"a.txt": "2"}},
557      {"kind": "advance_base", "repo": "alpha", "files_set": {"main.txt": "1"}},
558      {"kind": "set_mergeability", "repo": "alpha", "pr_number": 1, "mergeable": true, "mergeable_state": "behind"},
559      {"kind": "advance_time_ms", "ms": 60000}
560    ]}
561  ]
562}"#,
563        );
564        let actions = &m.steps[0].actions;
565        assert_eq!(actions.len(), 10);
566    }
567}