1use processkit::{Error, Result};
6use serde::Deserialize;
7use serde::de::DeserializeOwned;
8
9use crate::BINARY;
10
11#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
14#[non_exhaustive]
15pub struct MergeRequest {
16 pub iid: u64,
19 pub title: String,
21 pub state: String,
24 #[serde(default)]
26 pub source_branch: String,
27 #[serde(default)]
29 pub target_branch: String,
30 #[serde(default)]
32 pub web_url: String,
33 #[serde(default)]
36 pub draft: bool,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
41#[non_exhaustive]
42pub struct Project {
43 pub name: String,
45 #[serde(default)]
47 pub path_with_namespace: String,
48 #[serde(default)]
50 pub default_branch: String,
51 #[serde(default)]
53 pub web_url: String,
54 #[serde(default)]
58 pub visibility: Option<String>,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
65#[non_exhaustive]
66pub struct Issue {
67 #[serde(rename = "iid")]
73 pub number: u64,
74 pub title: String,
76 pub state: String,
79 #[serde(rename = "description", default)]
82 pub body: String,
83 #[serde(rename = "web_url", default)]
85 pub url: String,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
91#[non_exhaustive]
92pub struct Release {
93 pub tag_name: String,
96 #[serde(default)]
98 pub name: String,
99 #[serde(rename = "_links", default, deserialize_with = "self_link")]
103 pub url: String,
104 #[serde(rename = "released_at", default)]
107 pub published_at: String,
108}
109
110fn self_link<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
114where
115 D: serde::Deserializer<'de>,
116{
117 #[derive(Deserialize)]
118 struct Links {
119 #[serde(rename = "self", default)]
120 self_url: String,
121 }
122 let links = Option::<Links>::deserialize(deserializer)?;
123 Ok(links.map(|l| l.self_url).unwrap_or_default())
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129#[non_exhaustive]
130pub enum CiStatus {
131 Passing,
133 Failing,
135 Pending,
137 None,
139}
140
141impl CiStatus {
142 pub(crate) fn from_gitlab(status: &str) -> Self {
145 match status {
146 "success" => CiStatus::Passing,
147 "failed" | "canceled" | "cancelled" => CiStatus::Failing,
148 "skipped" | "" => CiStatus::None,
149 "running"
150 | "pending"
151 | "created"
152 | "preparing"
153 | "scheduled"
154 | "waiting_for_resource"
155 | "manual" => CiStatus::Pending,
156 _ => CiStatus::Pending,
157 }
158 }
159}
160
161pub(crate) fn from_json<T: DeserializeOwned>(json: &str) -> Result<T> {
164 serde_json::from_str(json).map_err(|e| Error::Parse {
165 program: BINARY.to_string(),
166 message: e.to_string(),
167 })
168}
169
170#[derive(Deserialize)]
174struct MrPipelineJson {
175 #[serde(default)]
176 head_pipeline: Option<PipelineJson>,
177 #[serde(default)]
178 pipeline: Option<PipelineJson>,
179}
180
181#[derive(Deserialize)]
182struct PipelineJson {
183 #[serde(default)]
184 status: String,
185}
186
187pub(crate) fn parse_ci_status(json: &str) -> Result<CiStatus> {
191 let raw: MrPipelineJson = from_json(json)?;
192 let status = raw
193 .head_pipeline
194 .or(raw.pipeline)
195 .map(|p| p.status)
196 .unwrap_or_default();
197 Ok(CiStatus::from_gitlab(&status))
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 #[test]
205 fn parses_mr_list() {
206 let json = r#"[
207 {"iid": 12, "title": "Add feature", "state": "opened",
208 "source_branch": "feat/x", "target_branch": "main",
209 "web_url": "https://gl/mr/12", "draft": false}
210 ]"#;
211 let mrs: Vec<MergeRequest> = from_json(json).expect("parse mrs");
212 assert_eq!(mrs.len(), 1);
213 assert_eq!(
214 mrs[0],
215 MergeRequest {
216 iid: 12,
217 title: "Add feature".into(),
218 state: "opened".into(),
219 source_branch: "feat/x".into(),
220 target_branch: "main".into(),
221 web_url: "https://gl/mr/12".into(),
222 draft: false,
223 }
224 );
225 }
226
227 #[test]
230 fn mr_tolerates_missing_optional_fields() {
231 let json = r#"{"iid": 5, "title": "wip", "state": "opened", "draft": true}"#;
232 let mr: MergeRequest = from_json(json).expect("parse mr");
233 assert_eq!(mr.source_branch, "");
234 assert_eq!(mr.web_url, "");
235 assert!(mr.draft);
236 }
237
238 #[test]
239 fn parses_issue_list() {
240 let json = r#"[
242 {"iid": 1, "title": "Fix bug", "state": "opened",
243 "description": "the body", "web_url": "https://gl/i/1"}
244 ]"#;
245 let issues: Vec<Issue> = from_json(json).expect("parse issues");
246 assert_eq!(issues.len(), 1);
247 assert_eq!(
248 issues[0],
249 Issue {
250 number: 1,
251 title: "Fix bug".into(),
252 state: "opened".into(),
253 body: "the body".into(),
254 url: "https://gl/i/1".into(),
255 }
256 );
257 }
258
259 #[test]
262 fn issue_tolerates_missing_optional_fields() {
263 let json = r#"{"iid": 9, "title": "wip", "state": "closed"}"#;
264 let issue: Issue = from_json(json).expect("parse issue");
265 assert_eq!(issue.body, "");
266 assert_eq!(issue.url, "");
267 }
268
269 #[test]
270 fn parses_release_view() {
271 let json = r#"{
274 "tag_name": "v1.0", "name": "Release 1.0",
275 "released_at": "2026-01-02T03:04:05.000Z",
276 "_links": {"self": "https://gl/-/releases/v1.0"}
277 }"#;
278 let rel: Release = from_json(json).expect("parse release");
279 assert_eq!(
280 rel,
281 Release {
282 tag_name: "v1.0".into(),
283 name: "Release 1.0".into(),
284 url: "https://gl/-/releases/v1.0".into(),
285 published_at: "2026-01-02T03:04:05.000Z".into(),
286 }
287 );
288 }
289
290 #[test]
293 fn release_tolerates_missing_links_and_date() {
294 let json = r#"{"tag_name": "v2.0"}"#;
295 let rel: Release = from_json(json).expect("parse release");
296 assert_eq!(rel.name, "");
297 assert_eq!(rel.url, "");
298 assert_eq!(rel.published_at, "");
299 }
300
301 #[test]
302 fn parses_project_view() {
303 let json = r#"{
304 "name": "cli", "path_with_namespace": "gitlab-org/cli",
305 "default_branch": "main", "web_url": "https://gl/p",
306 "visibility": "public"
307 }"#;
308 let p: Project = from_json(json).expect("parse project");
309 assert_eq!(p.name, "cli");
310 assert_eq!(p.path_with_namespace, "gitlab-org/cli");
311 assert_eq!(p.default_branch, "main");
312 assert_eq!(p.visibility.as_deref(), Some("public"));
313 }
314
315 #[test]
318 fn project_tolerates_missing_visibility() {
319 let json = r#"{"name":"cli","path_with_namespace":"o/cli","default_branch":"main"}"#;
320 let p: Project = from_json(json).expect("parse project");
321 assert_eq!(p.visibility, None);
322 }
323
324 #[test]
325 fn malformed_json_is_a_parse_error() {
326 match from_json::<Vec<MergeRequest>>("not json").unwrap_err() {
327 Error::Parse { .. } => {}
328 other => panic!("expected Parse, got {other:?}"),
329 }
330 }
331
332 #[test]
333 fn ci_status_buckets_pipeline_states() {
334 assert_eq!(CiStatus::from_gitlab("success"), CiStatus::Passing);
335 assert_eq!(CiStatus::from_gitlab("failed"), CiStatus::Failing);
336 assert_eq!(CiStatus::from_gitlab("canceled"), CiStatus::Failing);
337 assert_eq!(CiStatus::from_gitlab("running"), CiStatus::Pending);
338 assert_eq!(CiStatus::from_gitlab("manual"), CiStatus::Pending);
339 assert_eq!(CiStatus::from_gitlab("skipped"), CiStatus::None);
340 assert_eq!(CiStatus::from_gitlab(""), CiStatus::None);
341 assert_eq!(CiStatus::from_gitlab("brand_new"), CiStatus::Pending);
343 }
344
345 #[test]
346 fn parse_ci_status_reads_head_pipeline_then_falls_back() {
347 let json =
349 r#"{"iid":1,"head_pipeline":{"status":"success"},"pipeline":{"status":"failed"}}"#;
350 assert_eq!(parse_ci_status(json).unwrap(), CiStatus::Passing);
351 let json = r#"{"iid":1,"pipeline":{"status":"failed"}}"#;
353 assert_eq!(parse_ci_status(json).unwrap(), CiStatus::Failing);
354 let json = r#"{"iid":1}"#;
356 assert_eq!(parse_ci_status(json).unwrap(), CiStatus::None);
357 }
358}