1use crate::root_envelopes::{RootEnvelopeMode, attach_telemetry_meta, serialize_named_json_output};
4use serde::Serialize;
5
6pub const REVIEW_ID_MARKER_PREFIX: &str = "<!-- fallow-review-id: ";
8
9const REVIEW_ID_MARKER_SUFFIX: &str = " -->";
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
14#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
15#[serde(transparent)]
16pub struct ReviewId(
17 #[cfg_attr(
18 feature = "schema",
19 schemars(length(min = 1, max = 64), regex(pattern = r"^[A-Za-z0-9._-]+$"))
20 )]
21 String,
22);
23
24impl ReviewId {
25 pub fn parse(value: impl Into<String>) -> Result<Self, String> {
27 let value = value.into();
28 if value.is_empty() || value.len() > 64 {
29 return Err("review id must contain between 1 and 64 bytes".to_owned());
30 }
31 if !value
32 .bytes()
33 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
34 {
35 return Err(
36 "review id may contain only ASCII letters, digits, '.', '_' and '-'".to_owned(),
37 );
38 }
39 Ok(Self(value))
40 }
41
42 #[must_use]
44 pub fn as_str(&self) -> &str {
45 &self.0
46 }
47}
48
49#[must_use]
51pub fn review_id_marker(review_id: &ReviewId) -> String {
52 format!(
53 "{REVIEW_ID_MARKER_PREFIX}{}{REVIEW_ID_MARKER_SUFFIX}",
54 review_id.as_str()
55 )
56}
57
58pub fn parse_review_id_marker(body: &str) -> Result<Option<ReviewId>, String> {
60 let mut found = None;
61 for line in body.lines() {
62 if !line.contains("fallow-review-id") {
63 continue;
64 }
65 let value = line
66 .strip_prefix(REVIEW_ID_MARKER_PREFIX)
67 .and_then(|line| line.strip_suffix(REVIEW_ID_MARKER_SUFFIX))
68 .ok_or_else(|| "malformed fallow review-id marker".to_owned())?;
69 let review_id = ReviewId::parse(value.to_owned())
70 .map_err(|error| format!("invalid fallow review-id marker: {error}"))?;
71 if found.replace(review_id).is_some() {
72 return Err("duplicate fallow review-id marker".to_owned());
73 }
74 }
75 Ok(found)
76}
77
78pub fn validate_review_body_scope(body: &str, review_id: Option<&ReviewId>) -> Result<(), String> {
80 let body_review_id = parse_review_id_marker(body)?;
81 if body_review_id.as_ref() != review_id {
82 return Err("review body marker does not match meta.review_id".to_owned());
83 }
84 Ok(())
85}
86
87#[must_use]
89pub fn body_matches_review_id(body: &str, review_id: Option<&ReviewId>) -> bool {
90 validate_review_body_scope(body, review_id).is_ok()
91}
92
93#[derive(Debug, Clone, Serialize)]
95#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
96#[cfg_attr(
97 feature = "schema",
98 schemars(title = "fallow --format review-github / review-gitlab")
99)]
100pub struct ReviewEnvelopeOutput {
101 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub event: Option<ReviewEnvelopeEvent>,
104 pub body: String,
106 #[serde(default = "ReviewEnvelopeSummary::empty_default")]
108 pub summary: ReviewEnvelopeSummary,
109 pub comments: Vec<ReviewComment>,
111 #[serde(default = "default_marker_regex")]
114 pub marker_regex: String,
115 #[serde(default = "default_marker_regex_flags")]
117 pub marker_regex_flags: String,
118 pub meta: ReviewEnvelopeMeta,
120}
121
122#[doc(hidden)]
124#[derive(Debug, Serialize)]
125#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
126#[cfg_attr(feature = "schema", schemars(rename = "ReviewEnvelopeOutput"))]
127#[cfg_attr(
128 feature = "schema",
129 schemars(title = "fallow --format review-github / review-gitlab")
130)]
131pub struct ReviewEnvelopeWireOutput<'a> {
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 event: Option<ReviewEnvelopeEvent>,
134 body: &'a str,
135 #[cfg_attr(
136 feature = "schema",
137 schemars(default = "ReviewEnvelopeSummary::empty_default")
138 )]
139 summary: &'a ReviewEnvelopeSummary,
140 comments: &'a [ReviewComment],
141 #[serde(default = "default_marker_regex")]
142 marker_regex: &'a str,
143 #[serde(default = "default_marker_regex_flags")]
144 marker_regex_flags: &'a str,
145 meta: ReviewEnvelopeWireMeta<'a>,
146}
147
148#[derive(Debug, Serialize)]
150#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
151#[cfg_attr(feature = "schema", schemars(rename = "ReviewEnvelopeMeta"))]
152struct ReviewEnvelopeWireMeta<'a> {
153 schema: ReviewEnvelopeSchema,
154 provider: ReviewProvider,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 check_conclusion: Option<ReviewCheckConclusion>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
158 review_id: Option<&'a ReviewId>,
159}
160
161fn serialize_review_contract_json_output<T: Serialize>(
162 output: T,
163 kind: &'static str,
164 mode: RootEnvelopeMode,
165 analysis_run_id: Option<&str>,
166) -> Result<serde_json::Value, serde_json::Error> {
167 let mut value = serialize_named_json_output(output, kind, mode)?;
168 attach_telemetry_meta(&mut value, analysis_run_id);
169 Ok(value)
170}
171
172pub fn serialize_review_envelope_json_output(
178 output: ReviewEnvelopeOutput,
179 mode: RootEnvelopeMode,
180 analysis_run_id: Option<&str>,
181) -> Result<serde_json::Value, serde_json::Error> {
182 serialize_review_contract_json_output(output, "review-envelope", mode, analysis_run_id)
183}
184
185pub fn serialize_scoped_review_envelope_json_output(
187 output: &ReviewEnvelopeOutput,
188 review_id: &ReviewId,
189 mode: RootEnvelopeMode,
190 analysis_run_id: Option<&str>,
191) -> Result<serde_json::Value, String> {
192 validate_review_envelope_scope(output, Some(review_id))?;
193 let wire = ReviewEnvelopeWireOutput {
194 event: output.event,
195 body: &output.body,
196 summary: &output.summary,
197 comments: &output.comments,
198 marker_regex: &output.marker_regex,
199 marker_regex_flags: &output.marker_regex_flags,
200 meta: ReviewEnvelopeWireMeta {
201 schema: output.meta.schema,
202 provider: output.meta.provider,
203 check_conclusion: output.meta.check_conclusion,
204 review_id: Some(review_id),
205 },
206 };
207 serialize_review_contract_json_output(wire, "review-envelope", mode, analysis_run_id)
208 .map_err(|error| error.to_string())
209}
210
211fn validate_review_envelope_scope(
212 output: &ReviewEnvelopeOutput,
213 review_id: Option<&ReviewId>,
214) -> Result<(), String> {
215 validate_review_body_scope(&output.body, review_id)?;
216 for comment in &output.comments {
217 let body = match comment {
218 ReviewComment::GitHub(comment) => &comment.body,
219 ReviewComment::GitLab(comment) => &comment.body,
220 };
221 validate_review_body_scope(body, review_id)?;
222 }
223 Ok(())
224}
225
226#[must_use]
228pub fn default_marker_regex() -> String {
229 MARKER_REGEX_V2.to_owned()
230}
231
232#[must_use]
234pub fn default_marker_regex_flags() -> String {
235 MARKER_REGEX_FLAGS_V2.to_owned()
236}
237
238pub const MARKER_REGEX_V2: &str =
240 r"^<!-- fallow-fingerprint:v2: ((?:[a-z]+:)?[0-9a-f]{16}) -->\s*$";
241
242pub const MARKER_REGEX_FLAGS_V2: &str = "m";
244
245#[derive(Debug, Clone, Serialize, Default)]
247#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
248pub struct ReviewEnvelopeSummary {
249 pub body: String,
251 pub fingerprint: String,
253}
254
255impl ReviewEnvelopeSummary {
256 #[must_use]
258 #[allow(
259 dead_code,
260 reason = "referenced via serde default attr; no direct callsite until Deserialize is derived"
261 )]
262 pub fn empty_default() -> Self {
263 Self::default()
264 }
265}
266
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
269#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
270pub enum ReviewEnvelopeEvent {
271 #[serde(rename = "COMMENT")]
273 Comment,
274}
275
276#[derive(Debug, Clone, Serialize)]
281#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
282#[serde(untagged)]
283pub enum ReviewComment {
284 GitHub(GitHubReviewComment),
286 GitLab(GitLabReviewComment),
288}
289
290#[derive(Debug, Clone, Serialize)]
292#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
293pub struct GitHubReviewComment {
294 pub path: String,
296 pub line: u32,
298 pub side: GitHubReviewSide,
300 pub body: String,
302 pub fingerprint: String,
304 #[serde(default, skip_serializing_if = "is_false")]
307 pub truncated: bool,
308}
309
310#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
312#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
313pub enum GitHubReviewSide {
314 #[serde(rename = "RIGHT")]
316 Right,
317}
318
319#[derive(Debug, Clone, Serialize)]
321#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
322pub struct GitLabReviewComment {
323 pub body: String,
325 pub position: GitLabReviewPosition,
327 pub fingerprint: String,
329 #[serde(default, skip_serializing_if = "is_false")]
332 pub truncated: bool,
333}
334
335#[must_use]
337#[allow(
338 clippy::trivially_copy_pass_by_ref,
339 reason = "serde's skip_serializing_if requires fn(&T) -> bool"
340)]
341pub fn is_false(value: &bool) -> bool {
342 !*value
343}
344
345#[derive(Debug, Clone, Serialize)]
348#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
349pub struct GitLabReviewPosition {
350 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub base_sha: Option<String>,
353 #[serde(default, skip_serializing_if = "Option::is_none")]
355 pub start_sha: Option<String>,
356 #[serde(default, skip_serializing_if = "Option::is_none")]
358 pub head_sha: Option<String>,
359 pub position_type: GitLabReviewPositionType,
361 pub old_path: String,
364 pub new_path: String,
366 pub new_line: u32,
368}
369
370#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
372#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
373#[serde(rename_all = "lowercase")]
374pub enum GitLabReviewPositionType {
375 Text,
377}
378
379#[derive(Debug, Clone, Serialize)]
381#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
382pub struct ReviewEnvelopeMeta {
383 pub schema: ReviewEnvelopeSchema,
385 pub provider: ReviewProvider,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
389 pub check_conclusion: Option<ReviewCheckConclusion>,
390}
391
392#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
394#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
395pub enum ReviewEnvelopeSchema {
396 #[serde(rename = "fallow-review-envelope/v1")]
398 #[allow(
399 dead_code,
400 reason = "kept for forward-compat with v1 historical inputs once Deserialize is derived"
401 )]
402 V1,
403 #[serde(rename = "fallow-review-envelope/v2")]
405 V2,
406 #[serde(rename = "fallow-review-envelope/v3")]
408 V3,
409}
410
411#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
413#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
414#[serde(rename_all = "lowercase")]
415pub enum ReviewProvider {
416 Github,
418 Gitlab,
420}
421
422#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
425#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
426#[serde(rename_all = "lowercase")]
427pub enum ReviewCheckConclusion {
428 Success,
430 Neutral,
432 Failure,
434}
435
436#[derive(Debug, Clone, Serialize)]
440#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
441#[cfg_attr(
442 feature = "schema",
443 schemars(title = "fallow ci reconcile-review --format json")
444)]
445pub struct ReviewReconcileOutput {
446 pub schema: ReviewReconcileSchema,
448 pub provider: ReviewProvider,
450 pub target: Option<String>,
452 pub dry_run: bool,
454 pub comments: u32,
456 pub current_fingerprints: u32,
458 pub existing_fingerprints: u32,
461 pub new_fingerprints: u32,
463 pub stale_fingerprints: u32,
465 pub new: Vec<String>,
467 pub stale: Vec<String>,
469 pub provider_warning: Option<String>,
471 pub resolution_comments_posted: u32,
473 pub threads_resolved: u32,
475 #[serde(default, skip_serializing_if = "Option::is_none")]
477 pub apply_hint: Option<String>,
478 pub apply_errors: Vec<String>,
480 #[serde(default, skip_serializing_if = "Vec::is_empty")]
482 pub failed_fingerprints: Vec<String>,
483 #[serde(default, skip_serializing_if = "Vec::is_empty")]
485 pub unapplied_fingerprints: Vec<String>,
486}
487
488pub fn serialize_review_reconcile_json_output(
495 output: ReviewReconcileOutput,
496 mode: RootEnvelopeMode,
497 analysis_run_id: Option<&str>,
498) -> Result<serde_json::Value, serde_json::Error> {
499 serialize_review_contract_json_output(output, "review-reconcile", mode, analysis_run_id)
500}
501
502#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
504#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
505pub enum ReviewReconcileSchema {
506 #[serde(rename = "fallow-review-reconcile/v1")]
508 V1,
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514
515 fn legacy_review_envelope(body: &str) -> ReviewEnvelopeOutput {
516 ReviewEnvelopeOutput {
517 event: None,
518 body: body.to_owned(),
519 summary: ReviewEnvelopeSummary::default(),
520 comments: Vec::new(),
521 marker_regex: default_marker_regex(),
522 marker_regex_flags: default_marker_regex_flags(),
523 meta: ReviewEnvelopeMeta {
524 schema: ReviewEnvelopeSchema::V2,
525 provider: ReviewProvider::Github,
526 check_conclusion: None,
527 },
528 }
529 }
530
531 #[test]
532 fn review_envelope_json_output_uses_output_owned_root_contract() {
533 let output = legacy_review_envelope("body");
534
535 let value = serialize_review_envelope_json_output(
536 output,
537 RootEnvelopeMode::Tagged,
538 Some("run-review"),
539 )
540 .expect("review envelope should serialize");
541
542 assert_eq!(value["kind"], "review-envelope");
543 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-review");
544 }
545
546 #[test]
547 fn legacy_serializer_is_byte_shape_equal_to_the_public_dto() {
548 let output = legacy_review_envelope("body");
549 let mut expected =
550 serde_json::to_value(&output).expect("legacy review envelope should serialize");
551 crate::apply_root_kind(&mut expected, "review-envelope", RootEnvelopeMode::Tagged);
552 let actual = serialize_review_envelope_json_output(output, RootEnvelopeMode::Tagged, None)
553 .expect("review envelope should serialize");
554
555 assert_eq!(
556 serde_json::to_vec(&actual).expect("actual review envelope should encode"),
557 serde_json::to_vec(&expected).expect("expected review envelope should encode")
558 );
559 assert!(actual["meta"].get("review_id").is_none());
560 }
561
562 #[test]
563 fn scoped_serializer_adds_only_typed_review_id_to_legacy_shape() {
564 let review_id = ReviewId::parse("frontend").expect("review id should be valid");
565 let output = legacy_review_envelope("body\n<!-- fallow-review-id: frontend -->");
566 let value = serialize_scoped_review_envelope_json_output(
567 &output,
568 &review_id,
569 RootEnvelopeMode::Tagged,
570 None,
571 )
572 .expect("scoped review envelope should serialize");
573
574 assert_eq!(value["meta"]["review_id"], "frontend");
575 assert_eq!(value["meta"]["provider"], "github");
576 }
577
578 #[cfg(feature = "schema")]
579 #[test]
580 fn canonical_wire_schema_has_one_optional_typed_review_id() {
581 let schema = serde_json::to_value(schemars::schema_for!(ReviewEnvelopeWireOutput<'static>))
582 .expect("review envelope schema should serialize");
583 let meta = &schema["$defs"]["ReviewEnvelopeMeta"];
584 let required = schema["required"]
585 .as_array()
586 .expect("review envelope schema should list required fields");
587
588 assert!(!required.iter().any(|field| field == "summary"));
589 assert!(meta["properties"]["review_id"].is_object());
590 assert!(
591 !meta["required"]
592 .as_array()
593 .is_some_and(|required| required.iter().any(|field| field == "review_id"))
594 );
595 }
596
597 #[test]
598 fn review_reconcile_json_output_uses_output_owned_root_contract() {
599 let output = ReviewReconcileOutput {
600 schema: ReviewReconcileSchema::V1,
601 provider: ReviewProvider::Github,
602 target: None,
603 dry_run: true,
604 comments: 0,
605 current_fingerprints: 0,
606 existing_fingerprints: 0,
607 new_fingerprints: 0,
608 stale_fingerprints: 0,
609 new: Vec::new(),
610 stale: Vec::new(),
611 provider_warning: None,
612 resolution_comments_posted: 0,
613 threads_resolved: 0,
614 apply_hint: None,
615 apply_errors: Vec::new(),
616 failed_fingerprints: Vec::new(),
617 unapplied_fingerprints: Vec::new(),
618 };
619
620 let value = serialize_review_reconcile_json_output(
621 output,
622 RootEnvelopeMode::Tagged,
623 Some("run-reconcile"),
624 )
625 .expect("review reconcile should serialize");
626
627 assert_eq!(value["kind"], "review-reconcile");
628 assert_eq!(
629 value["_meta"]["telemetry"]["analysis_run_id"],
630 "run-reconcile"
631 );
632 }
633
634 #[test]
635 fn review_id_accepts_only_the_schema_character_set_and_length() {
636 assert_eq!(
637 ReviewId::parse("frontend.review-1")
638 .expect("review id should be valid")
639 .as_str(),
640 "frontend.review-1"
641 );
642 assert!(ReviewId::parse("").is_err());
643 assert!(ReviewId::parse("has space").is_err());
644 assert!(ReviewId::parse("é").is_err());
645 assert!(ReviewId::parse("a".repeat(65)).is_err());
646 }
647}