Skip to main content

bb_cli/api/
models.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Deserialize)]
4pub struct User {
5    pub uuid: Option<String>,
6    pub account_id: Option<String>,
7    pub display_name: Option<String>,
8    pub nickname: Option<String>,
9}
10
11impl User {
12    /// The name a human recognizes. `display_name` is what the Bitbucket web ui
13    /// shows, so it is preferred over the nickname. A bare `uuid` (the `{uuid}`
14    /// escape hatch has no names at all) is the next best identifier — it still
15    /// names somebody, unlike the final `"-"` fallback.
16    pub fn name(&self) -> &str {
17        self.display_name
18            .as_deref()
19            .or(self.nickname.as_deref())
20            .or(self.uuid.as_deref())
21            .unwrap_or("-")
22    }
23}
24
25#[derive(Debug, Deserialize)]
26pub struct BranchName {
27    pub name: Option<String>,
28}
29
30#[derive(Debug, Deserialize)]
31pub struct Endpoint {
32    pub branch: Option<BranchName>,
33}
34
35#[derive(Debug, Clone, Deserialize, Serialize)]
36pub struct Link {
37    pub href: Option<String>,
38}
39
40#[derive(Debug, Deserialize)]
41pub struct Links {
42    pub html: Option<Link>,
43}
44
45#[derive(Debug, Deserialize)]
46pub struct Participant {
47    pub user: Option<User>,
48    pub state: Option<String>,
49    pub role: Option<String>,
50    #[serde(default)]
51    pub approved: bool,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
55#[serde(rename_all = "snake_case")]
56pub enum ReviewState {
57    Approved,
58    ChangesRequested,
59    Pending,
60}
61
62impl ReviewState {
63    pub fn from_api(state: Option<&str>) -> Self {
64        match state {
65            Some("approved") => Self::Approved,
66            Some("changes_requested") => Self::ChangesRequested,
67            _ => Self::Pending,
68        }
69    }
70
71    pub fn mark(self) -> &'static str {
72        match self {
73            Self::Approved => "✓",
74            Self::ChangesRequested => "✗",
75            Self::Pending => "·",
76        }
77    }
78
79    pub fn as_str(self) -> &'static str {
80        match self {
81            Self::Approved => "approved",
82            Self::ChangesRequested => "changes_requested",
83            Self::Pending => "pending",
84        }
85    }
86}
87
88#[derive(Debug, Clone, Serialize)]
89pub struct ReviewerState {
90    pub name: String,
91    pub uuid: Option<String>,
92    pub state: ReviewState,
93}
94
95/// One entry from `…/pullrequests/{id}/statuses`. Every field is optional
96/// because a reporter may omit any of them, and a missing name must not cost
97/// us the row.
98#[derive(Debug, Clone, Deserialize, Serialize)]
99pub struct BuildStatus {
100    pub key: Option<String>,
101    pub name: Option<String>,
102    pub state: Option<String>,
103    pub url: Option<String>,
104}
105
106/// A pull request can carry one status per reporting tool, so the table needs a
107/// single word. `None` covers both "no checks reported" and "a state this
108/// version does not recognise" — an unknown future state must never fail a list.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
110#[serde(rename_all = "lowercase")]
111pub enum BuildState {
112    Failed,
113    Stopped,
114    InProgress,
115    Successful,
116    None,
117}
118
119impl BuildState {
120    pub fn from_api(state: Option<&str>) -> Self {
121        match state.map(str::to_ascii_uppercase).as_deref() {
122            Some("FAILED") => Self::Failed,
123            Some("STOPPED") => Self::Stopped,
124            Some("INPROGRESS") => Self::InProgress,
125            Some("SUCCESSFUL") => Self::Successful,
126            _ => Self::None,
127        }
128    }
129
130    /// Worst first. Used only for the rollup ordering.
131    pub fn rank(self) -> u8 {
132        match self {
133            Self::Failed => 0,
134            Self::Stopped => 1,
135            Self::InProgress => 2,
136            Self::Successful => 3,
137            Self::None => 4,
138        }
139    }
140
141    pub fn label(self) -> &'static str {
142        match self {
143            Self::Failed => "FAILED",
144            Self::Stopped => "STOPPED",
145            Self::InProgress => "INPROGRESS",
146            Self::Successful => "SUCCESSFUL",
147            Self::None => "-",
148        }
149    }
150
151    /// Worst-wins: one failing check needs attention whatever else passed.
152    pub fn rollup(statuses: &[BuildStatus]) -> Self {
153        statuses
154            .iter()
155            .map(|s| Self::from_api(s.state.as_deref()))
156            .min_by_key(|s| s.rank())
157            .unwrap_or(Self::None)
158    }
159}
160
161#[derive(Debug, Deserialize)]
162pub struct PullRequest {
163    pub id: u64,
164    pub title: Option<String>,
165    pub state: Option<String>,
166    pub author: Option<User>,
167    pub source: Option<Endpoint>,
168    pub destination: Option<Endpoint>,
169    pub links: Option<Links>,
170    #[serde(default)]
171    pub reviewers: Vec<User>,
172    #[serde(default)]
173    pub participants: Vec<Participant>,
174    #[serde(default)]
175    pub draft: bool,
176    /// The api's own rfc3339 string, passed through unformatted: the consumer is
177    /// usually an agent computing an age, and a pre-formatted "3 days ago" would
178    /// throw away the precision it needs.
179    pub updated_on: Option<String>,
180    /// Every comment on the pull request, inline and general, replies included —
181    /// bitbucket's own counter. `None` when the api did not return the field,
182    /// which is not the same as zero: a caller that treats absence as "no
183    /// comments" would silently hide the very activity this exists to surface,
184    /// so absence must be read as "unknown, look properly".
185    pub comment_count: Option<u64>,
186    /// The description as bitbucket still returns it at the top level. The
187    /// OpenAPI schema documents `summary.raw` instead, so read through
188    /// `description_text`, which prefers that.
189    pub description: Option<String>,
190    pub summary: Option<CommentContent>,
191}
192
193/// A Bitbucket project, the container a repository lives in.
194#[derive(Debug, Clone, Deserialize, Serialize)]
195pub struct Project {
196    pub key: Option<String>,
197    pub name: Option<String>,
198    pub uuid: Option<String>,
199    pub is_private: Option<bool>,
200}
201
202impl Project {
203    pub fn key_or_dash(&self) -> &str {
204        self.key.as_deref().unwrap_or("-")
205    }
206
207    pub fn name_or_dash(&self) -> &str {
208        self.name.as_deref().unwrap_or("-")
209    }
210
211    pub fn access(&self) -> &'static str {
212        access_word(self.is_private)
213    }
214}
215
216/// One entry of `links.clone[]`, which Bitbucket returns as a list tagged by
217/// protocol rather than as named fields.
218#[derive(Debug, Clone, Deserialize, Serialize)]
219pub struct CloneLink {
220    pub name: Option<String>,
221    pub href: Option<String>,
222}
223
224#[derive(Debug, Clone, Deserialize, Serialize)]
225pub struct RepositoryLinks {
226    pub html: Option<Link>,
227    #[serde(default)]
228    pub clone: Option<Vec<CloneLink>>,
229}
230
231/// `is_private` rendered as a word. `false` in a column is ambiguous about
232/// which way it points, so neither list command prints a bare boolean.
233fn access_word(is_private: Option<bool>) -> &'static str {
234    match is_private {
235        Some(true) => "private",
236        Some(false) => "public",
237        None => "-",
238    }
239}
240
241/// A repository as returned by `GET /repositories/{workspace}` and by the
242/// creation endpoint. `full_name` is `"workspace/repo"`, which
243/// `RepoSlug::parse` accepts directly.
244#[derive(Debug, Clone, Deserialize, Serialize)]
245pub struct Repository {
246    pub full_name: Option<String>,
247    pub name: Option<String>,
248    pub slug: Option<String>,
249    pub description: Option<String>,
250    pub is_private: Option<bool>,
251    pub project: Option<Project>,
252    pub updated_on: Option<String>,
253    pub links: Option<RepositoryLinks>,
254}
255
256impl Repository {
257    /// The clone url the server reported: ssh by preference, https otherwise.
258    /// Never assembled locally — a hand-built url would be wrong for a
259    /// workspace on a custom domain, and a wrong clone url is worse than none.
260    pub fn clone_url(&self) -> Option<&str> {
261        let clones = self.links.as_ref()?.clone.as_ref()?;
262        let by_name = |want: &str| {
263            clones
264                .iter()
265                .find(|c| c.name.as_deref() == Some(want))
266                .and_then(|c| c.href.as_deref())
267        };
268        by_name("ssh").or_else(|| by_name("https"))
269    }
270
271    pub fn html_url(&self) -> Option<&str> {
272        self.links.as_ref()?.html.as_ref()?.href.as_deref()
273    }
274
275    pub fn display_name(&self) -> &str {
276        self.slug
277            .as_deref()
278            .or(self.name.as_deref())
279            .or(self.full_name.as_deref())
280            .unwrap_or("-")
281    }
282
283    pub fn project_key(&self) -> &str {
284        self.project
285            .as_ref()
286            .map(|p| p.key_or_dash())
287            .unwrap_or("-")
288    }
289
290    pub fn access(&self) -> &'static str {
291        access_word(self.is_private)
292    }
293}
294
295impl PullRequest {
296    /// The description text, empty when there is none. `summary.raw` is the
297    /// documented field; the top-level `description` is the fallback.
298    pub fn description_text(&self) -> &str {
299        self.summary
300            .as_ref()
301            .and_then(|s| s.raw.as_deref())
302            .or(self.description.as_deref())
303            .unwrap_or("")
304    }
305
306    pub fn source_branch(&self) -> &str {
307        self.source
308            .as_ref()
309            .and_then(|e| e.branch.as_ref())
310            .and_then(|b| b.name.as_deref())
311            .unwrap_or("-")
312    }
313
314    pub fn destination_branch(&self) -> &str {
315        self.destination
316            .as_ref()
317            .and_then(|e| e.branch.as_ref())
318            .and_then(|b| b.name.as_deref())
319            .unwrap_or("-")
320    }
321
322    pub fn html_url(&self) -> &str {
323        self.links
324            .as_ref()
325            .and_then(|l| l.html.as_ref())
326            .and_then(|l| l.href.as_deref())
327            .unwrap_or("-")
328    }
329
330    pub fn author_name(&self) -> &str {
331        self.author
332            .as_ref()
333            .and_then(|a| a.nickname.as_deref().or(a.display_name.as_deref()))
334            .unwrap_or("-")
335    }
336
337    /// Who is on the hook for this pull request, and what each has decided.
338    ///
339    /// `reviewers[]` is the tagged set but carries no decision; `participants[]`
340    /// carries the decision but also includes people who only commented. So
341    /// participants with the REVIEWER role are the primary source, and anyone
342    /// tagged who has not shown up there yet is appended as Pending.
343    pub fn reviewer_states(&self) -> Vec<ReviewerState> {
344        let mut out: Vec<ReviewerState> = self
345            .participants
346            .iter()
347            .filter(|p| p.role.as_deref() == Some("REVIEWER"))
348            .filter_map(|p| {
349                p.user.as_ref().map(|u| ReviewerState {
350                    name: u.name().to_string(),
351                    uuid: u.uuid.clone(),
352                    state: ReviewState::from_api(p.state.as_deref()),
353                })
354            })
355            .collect();
356
357        for reviewer in &self.reviewers {
358            let already = out.iter().any(|seen| {
359                match (seen.uuid.as_deref(), reviewer.uuid.as_deref()) {
360                    (Some(a), Some(b)) => a == b,
361                    // One side has no uuid, so the name is all there is to match on.
362                    _ => seen.name == reviewer.name(),
363                }
364            });
365            if !already {
366                out.push(ReviewerState {
367                    name: reviewer.name().to_string(),
368                    uuid: reviewer.uuid.clone(),
369                    state: ReviewState::Pending,
370                });
371            }
372        }
373
374        out
375    }
376
377    /// Bitbucket keeps `draft` as a boolean while `state` stays OPEN, so the two
378    /// have to be folded into one word for the table.
379    pub fn display_state(&self) -> String {
380        if self.draft {
381            return "Draft".to_string();
382        }
383        match self.state.as_deref() {
384            Some(state) if !state.is_empty() => {
385                let lower = state.to_lowercase();
386                let mut chars = lower.chars();
387                match chars.next() {
388                    Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
389                    None => "-".to_string(),
390                }
391            }
392            _ => "-".to_string(),
393        }
394    }
395}
396
397#[derive(Debug, Deserialize)]
398pub struct CommentContent {
399    pub raw: Option<String>,
400}
401
402#[derive(Debug, Deserialize)]
403pub struct Inline {
404    pub path: Option<String>,
405    pub from: Option<u64>,
406    pub to: Option<u64>,
407}
408
409#[derive(Debug, Deserialize)]
410pub struct CommentParent {
411    pub id: u64,
412}
413
414#[derive(Debug, Deserialize)]
415pub struct Comment {
416    pub id: u64,
417    pub content: Option<CommentContent>,
418    pub user: Option<User>,
419    pub created_on: Option<String>,
420    pub inline: Option<Inline>,
421    /// Set on a reply, holding the comment it answers.
422    pub parent: Option<CommentParent>,
423    #[serde(default)]
424    pub deleted: bool,
425    /// Present (even as `{}`) when the inline thread has been resolved.
426    pub resolution: Option<serde_json::Value>,
427    /// True while the comment is a draft only its author can see.
428    #[serde(default)]
429    pub pending: bool,
430}
431
432impl Comment {
433    pub fn is_inline(&self) -> bool {
434        self.inline
435            .as_ref()
436            .and_then(|i| i.path.as_deref())
437            .is_some_and(|p| !p.is_empty())
438    }
439
440    pub fn is_resolved(&self) -> bool {
441        self.resolution.is_some()
442    }
443
444    pub fn parent_id(&self) -> Option<u64> {
445        self.parent.as_ref().map(|p| p.id)
446    }
447
448    pub fn body(&self) -> String {
449        if self.deleted {
450            return "[deleted]".to_string();
451        }
452        self.content
453            .as_ref()
454            .and_then(|c| c.raw.clone())
455            .unwrap_or_default()
456    }
457
458    pub fn author(&self) -> &str {
459        self.user
460            .as_ref()
461            .and_then(|u| u.display_name.as_deref())
462            .unwrap_or("Unknown")
463    }
464}
465
466#[derive(Debug, Deserialize)]
467pub struct CommitAuthor {
468    pub user: Option<User>,
469    pub raw: Option<String>,
470}
471
472#[derive(Debug, Deserialize)]
473pub struct CommitTarget {
474    pub author: Option<CommitAuthor>,
475    pub date: Option<String>,
476}
477
478#[derive(Debug, Deserialize)]
479pub struct BranchRef {
480    pub name: String,
481    pub target: Option<CommitTarget>,
482}
483
484impl BranchRef {
485    pub fn owner(&self) -> String {
486        self.target
487            .as_ref()
488            .and_then(|t| t.author.as_ref())
489            .and_then(|a| {
490                a.user
491                    .as_ref()
492                    .and_then(|u| u.display_name.clone())
493                    .or_else(|| a.raw.clone())
494            })
495            .unwrap_or_else(|| "-".to_string())
496    }
497}
498
499#[derive(Debug, Deserialize)]
500pub struct CommitSummary {
501    pub raw: Option<String>,
502}
503
504#[derive(Debug, Deserialize)]
505pub struct Commit {
506    pub hash: Option<String>,
507    pub summary: Option<CommitSummary>,
508}
509
510#[derive(Debug, Deserialize)]
511pub struct DiffStatEntry {
512    pub status: Option<String>,
513    #[serde(rename = "new")]
514    pub new_file: Option<PathEntry>,
515    #[serde(rename = "old")]
516    pub old_file: Option<PathEntry>,
517}
518
519#[derive(Debug, Deserialize)]
520pub struct PathEntry {
521    pub path: Option<String>,
522}
523
524impl DiffStatEntry {
525    pub fn path(&self) -> &str {
526        self.new_file
527            .as_ref()
528            .and_then(|p| p.path.as_deref())
529            .or_else(|| self.old_file.as_ref().and_then(|p| p.path.as_deref()))
530            .unwrap_or("-")
531    }
532}
533
534#[derive(Debug, Serialize)]
535pub struct ReviewerRef {
536    pub uuid: String,
537}
538
539#[cfg(test)]
540#[allow(clippy::unwrap_used, clippy::expect_used)]
541mod tests {
542    use super::*;
543
544    fn pr_from(json: serde_json::Value) -> PullRequest {
545        serde_json::from_value(json).expect("fixture should deserialize")
546    }
547
548    #[test]
549    fn description_text_prefers_summary_raw() {
550        let pr = pr_from(serde_json::json!({
551            "id": 1,
552            "description": "old copy",
553            "summary": { "raw": "documented copy", "markup": "markdown" }
554        }));
555        assert_eq!(pr.description_text(), "documented copy");
556    }
557
558    #[test]
559    fn description_text_falls_back_to_description_then_empty() {
560        let pr = pr_from(serde_json::json!({ "id": 1, "description": "only this" }));
561        assert_eq!(pr.description_text(), "only this");
562        let bare = pr_from(serde_json::json!({ "id": 1 }));
563        assert_eq!(bare.description_text(), "");
564    }
565
566    #[test]
567    fn reviewer_states_reads_state_from_participants() {
568        let pr = pr_from(serde_json::json!({
569            "id": 1,
570            "reviewers": [
571                { "uuid": "{a}", "display_name": "Ana" },
572                { "uuid": "{b}", "display_name": "Bo" },
573                { "uuid": "{c}", "display_name": "Cy" }
574            ],
575            "participants": [
576                { "role": "REVIEWER", "state": "approved", "user": { "uuid": "{a}", "display_name": "Ana" } },
577                { "role": "REVIEWER", "state": "changes_requested", "user": { "uuid": "{b}", "display_name": "Bo" } },
578                { "role": "REVIEWER", "state": null, "user": { "uuid": "{c}", "display_name": "Cy" } }
579            ]
580        }));
581
582        let states = pr.reviewer_states();
583        assert_eq!(states.len(), 3);
584        assert_eq!(states[0].name, "Ana");
585        assert_eq!(states[0].state, ReviewState::Approved);
586        assert_eq!(states[1].state, ReviewState::ChangesRequested);
587        assert_eq!(states[2].state, ReviewState::Pending);
588    }
589
590    /// A tagged reviewer who has not opened the pull request at all is absent from
591    /// `participants`. They must still be listed, or the column under-reports who is
592    /// on the hook.
593    #[test]
594    fn reviewer_states_includes_a_reviewer_missing_from_participants() {
595        let pr = pr_from(serde_json::json!({
596            "id": 1,
597            "reviewers": [{ "uuid": "{a}", "display_name": "Ana" }],
598            "participants": []
599        }));
600
601        let states = pr.reviewer_states();
602        assert_eq!(states.len(), 1);
603        assert_eq!(states[0].name, "Ana");
604        assert_eq!(states[0].state, ReviewState::Pending);
605    }
606
607    /// Someone who merely commented has role PARTICIPANT. Counting them as a
608    /// reviewer would invent reviewers nobody tagged.
609    #[test]
610    fn reviewer_states_excludes_plain_participants() {
611        let pr = pr_from(serde_json::json!({
612            "id": 1,
613            "reviewers": [],
614            "participants": [
615                { "role": "PARTICIPANT", "state": "approved", "user": { "uuid": "{z}", "display_name": "Zed" } }
616            ]
617        }));
618
619        assert!(pr.reviewer_states().is_empty());
620    }
621
622    /// The same person appears in both arrays; they must be listed once, with the
623    /// participant state rather than a duplicate Pending row.
624    #[test]
625    fn reviewer_states_does_not_duplicate_across_both_arrays() {
626        let pr = pr_from(serde_json::json!({
627            "id": 1,
628            "reviewers": [{ "uuid": "{a}", "display_name": "Ana" }],
629            "participants": [
630                { "role": "REVIEWER", "state": "approved", "user": { "uuid": "{a}", "display_name": "Ana" } }
631            ]
632        }));
633
634        let states = pr.reviewer_states();
635        assert_eq!(states.len(), 1);
636        assert_eq!(states[0].state, ReviewState::Approved);
637    }
638
639    /// Dedup must survive a missing uuid on one side by falling back to the name.
640    #[test]
641    fn reviewer_states_dedups_by_name_when_a_uuid_is_absent() {
642        let pr = pr_from(serde_json::json!({
643            "id": 1,
644            "reviewers": [{ "display_name": "Ana" }],
645            "participants": [
646                { "role": "REVIEWER", "state": "approved", "user": { "display_name": "Ana" } }
647            ]
648        }));
649
650        assert_eq!(pr.reviewer_states().len(), 1);
651    }
652
653    #[test]
654    fn marks_are_stable_glyphs() {
655        assert_eq!(ReviewState::Approved.mark(), "✓");
656        assert_eq!(ReviewState::ChangesRequested.mark(), "✗");
657        assert_eq!(ReviewState::Pending.mark(), "·");
658    }
659
660    #[test]
661    fn review_state_serializes_in_snake_case() {
662        let json = serde_json::to_string(&ReviewState::ChangesRequested).unwrap();
663        assert_eq!(json, "\"changes_requested\"");
664    }
665
666    #[test]
667    fn draft_wins_over_open_state() {
668        let pr = pr_from(serde_json::json!({ "id": 1, "state": "OPEN", "draft": true }));
669        assert_eq!(pr.display_state(), "Draft");
670    }
671
672    #[test]
673    fn display_state_title_cases_the_api_value() {
674        let pr = pr_from(serde_json::json!({ "id": 1, "state": "DECLINED" }));
675        assert_eq!(pr.display_state(), "Declined");
676    }
677
678    #[test]
679    fn display_state_without_a_state_is_a_dash() {
680        let pr = pr_from(serde_json::json!({ "id": 1 }));
681        assert_eq!(pr.display_state(), "-");
682    }
683
684    #[test]
685    fn user_name_prefers_display_name_then_nickname() {
686        let full: User = serde_json::from_value(
687            serde_json::json!({ "display_name": "Ana Cruz", "nickname": "ana" }),
688        )
689        .unwrap();
690        assert_eq!(full.name(), "Ana Cruz");
691
692        let nick_only: User =
693            serde_json::from_value(serde_json::json!({ "nickname": "ana" })).unwrap();
694        assert_eq!(nick_only.name(), "ana");
695
696        let empty: User = serde_json::from_value(serde_json::json!({})).unwrap();
697        assert_eq!(empty.name(), "-");
698    }
699
700    /// The `{uuid}` escape hatch has no names at all; `name()` must still
701    /// identify the person rather than falling through to `"-"`.
702    #[test]
703    fn user_name_falls_back_to_uuid_when_no_names_are_set() {
704        let uuid_only: User =
705            serde_json::from_value(serde_json::json!({ "uuid": "{5f3a}" })).unwrap();
706        assert_eq!(uuid_only.name(), "{5f3a}");
707    }
708
709    fn status(state: Option<&str>) -> BuildStatus {
710        BuildStatus {
711            key: Some("PIPELINE".into()),
712            name: Some("Pipeline #1".into()),
713            state: state.map(str::to_string),
714            url: None,
715        }
716    }
717
718    #[test]
719    fn build_state_from_api_is_case_insensitive() {
720        assert_eq!(
721            BuildState::from_api(Some("SUCCESSFUL")),
722            BuildState::Successful
723        );
724        assert_eq!(
725            BuildState::from_api(Some("successful")),
726            BuildState::Successful
727        );
728        assert_eq!(
729            BuildState::from_api(Some("InProgress")),
730            BuildState::InProgress
731        );
732        assert_eq!(BuildState::from_api(Some("FAILED")), BuildState::Failed);
733        assert_eq!(BuildState::from_api(Some("STOPPED")), BuildState::Stopped);
734    }
735
736    #[test]
737    fn build_state_from_api_degrades_on_unknown_and_missing() {
738        assert_eq!(BuildState::from_api(Some("TELEPORTED")), BuildState::None);
739        assert_eq!(BuildState::from_api(None), BuildState::None);
740    }
741
742    #[test]
743    fn rollup_of_empty_is_none() {
744        assert_eq!(BuildState::rollup(&[]), BuildState::None);
745    }
746
747    #[test]
748    fn rollup_is_worst_wins() {
749        // Every state loses to a failure, whichever order they arrive in.
750        for other in ["SUCCESSFUL", "INPROGRESS", "STOPPED"] {
751            assert_eq!(
752                BuildState::rollup(&[status(Some(other)), status(Some("FAILED"))]),
753                BuildState::Failed
754            );
755            assert_eq!(
756                BuildState::rollup(&[status(Some("FAILED")), status(Some(other))]),
757                BuildState::Failed
758            );
759        }
760        assert_eq!(
761            BuildState::rollup(&[status(Some("SUCCESSFUL")), status(Some("STOPPED"))]),
762            BuildState::Stopped
763        );
764        assert_eq!(
765            BuildState::rollup(&[status(Some("SUCCESSFUL")), status(Some("INPROGRESS"))]),
766            BuildState::InProgress
767        );
768        assert_eq!(
769            BuildState::rollup(&[status(Some("SUCCESSFUL")), status(Some("SUCCESSFUL"))]),
770            BuildState::Successful
771        );
772    }
773
774    /// An unrecognised state must not be treated as worse than everything else,
775    /// or one unknown reporter would paint every pull request red.
776    #[test]
777    fn rollup_ignores_unknown_states_next_to_a_real_one() {
778        assert_eq!(
779            BuildState::rollup(&[status(None), status(Some("SUCCESSFUL"))]),
780            BuildState::Successful
781        );
782    }
783
784    #[test]
785    fn build_state_serialises_lowercase() {
786        assert_eq!(
787            serde_json::to_string(&BuildState::InProgress).unwrap(),
788            "\"inprogress\""
789        );
790        assert_eq!(
791            serde_json::to_string(&BuildState::None).unwrap(),
792            "\"none\""
793        );
794    }
795
796    #[test]
797    fn build_state_labels_match_bitbucket_wording() {
798        assert_eq!(BuildState::Failed.label(), "FAILED");
799        assert_eq!(BuildState::Stopped.label(), "STOPPED");
800        assert_eq!(BuildState::InProgress.label(), "INPROGRESS");
801        assert_eq!(BuildState::Successful.label(), "SUCCESSFUL");
802        assert_eq!(BuildState::None.label(), "-");
803    }
804
805    #[test]
806    fn pull_request_carries_updated_on() {
807        let pr: PullRequest =
808            serde_json::from_str(r#"{"id":1,"updated_on":"2026-08-10T09:00:00+00:00"}"#).unwrap();
809        assert_eq!(pr.updated_on.as_deref(), Some("2026-08-10T09:00:00+00:00"));
810    }
811
812    #[test]
813    fn pull_request_without_updated_on_is_none() {
814        let pr: PullRequest = serde_json::from_str(r#"{"id":1}"#).unwrap();
815        assert!(pr.updated_on.is_none());
816    }
817
818    #[test]
819    fn repository_deserialises() {
820        let repo: Repository = serde_json::from_str(r#"{"full_name":"acme/api"}"#).unwrap();
821        assert_eq!(repo.full_name.as_deref(), Some("acme/api"));
822    }
823
824    #[test]
825    fn repository_tolerates_a_missing_full_name() {
826        let repo: Repository = serde_json::from_str(r#"{}"#).unwrap();
827        assert!(repo.full_name.is_none());
828    }
829
830    #[test]
831    fn repository_reads_ssh_clone_url_in_preference_to_https() {
832        let json = serde_json::json!({
833            "full_name": "acme/api",
834            "links": { "clone": [
835                { "name": "https", "href": "https://bitbucket.org/acme/api.git" },
836                { "name": "ssh", "href": "git@bitbucket.org:acme/api.git" }
837            ]}
838        });
839        let repo: Repository = serde_json::from_value(json).unwrap();
840        assert_eq!(repo.clone_url(), Some("git@bitbucket.org:acme/api.git"));
841    }
842
843    #[test]
844    fn repository_falls_back_to_https_clone_url() {
845        let json = serde_json::json!({
846            "links": { "clone": [{ "name": "https", "href": "https://bitbucket.org/acme/api.git" }] }
847        });
848        let repo: Repository = serde_json::from_value(json).unwrap();
849        assert_eq!(repo.clone_url(), Some("https://bitbucket.org/acme/api.git"));
850    }
851
852    #[test]
853    fn repository_tolerates_a_response_with_nothing_in_it() {
854        // Every field is Option-tolerant by house rule, and the accessors must not
855        // panic on the emptiest body the api could return.
856        let repo: Repository = serde_json::from_value(serde_json::json!({})).unwrap();
857        assert_eq!(repo.clone_url(), None);
858        assert_eq!(repo.project_key(), "-");
859        assert_eq!(repo.access(), "-");
860    }
861
862    #[test]
863    fn repository_renders_privacy_as_a_word_not_a_boolean() {
864        let private: Repository =
865            serde_json::from_value(serde_json::json!({ "is_private": true })).unwrap();
866        let public: Repository =
867            serde_json::from_value(serde_json::json!({ "is_private": false })).unwrap();
868        assert_eq!(private.access(), "private");
869        assert_eq!(public.access(), "public");
870    }
871
872    #[test]
873    fn repository_reads_its_project_key() {
874        let repo: Repository = serde_json::from_value(serde_json::json!({
875            "project": { "key": "ENG", "name": "Engineering" }
876        }))
877        .unwrap();
878        assert_eq!(repo.project_key(), "ENG");
879    }
880}