Skip to main content

jar_genesis/
review.rs

1use crate::github;
2use crate::hash;
3use crate::types::{CollectedReviews, EmbeddedReview, MetaReview, Verdict};
4
5/// Parse a `/review` comment body into an EmbeddedReview.
6/// Returns None if the comment is malformed, with warnings added to the list.
7pub fn parse_review_comment(
8    body: &str,
9    reviewer: &str,
10    head_sha: &str,
11    targets: &[String],
12    warnings: &mut Vec<String>,
13) -> Option<EmbeddedReview> {
14    let body = hash::strip_carriage_returns(body);
15    let lines: Vec<&str> = body.lines().collect();
16
17    let mut difficulty = None;
18    let mut novelty = None;
19    let mut design = None;
20    let mut verdict = None;
21
22    for line in &lines {
23        let line = line.trim();
24        if let Some(rest) = line.strip_prefix("difficulty:") {
25            difficulty = Some(parse_ranking(
26                rest,
27                reviewer,
28                "difficulty",
29                head_sha,
30                targets,
31                warnings,
32            ));
33        } else if let Some(rest) = line.strip_prefix("novelty:") {
34            novelty = Some(parse_ranking(
35                rest, reviewer, "novelty", head_sha, targets, warnings,
36            ));
37        } else if let Some(rest) = line.strip_prefix("design:") {
38            design = Some(parse_ranking(
39                rest, reviewer, "design", head_sha, targets, warnings,
40            ));
41        } else if let Some(rest) = line.strip_prefix("verdict:") {
42            let v = rest.trim();
43            verdict = match v {
44                "merge" => Some(Verdict::Merge),
45                "notMerge" => Some(Verdict::NotMerge),
46                other => {
47                    warnings.push(format!("reviewer {reviewer}: invalid verdict '{other}'"));
48                    None
49                }
50            };
51        }
52    }
53
54    let difficulty = difficulty?;
55    let novelty = novelty?;
56    let design = design?;
57    let verdict = verdict?;
58
59    // Validate ranking counts: should be len(targets) + 1 (for currentPR)
60    let expected = targets.len() + 1;
61    for (name, ranking) in [
62        ("difficulty", &difficulty),
63        ("novelty", &novelty),
64        ("design", &design),
65    ] {
66        if ranking.len() != expected {
67            warnings.push(format!(
68                "reviewer {reviewer}: {name} ranking has {} entries, expected {expected}",
69                ranking.len()
70            ));
71        }
72    }
73
74    Some(EmbeddedReview {
75        reviewer: reviewer.to_string(),
76        difficulty_ranking: difficulty,
77        novelty_ranking: novelty,
78        design_quality_ranking: design,
79        verdict,
80    })
81}
82
83/// Parse a ranking line: comma-separated short hashes, expanding each.
84fn parse_ranking(
85    line: &str,
86    reviewer: &str,
87    dimension: &str,
88    head_sha: &str,
89    targets: &[String],
90    warnings: &mut Vec<String>,
91) -> Vec<String> {
92    let mut result = Vec::new();
93    for item in line.split(',') {
94        let item = item.trim();
95        if item.is_empty() {
96            continue;
97        }
98        // Normalize: strip URLs, replace currentPR
99        let normalized = hash::normalize_commit_ref(item);
100        let normalized = if normalized == "currentPR" {
101            head_sha.to_string()
102        } else {
103            normalized
104        };
105        // Expand short hash against targets + head_sha
106        let mut candidates = targets.to_vec();
107        candidates.push(head_sha.to_string());
108        match hash::expand_short_hash(&normalized, &candidates) {
109            Ok(full) => result.push(full),
110            Err(e) => {
111                warnings.push(format!("reviewer {reviewer}: {dimension} ranking: {e}"));
112                // Include the raw value so it occupies a position
113                result.push(normalized);
114            }
115        }
116    }
117    result
118}
119
120/// Collect all reviews and meta-reviews from a PR via GitHub API.
121pub fn collect(
122    pr: u64,
123    head_sha: &str,
124    targets: &[String],
125) -> Result<CollectedReviews, Box<dyn std::error::Error>> {
126    let mut warnings = Vec::new();
127
128    // Fetch all comments on the PR
129    let repo = std::env::var("GITHUB_REPOSITORY").unwrap_or_else(|_| {
130        let output = github::gh(&[
131            "repo",
132            "view",
133            "--json",
134            "nameWithOwner",
135            "--jq",
136            ".nameWithOwner",
137        ])
138        .expect("failed to get repo name");
139        output.trim().to_string()
140    });
141
142    let comments_output = github::gh(&[
143        "api",
144        &format!("repos/{repo}/issues/{pr}/comments"),
145        "--paginate",
146        "--jq",
147        r#"[.[] | select(.body | startswith("/review")) | {id: .id, author: .user.login, body: .body}]"#,
148    ])?;
149
150    let comments: Vec<serde_json::Value> = serde_json::from_str(comments_output.trim())?;
151
152    // Parse reviews (last review per author wins)
153    let mut reviews: Vec<EmbeddedReview> = Vec::new();
154    let mut review_comment_ids: Vec<(String, u64)> = Vec::new(); // (reviewer, comment_id)
155
156    for comment in &comments {
157        let id = comment["id"].as_u64().unwrap_or(0);
158        let author = comment["author"].as_str().unwrap_or("");
159        let body = comment["body"].as_str().unwrap_or("");
160
161        if let Some(review) = parse_review_comment(body, author, head_sha, targets, &mut warnings) {
162            // Remove existing review from same reviewer
163            reviews.retain(|r| r.reviewer != author);
164            review_comment_ids.retain(|(r, _)| r != author);
165            reviews.push(review);
166            review_comment_ids.push((author.to_string(), id));
167        }
168    }
169
170    // Collect meta-reviews: 👍/👎 reactions on latest /review comment per reviewer
171    let mut meta_reviews: Vec<MetaReview> = Vec::new();
172
173    for (target_reviewer, comment_id) in &review_comment_ids {
174        let reactions_output = github::gh(&[
175            "api",
176            &format!("repos/{repo}/issues/comments/{comment_id}/reactions"),
177            "--jq",
178            r#"[.[] | select(.content == "+1" or .content == "-1") | {user: .user.login, content: .content}]"#,
179        ]);
180
181        let reactions: Vec<serde_json::Value> = match reactions_output {
182            Ok(output) => serde_json::from_str(output.trim()).unwrap_or_default(),
183            Err(_) => Vec::new(),
184        };
185
186        for reaction in &reactions {
187            let meta_reviewer = reaction["user"].as_str().unwrap_or("");
188            let content = reaction["content"].as_str().unwrap_or("");
189            let approve = content == "+1";
190
191            meta_reviews.push(MetaReview {
192                meta_reviewer: meta_reviewer.to_string(),
193                target_reviewer: target_reviewer.to_string(),
194                approve,
195            });
196        }
197    }
198
199    Ok(CollectedReviews {
200        reviews,
201        meta_reviews,
202        warnings,
203    })
204}
205
206/// Collect reviews from a PR and print as JSON (CLI entry point).
207pub fn collect_and_print(
208    pr: u64,
209    head_sha: Option<&str>,
210    targets_json: Option<&str>,
211) -> Result<(), Box<dyn std::error::Error>> {
212    let head_sha = head_sha.unwrap_or("");
213    let targets: Vec<String> = match targets_json {
214        Some(json) => serde_json::from_str(json)?,
215        None => Vec::new(),
216    };
217
218    let collected = collect(pr, head_sha, &targets)?;
219    println!("{}", serde_json::to_string(&collected)?);
220    Ok(())
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    const HEAD_SHA: &str = "36d25a6a86c547b9d6b89971a501b966b89d5351";
228    const TARGET_A: &str = "204e93abf18ab00e339d92787c6f807269517cdf";
229    const TARGET_B: &str = "b012110bedc7f0ffca3ae37f38915afbc229c26e";
230
231    fn targets() -> Vec<String> {
232        vec![TARGET_A.to_string(), TARGET_B.to_string()]
233    }
234
235    #[test]
236    fn test_parse_well_formed_review() {
237        let body = "/review\ndifficulty: 204e93ab, currentPR, b012110b\nnovelty: currentPR, 204e93ab, b012110b\ndesign: 204e93ab, currentPR, b012110b\nverdict: merge\n\nGreat work!";
238        let mut warnings = vec![];
239        let review =
240            parse_review_comment(body, "alice", HEAD_SHA, &targets(), &mut warnings).unwrap();
241        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
242        assert_eq!(
243            review.difficulty_ranking,
244            vec![TARGET_A, HEAD_SHA, TARGET_B]
245        );
246        assert_eq!(review.novelty_ranking, vec![HEAD_SHA, TARGET_A, TARGET_B]);
247        assert_eq!(review.verdict, Verdict::Merge);
248    }
249
250    #[test]
251    fn test_parse_review_with_carriage_returns() {
252        let body = "/review\r\ndifficulty: 204e93ab, currentPR\r\nnovelty: currentPR, 204e93ab\r\ndesign: 204e93ab, currentPR\r\nverdict: merge\r\n";
253        let mut warnings = vec![];
254        let review =
255            parse_review_comment(body, "bob", HEAD_SHA, &targets(), &mut warnings).unwrap();
256        // Ranking count warnings expected (2 entries, expected 3)
257        assert_eq!(review.verdict, Verdict::Merge);
258    }
259
260    #[test]
261    fn test_parse_review_with_github_urls() {
262        let url_a = format!("https://github.com/jarchain/jar/commit/{TARGET_A}");
263        let body = format!(
264            "/review\ndifficulty: {url_a}, currentPR, b012110b\nnovelty: currentPR, {url_a}, b012110b\ndesign: {url_a}, currentPR, b012110b\nverdict: merge"
265        );
266        let mut warnings = vec![];
267        let review =
268            parse_review_comment(&body, "carol", HEAD_SHA, &targets(), &mut warnings).unwrap();
269        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
270        assert_eq!(
271            review.difficulty_ranking,
272            vec![TARGET_A, HEAD_SHA, TARGET_B]
273        );
274    }
275
276    #[test]
277    fn test_parse_review_invalid_verdict() {
278        let body =
279            "/review\ndifficulty: currentPR\nnovelty: currentPR\ndesign: currentPR\nverdict: maybe";
280        let mut warnings = vec![];
281        let review = parse_review_comment(body, "dave", HEAD_SHA, &targets(), &mut warnings);
282        assert!(review.is_none());
283        assert!(warnings.iter().any(|w| w.contains("invalid verdict")));
284    }
285
286    #[test]
287    fn test_parse_review_missing_field() {
288        let body = "/review\ndifficulty: currentPR\nnovelty: currentPR\nverdict: merge";
289        let mut warnings = vec![];
290        let review = parse_review_comment(body, "eve", HEAD_SHA, &targets(), &mut warnings);
291        assert!(review.is_none()); // missing design
292    }
293
294    #[test]
295    fn test_last_review_per_author_wins() {
296        // Simulate two reviews from the same author by calling parse twice
297        let body1 = "/review\ndifficulty: 204e93ab, currentPR, b012110b\nnovelty: 204e93ab, currentPR, b012110b\ndesign: 204e93ab, currentPR, b012110b\nverdict: notMerge";
298        let body2 = "/review\ndifficulty: currentPR, 204e93ab, b012110b\nnovelty: currentPR, 204e93ab, b012110b\ndesign: currentPR, 204e93ab, b012110b\nverdict: merge";
299        let mut warnings = vec![];
300        let mut reviews: Vec<EmbeddedReview> = Vec::new();
301
302        if let Some(r) = parse_review_comment(body1, "alice", HEAD_SHA, &targets(), &mut warnings) {
303            reviews.retain(|r| r.reviewer != "alice");
304            reviews.push(r);
305        }
306        if let Some(r) = parse_review_comment(body2, "alice", HEAD_SHA, &targets(), &mut warnings) {
307            reviews.retain(|r| r.reviewer != "alice");
308            reviews.push(r);
309        }
310
311        assert_eq!(reviews.len(), 1);
312        assert_eq!(reviews[0].verdict, Verdict::Merge); // second review wins
313        assert_eq!(reviews[0].difficulty_ranking[0], HEAD_SHA); // currentPR first
314    }
315
316    #[test]
317    fn test_ranking_count_warning() {
318        // Only 1 entry instead of expected 3 (2 targets + currentPR)
319        let body =
320            "/review\ndifficulty: currentPR\nnovelty: currentPR\ndesign: currentPR\nverdict: merge";
321        let mut warnings = vec![];
322        let review =
323            parse_review_comment(body, "frank", HEAD_SHA, &targets(), &mut warnings).unwrap();
324        assert_eq!(review.verdict, Verdict::Merge);
325        assert_eq!(warnings.len(), 3); // one warning per dimension
326        assert!(warnings[0].contains("1 entries, expected 3"));
327    }
328
329    #[test]
330    fn test_parse_review_with_prose_after_verdict() {
331        // Real reviews have explanation text after the structured fields
332        let body = "/review\n\
333            difficulty: 204e93ab, currentPR, b012110b\n\
334            novelty: currentPR, 204e93ab, b012110b\n\
335            design: 204e93ab, currentPR, b012110b\n\
336            verdict: merge\n\
337            \n\
338            Strong architectural contribution. The prCreatedAt anchor eliminates\n\
339            a class of concurrency bugs with a stateless solution.";
340        let mut warnings = vec![];
341        let review =
342            parse_review_comment(body, "alice", HEAD_SHA, &targets(), &mut warnings).unwrap();
343        assert!(
344            warnings.is_empty(),
345            "prose should not interfere: {warnings:?}"
346        );
347        assert_eq!(review.verdict, Verdict::Merge);
348    }
349
350    #[test]
351    fn test_parse_review_notmerge_verdict() {
352        let body = "/review\n\
353            difficulty: currentPR, 204e93ab, b012110b\n\
354            novelty: currentPR, 204e93ab, b012110b\n\
355            design: currentPR, 204e93ab, b012110b\n\
356            verdict: notMerge\n\
357            \n\
358            Existing tests modified — waiting for human review.";
359        let mut warnings = vec![];
360        let review =
361            parse_review_comment(body, "bot", HEAD_SHA, &targets(), &mut warnings).unwrap();
362        assert_eq!(review.verdict, Verdict::NotMerge);
363    }
364
365    #[test]
366    fn test_parse_review_all_urls() {
367        // PR #97 scenario: all hashes are GitHub URLs
368        let url_a = format!("https://github.com/jarchain/jar/commit/{TARGET_A}");
369        let url_b = format!("https://github.com/jarchain/jar/commit/{TARGET_B}");
370        let body = format!(
371            "/review\n\
372            difficulty: {url_a}, currentPR, {url_b}\n\
373            novelty: currentPR, {url_a}, {url_b}\n\
374            design: {url_a}, currentPR, {url_b}\n\
375            verdict: merge"
376        );
377        let mut warnings = vec![];
378        let review =
379            parse_review_comment(&body, "carol", HEAD_SHA, &targets(), &mut warnings).unwrap();
380        assert!(warnings.is_empty(), "URLs should normalize: {warnings:?}");
381        assert_eq!(
382            review.difficulty_ranking,
383            vec![TARGET_A, HEAD_SHA, TARGET_B]
384        );
385        assert_eq!(review.novelty_ranking, vec![HEAD_SHA, TARGET_A, TARGET_B]);
386    }
387
388    #[test]
389    fn test_multiple_reviews_different_authors() {
390        let body1 = "/review\ndifficulty: 204e93ab, currentPR, b012110b\nnovelty: 204e93ab, currentPR, b012110b\ndesign: 204e93ab, currentPR, b012110b\nverdict: merge";
391        let body2 = "/review\ndifficulty: currentPR, 204e93ab, b012110b\nnovelty: currentPR, 204e93ab, b012110b\ndesign: currentPR, 204e93ab, b012110b\nverdict: notMerge";
392
393        let mut warnings = vec![];
394        let mut reviews: Vec<EmbeddedReview> = Vec::new();
395
396        if let Some(r) = parse_review_comment(body1, "alice", HEAD_SHA, &targets(), &mut warnings) {
397            reviews.push(r);
398        }
399        if let Some(r) = parse_review_comment(body2, "bob", HEAD_SHA, &targets(), &mut warnings) {
400            reviews.push(r);
401        }
402
403        assert_eq!(reviews.len(), 2);
404        assert_eq!(reviews[0].reviewer, "alice");
405        assert_eq!(reviews[0].verdict, Verdict::Merge);
406        assert_eq!(reviews[1].reviewer, "bob");
407        assert_eq!(reviews[1].verdict, Verdict::NotMerge);
408    }
409
410    #[test]
411    fn test_signed_commit_matches_real_trailer_format() {
412        // A real Genesis-Commit trailer from the repo
413        let trailer = r#"{"id":"cf701ea84d9e1ab600c834e9d5bf7dee0829f2a1","prId":118,"author":"mariopino","mergeEpoch":1774471809,"prCreatedAt":1774471183,"comparisonTargets":["25c798d8161147e2360e620feb86372f0d897f15"],"reviews":[{"reviewer":"sorpaas","difficultyRanking":["25c798d8161147e2360e620feb86372f0d897f15","cf701ea84d9e1ab600c834e9d5bf7dee0829f2a1"],"noveltyRanking":["25c798d8161147e2360e620feb86372f0d897f15","cf701ea84d9e1ab600c834e9d5bf7dee0829f2a1"],"designQualityRanking":["25c798d8161147e2360e620feb86372f0d897f15","cf701ea84d9e1ab600c834e9d5bf7dee0829f2a1"],"verdict":"merge"}],"metaReviews":[],"founderOverride":false}"#;
414
415        // Verify it deserializes into our SignedCommit type
416        let commit: crate::types::SignedCommit = serde_json::from_str(trailer).unwrap();
417        assert_eq!(commit.id, "cf701ea84d9e1ab600c834e9d5bf7dee0829f2a1");
418        assert_eq!(commit.pr_id, 118);
419        assert_eq!(commit.author, "mariopino");
420        assert_eq!(commit.pr_created_at, Some(1774471183));
421        assert!(!commit.founder_override);
422        assert_eq!(commit.reviews.len(), 1);
423        assert_eq!(commit.reviews[0].verdict, Verdict::Merge);
424
425        // Verify round-trip: serialize back and compare field by field
426        let reserialized = serde_json::to_string(&commit).unwrap();
427        let original: serde_json::Value = serde_json::from_str(trailer).unwrap();
428        let roundtrip: serde_json::Value = serde_json::from_str(&reserialized).unwrap();
429        assert_eq!(original, roundtrip, "round-trip mismatch");
430    }
431
432    #[test]
433    fn test_commit_index_matches_real_trailer_format() {
434        let trailer = r#"{"commitHash":"c395102ceab5cdbf22b88f9a3d80175c2d76ce14","contributor":"sorpaas","epoch":1774080150,"founderOverride":false,"mergeVotes":["sorpaas"],"metaReviews":[],"rejectVotes":[],"reviewers":["sorpaas"],"score":{"designQuality":100,"difficulty":100,"novelty":100},"weightDelta":100}"#;
435
436        let index: crate::types::CommitIndex = serde_json::from_str(trailer).unwrap();
437        assert_eq!(
438            index.commit_hash,
439            "c395102ceab5cdbf22b88f9a3d80175c2d76ce14"
440        );
441        assert_eq!(index.contributor, "sorpaas");
442        assert_eq!(index.score.difficulty, 100);
443        assert_eq!(index.weight_delta, 100);
444
445        // Round-trip
446        let reserialized = serde_json::to_string(&index).unwrap();
447        let original: serde_json::Value = serde_json::from_str(trailer).unwrap();
448        let roundtrip: serde_json::Value = serde_json::from_str(&reserialized).unwrap();
449        assert_eq!(original, roundtrip, "round-trip mismatch");
450    }
451}