1use 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 pub actions_applied: usize,
26 pub prs_touched: Vec<String>,
28 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
86pub 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 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 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 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 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}