Skip to main content

fallow_output/
review_envelopes.rs

1//! Review integration output envelopes.
2
3use crate::root_envelopes::{attach_telemetry_meta, serialize_named_json_output};
4use serde::Serialize;
5
6/// Prefix for the exact review-scope marker appended to generated bodies.
7pub const REVIEW_ID_MARKER_PREFIX: &str = "<!-- fallow-review-id: ";
8
9const REVIEW_ID_MARKER_SUFFIX: &str = " -->";
10
11/// Stable identifier used to isolate independent review integrations on the
12/// same pull or merge request.
13#[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    /// Parse and validate a review identifier.
26    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    /// Return the validated identifier.
43    #[must_use]
44    pub fn as_str(&self) -> &str {
45        &self.0
46    }
47}
48
49/// Render the exact review-scope marker line.
50#[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
58/// Parse the sole canonical review-scope marker from a rendered body.
59pub 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
78/// Require a body marker to match the expected review scope exactly.
79pub 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/// Whether a rendered body belongs to the expected review scope.
88#[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/// Envelope emitted by `fallow --format review-github` / `review-gitlab`.
94#[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    /// GitHub review event to submit with; absent for GitLab.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub event: Option<ReviewEnvelopeEvent>,
104    /// Top-level review body markdown.
105    pub body: String,
106    /// Sticky summary comment with its own fingerprint.
107    #[serde(default = "ReviewEnvelopeSummary::empty_default")]
108    pub summary: ReviewEnvelopeSummary,
109    /// Inline review comments, all from the same provider.
110    pub comments: Vec<ReviewComment>,
111    /// Regex integrations use to find fallow fingerprint markers in existing
112    /// comment bodies. It matches the current `v3` marker and the older `v2`
113    /// marker. Match a captured value against `comments[].fingerprint` and
114    /// `comments[].legacy_fingerprint`.
115    #[serde(default = "default_marker_regex")]
116    pub marker_regex: String,
117    /// Regex flags accompanying `marker_regex` (multiline).
118    #[serde(default = "default_marker_regex_flags")]
119    pub marker_regex_flags: String,
120    /// Schema, provider, and check-conclusion metadata.
121    pub meta: ReviewEnvelopeMeta,
122}
123
124/// Envelope emitted by `fallow --format review-github` / `review-gitlab`.
125#[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/// `meta` block inside [`ReviewEnvelopeOutput`].
151#[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
173/// Serialize the review envelope contract emitted by CI review formats.
174///
175/// # Errors
176///
177/// Returns a serde error when the review envelope cannot be converted to JSON.
178pub 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
185/// Serialize a scoped review envelope through the canonical derived wire type.
186pub 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/// Default for [`ReviewEnvelopeOutput::marker_regex`].
226#[must_use]
227pub fn default_marker_regex() -> String {
228    MARKER_REGEX_V3.to_owned()
229}
230
231/// Default for [`ReviewEnvelopeOutput::marker_regex_flags`].
232#[must_use]
233pub fn default_marker_regex_flags() -> String {
234    MARKER_REGEX_FLAGS_V3.to_owned()
235}
236
237/// Canonical marker-regex literal. It matches the current `v3` marker and the
238/// older `v2` marker, so an integration still finds comments that an older
239/// release wrote.
240pub const MARKER_REGEX_V3: &str =
241    r"^<!-- fallow-fingerprint:v[23]: ((?:[a-z]+:)?[0-9a-f]{16}) -->\s*$";
242
243/// Canonical marker-regex flags.
244pub const MARKER_REGEX_FLAGS_V3: &str = "m";
245
246/// Summary block on [`ReviewEnvelopeOutput`].
247#[derive(Debug, Clone, Serialize, Default)]
248#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
249pub struct ReviewEnvelopeSummary {
250    /// Summary comment body markdown.
251    pub body: String,
252    /// Stable fingerprint of the summary body, used for sticky updates.
253    pub fingerprint: String,
254}
255
256impl ReviewEnvelopeSummary {
257    /// Empty-default factory for [`ReviewEnvelopeOutput::summary`].
258    #[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/// Singleton GitHub review-event marker.
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
270#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
271pub enum ReviewEnvelopeEvent {
272    /// Submit as a non-blocking `COMMENT` review.
273    #[serde(rename = "COMMENT")]
274    Comment,
275}
276
277/// Per-line review comment. Schema is an `anyOf` between GitHub and GitLab
278/// shapes; at runtime every entry in a single envelope comes from the same
279/// provider because the envelope is built from one provider's branch in
280/// `crates/cli/src/report/ci/review.rs::render_review_envelope`.
281#[derive(Debug, Clone, Serialize)]
282#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
283#[serde(untagged)]
284pub enum ReviewComment {
285    /// GitHub pull-request review comment.
286    GitHub(GitHubReviewComment),
287    /// GitLab merge-request discussion comment.
288    GitLab(GitLabReviewComment),
289}
290
291/// GitHub pull-request review comment.
292#[derive(Debug, Clone, Serialize)]
293#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
294pub struct GitHubReviewComment {
295    /// File path relative to the repository root.
296    pub path: String,
297    /// 1-based line on the new side of the diff.
298    pub line: u32,
299    /// Diff side; always `RIGHT` (the new side).
300    pub side: GitHubReviewSide,
301    /// Comment body markdown, fingerprint marker included.
302    pub body: String,
303    /// Stable finding fingerprint used for comment reconciliation.
304    pub fingerprint: String,
305    /// The fingerprint that an older Fallow release wrote into the
306    /// `fallow-fingerprint:v2:` marker of this comment, when it is different
307    /// from `fingerprint`. For one release, an existing comment whose marker
308    /// holds this value is the same comment. Omitted when equal.
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub legacy_fingerprint: Option<String>,
311    /// True when the body was cut to fit the provider size limit; omitted
312    /// when false.
313    #[serde(default, skip_serializing_if = "is_false")]
314    pub truncated: bool,
315}
316
317/// Singleton side discriminator for [`GitHubReviewComment::side`].
318#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
319#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
320pub enum GitHubReviewSide {
321    /// New side of the diff; the only side fallow comments on.
322    #[serde(rename = "RIGHT")]
323    Right,
324}
325
326/// GitLab merge-request discussion comment.
327#[derive(Debug, Clone, Serialize)]
328#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
329pub struct GitLabReviewComment {
330    /// Comment body markdown, fingerprint marker included.
331    pub body: String,
332    /// Diff position anchoring the discussion.
333    pub position: GitLabReviewPosition,
334    /// Stable finding fingerprint used for comment reconciliation.
335    pub fingerprint: String,
336    /// The fingerprint that an older Fallow release wrote into the
337    /// `fallow-fingerprint:v2:` marker of this comment, when it is different
338    /// from `fingerprint`. For one release, an existing comment whose marker
339    /// holds this value is the same comment. Omitted when equal.
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub legacy_fingerprint: Option<String>,
342    /// True when the body was cut to fit the provider size limit; omitted
343    /// when false.
344    #[serde(default, skip_serializing_if = "is_false")]
345    pub truncated: bool,
346}
347
348/// Helper for `skip_serializing_if = "is_false"` on `truncated` fields.
349#[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/// `position` block inside [`GitLabReviewComment`]. Mirrors the GitLab
359/// merge-request discussion-position API.
360#[derive(Debug, Clone, Serialize)]
361#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
362pub struct GitLabReviewPosition {
363    /// Merge-base SHA of the MR diff; absent when refs were not supplied.
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub base_sha: Option<String>,
366    /// First commit SHA of the MR diff; absent when refs were not supplied.
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub start_sha: Option<String>,
369    /// Head commit SHA of the MR diff; absent when refs were not supplied.
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub head_sha: Option<String>,
372    /// Position type; always `text`.
373    pub position_type: GitLabReviewPositionType,
374    /// Pre-rename path when the diff renamed the file, else the same as
375    /// `new_path`.
376    pub old_path: String,
377    /// File path on the new side of the diff.
378    pub new_path: String,
379    /// 1-based line on the new side of the diff.
380    pub new_line: u32,
381}
382
383/// Singleton position-type discriminator for [`GitLabReviewPosition`].
384#[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-file diff position; the only type fallow emits.
389    Text,
390}
391
392/// `meta` block inside [`ReviewEnvelopeOutput`].
393#[derive(Debug, Clone, Serialize)]
394#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
395pub struct ReviewEnvelopeMeta {
396    /// Review-envelope schema version tag.
397    pub schema: ReviewEnvelopeSchema,
398    /// Provider the envelope was rendered for.
399    pub provider: ReviewProvider,
400    /// GitHub Checks conclusion; absent for GitLab.
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub check_conclusion: Option<ReviewCheckConclusion>,
403}
404
405/// Schema-version discriminator for the review envelope.
406#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
407#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
408pub enum ReviewEnvelopeSchema {
409    /// Historical first release of the review envelope format.
410    #[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    /// Issue #528 review envelope format.
417    #[serde(rename = "fallow-review-envelope/v2")]
418    V2,
419    /// Gate-aware conclusion semantics, including failures without findings.
420    #[serde(rename = "fallow-review-envelope/v3")]
421    V3,
422}
423
424/// Review-envelope provider tag.
425#[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 pull-request review envelope.
430    Github,
431    /// GitLab merge-request discussion envelope.
432    Gitlab,
433}
434
435/// `meta.check_conclusion` for the GitHub review envelope. Maps to the
436/// GitHub Checks API conclusion field.
437#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
438#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
439#[serde(rename_all = "lowercase")]
440pub enum ReviewCheckConclusion {
441    /// The gate passed, whether or not inline findings were selected.
442    Success,
443    /// Findings but none gated as failure.
444    Neutral,
445    /// The gate failed, including failures without an inline finding.
446    Failure,
447}
448
449/// Envelope emitted by `fallow ci reconcile-review --format json`. Used by
450/// CI integrations to drive comment carry-over and stale-comment cleanup
451/// across PR / MR revisions.
452#[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    /// Reconcile schema version tag.
460    pub schema: ReviewReconcileSchema,
461    /// Provider whose comments were reconciled.
462    pub provider: ReviewProvider,
463    /// PR / MR reference that was reconciled, when one was resolved.
464    pub target: Option<String>,
465    /// True when no provider mutations were performed.
466    pub dry_run: bool,
467    /// Inline comments in the review envelope being reconciled.
468    pub comments: u32,
469    /// Distinct fingerprints in the current run's findings.
470    pub current_fingerprints: u32,
471    /// Distinct fingerprints with an open Fallow lifecycle, including
472    /// provider-resolved discussions not yet closed by a Fallow marker.
473    pub existing_fingerprints: u32,
474    /// Fingerprints present in the current run but not yet commented.
475    pub new_fingerprints: u32,
476    /// Fingerprints with open Fallow lifecycles whose findings no longer exist.
477    pub stale_fingerprints: u32,
478    /// The new fingerprints themselves.
479    pub new: Vec<String>,
480    /// The stale fingerprints themselves.
481    pub stale: Vec<String>,
482    /// Non-fatal provider API warning encountered during reconciliation.
483    pub provider_warning: Option<String>,
484    /// Resolution replies posted to stale comment threads.
485    pub resolution_comments_posted: u32,
486    /// Provider discussion threads resolved or re-closed.
487    pub threads_resolved: u32,
488    /// Remediation guidance when the apply loop stopped before finishing.
489    #[serde(default, skip_serializing_if = "Option::is_none")]
490    pub apply_hint: Option<String>,
491    /// Errors encountered while applying provider mutations.
492    pub apply_errors: Vec<String>,
493    /// Fingerprints whose provider mutation failed.
494    #[serde(default, skip_serializing_if = "Vec::is_empty")]
495    pub failed_fingerprints: Vec<String>,
496    /// Fingerprints left unprocessed after a failure aborted the apply loop.
497    #[serde(default, skip_serializing_if = "Vec::is_empty")]
498    pub unapplied_fingerprints: Vec<String>,
499}
500
501/// Serialize the review reconcile contract.
502///
503/// # Errors
504///
505/// Returns a serde error when the review reconcile output cannot be converted
506/// to JSON.
507pub 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/// Schema-version discriminator for the review reconcile envelope.
515#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
516#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
517pub enum ReviewReconcileSchema {
518    /// First release of the review reconcile format.
519    #[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}