Skip to main content

fallow_output/
review_envelopes.rs

1//! Review integration output envelopes.
2
3use crate::root_envelopes::{RootEnvelopeMode, 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.
113    #[serde(default = "default_marker_regex")]
114    pub marker_regex: String,
115    /// Regex flags accompanying `marker_regex` (multiline).
116    #[serde(default = "default_marker_regex_flags")]
117    pub marker_regex_flags: String,
118    /// Schema, provider, and check-conclusion metadata.
119    pub meta: ReviewEnvelopeMeta,
120}
121
122/// Envelope emitted by `fallow --format review-github` / `review-gitlab`.
123#[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/// `meta` block inside [`ReviewEnvelopeOutput`].
149#[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
172/// Serialize the review envelope contract emitted by CI review formats.
173///
174/// # Errors
175///
176/// Returns a serde error when the review envelope cannot be converted to JSON.
177pub 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
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    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/// Default for [`ReviewEnvelopeOutput::marker_regex`].
227#[must_use]
228pub fn default_marker_regex() -> String {
229    MARKER_REGEX_V2.to_owned()
230}
231
232/// Default for [`ReviewEnvelopeOutput::marker_regex_flags`].
233#[must_use]
234pub fn default_marker_regex_flags() -> String {
235    MARKER_REGEX_FLAGS_V2.to_owned()
236}
237
238/// Canonical v2 marker-regex literal.
239pub const MARKER_REGEX_V2: &str =
240    r"^<!-- fallow-fingerprint:v2: ((?:[a-z]+:)?[0-9a-f]{16}) -->\s*$";
241
242/// Canonical v2 marker-regex flags.
243pub const MARKER_REGEX_FLAGS_V2: &str = "m";
244
245/// Summary block on [`ReviewEnvelopeOutput`].
246#[derive(Debug, Clone, Serialize, Default)]
247#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
248pub struct ReviewEnvelopeSummary {
249    /// Summary comment body markdown.
250    pub body: String,
251    /// Stable fingerprint of the summary body, used for sticky updates.
252    pub fingerprint: String,
253}
254
255impl ReviewEnvelopeSummary {
256    /// Empty-default factory for [`ReviewEnvelopeOutput::summary`].
257    #[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/// Singleton GitHub review-event marker.
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
269#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
270pub enum ReviewEnvelopeEvent {
271    /// Submit as a non-blocking `COMMENT` review.
272    #[serde(rename = "COMMENT")]
273    Comment,
274}
275
276/// Per-line review comment. Schema is an `anyOf` between GitHub and GitLab
277/// shapes; at runtime every entry in a single envelope comes from the same
278/// provider because the envelope is built from one provider's branch in
279/// `crates/cli/src/report/ci/review.rs::render_review_envelope`.
280#[derive(Debug, Clone, Serialize)]
281#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
282#[serde(untagged)]
283pub enum ReviewComment {
284    /// GitHub pull-request review comment.
285    GitHub(GitHubReviewComment),
286    /// GitLab merge-request discussion comment.
287    GitLab(GitLabReviewComment),
288}
289
290/// GitHub pull-request review comment.
291#[derive(Debug, Clone, Serialize)]
292#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
293pub struct GitHubReviewComment {
294    /// File path relative to the repository root.
295    pub path: String,
296    /// 1-based line on the new side of the diff.
297    pub line: u32,
298    /// Diff side; always `RIGHT` (the new side).
299    pub side: GitHubReviewSide,
300    /// Comment body markdown, fingerprint marker included.
301    pub body: String,
302    /// Stable finding fingerprint used for comment reconciliation.
303    pub fingerprint: String,
304    /// True when the body was cut to fit the provider size limit; omitted
305    /// when false.
306    #[serde(default, skip_serializing_if = "is_false")]
307    pub truncated: bool,
308}
309
310/// Singleton side discriminator for [`GitHubReviewComment::side`].
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
312#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
313pub enum GitHubReviewSide {
314    /// New side of the diff; the only side fallow comments on.
315    #[serde(rename = "RIGHT")]
316    Right,
317}
318
319/// GitLab merge-request discussion comment.
320#[derive(Debug, Clone, Serialize)]
321#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
322pub struct GitLabReviewComment {
323    /// Comment body markdown, fingerprint marker included.
324    pub body: String,
325    /// Diff position anchoring the discussion.
326    pub position: GitLabReviewPosition,
327    /// Stable finding fingerprint used for comment reconciliation.
328    pub fingerprint: String,
329    /// True when the body was cut to fit the provider size limit; omitted
330    /// when false.
331    #[serde(default, skip_serializing_if = "is_false")]
332    pub truncated: bool,
333}
334
335/// Helper for `skip_serializing_if = "is_false"` on `truncated` fields.
336#[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/// `position` block inside [`GitLabReviewComment`]. Mirrors the GitLab
346/// merge-request discussion-position API.
347#[derive(Debug, Clone, Serialize)]
348#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
349pub struct GitLabReviewPosition {
350    /// Merge-base SHA of the MR diff; absent when refs were not supplied.
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    pub base_sha: Option<String>,
353    /// First commit SHA of the MR diff; absent when refs were not supplied.
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub start_sha: Option<String>,
356    /// Head commit SHA of the MR diff; absent when refs were not supplied.
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub head_sha: Option<String>,
359    /// Position type; always `text`.
360    pub position_type: GitLabReviewPositionType,
361    /// Pre-rename path when the diff renamed the file, else the same as
362    /// `new_path`.
363    pub old_path: String,
364    /// File path on the new side of the diff.
365    pub new_path: String,
366    /// 1-based line on the new side of the diff.
367    pub new_line: u32,
368}
369
370/// Singleton position-type discriminator for [`GitLabReviewPosition`].
371#[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-file diff position; the only type fallow emits.
376    Text,
377}
378
379/// `meta` block inside [`ReviewEnvelopeOutput`].
380#[derive(Debug, Clone, Serialize)]
381#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
382pub struct ReviewEnvelopeMeta {
383    /// Review-envelope schema version tag.
384    pub schema: ReviewEnvelopeSchema,
385    /// Provider the envelope was rendered for.
386    pub provider: ReviewProvider,
387    /// GitHub Checks conclusion; absent for GitLab.
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub check_conclusion: Option<ReviewCheckConclusion>,
390}
391
392/// Schema-version discriminator for the review envelope.
393#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
394#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
395pub enum ReviewEnvelopeSchema {
396    /// Historical first release of the review envelope format.
397    #[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    /// Issue #528 review envelope format.
404    #[serde(rename = "fallow-review-envelope/v2")]
405    V2,
406    /// Gate-aware conclusion semantics, including failures without findings.
407    #[serde(rename = "fallow-review-envelope/v3")]
408    V3,
409}
410
411/// Review-envelope provider tag.
412#[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 pull-request review envelope.
417    Github,
418    /// GitLab merge-request discussion envelope.
419    Gitlab,
420}
421
422/// `meta.check_conclusion` for the GitHub review envelope. Maps to the
423/// GitHub Checks API conclusion field.
424#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
425#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
426#[serde(rename_all = "lowercase")]
427pub enum ReviewCheckConclusion {
428    /// The gate passed, whether or not inline findings were selected.
429    Success,
430    /// Findings but none gated as failure.
431    Neutral,
432    /// The gate failed, including failures without an inline finding.
433    Failure,
434}
435
436/// Envelope emitted by `fallow ci reconcile-review --format json`. Used by
437/// CI integrations to drive comment carry-over and stale-comment cleanup
438/// across PR / MR revisions.
439#[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    /// Reconcile schema version tag.
447    pub schema: ReviewReconcileSchema,
448    /// Provider whose comments were reconciled.
449    pub provider: ReviewProvider,
450    /// PR / MR reference that was reconciled, when one was resolved.
451    pub target: Option<String>,
452    /// True when no provider mutations were performed.
453    pub dry_run: bool,
454    /// Inline comments in the review envelope being reconciled.
455    pub comments: u32,
456    /// Distinct fingerprints in the current run's findings.
457    pub current_fingerprints: u32,
458    /// Distinct fingerprints with an open Fallow lifecycle, including
459    /// provider-resolved discussions not yet closed by a Fallow marker.
460    pub existing_fingerprints: u32,
461    /// Fingerprints present in the current run but not yet commented.
462    pub new_fingerprints: u32,
463    /// Fingerprints with open Fallow lifecycles whose findings no longer exist.
464    pub stale_fingerprints: u32,
465    /// The new fingerprints themselves.
466    pub new: Vec<String>,
467    /// The stale fingerprints themselves.
468    pub stale: Vec<String>,
469    /// Non-fatal provider API warning encountered during reconciliation.
470    pub provider_warning: Option<String>,
471    /// Resolution replies posted to stale comment threads.
472    pub resolution_comments_posted: u32,
473    /// Provider discussion threads resolved or re-closed.
474    pub threads_resolved: u32,
475    /// Remediation guidance when the apply loop stopped before finishing.
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub apply_hint: Option<String>,
478    /// Errors encountered while applying provider mutations.
479    pub apply_errors: Vec<String>,
480    /// Fingerprints whose provider mutation failed.
481    #[serde(default, skip_serializing_if = "Vec::is_empty")]
482    pub failed_fingerprints: Vec<String>,
483    /// Fingerprints left unprocessed after a failure aborted the apply loop.
484    #[serde(default, skip_serializing_if = "Vec::is_empty")]
485    pub unapplied_fingerprints: Vec<String>,
486}
487
488/// Serialize the review reconcile contract.
489///
490/// # Errors
491///
492/// Returns a serde error when the review reconcile output cannot be converted
493/// to JSON.
494pub 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/// Schema-version discriminator for the review reconcile envelope.
503#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
504#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
505pub enum ReviewReconcileSchema {
506    /// First release of the review reconcile format.
507    #[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}