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/// Envelope emitted by `fallow --format review-github` / `review-gitlab`.
7#[derive(Debug, Clone, Serialize)]
8#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
9#[cfg_attr(
10    feature = "schema",
11    schemars(title = "fallow --format review-github / review-gitlab")
12)]
13pub struct ReviewEnvelopeOutput {
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub event: Option<ReviewEnvelopeEvent>,
16    pub body: String,
17    #[serde(default = "ReviewEnvelopeSummary::empty_default")]
18    pub summary: ReviewEnvelopeSummary,
19    pub comments: Vec<ReviewComment>,
20    #[serde(default = "default_marker_regex")]
21    pub marker_regex: String,
22    #[serde(default = "default_marker_regex_flags")]
23    pub marker_regex_flags: String,
24    pub meta: ReviewEnvelopeMeta,
25}
26
27fn serialize_review_contract_json_output<T: Serialize>(
28    output: T,
29    kind: &'static str,
30    mode: RootEnvelopeMode,
31    analysis_run_id: Option<&str>,
32) -> Result<serde_json::Value, serde_json::Error> {
33    let mut value = serialize_named_json_output(output, kind, mode)?;
34    attach_telemetry_meta(&mut value, analysis_run_id);
35    Ok(value)
36}
37
38/// Serialize the review envelope contract emitted by CI review formats.
39///
40/// # Errors
41///
42/// Returns a serde error when the review envelope cannot be converted to JSON.
43pub fn serialize_review_envelope_json_output(
44    output: ReviewEnvelopeOutput,
45    mode: RootEnvelopeMode,
46    analysis_run_id: Option<&str>,
47) -> Result<serde_json::Value, serde_json::Error> {
48    serialize_review_contract_json_output(output, "review-envelope", mode, analysis_run_id)
49}
50
51/// Default for [`ReviewEnvelopeOutput::marker_regex`].
52#[must_use]
53pub fn default_marker_regex() -> String {
54    MARKER_REGEX_V2.to_owned()
55}
56
57/// Default for [`ReviewEnvelopeOutput::marker_regex_flags`].
58#[must_use]
59pub fn default_marker_regex_flags() -> String {
60    MARKER_REGEX_FLAGS_V2.to_owned()
61}
62
63/// Canonical v2 marker-regex literal.
64pub const MARKER_REGEX_V2: &str =
65    r"^<!-- fallow-fingerprint:v2: ((?:[a-z]+:)?[0-9a-f]{16}) -->\s*$";
66
67/// Canonical v2 marker-regex flags.
68pub const MARKER_REGEX_FLAGS_V2: &str = "m";
69
70/// Summary block on [`ReviewEnvelopeOutput`].
71#[derive(Debug, Clone, Serialize, Default)]
72#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
73pub struct ReviewEnvelopeSummary {
74    pub body: String,
75    pub fingerprint: String,
76}
77
78impl ReviewEnvelopeSummary {
79    /// Empty-default factory for [`ReviewEnvelopeOutput::summary`].
80    #[must_use]
81    #[allow(
82        dead_code,
83        reason = "referenced via serde default attr; no direct callsite until Deserialize is derived"
84    )]
85    pub fn empty_default() -> Self {
86        Self::default()
87    }
88}
89
90/// Singleton GitHub review-event marker.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
92#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
93pub enum ReviewEnvelopeEvent {
94    #[serde(rename = "COMMENT")]
95    Comment,
96}
97
98/// Per-line review comment. Schema is an `anyOf` between GitHub and GitLab
99/// shapes; at runtime every entry in a single envelope comes from the same
100/// provider because the envelope is built from one provider's branch in
101/// `crates/cli/src/report/ci/review.rs::render_review_envelope`.
102#[derive(Debug, Clone, Serialize)]
103#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
104#[serde(untagged)]
105pub enum ReviewComment {
106    GitHub(GitHubReviewComment),
107    GitLab(GitLabReviewComment),
108}
109
110/// GitHub pull-request review comment.
111#[derive(Debug, Clone, Serialize)]
112#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
113pub struct GitHubReviewComment {
114    pub path: String,
115    pub line: u32,
116    pub side: GitHubReviewSide,
117    pub body: String,
118    pub fingerprint: String,
119    #[serde(default, skip_serializing_if = "is_false")]
120    pub truncated: bool,
121}
122
123/// Singleton side discriminator for [`GitHubReviewComment::side`].
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
125#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
126pub enum GitHubReviewSide {
127    #[serde(rename = "RIGHT")]
128    Right,
129}
130
131/// GitLab merge-request discussion comment.
132#[derive(Debug, Clone, Serialize)]
133#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
134pub struct GitLabReviewComment {
135    pub body: String,
136    pub position: GitLabReviewPosition,
137    pub fingerprint: String,
138    #[serde(default, skip_serializing_if = "is_false")]
139    pub truncated: bool,
140}
141
142/// Helper for `skip_serializing_if = "is_false"` on `truncated` fields.
143#[must_use]
144#[allow(
145    clippy::trivially_copy_pass_by_ref,
146    reason = "serde's skip_serializing_if requires fn(&T) -> bool"
147)]
148pub fn is_false(value: &bool) -> bool {
149    !*value
150}
151
152/// `position` block inside [`GitLabReviewComment`]. Mirrors the GitLab
153/// merge-request discussion-position API.
154#[derive(Debug, Clone, Serialize)]
155#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
156pub struct GitLabReviewPosition {
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub base_sha: Option<String>,
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub start_sha: Option<String>,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub head_sha: Option<String>,
163    pub position_type: GitLabReviewPositionType,
164    pub old_path: String,
165    pub new_path: String,
166    pub new_line: u32,
167}
168
169/// Singleton position-type discriminator for [`GitLabReviewPosition`].
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
171#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
172#[serde(rename_all = "lowercase")]
173pub enum GitLabReviewPositionType {
174    Text,
175}
176
177/// `meta` block inside [`ReviewEnvelopeOutput`].
178#[derive(Debug, Clone, Serialize)]
179#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
180pub struct ReviewEnvelopeMeta {
181    pub schema: ReviewEnvelopeSchema,
182    pub provider: ReviewProvider,
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub check_conclusion: Option<ReviewCheckConclusion>,
185}
186
187/// Schema-version discriminator for the review envelope.
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
189#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
190pub enum ReviewEnvelopeSchema {
191    /// Historical first release of the review envelope format.
192    #[serde(rename = "fallow-review-envelope/v1")]
193    #[allow(
194        dead_code,
195        reason = "kept for forward-compat with v1 historical inputs once Deserialize is derived"
196    )]
197    V1,
198    /// Issue #528 review envelope format.
199    #[serde(rename = "fallow-review-envelope/v2")]
200    V2,
201}
202
203/// Review-envelope provider tag.
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
205#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
206#[serde(rename_all = "lowercase")]
207pub enum ReviewProvider {
208    /// GitHub pull-request review envelope.
209    Github,
210    /// GitLab merge-request discussion envelope.
211    Gitlab,
212}
213
214/// `meta.check_conclusion` for the GitHub review envelope. Maps to the
215/// GitHub Checks API conclusion field.
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
217#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
218#[serde(rename_all = "lowercase")]
219pub enum ReviewCheckConclusion {
220    /// No findings.
221    Success,
222    /// Findings but none gated as failure.
223    Neutral,
224    /// At least one finding gated as failure.
225    Failure,
226}
227
228/// Envelope emitted by `fallow ci reconcile-review --format json`. Used by
229/// CI integrations to drive comment carry-over and stale-comment cleanup
230/// across PR / MR revisions.
231#[derive(Debug, Clone, Serialize)]
232#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
233#[cfg_attr(
234    feature = "schema",
235    schemars(title = "fallow ci reconcile-review --format json")
236)]
237pub struct ReviewReconcileOutput {
238    pub schema: ReviewReconcileSchema,
239    pub provider: ReviewProvider,
240    pub target: Option<String>,
241    pub dry_run: bool,
242    pub comments: u32,
243    pub current_fingerprints: u32,
244    pub existing_fingerprints: u32,
245    pub new_fingerprints: u32,
246    pub stale_fingerprints: u32,
247    pub new: Vec<String>,
248    pub stale: Vec<String>,
249    pub provider_warning: Option<String>,
250    pub resolution_comments_posted: u32,
251    pub threads_resolved: u32,
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub apply_hint: Option<String>,
254    pub apply_errors: Vec<String>,
255    #[serde(default, skip_serializing_if = "Vec::is_empty")]
256    pub failed_fingerprints: Vec<String>,
257    #[serde(default, skip_serializing_if = "Vec::is_empty")]
258    pub unapplied_fingerprints: Vec<String>,
259}
260
261/// Serialize the review reconcile contract.
262///
263/// # Errors
264///
265/// Returns a serde error when the review reconcile output cannot be converted
266/// to JSON.
267pub fn serialize_review_reconcile_json_output(
268    output: ReviewReconcileOutput,
269    mode: RootEnvelopeMode,
270    analysis_run_id: Option<&str>,
271) -> Result<serde_json::Value, serde_json::Error> {
272    serialize_review_contract_json_output(output, "review-reconcile", mode, analysis_run_id)
273}
274
275/// Schema-version discriminator for the review reconcile envelope.
276#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
277#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
278pub enum ReviewReconcileSchema {
279    /// First release of the review reconcile format.
280    #[serde(rename = "fallow-review-reconcile/v1")]
281    V1,
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn review_envelope_json_output_uses_output_owned_root_contract() {
290        let output = ReviewEnvelopeOutput {
291            event: None,
292            body: "body".to_string(),
293            summary: ReviewEnvelopeSummary::default(),
294            comments: Vec::new(),
295            marker_regex: default_marker_regex(),
296            marker_regex_flags: default_marker_regex_flags(),
297            meta: ReviewEnvelopeMeta {
298                schema: ReviewEnvelopeSchema::V2,
299                provider: ReviewProvider::Github,
300                check_conclusion: None,
301            },
302        };
303
304        let value = serialize_review_envelope_json_output(
305            output,
306            RootEnvelopeMode::Tagged,
307            Some("run-review"),
308        )
309        .expect("review envelope should serialize");
310
311        assert_eq!(value["kind"], "review-envelope");
312        assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-review");
313    }
314
315    #[test]
316    fn review_reconcile_json_output_uses_output_owned_root_contract() {
317        let output = ReviewReconcileOutput {
318            schema: ReviewReconcileSchema::V1,
319            provider: ReviewProvider::Github,
320            target: None,
321            dry_run: true,
322            comments: 0,
323            current_fingerprints: 0,
324            existing_fingerprints: 0,
325            new_fingerprints: 0,
326            stale_fingerprints: 0,
327            new: Vec::new(),
328            stale: Vec::new(),
329            provider_warning: None,
330            resolution_comments_posted: 0,
331            threads_resolved: 0,
332            apply_hint: None,
333            apply_errors: Vec::new(),
334            failed_fingerprints: Vec::new(),
335            unapplied_fingerprints: Vec::new(),
336        };
337
338        let value = serialize_review_reconcile_json_output(
339            output,
340            RootEnvelopeMode::Tagged,
341            Some("run-reconcile"),
342        )
343        .expect("review reconcile should serialize");
344
345        assert_eq!(value["kind"], "review-reconcile");
346        assert_eq!(
347            value["_meta"]["telemetry"]["analysis_run_id"],
348            "run-reconcile"
349        );
350    }
351}