1use crate::github;
2use crate::hash;
3use crate::types::{CollectedReviews, EmbeddedReview, MetaReview, Verdict};
4
5pub 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 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
83fn 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 let normalized = hash::normalize_commit_ref(item);
100 let normalized = if normalized == "currentPR" {
101 head_sha.to_string()
102 } else {
103 normalized
104 };
105 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 result.push(normalized);
114 }
115 }
116 }
117 result
118}
119
120pub 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 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 let mut reviews: Vec<EmbeddedReview> = Vec::new();
154 let mut review_comment_ids: Vec<(String, u64)> = Vec::new(); 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 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 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
206pub 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 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()); }
293
294 #[test]
295 fn test_last_review_per_author_wins() {
296 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); assert_eq!(reviews[0].difficulty_ranking[0], HEAD_SHA); }
315
316 #[test]
317 fn test_ranking_count_warning() {
318 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); assert!(warnings[0].contains("1 entries, expected 3"));
327 }
328
329 #[test]
330 fn test_parse_review_with_prose_after_verdict() {
331 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 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 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 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 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 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}