Skip to main content

jar_genesis/workflow/
merge.rs

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
12/// Run the merge workflow for a PR.
13pub fn run(pr: u64, founder_override: bool) -> Result<(), Box<dyn std::error::Error>> {
14    // --- Step 0: Guard against non-open PRs ---
15    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    // --- Step 1: Read and verify cache ---
25    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    // --- Step 2: Get PR details ---
38    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    // Parse PR body flags
50    if let Some(genesis_author) = parse_flag(pr_body, "Set-Genesis-Author") {
51        author = genesis_author;
52    }
53
54    // --- Step 3: Compute comparison targets ---
55    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    // --- Step 4: Check no commits after last review ---
81    check_no_new_commits(pr)?;
82
83    // --- Step 5: Collect reviews ---
84    let collected = review::collect(pr, &head_sha, &targets)?;
85
86    // --- Step 6: Re-check quorum (defense-in-depth) ---
87    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    // --- Step 7: Build SignedCommit and evaluate ---
113    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    // Post warnings
146    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    // Strip warnings from index for trailer
164    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    // --- Step 8: Wait for CI ---
170    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    // --- Step 9: Merge with trailers ---
179    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    // Confirm merge
192    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    // --- Step 10: Update genesis-state cache ---
218    update_cache(
219        pr,
220        &spec_dir,
221        &cache_indices,
222        &index_for_trailer,
223        &commit_json,
224    )?;
225
226    // --- Step 11: Verify cache integrity ---
227    // Fetch latest master (includes our merge) without touching the working tree.
228    git::git_cmd(&["fetch", "origin", "master"])?;
229    replay::verify_cache()?;
230
231    Ok(())
232}
233
234/// Update the genesis-state branch with the new index and ranking.
235fn 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    // Update genesis.json
243    let mut updated_indices = cache_indices.to_vec();
244    updated_indices.push(new_index.clone());
245
246    // Compute updated ranking from all SignedCommits in git history.
247    // Use `git fetch` + `origin/master` ref instead of `git pull` to avoid
248    // working tree conflicts (cargo build dirties Cargo.lock).
249    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    // Update ranking.json
274    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    // Update scores.json (v3 BT output, if present)
281    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    // Write to genesis-state branch via worktree
290    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; // Only check leading lines before first blank line
394        }
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}