Skip to main content

vcs_gitlab/
parse.rs

1//! Typed results from `glab … --output json` and the deserialization helpers.
2//! Parsing is pure (over GitLab's REST JSON, which `glab` emits verbatim), so
3//! these tests are hermetic and run on CI.
4
5use processkit::{Error, Result};
6use serde::Deserialize;
7use serde::de::DeserializeOwned;
8
9use crate::BINARY;
10
11/// A merge request (`glab mr list/view --output json`). The fields are GitLab's
12/// REST `MergeRequest` object, which `glab` passes through unchanged.
13#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
14#[non_exhaustive]
15pub struct MergeRequest {
16    /// The **project-scoped** id (`iid`) — the `<id>` other `glab mr` commands
17    /// take. (GitLab's global `id` is deliberately not surfaced.)
18    pub iid: u64,
19    /// MR title.
20    pub title: String,
21    /// State, e.g. `"opened"`, `"closed"`, `"merged"`, `"locked"` (GitLab's
22    /// lower-case spelling — note it is `"opened"`, not `"open"`).
23    pub state: String,
24    /// Source (head) branch name.
25    #[serde(default)]
26    pub source_branch: String,
27    /// Target (base) branch name.
28    #[serde(default)]
29    pub target_branch: String,
30    /// Web URL.
31    #[serde(default)]
32    pub web_url: String,
33    /// Whether the MR is a draft (GitLab's `draft`; the deprecated
34    /// `work_in_progress` is not read).
35    #[serde(default)]
36    pub draft: bool,
37}
38
39/// A project (`glab repo view --output json`) — GitLab's REST `Project` object.
40#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
41#[non_exhaustive]
42pub struct Project {
43    /// Project name (the last path segment's display name).
44    pub name: String,
45    /// Full namespace path, e.g. `"group/subgroup/repo"`.
46    #[serde(default)]
47    pub path_with_namespace: String,
48    /// Default branch name (empty for an empty project).
49    #[serde(default)]
50    pub default_branch: String,
51    /// Web URL.
52    #[serde(default)]
53    pub web_url: String,
54    /// Visibility, e.g. `"public"`, `"internal"`, `"private"`. `None` when glab
55    /// omits the field — a consumer must treat an absent visibility as *unknown*,
56    /// not as private (see [`ForgeRepo::private`](../../vcs_forge/struct.ForgeRepo.html)).
57    #[serde(default)]
58    pub visibility: Option<String>,
59}
60
61/// An issue (`glab issue list/view --output json`). The fields are GitLab's
62/// REST `Issue` object, which `glab` passes through unchanged. Mirrors
63/// [`MergeRequest`]'s shape (project-scoped `iid`, tolerant optional fields).
64#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
65#[non_exhaustive]
66pub struct Issue {
67    /// The **project-scoped** id (`iid`) — the `<id>` other `glab issue`
68    /// commands take. (GitLab's global `id` is deliberately not surfaced.)
69    /// Surfaced through the public field name `number` for cross-forge
70    /// consistency with [`vcs-github`](https://crates.io/crates/vcs-github)'s
71    /// `Issue`.
72    #[serde(rename = "iid")]
73    pub number: u64,
74    /// Issue title.
75    pub title: String,
76    /// State, e.g. `"opened"`, `"closed"` (GitLab's lower-case spelling — note
77    /// it is `"opened"`, not `"open"`).
78    pub state: String,
79    /// Issue body (GitLab's `description`, markdown). `glab issue list` does
80    /// include it, but it can be absent/null, so it is tolerant.
81    #[serde(rename = "description", default)]
82    pub body: String,
83    /// Web URL.
84    #[serde(rename = "web_url", default)]
85    pub url: String,
86}
87
88/// A release (`glab release list/view --output json`) — GitLab's REST
89/// `Release` object, which `glab` passes through unchanged.
90#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
91#[non_exhaustive]
92pub struct Release {
93    /// The Git tag the release is attached to (the `<tag>`
94    /// [`release_view`](crate::GitLabApi::release_view) takes).
95    pub tag_name: String,
96    /// Release title (may be empty/absent — GitLab defaults it to the tag).
97    #[serde(default)]
98    pub name: String,
99    /// Web URL of the release page. GitLab carries it as `_links.self` (there
100    /// is no top-level `web_url` on a release), so it is pulled off that nested
101    /// object; empty when absent.
102    #[serde(rename = "_links", default, deserialize_with = "self_link")]
103    pub url: String,
104    /// Publication timestamp (GitLab's `released_at`, ISO 8601); empty when
105    /// absent (e.g. an upcoming/unpublished release).
106    #[serde(rename = "released_at", default)]
107    pub published_at: String,
108}
109
110/// Deserialize a `Release`'s `url` from GitLab's `_links.self`. The links object
111/// can be absent or have a null/missing `self`; any of those yield an empty
112/// string rather than erroring (matching the tolerant `#[serde(default)]` style).
113fn 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/// The coarse CI/pipeline outcome for an MR (`glab mr view … --output json`'s
127/// `head_pipeline.status`), bucketed into the four states a caller acts on.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129#[non_exhaustive]
130pub enum CiStatus {
131    /// The pipeline succeeded (`success`).
132    Passing,
133    /// The pipeline failed or was canceled (`failed`/`canceled`).
134    Failing,
135    /// The pipeline is still going (`running`/`pending`/`created`/…).
136    Pending,
137    /// No pipeline ran (none attached, or `skipped`).
138    None,
139}
140
141impl CiStatus {
142    /// Bucket a raw GitLab pipeline `status` string. Unknown values read as
143    /// [`Pending`](CiStatus::Pending) (conservative — "not known to be done").
144    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
161/// Deserialize `glab … --output json` output into `T`, mapping parse errors to
162/// [`Error::Parse`].
163pub(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// The MR JSON carries the pipeline as a nested object; deserialize just the
171// status off it. `head_pipeline` is the current one; `pipeline` is the older
172// alias — accept either.
173#[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
187/// Parse the CI/pipeline status out of `glab mr view <id> --output json` —
188/// `head_pipeline.status` (falling back to the deprecated `pipeline.status`);
189/// no pipeline at all is [`CiStatus::None`].
190pub(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    // glab/GitLab omit fields that don't apply; the DTO must tolerate a minimal
228    // object (only the required `iid`/`title`/`state`).
229    #[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        // Field shapes from the GitLab Issues API: iid/title/state/description/web_url.
241        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    // glab/GitLab can omit description/web_url; the DTO must tolerate a minimal
260    // object (only the required `iid`/`title`/`state`).
261    #[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        // Field shapes from the GitLab Releases API: tag_name/name/released_at,
272        // and the URL nested under `_links.self` (no top-level web_url).
273        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    // A release with no `_links` and no `released_at` (e.g. an upcoming release)
291    // must deserialize with empty url/published_at, not error.
292    #[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    // glab omits `visibility` for some responses; it must deserialize to `None`
316    // (unknown), never a default that a consumer could mistake for private.
317    #[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        // Unknown future states read as Pending, not a panic.
342        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        // head_pipeline wins.
348        let json =
349            r#"{"iid":1,"head_pipeline":{"status":"success"},"pipeline":{"status":"failed"}}"#;
350        assert_eq!(parse_ci_status(json).unwrap(), CiStatus::Passing);
351        // Falls back to the deprecated `pipeline` when there's no head_pipeline.
352        let json = r#"{"iid":1,"pipeline":{"status":"failed"}}"#;
353        assert_eq!(parse_ci_status(json).unwrap(), CiStatus::Failing);
354        // No pipeline at all → None.
355        let json = r#"{"iid":1}"#;
356        assert_eq!(parse_ci_status(json).unwrap(), CiStatus::None);
357    }
358}