1use crate::root_envelopes::{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")]
116 pub marker_regex: String,
117 #[serde(default = "default_marker_regex_flags")]
119 pub marker_regex_flags: String,
120 pub meta: ReviewEnvelopeMeta,
122}
123
124#[doc(hidden)]
126#[derive(Debug, Serialize)]
127#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
128#[cfg_attr(feature = "schema", schemars(rename = "ReviewEnvelopeOutput"))]
129#[cfg_attr(
130 feature = "schema",
131 schemars(title = "fallow --format review-github / review-gitlab")
132)]
133pub struct ReviewEnvelopeWireOutput<'a> {
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 event: Option<ReviewEnvelopeEvent>,
136 body: &'a str,
137 #[cfg_attr(
138 feature = "schema",
139 schemars(default = "ReviewEnvelopeSummary::empty_default")
140 )]
141 summary: &'a ReviewEnvelopeSummary,
142 comments: &'a [ReviewComment],
143 #[serde(default = "default_marker_regex")]
144 marker_regex: &'a str,
145 #[serde(default = "default_marker_regex_flags")]
146 marker_regex_flags: &'a str,
147 meta: ReviewEnvelopeWireMeta<'a>,
148}
149
150#[derive(Debug, Serialize)]
152#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
153#[cfg_attr(feature = "schema", schemars(rename = "ReviewEnvelopeMeta"))]
154struct ReviewEnvelopeWireMeta<'a> {
155 schema: ReviewEnvelopeSchema,
156 provider: ReviewProvider,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
158 check_conclusion: Option<ReviewCheckConclusion>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
160 review_id: Option<&'a ReviewId>,
161}
162
163fn serialize_review_contract_json_output<T: Serialize>(
164 output: T,
165 kind: &'static str,
166 analysis_run_id: Option<&str>,
167) -> Result<serde_json::Value, serde_json::Error> {
168 let mut value = serialize_named_json_output(output, kind)?;
169 attach_telemetry_meta(&mut value, analysis_run_id);
170 Ok(value)
171}
172
173pub fn serialize_review_envelope_json_output(
179 output: ReviewEnvelopeOutput,
180 analysis_run_id: Option<&str>,
181) -> Result<serde_json::Value, serde_json::Error> {
182 serialize_review_contract_json_output(output, "review-envelope", analysis_run_id)
183}
184
185pub fn serialize_scoped_review_envelope_json_output(
187 output: &ReviewEnvelopeOutput,
188 review_id: &ReviewId,
189 analysis_run_id: Option<&str>,
190) -> Result<serde_json::Value, String> {
191 validate_review_envelope_scope(output, Some(review_id))?;
192 let wire = ReviewEnvelopeWireOutput {
193 event: output.event,
194 body: &output.body,
195 summary: &output.summary,
196 comments: &output.comments,
197 marker_regex: &output.marker_regex,
198 marker_regex_flags: &output.marker_regex_flags,
199 meta: ReviewEnvelopeWireMeta {
200 schema: output.meta.schema,
201 provider: output.meta.provider,
202 check_conclusion: output.meta.check_conclusion,
203 review_id: Some(review_id),
204 },
205 };
206 serialize_review_contract_json_output(wire, "review-envelope", analysis_run_id)
207 .map_err(|error| error.to_string())
208}
209
210fn validate_review_envelope_scope(
211 output: &ReviewEnvelopeOutput,
212 review_id: Option<&ReviewId>,
213) -> Result<(), String> {
214 validate_review_body_scope(&output.body, review_id)?;
215 for comment in &output.comments {
216 let body = match comment {
217 ReviewComment::GitHub(comment) => &comment.body,
218 ReviewComment::GitLab(comment) => &comment.body,
219 };
220 validate_review_body_scope(body, review_id)?;
221 }
222 Ok(())
223}
224
225#[must_use]
227pub fn default_marker_regex() -> String {
228 MARKER_REGEX_V3.to_owned()
229}
230
231#[must_use]
233pub fn default_marker_regex_flags() -> String {
234 MARKER_REGEX_FLAGS_V3.to_owned()
235}
236
237pub const MARKER_REGEX_V3: &str =
241 r"^<!-- fallow-fingerprint:v[23]: ((?:[a-z]+:)?[0-9a-f]{16}) -->\s*$";
242
243pub const MARKER_REGEX_FLAGS_V3: &str = "m";
245
246#[derive(Debug, Clone, Serialize, Default)]
248#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
249pub struct ReviewEnvelopeSummary {
250 pub body: String,
252 pub fingerprint: String,
254}
255
256impl ReviewEnvelopeSummary {
257 #[must_use]
259 #[allow(
260 dead_code,
261 reason = "referenced via serde default attr; no direct callsite until Deserialize is derived"
262 )]
263 pub fn empty_default() -> Self {
264 Self::default()
265 }
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
270#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
271pub enum ReviewEnvelopeEvent {
272 #[serde(rename = "COMMENT")]
274 Comment,
275}
276
277#[derive(Debug, Clone, Serialize)]
282#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
283#[serde(untagged)]
284pub enum ReviewComment {
285 GitHub(GitHubReviewComment),
287 GitLab(GitLabReviewComment),
289}
290
291#[derive(Debug, Clone, Serialize)]
293#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
294pub struct GitHubReviewComment {
295 pub path: String,
297 pub line: u32,
299 pub side: GitHubReviewSide,
301 pub body: String,
303 pub fingerprint: String,
305 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub legacy_fingerprint: Option<String>,
311 #[serde(default, skip_serializing_if = "is_false")]
314 pub truncated: bool,
315}
316
317#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
319#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
320pub enum GitHubReviewSide {
321 #[serde(rename = "RIGHT")]
323 Right,
324}
325
326#[derive(Debug, Clone, Serialize)]
328#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
329pub struct GitLabReviewComment {
330 pub body: String,
332 pub position: GitLabReviewPosition,
334 pub fingerprint: String,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub legacy_fingerprint: Option<String>,
342 #[serde(default, skip_serializing_if = "is_false")]
345 pub truncated: bool,
346}
347
348#[must_use]
350#[allow(
351 clippy::trivially_copy_pass_by_ref,
352 reason = "serde's skip_serializing_if requires fn(&T) -> bool"
353)]
354pub fn is_false(value: &bool) -> bool {
355 !*value
356}
357
358#[derive(Debug, Clone, Serialize)]
361#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
362pub struct GitLabReviewPosition {
363 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub base_sha: Option<String>,
366 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub start_sha: Option<String>,
369 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub head_sha: Option<String>,
372 pub position_type: GitLabReviewPositionType,
374 pub old_path: String,
377 pub new_path: String,
379 pub new_line: u32,
381}
382
383#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
385#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
386#[serde(rename_all = "lowercase")]
387pub enum GitLabReviewPositionType {
388 Text,
390}
391
392#[derive(Debug, Clone, Serialize)]
394#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
395pub struct ReviewEnvelopeMeta {
396 pub schema: ReviewEnvelopeSchema,
398 pub provider: ReviewProvider,
400 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub check_conclusion: Option<ReviewCheckConclusion>,
403}
404
405#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
407#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
408pub enum ReviewEnvelopeSchema {
409 #[serde(rename = "fallow-review-envelope/v1")]
411 #[allow(
412 dead_code,
413 reason = "kept for forward-compat with v1 historical inputs once Deserialize is derived"
414 )]
415 V1,
416 #[serde(rename = "fallow-review-envelope/v2")]
418 V2,
419 #[serde(rename = "fallow-review-envelope/v3")]
421 V3,
422}
423
424#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
426#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
427#[serde(rename_all = "lowercase")]
428pub enum ReviewProvider {
429 Github,
431 Gitlab,
433}
434
435#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
438#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
439#[serde(rename_all = "lowercase")]
440pub enum ReviewCheckConclusion {
441 Success,
443 Neutral,
445 Failure,
447}
448
449#[derive(Debug, Clone, Serialize)]
453#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
454#[cfg_attr(
455 feature = "schema",
456 schemars(title = "fallow ci reconcile-review --format json")
457)]
458pub struct ReviewReconcileOutput {
459 pub schema: ReviewReconcileSchema,
461 pub provider: ReviewProvider,
463 pub target: Option<String>,
465 pub dry_run: bool,
467 pub comments: u32,
469 pub current_fingerprints: u32,
471 pub existing_fingerprints: u32,
474 pub new_fingerprints: u32,
476 pub stale_fingerprints: u32,
478 pub new: Vec<String>,
480 pub stale: Vec<String>,
482 pub provider_warning: Option<String>,
484 pub resolution_comments_posted: u32,
486 pub threads_resolved: u32,
488 #[serde(default, skip_serializing_if = "Option::is_none")]
490 pub apply_hint: Option<String>,
491 pub apply_errors: Vec<String>,
493 #[serde(default, skip_serializing_if = "Vec::is_empty")]
495 pub failed_fingerprints: Vec<String>,
496 #[serde(default, skip_serializing_if = "Vec::is_empty")]
498 pub unapplied_fingerprints: Vec<String>,
499}
500
501pub fn serialize_review_reconcile_json_output(
508 output: ReviewReconcileOutput,
509 analysis_run_id: Option<&str>,
510) -> Result<serde_json::Value, serde_json::Error> {
511 serialize_review_contract_json_output(output, "review-reconcile", analysis_run_id)
512}
513
514#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
516#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
517pub enum ReviewReconcileSchema {
518 #[serde(rename = "fallow-review-reconcile/v1")]
520 V1,
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526
527 fn legacy_review_envelope(body: &str) -> ReviewEnvelopeOutput {
528 ReviewEnvelopeOutput {
529 event: None,
530 body: body.to_owned(),
531 summary: ReviewEnvelopeSummary::default(),
532 comments: Vec::new(),
533 marker_regex: default_marker_regex(),
534 marker_regex_flags: default_marker_regex_flags(),
535 meta: ReviewEnvelopeMeta {
536 schema: ReviewEnvelopeSchema::V2,
537 provider: ReviewProvider::Github,
538 check_conclusion: None,
539 },
540 }
541 }
542
543 #[test]
544 fn review_envelope_json_output_uses_output_owned_root_contract() {
545 let output = legacy_review_envelope("body");
546
547 let value = serialize_review_envelope_json_output(output, Some("run-review"))
548 .expect("review envelope should serialize");
549
550 assert_eq!(value["kind"], "review-envelope");
551 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-review");
552 }
553
554 #[test]
555 fn legacy_serializer_is_byte_shape_equal_to_the_public_dto() {
556 let output = legacy_review_envelope("body");
557 let mut expected =
558 serde_json::to_value(&output).expect("legacy review envelope should serialize");
559 crate::root_envelopes::apply_root_kind(&mut expected, "review-envelope");
560 let actual = serialize_review_envelope_json_output(output, None)
561 .expect("review envelope should serialize");
562
563 assert_eq!(
564 serde_json::to_vec(&actual).expect("actual review envelope should encode"),
565 serde_json::to_vec(&expected).expect("expected review envelope should encode")
566 );
567 assert!(actual["meta"].get("review_id").is_none());
568 }
569
570 #[test]
571 fn scoped_serializer_adds_only_typed_review_id_to_legacy_shape() {
572 let review_id = ReviewId::parse("frontend").expect("review id should be valid");
573 let output = legacy_review_envelope("body\n<!-- fallow-review-id: frontend -->");
574 let value = serialize_scoped_review_envelope_json_output(&output, &review_id, None)
575 .expect("scoped review envelope should serialize");
576
577 assert_eq!(value["meta"]["review_id"], "frontend");
578 assert_eq!(value["meta"]["provider"], "github");
579 }
580
581 #[cfg(feature = "schema")]
582 #[test]
583 fn canonical_wire_schema_has_one_optional_typed_review_id() {
584 let schema = serde_json::to_value(schemars::schema_for!(ReviewEnvelopeWireOutput<'static>))
585 .expect("review envelope schema should serialize");
586 let meta = &schema["$defs"]["ReviewEnvelopeMeta"];
587 let required = schema["required"]
588 .as_array()
589 .expect("review envelope schema should list required fields");
590
591 assert!(!required.iter().any(|field| field == "summary"));
592 assert!(meta["properties"]["review_id"].is_object());
593 assert!(
594 !meta["required"]
595 .as_array()
596 .is_some_and(|required| required.iter().any(|field| field == "review_id"))
597 );
598 }
599
600 #[test]
601 fn review_reconcile_json_output_uses_output_owned_root_contract() {
602 let output = ReviewReconcileOutput {
603 schema: ReviewReconcileSchema::V1,
604 provider: ReviewProvider::Github,
605 target: None,
606 dry_run: true,
607 comments: 0,
608 current_fingerprints: 0,
609 existing_fingerprints: 0,
610 new_fingerprints: 0,
611 stale_fingerprints: 0,
612 new: Vec::new(),
613 stale: Vec::new(),
614 provider_warning: None,
615 resolution_comments_posted: 0,
616 threads_resolved: 0,
617 apply_hint: None,
618 apply_errors: Vec::new(),
619 failed_fingerprints: Vec::new(),
620 unapplied_fingerprints: Vec::new(),
621 };
622
623 let value = serialize_review_reconcile_json_output(output, Some("run-reconcile"))
624 .expect("review reconcile should serialize");
625
626 assert_eq!(value["kind"], "review-reconcile");
627 assert_eq!(
628 value["_meta"]["telemetry"]["analysis_run_id"],
629 "run-reconcile"
630 );
631 }
632
633 #[test]
634 fn review_id_accepts_only_the_schema_character_set_and_length() {
635 assert_eq!(
636 ReviewId::parse("frontend.review-1")
637 .expect("review id should be valid")
638 .as_str(),
639 "frontend.review-1"
640 );
641 assert!(ReviewId::parse("").is_err());
642 assert!(ReviewId::parse("has space").is_err());
643 assert!(ReviewId::parse("é").is_err());
644 assert!(ReviewId::parse("a".repeat(65)).is_err());
645 }
646}