1use crate::root_envelopes::{RootEnvelopeMode, attach_telemetry_meta, serialize_named_json_output};
4use serde::Serialize;
5
6#[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
38pub 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#[must_use]
53pub fn default_marker_regex() -> String {
54 MARKER_REGEX_V2.to_owned()
55}
56
57#[must_use]
59pub fn default_marker_regex_flags() -> String {
60 MARKER_REGEX_FLAGS_V2.to_owned()
61}
62
63pub const MARKER_REGEX_V2: &str =
65 r"^<!-- fallow-fingerprint:v2: ((?:[a-z]+:)?[0-9a-f]{16}) -->\s*$";
66
67pub const MARKER_REGEX_FLAGS_V2: &str = "m";
69
70#[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 #[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
189#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
190pub enum ReviewEnvelopeSchema {
191 #[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 #[serde(rename = "fallow-review-envelope/v2")]
200 V2,
201}
202
203#[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,
210 Gitlab,
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
217#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
218#[serde(rename_all = "lowercase")]
219pub enum ReviewCheckConclusion {
220 Success,
222 Neutral,
224 Failure,
226}
227
228#[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
261pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
277#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
278pub enum ReviewReconcileSchema {
279 #[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}