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}
428
429impl Comment {
430    pub fn is_inline(&self) -> bool {
431        self.inline
432            .as_ref()
433            .and_then(|i| i.path.as_deref())
434            .is_some_and(|p| !p.is_empty())
435    }
436
437    pub fn is_resolved(&self) -> bool {
438        self.resolution.is_some()
439    }
440
441    pub fn parent_id(&self) -> Option<u64> {
442        self.parent.as_ref().map(|p| p.id)
443    }
444
445    pub fn body(&self) -> String {
446        if self.deleted {
447            return "[deleted]".to_string();
448        }
449        self.content
450            .as_ref()
451            .and_then(|c| c.raw.clone())
452            .unwrap_or_default()
453    }
454
455    pub fn author(&self) -> &str {
456        self.user
457            .as_ref()
458            .and_then(|u| u.display_name.as_deref())
459            .unwrap_or("Unknown")
460    }
461}
462
463#[derive(Debug, Deserialize)]
464pub struct CommitAuthor {
465    pub user: Option<User>,
466    pub raw: Option<String>,
467}
468
469#[derive(Debug, Deserialize)]
470pub struct CommitTarget {
471    pub author: Option<CommitAuthor>,
472    pub date: Option<String>,
473}
474
475#[derive(Debug, Deserialize)]
476pub struct BranchRef {
477    pub name: String,
478    pub target: Option<CommitTarget>,
479}
480
481impl BranchRef {
482    pub fn owner(&self) -> String {
483        self.target
484            .as_ref()
485            .and_then(|t| t.author.as_ref())
486            .and_then(|a| {
487                a.user
488                    .as_ref()
489                    .and_then(|u| u.display_name.clone())
490                    .or_else(|| a.raw.clone())
491            })
492            .unwrap_or_else(|| "-".to_string())
493    }
494}
495
496#[derive(Debug, Deserialize)]
497pub struct CommitSummary {
498    pub raw: Option<String>,
499}
500
501#[derive(Debug, Deserialize)]
502pub struct Commit {
503    pub hash: Option<String>,
504    pub summary: Option<CommitSummary>,
505}
506
507#[derive(Debug, Deserialize)]
508pub struct DiffStatEntry {
509    pub status: Option<String>,
510    #[serde(rename = "new")]
511    pub new_file: Option<PathEntry>,
512    #[serde(rename = "old")]
513    pub old_file: Option<PathEntry>,
514}
515
516#[derive(Debug, Deserialize)]
517pub struct PathEntry {
518    pub path: Option<String>,
519}
520
521impl DiffStatEntry {
522    pub fn path(&self) -> &str {
523        self.new_file
524            .as_ref()
525            .and_then(|p| p.path.as_deref())
526            .or_else(|| self.old_file.as_ref().and_then(|p| p.path.as_deref()))
527            .unwrap_or("-")
528    }
529}
530
531#[derive(Debug, Serialize)]
532pub struct ReviewerRef {
533    pub uuid: String,
534}
535
536#[cfg(test)]
537#[allow(clippy::unwrap_used, clippy::expect_used)]
538mod tests {
539    use super::*;
540
541    fn pr_from(json: serde_json::Value) -> PullRequest {
542        serde_json::from_value(json).expect("fixture should deserialize")
543    }
544
545    #[test]
546    fn description_text_prefers_summary_raw() {
547        let pr = pr_from(serde_json::json!({
548            "id": 1,
549            "description": "old copy",
550            "summary": { "raw": "documented copy", "markup": "markdown" }
551        }));
552        assert_eq!(pr.description_text(), "documented copy");
553    }
554
555    #[test]
556    fn description_text_falls_back_to_description_then_empty() {
557        let pr = pr_from(serde_json::json!({ "id": 1, "description": "only this" }));
558        assert_eq!(pr.description_text(), "only this");
559        let bare = pr_from(serde_json::json!({ "id": 1 }));
560        assert_eq!(bare.description_text(), "");
561    }
562
563    #[test]
564    fn reviewer_states_reads_state_from_participants() {
565        let pr = pr_from(serde_json::json!({
566            "id": 1,
567            "reviewers": [
568                { "uuid": "{a}", "display_name": "Ana" },
569                { "uuid": "{b}", "display_name": "Bo" },
570                { "uuid": "{c}", "display_name": "Cy" }
571            ],
572            "participants": [
573                { "role": "REVIEWER", "state": "approved", "user": { "uuid": "{a}", "display_name": "Ana" } },
574                { "role": "REVIEWER", "state": "changes_requested", "user": { "uuid": "{b}", "display_name": "Bo" } },
575                { "role": "REVIEWER", "state": null, "user": { "uuid": "{c}", "display_name": "Cy" } }
576            ]
577        }));
578
579        let states = pr.reviewer_states();
580        assert_eq!(states.len(), 3);
581        assert_eq!(states[0].name, "Ana");
582        assert_eq!(states[0].state, ReviewState::Approved);
583        assert_eq!(states[1].state, ReviewState::ChangesRequested);
584        assert_eq!(states[2].state, ReviewState::Pending);
585    }
586
587    /// A tagged reviewer who has not opened the pull request at all is absent from
588    /// `participants`. They must still be listed, or the column under-reports who is
589    /// on the hook.
590    #[test]
591    fn reviewer_states_includes_a_reviewer_missing_from_participants() {
592        let pr = pr_from(serde_json::json!({
593            "id": 1,
594            "reviewers": [{ "uuid": "{a}", "display_name": "Ana" }],
595            "participants": []
596        }));
597
598        let states = pr.reviewer_states();
599        assert_eq!(states.len(), 1);
600        assert_eq!(states[0].name, "Ana");
601        assert_eq!(states[0].state, ReviewState::Pending);
602    }
603
604    /// Someone who merely commented has role PARTICIPANT. Counting them as a
605    /// reviewer would invent reviewers nobody tagged.
606    #[test]
607    fn reviewer_states_excludes_plain_participants() {
608        let pr = pr_from(serde_json::json!({
609            "id": 1,
610            "reviewers": [],
611            "participants": [
612                { "role": "PARTICIPANT", "state": "approved", "user": { "uuid": "{z}", "display_name": "Zed" } }
613            ]
614        }));
615
616        assert!(pr.reviewer_states().is_empty());
617    }
618
619    /// The same person appears in both arrays; they must be listed once, with the
620    /// participant state rather than a duplicate Pending row.
621    #[test]
622    fn reviewer_states_does_not_duplicate_across_both_arrays() {
623        let pr = pr_from(serde_json::json!({
624            "id": 1,
625            "reviewers": [{ "uuid": "{a}", "display_name": "Ana" }],
626            "participants": [
627                { "role": "REVIEWER", "state": "approved", "user": { "uuid": "{a}", "display_name": "Ana" } }
628            ]
629        }));
630
631        let states = pr.reviewer_states();
632        assert_eq!(states.len(), 1);
633        assert_eq!(states[0].state, ReviewState::Approved);
634    }
635
636    /// Dedup must survive a missing uuid on one side by falling back to the name.
637    #[test]
638    fn reviewer_states_dedups_by_name_when_a_uuid_is_absent() {
639        let pr = pr_from(serde_json::json!({
640            "id": 1,
641            "reviewers": [{ "display_name": "Ana" }],
642            "participants": [
643                { "role": "REVIEWER", "state": "approved", "user": { "display_name": "Ana" } }
644            ]
645        }));
646
647        assert_eq!(pr.reviewer_states().len(), 1);
648    }
649
650    #[test]
651    fn marks_are_stable_glyphs() {
652        assert_eq!(ReviewState::Approved.mark(), "✓");
653        assert_eq!(ReviewState::ChangesRequested.mark(), "✗");
654        assert_eq!(ReviewState::Pending.mark(), "·");
655    }
656
657    #[test]
658    fn review_state_serializes_in_snake_case() {
659        let json = serde_json::to_string(&ReviewState::ChangesRequested).unwrap();
660        assert_eq!(json, "\"changes_requested\"");
661    }
662
663    #[test]
664    fn draft_wins_over_open_state() {
665        let pr = pr_from(serde_json::json!({ "id": 1, "state": "OPEN", "draft": true }));
666        assert_eq!(pr.display_state(), "Draft");
667    }
668
669    #[test]
670    fn display_state_title_cases_the_api_value() {
671        let pr = pr_from(serde_json::json!({ "id": 1, "state": "DECLINED" }));
672        assert_eq!(pr.display_state(), "Declined");
673    }
674
675    #[test]
676    fn display_state_without_a_state_is_a_dash() {
677        let pr = pr_from(serde_json::json!({ "id": 1 }));
678        assert_eq!(pr.display_state(), "-");
679    }
680
681    #[test]
682    fn user_name_prefers_display_name_then_nickname() {
683        let full: User = serde_json::from_value(
684            serde_json::json!({ "display_name": "Ana Cruz", "nickname": "ana" }),
685        )
686        .unwrap();
687        assert_eq!(full.name(), "Ana Cruz");
688
689        let nick_only: User =
690            serde_json::from_value(serde_json::json!({ "nickname": "ana" })).unwrap();
691        assert_eq!(nick_only.name(), "ana");
692
693        let empty: User = serde_json::from_value(serde_json::json!({})).unwrap();
694        assert_eq!(empty.name(), "-");
695    }
696
697    /// The `{uuid}` escape hatch has no names at all; `name()` must still
698    /// identify the person rather than falling through to `"-"`.
699    #[test]
700    fn user_name_falls_back_to_uuid_when_no_names_are_set() {
701        let uuid_only: User =
702            serde_json::from_value(serde_json::json!({ "uuid": "{5f3a}" })).unwrap();
703        assert_eq!(uuid_only.name(), "{5f3a}");
704    }
705
706    fn status(state: Option<&str>) -> BuildStatus {
707        BuildStatus {
708            key: Some("PIPELINE".into()),
709            name: Some("Pipeline #1".into()),
710            state: state.map(str::to_string),
711            url: None,
712        }
713    }
714
715    #[test]
716    fn build_state_from_api_is_case_insensitive() {
717        assert_eq!(
718            BuildState::from_api(Some("SUCCESSFUL")),
719            BuildState::Successful
720        );
721        assert_eq!(
722            BuildState::from_api(Some("successful")),
723            BuildState::Successful
724        );
725        assert_eq!(
726            BuildState::from_api(Some("InProgress")),
727            BuildState::InProgress
728        );
729        assert_eq!(BuildState::from_api(Some("FAILED")), BuildState::Failed);
730        assert_eq!(BuildState::from_api(Some("STOPPED")), BuildState::Stopped);
731    }
732
733    #[test]
734    fn build_state_from_api_degrades_on_unknown_and_missing() {
735        assert_eq!(BuildState::from_api(Some("TELEPORTED")), BuildState::None);
736        assert_eq!(BuildState::from_api(None), BuildState::None);
737    }
738
739    #[test]
740    fn rollup_of_empty_is_none() {
741        assert_eq!(BuildState::rollup(&[]), BuildState::None);
742    }
743
744    #[test]
745    fn rollup_is_worst_wins() {
746        // Every state loses to a failure, whichever order they arrive in.
747        for other in ["SUCCESSFUL", "INPROGRESS", "STOPPED"] {
748            assert_eq!(
749                BuildState::rollup(&[status(Some(other)), status(Some("FAILED"))]),
750                BuildState::Failed
751            );
752            assert_eq!(
753                BuildState::rollup(&[status(Some("FAILED")), status(Some(other))]),
754                BuildState::Failed
755            );
756        }
757        assert_eq!(
758            BuildState::rollup(&[status(Some("SUCCESSFUL")), status(Some("STOPPED"))]),
759            BuildState::Stopped
760        );
761        assert_eq!(
762            BuildState::rollup(&[status(Some("SUCCESSFUL")), status(Some("INPROGRESS"))]),
763            BuildState::InProgress
764        );
765        assert_eq!(
766            BuildState::rollup(&[status(Some("SUCCESSFUL")), status(Some("SUCCESSFUL"))]),
767            BuildState::Successful
768        );
769    }
770
771    /// An unrecognised state must not be treated as worse than everything else,
772    /// or one unknown reporter would paint every pull request red.
773    #[test]
774    fn rollup_ignores_unknown_states_next_to_a_real_one() {
775        assert_eq!(
776            BuildState::rollup(&[status(None), status(Some("SUCCESSFUL"))]),
777            BuildState::Successful
778        );
779    }
780
781    #[test]
782    fn build_state_serialises_lowercase() {
783        assert_eq!(
784            serde_json::to_string(&BuildState::InProgress).unwrap(),
785            "\"inprogress\""
786        );
787        assert_eq!(
788            serde_json::to_string(&BuildState::None).unwrap(),
789            "\"none\""
790        );
791    }
792
793    #[test]
794    fn build_state_labels_match_bitbucket_wording() {
795        assert_eq!(BuildState::Failed.label(), "FAILED");
796        assert_eq!(BuildState::Stopped.label(), "STOPPED");
797        assert_eq!(BuildState::InProgress.label(), "INPROGRESS");
798        assert_eq!(BuildState::Successful.label(), "SUCCESSFUL");
799        assert_eq!(BuildState::None.label(), "-");
800    }
801
802    #[test]
803    fn pull_request_carries_updated_on() {
804        let pr: PullRequest =
805            serde_json::from_str(r#"{"id":1,"updated_on":"2026-08-10T09:00:00+00:00"}"#).unwrap();
806        assert_eq!(pr.updated_on.as_deref(), Some("2026-08-10T09:00:00+00:00"));
807    }
808
809    #[test]
810    fn pull_request_without_updated_on_is_none() {
811        let pr: PullRequest = serde_json::from_str(r#"{"id":1}"#).unwrap();
812        assert!(pr.updated_on.is_none());
813    }
814
815    #[test]
816    fn repository_deserialises() {
817        let repo: Repository = serde_json::from_str(r#"{"full_name":"acme/api"}"#).unwrap();
818        assert_eq!(repo.full_name.as_deref(), Some("acme/api"));
819    }
820
821    #[test]
822    fn repository_tolerates_a_missing_full_name() {
823        let repo: Repository = serde_json::from_str(r#"{}"#).unwrap();
824        assert!(repo.full_name.is_none());
825    }
826
827    #[test]
828    fn repository_reads_ssh_clone_url_in_preference_to_https() {
829        let json = serde_json::json!({
830            "full_name": "acme/api",
831            "links": { "clone": [
832                { "name": "https", "href": "https://bitbucket.org/acme/api.git" },
833                { "name": "ssh", "href": "git@bitbucket.org:acme/api.git" }
834            ]}
835        });
836        let repo: Repository = serde_json::from_value(json).unwrap();
837        assert_eq!(repo.clone_url(), Some("git@bitbucket.org:acme/api.git"));
838    }
839
840    #[test]
841    fn repository_falls_back_to_https_clone_url() {
842        let json = serde_json::json!({
843            "links": { "clone": [{ "name": "https", "href": "https://bitbucket.org/acme/api.git" }] }
844        });
845        let repo: Repository = serde_json::from_value(json).unwrap();
846        assert_eq!(repo.clone_url(), Some("https://bitbucket.org/acme/api.git"));
847    }
848
849    #[test]
850    fn repository_tolerates_a_response_with_nothing_in_it() {
851        // Every field is Option-tolerant by house rule, and the accessors must not
852        // panic on the emptiest body the api could return.
853        let repo: Repository = serde_json::from_value(serde_json::json!({})).unwrap();
854        assert_eq!(repo.clone_url(), None);
855        assert_eq!(repo.project_key(), "-");
856        assert_eq!(repo.access(), "-");
857    }
858
859    #[test]
860    fn repository_renders_privacy_as_a_word_not_a_boolean() {
861        let private: Repository =
862            serde_json::from_value(serde_json::json!({ "is_private": true })).unwrap();
863        let public: Repository =
864            serde_json::from_value(serde_json::json!({ "is_private": false })).unwrap();
865        assert_eq!(private.access(), "private");
866        assert_eq!(public.access(), "public");
867    }
868
869    #[test]
870    fn repository_reads_its_project_key() {
871        let repo: Repository = serde_json::from_value(serde_json::json!({
872            "project": { "key": "ENG", "name": "Engineering" }
873        }))
874        .unwrap();
875        assert_eq!(repo.project_key(), "ENG");
876    }
877}