1use std::path::Path;
2
3use crate::cache;
4use crate::git;
5use crate::github;
6use crate::lean;
7use crate::replay;
8use crate::review;
9use crate::snapshot;
10use crate::types::{MergeReadiness, SelectTargetsOutput};
11
12pub fn run(pr: u64, founder_override: bool) -> Result<(), Box<dyn std::error::Error>> {
14 let state_json = github::pr_view(pr, "state")?;
16 let state = state_json["state"].as_str().unwrap_or("");
17 if state != "OPEN" {
18 return Err(format!("PR #{pr} is not open (state: {state})").into());
19 }
20
21 let repo_root = git::repo_root()?;
22 let spec_dir = Path::new(&repo_root).join("spec");
23
24 git::fetch("origin", "genesis-state")?;
26 let cache_json = git::show_file("origin/genesis-state:genesis.json")?;
27 let cache_indices: Vec<serde_json::Value> = serde_json::from_str(&cache_json)?;
28
29 if let Err(e) = cache::check_staleness(&cache_indices, &spec_dir) {
30 github::pr_comment(
31 pr,
32 &format!("**JAR Bot:** Genesis cache is stale — cannot merge. {e}"),
33 )?;
34 return Err(e.into());
35 }
36
37 let pr_json = github::pr_view(pr, "headRefOid,author,createdAt,body")?;
39 let head_sha = pr_json["headRefOid"].as_str().unwrap_or("").to_string();
40 let mut author = pr_json["author"]["login"]
41 .as_str()
42 .unwrap_or("")
43 .to_string();
44 let pr_created_at = pr_json["createdAt"].as_str().unwrap_or("");
45 let pr_body = pr_json["body"].as_str().unwrap_or("");
46
47 let pr_created_epoch = parse_epoch(pr_created_at)?;
48
49 if let Some(genesis_author) = parse_flag(pr_body, "Set-Genesis-Author") {
51 author = genesis_author;
52 }
53
54 let ranking_json =
56 git::show_file("origin/genesis-state:ranking.json").unwrap_or_else(|_| "{}".to_string());
57 let ranking_map: serde_json::Value = serde_json::from_str(&ranking_json)?;
58 let scores_json =
59 git::show_file("origin/genesis-state:scores.json").unwrap_or_else(|_| "{}".to_string());
60 let scores_map: serde_json::Value = serde_json::from_str(&scores_json)?;
61
62 let snap = snapshot::find(&cache_indices, &ranking_map, &scores_map, pr_created_epoch)?;
63
64 let mut targets_input = serde_json::json!({
65 "prId": pr,
66 "prCreatedAt": pr_created_epoch,
67 "indices": cache_indices,
68 });
69 if let Some(s) = &snap {
70 targets_input["ranking"] = s.ranking.clone();
71 if let Some(v) = &s.variances {
72 targets_input["variances"] = v.clone();
73 }
74 }
75
76 let targets_output: SelectTargetsOutput =
77 lean::invoke("genesis_select_targets", &targets_input, &spec_dir)?;
78 let targets = targets_output.targets;
79
80 check_no_new_commits(pr)?;
82
83 let collected = review::collect(pr, &head_sha, &targets)?;
85
86 let check_input = serde_json::json!({
88 "reviews": collected.reviews,
89 "metaReviews": collected.meta_reviews,
90 "indices": cache_indices,
91 });
92 let readiness: MergeReadiness = lean::invoke("genesis_check_merge", &check_input, &spec_dir)?;
93
94 if !readiness.ready && !founder_override {
95 github::pr_comment(
96 pr,
97 &format!(
98 "**JAR Bot:** Quorum not reached — cannot merge.\n\
99 Merge weight: {}/{} (need >50%).",
100 readiness.merge_weight, readiness.total_weight
101 ),
102 )?;
103 return Err("quorum not reached".into());
104 }
105
106 let merge_type = if founder_override {
107 "founder override"
108 } else {
109 "quorum reached"
110 };
111
112 let epoch = std::time::SystemTime::now()
114 .duration_since(std::time::UNIX_EPOCH)?
115 .as_secs();
116
117 let commit_json = serde_json::json!({
118 "id": head_sha,
119 "prId": pr,
120 "author": author,
121 "mergeEpoch": epoch,
122 "prCreatedAt": pr_created_epoch,
123 "comparisonTargets": targets,
124 "reviews": collected.reviews,
125 "metaReviews": collected.meta_reviews,
126 "founderOverride": founder_override,
127 });
128
129 let mut eval_input = serde_json::json!({
130 "commit": commit_json,
131 "pastIndices": cache_indices,
132 });
133 if let Some(s) = &snap {
134 eval_input["ranking"] = s.ranking.clone();
135 if let Some(v) = &s.variances {
136 eval_input["variances"] = v.clone();
137 }
138 }
139
140 let index: serde_json::Value = lean::invoke("genesis_evaluate", &eval_input, &spec_dir)?;
141
142 let score = &index["score"];
143 let weight_delta = index["weightDelta"].as_u64().unwrap_or(0);
144
145 let mut all_warnings: Vec<String> = collected.warnings.clone();
147 if let Some(eval_warnings) = index["warnings"].as_array() {
148 for w in eval_warnings {
149 if let Some(s) = w.as_str() {
150 all_warnings.push(s.to_string());
151 }
152 }
153 }
154
155 if !all_warnings.is_empty() {
156 let warning_lines: String = all_warnings.iter().map(|w| format!("- {w}\n")).collect();
157 github::pr_comment(
158 pr,
159 &format!("**JAR Bot:** Evaluation warnings:\n{warning_lines}"),
160 )?;
161 }
162
163 let mut index_for_trailer = index.clone();
165 if let Some(obj) = index_for_trailer.as_object_mut() {
166 obj.remove("warnings");
167 }
168
169 if github::pr_checks_watch(pr).is_err() {
171 github::pr_comment(
172 pr,
173 &format!("**JAR Bot:** Checks failed — cannot merge ({merge_type})."),
174 )?;
175 return Err("checks failed".into());
176 }
177
178 let commit_compact = serde_json::to_string(&commit_json)?;
180 let index_compact = serde_json::to_string(&index_for_trailer)?;
181 let subject = format!(
182 "Merge PR #{pr}\n\n\
183 Genesis-Commit: {commit_compact}\n\
184 Genesis-Index: {index_compact}\n\
185 Genesis-PR: #{pr}\n\
186 Genesis-Author: {author}"
187 );
188
189 github::pr_merge(pr, &head_sha, &subject)?;
190
191 for attempt in 1..=5 {
193 let state_json = github::pr_view(pr, "state")?;
194 let state = state_json["state"].as_str().unwrap_or("");
195 if state == "MERGED" {
196 break;
197 }
198 if attempt == 5 {
199 github::pr_comment(
200 pr,
201 &format!("**JAR Bot:** Merge failed unexpectedly (state: {state})."),
202 )?;
203 return Err(format!("PR #{pr} not merged (state: {state})").into());
204 }
205 eprintln!("Waiting for merge state propagation (attempt {attempt}, state: {state})...");
206 std::thread::sleep(std::time::Duration::from_secs(2));
207 }
208
209 github::pr_comment(
210 pr,
211 &format!(
212 "**JAR Bot:** Merged ({merge_type}).\nScore: {}\nWeight delta: {weight_delta}",
213 serde_json::to_string(score)?
214 ),
215 )?;
216
217 update_cache(
219 pr,
220 &spec_dir,
221 &cache_indices,
222 &index_for_trailer,
223 &commit_json,
224 )?;
225
226 git::git_cmd(&["fetch", "origin", "master"])?;
229 replay::verify_cache()?;
230
231 Ok(())
232}
233
234fn update_cache(
236 pr: u64,
237 spec_dir: &Path,
238 cache_indices: &[serde_json::Value],
239 new_index: &serde_json::Value,
240 _new_commit: &serde_json::Value,
241) -> Result<(), Box<dyn std::error::Error>> {
242 let mut updated_indices = cache_indices.to_vec();
244 updated_indices.push(new_index.clone());
245
246 let genesis_commit = git::read_genesis_commit_hash(spec_dir)?;
250 git::git_cmd(&["fetch", "origin", "master"])?;
251
252 let merge_commits = git::log_merge_commits_ref(&genesis_commit, "origin/master")?;
253 let mut signed_commits = Vec::new();
254 for (_, message) in &merge_commits {
255 if let Some(commit_line) = git::parse_trailer(message, "Genesis-Commit")
256 && let Ok(mut commit) = serde_json::from_str::<serde_json::Value>(&commit_line)
257 {
258 crate::replay::expand_review_hashes_public(&mut commit);
259 signed_commits.push(commit);
260 }
261 }
262
263 let ranking_input = serde_json::json!({
264 "signedCommits": signed_commits,
265 "indices": updated_indices,
266 });
267 let ranking_output: serde_json::Value =
268 lean::invoke("genesis_ranking", &ranking_input, spec_dir)?;
269 let new_ranking = &ranking_output["ranking"];
270
271 let new_commit_hash = new_index["commitHash"].as_str().unwrap_or("");
272
273 let existing_ranking_json =
275 git::show_file("origin/genesis-state:ranking.json").unwrap_or_else(|_| "{}".to_string());
276 let mut existing_ranking: serde_json::Map<String, serde_json::Value> =
277 serde_json::from_str(&existing_ranking_json)?;
278 existing_ranking.insert(new_commit_hash.to_string(), new_ranking.clone());
279
280 let existing_scores_json =
282 git::show_file("origin/genesis-state:scores.json").unwrap_or_else(|_| "{}".to_string());
283 let mut existing_scores: serde_json::Map<String, serde_json::Value> =
284 serde_json::from_str(&existing_scores_json)?;
285 if let Some(scores) = ranking_output.get("scores") {
286 existing_scores.insert(new_commit_hash.to_string(), scores.clone());
287 }
288
289 git::fetch("origin", "genesis-state")?;
291 git::git_cmd(&[
292 "worktree",
293 "add",
294 "/tmp/genesis-state",
295 "origin/genesis-state",
296 ])?;
297 git::git_cmd_in(
298 "/tmp/genesis-state",
299 &["checkout", "-B", "genesis-state", "origin/genesis-state"],
300 )?;
301
302 std::fs::write(
303 "/tmp/genesis-state/genesis.json",
304 serde_json::to_string_pretty(&updated_indices)?,
305 )?;
306 std::fs::write(
307 "/tmp/genesis-state/ranking.json",
308 serde_json::to_string_pretty(&serde_json::Value::Object(existing_ranking))?,
309 )?;
310 std::fs::write(
311 "/tmp/genesis-state/scores.json",
312 serde_json::to_string_pretty(&serde_json::Value::Object(existing_scores))?,
313 )?;
314
315 git::git_cmd_in("/tmp/genesis-state", &["config", "user.name", "JAR Bot"])?;
316 git::git_cmd_in(
317 "/tmp/genesis-state",
318 &["config", "user.email", "legal@bitarray.dev"],
319 )?;
320 git::git_cmd_in(
321 "/tmp/genesis-state",
322 &["add", "genesis.json", "ranking.json", "scores.json"],
323 )?;
324 git::git_cmd_in(
325 "/tmp/genesis-state",
326 &[
327 "commit",
328 "-m",
329 &format!("genesis: update state for PR #{pr}"),
330 ],
331 )?;
332 git::git_cmd_in("/tmp/genesis-state", &["push", "origin", "genesis-state"])?;
333 git::git_cmd(&["worktree", "remove", "/tmp/genesis-state"])?;
334
335 Ok(())
336}
337
338fn check_no_new_commits(pr: u64) -> Result<(), Box<dyn std::error::Error>> {
339 let repo = std::env::var("GITHUB_REPOSITORY").unwrap_or_else(|_| {
340 github::gh(&[
341 "repo",
342 "view",
343 "--json",
344 "nameWithOwner",
345 "--jq",
346 ".nameWithOwner",
347 ])
348 .unwrap_or_default()
349 .trim()
350 .to_string()
351 });
352
353 let comments_json = github::pr_view(pr, "comments")?;
354 let last_review_at = comments_json["comments"].as_array().and_then(|comments| {
355 comments
356 .iter()
357 .rfind(|c| {
358 c["body"]
359 .as_str()
360 .map(|b| b.starts_with("/review"))
361 .unwrap_or(false)
362 })
363 .and_then(|c| c["createdAt"].as_str().map(|s| s.to_string()))
364 });
365
366 if let Some(last_review) = last_review_at {
367 let commits_output = github::gh(&[
368 "api",
369 &format!("repos/{repo}/pulls/{pr}/commits"),
370 "--jq",
371 "last | .commit.committer.date",
372 ])?;
373 let last_commit_at = commits_output.trim();
374
375 if !last_commit_at.is_empty() && last_commit_at > &last_review {
376 github::pr_comment(
377 pr,
378 &format!(
379 "**JAR Bot:** New commits pushed after the last review (commit: {last_commit_at}, review: {last_review}). Aborting merge — please re-review."
380 ),
381 )?;
382 return Err("commits pushed after last review".into());
383 }
384 }
385
386 Ok(())
387}
388
389fn parse_flag(body: &str, flag: &str) -> Option<String> {
390 let prefix = format!("{flag}:");
391 for line in body.lines() {
392 if line.is_empty() {
393 break; }
395 if let Some(rest) = line.strip_prefix(&prefix) {
396 let value = rest.trim().trim_start_matches('@');
397 if !value.is_empty() {
398 return Some(value.to_string());
399 }
400 }
401 }
402 None
403}
404
405fn parse_epoch(iso: &str) -> Result<u64, Box<dyn std::error::Error>> {
406 let output = std::process::Command::new("date")
407 .args(["-d", iso, "+%s"])
408 .output()?;
409 if !output.status.success() {
410 return Err(format!("failed to parse date '{iso}'").into());
411 }
412 Ok(String::from_utf8_lossy(&output.stdout).trim().parse()?)
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 #[test]
420 fn test_parse_flag_basic() {
421 let body = "Set-Genesis-Author: @alice\n\nThis PR does something.";
422 assert_eq!(
423 parse_flag(body, "Set-Genesis-Author"),
424 Some("alice".to_string())
425 );
426 }
427
428 #[test]
429 fn test_parse_flag_no_at() {
430 let body = "Set-Genesis-Author: alice\n\nBody text.";
431 assert_eq!(
432 parse_flag(body, "Set-Genesis-Author"),
433 Some("alice".to_string())
434 );
435 }
436
437 #[test]
438 fn test_parse_flag_missing() {
439 let body = "Some other text\n\nBody.";
440 assert_eq!(parse_flag(body, "Set-Genesis-Author"), None);
441 }
442
443 #[test]
444 fn test_parse_flag_after_blank_line_ignored() {
445 let body = "\nSet-Genesis-Author: @alice";
446 assert_eq!(parse_flag(body, "Set-Genesis-Author"), None);
447 }
448
449 #[test]
450 fn test_parse_flag_multiple_flags() {
451 let body = "Set-Genesis-Author: @alice\nSome-Other-Flag: value\n\nBody text.";
452 assert_eq!(
453 parse_flag(body, "Set-Genesis-Author"),
454 Some("alice".to_string())
455 );
456 assert_eq!(
457 parse_flag(body, "Some-Other-Flag"),
458 Some("value".to_string())
459 );
460 }
461}