1use std::collections::BTreeMap;
11use std::path::Path;
12
13use serde::{Deserialize, Serialize};
14
15use crate::value::VmError;
16
17pub const SCENARIO_TYPE: &str = "merge_captain_playground_scenario";
19
20#[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#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
38#[serde(default)]
39pub struct ScenarioRepo {
40 pub name: String,
41 pub default_branch: String,
42 pub files: BTreeMap<String, String>,
44 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 pub base: Option<String>,
57 pub fork_before_extra_commits: bool,
60 pub files_set: BTreeMap<String, String>,
62 pub files_delete: Vec<String>,
64 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 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 pub mergeable_state: String,
94 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 pub status: String,
105 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#[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#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
138#[serde(tag = "kind", rename_all = "snake_case")]
139pub enum ScenarioAction {
140 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 AddPullRequest {
153 #[serde(flatten)]
154 pr: ScenarioPullRequest,
155 },
156 ClosePullRequest { repo: String, pr_number: u64 },
158 MergePullRequest {
161 repo: String,
162 pr_number: u64,
163 #[serde(default)]
164 merge_method: Option<String>,
165 },
166 AddComment {
168 repo: String,
169 pr_number: u64,
170 user: String,
171 body: String,
172 },
173 SetLabels {
175 repo: String,
176 pr_number: u64,
177 labels: Vec<String>,
178 },
179 SetMergeQueueStatus {
181 repo: String,
182 pr_number: u64,
183 status: String,
185 },
186 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 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 SetMergeability {
210 repo: String,
211 pr_number: u64,
212 mergeable: Option<bool>,
213 mergeable_state: String,
214 },
215 AdvanceTimeMs { ms: u64 },
218}
219
220impl ScenarioManifest {
221 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 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
418fn 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
427fn 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}