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, Deserialize)]
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
80#[derive(Debug, Clone, Serialize)]
81pub struct ReviewerState {
82    pub name: String,
83    pub uuid: Option<String>,
84    pub state: ReviewState,
85}
86
87#[derive(Debug, Deserialize)]
88pub struct PullRequest {
89    pub id: u64,
90    pub title: Option<String>,
91    pub state: Option<String>,
92    pub author: Option<User>,
93    pub source: Option<Endpoint>,
94    pub destination: Option<Endpoint>,
95    pub links: Option<Links>,
96    #[serde(default)]
97    pub reviewers: Vec<User>,
98    #[serde(default)]
99    pub participants: Vec<Participant>,
100    #[serde(default)]
101    pub draft: bool,
102}
103
104impl PullRequest {
105    pub fn source_branch(&self) -> &str {
106        self.source
107            .as_ref()
108            .and_then(|e| e.branch.as_ref())
109            .and_then(|b| b.name.as_deref())
110            .unwrap_or("-")
111    }
112
113    pub fn destination_branch(&self) -> &str {
114        self.destination
115            .as_ref()
116            .and_then(|e| e.branch.as_ref())
117            .and_then(|b| b.name.as_deref())
118            .unwrap_or("-")
119    }
120
121    pub fn html_url(&self) -> &str {
122        self.links
123            .as_ref()
124            .and_then(|l| l.html.as_ref())
125            .and_then(|l| l.href.as_deref())
126            .unwrap_or("-")
127    }
128
129    pub fn author_name(&self) -> &str {
130        self.author
131            .as_ref()
132            .and_then(|a| a.nickname.as_deref().or(a.display_name.as_deref()))
133            .unwrap_or("-")
134    }
135
136    /// Who is on the hook for this pull request, and what each has decided.
137    ///
138    /// `reviewers[]` is the tagged set but carries no decision; `participants[]`
139    /// carries the decision but also includes people who only commented. So
140    /// participants with the REVIEWER role are the primary source, and anyone
141    /// tagged who has not shown up there yet is appended as Pending.
142    pub fn reviewer_states(&self) -> Vec<ReviewerState> {
143        let mut out: Vec<ReviewerState> = self
144            .participants
145            .iter()
146            .filter(|p| p.role.as_deref() == Some("REVIEWER"))
147            .filter_map(|p| {
148                p.user.as_ref().map(|u| ReviewerState {
149                    name: u.name().to_string(),
150                    uuid: u.uuid.clone(),
151                    state: ReviewState::from_api(p.state.as_deref()),
152                })
153            })
154            .collect();
155
156        for reviewer in &self.reviewers {
157            let already = out.iter().any(|seen| {
158                match (seen.uuid.as_deref(), reviewer.uuid.as_deref()) {
159                    (Some(a), Some(b)) => a == b,
160                    // One side has no uuid, so the name is all there is to match on.
161                    _ => seen.name == reviewer.name(),
162                }
163            });
164            if !already {
165                out.push(ReviewerState {
166                    name: reviewer.name().to_string(),
167                    uuid: reviewer.uuid.clone(),
168                    state: ReviewState::Pending,
169                });
170            }
171        }
172
173        out
174    }
175
176    /// Bitbucket keeps `draft` as a boolean while `state` stays OPEN, so the two
177    /// have to be folded into one word for the table.
178    pub fn display_state(&self) -> String {
179        if self.draft {
180            return "Draft".to_string();
181        }
182        match self.state.as_deref() {
183            Some(state) if !state.is_empty() => {
184                let lower = state.to_lowercase();
185                let mut chars = lower.chars();
186                match chars.next() {
187                    Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
188                    None => "-".to_string(),
189                }
190            }
191            _ => "-".to_string(),
192        }
193    }
194}
195
196#[derive(Debug, Deserialize)]
197pub struct CommentContent {
198    pub raw: Option<String>,
199}
200
201#[derive(Debug, Deserialize)]
202pub struct Inline {
203    pub path: Option<String>,
204    pub from: Option<u64>,
205    pub to: Option<u64>,
206}
207
208#[derive(Debug, Deserialize)]
209pub struct Comment {
210    pub id: u64,
211    pub content: Option<CommentContent>,
212    pub user: Option<User>,
213    pub created_on: Option<String>,
214    pub inline: Option<Inline>,
215    #[serde(default)]
216    pub deleted: bool,
217    /// Present (even as `{}`) when the inline thread has been resolved.
218    pub resolution: Option<serde_json::Value>,
219}
220
221impl Comment {
222    pub fn is_inline(&self) -> bool {
223        self.inline
224            .as_ref()
225            .and_then(|i| i.path.as_deref())
226            .is_some_and(|p| !p.is_empty())
227    }
228
229    pub fn is_resolved(&self) -> bool {
230        self.resolution.is_some()
231    }
232
233    pub fn body(&self) -> String {
234        if self.deleted {
235            return "[deleted]".to_string();
236        }
237        self.content
238            .as_ref()
239            .and_then(|c| c.raw.clone())
240            .unwrap_or_default()
241    }
242
243    pub fn author(&self) -> &str {
244        self.user
245            .as_ref()
246            .and_then(|u| u.display_name.as_deref())
247            .unwrap_or("Unknown")
248    }
249}
250
251#[derive(Debug, Deserialize)]
252pub struct CommitAuthor {
253    pub user: Option<User>,
254    pub raw: Option<String>,
255}
256
257#[derive(Debug, Deserialize)]
258pub struct CommitTarget {
259    pub author: Option<CommitAuthor>,
260    pub date: Option<String>,
261}
262
263#[derive(Debug, Deserialize)]
264pub struct BranchRef {
265    pub name: String,
266    pub target: Option<CommitTarget>,
267}
268
269impl BranchRef {
270    pub fn owner(&self) -> String {
271        self.target
272            .as_ref()
273            .and_then(|t| t.author.as_ref())
274            .and_then(|a| {
275                a.user
276                    .as_ref()
277                    .and_then(|u| u.display_name.clone())
278                    .or_else(|| a.raw.clone())
279            })
280            .unwrap_or_else(|| "-".to_string())
281    }
282}
283
284#[derive(Debug, Deserialize)]
285pub struct CommitSummary {
286    pub raw: Option<String>,
287}
288
289#[derive(Debug, Deserialize)]
290pub struct Commit {
291    pub hash: Option<String>,
292    pub summary: Option<CommitSummary>,
293}
294
295#[derive(Debug, Deserialize)]
296pub struct DiffStatEntry {
297    pub status: Option<String>,
298    #[serde(rename = "new")]
299    pub new_file: Option<PathEntry>,
300    #[serde(rename = "old")]
301    pub old_file: Option<PathEntry>,
302}
303
304#[derive(Debug, Deserialize)]
305pub struct PathEntry {
306    pub path: Option<String>,
307}
308
309impl DiffStatEntry {
310    pub fn path(&self) -> &str {
311        self.new_file
312            .as_ref()
313            .and_then(|p| p.path.as_deref())
314            .or_else(|| self.old_file.as_ref().and_then(|p| p.path.as_deref()))
315            .unwrap_or("-")
316    }
317}
318
319#[derive(Debug, Serialize)]
320pub struct ReviewerRef {
321    pub uuid: String,
322}
323
324#[cfg(test)]
325#[allow(clippy::unwrap_used, clippy::expect_used)]
326mod tests {
327    use super::*;
328
329    fn pr_from(json: serde_json::Value) -> PullRequest {
330        serde_json::from_value(json).expect("fixture should deserialize")
331    }
332
333    #[test]
334    fn reviewer_states_reads_state_from_participants() {
335        let pr = pr_from(serde_json::json!({
336            "id": 1,
337            "reviewers": [
338                { "uuid": "{a}", "display_name": "Ana" },
339                { "uuid": "{b}", "display_name": "Bo" },
340                { "uuid": "{c}", "display_name": "Cy" }
341            ],
342            "participants": [
343                { "role": "REVIEWER", "state": "approved", "user": { "uuid": "{a}", "display_name": "Ana" } },
344                { "role": "REVIEWER", "state": "changes_requested", "user": { "uuid": "{b}", "display_name": "Bo" } },
345                { "role": "REVIEWER", "state": null, "user": { "uuid": "{c}", "display_name": "Cy" } }
346            ]
347        }));
348
349        let states = pr.reviewer_states();
350        assert_eq!(states.len(), 3);
351        assert_eq!(states[0].name, "Ana");
352        assert_eq!(states[0].state, ReviewState::Approved);
353        assert_eq!(states[1].state, ReviewState::ChangesRequested);
354        assert_eq!(states[2].state, ReviewState::Pending);
355    }
356
357    /// A tagged reviewer who has not opened the pull request at all is absent from
358    /// `participants`. They must still be listed, or the column under-reports who is
359    /// on the hook.
360    #[test]
361    fn reviewer_states_includes_a_reviewer_missing_from_participants() {
362        let pr = pr_from(serde_json::json!({
363            "id": 1,
364            "reviewers": [{ "uuid": "{a}", "display_name": "Ana" }],
365            "participants": []
366        }));
367
368        let states = pr.reviewer_states();
369        assert_eq!(states.len(), 1);
370        assert_eq!(states[0].name, "Ana");
371        assert_eq!(states[0].state, ReviewState::Pending);
372    }
373
374    /// Someone who merely commented has role PARTICIPANT. Counting them as a
375    /// reviewer would invent reviewers nobody tagged.
376    #[test]
377    fn reviewer_states_excludes_plain_participants() {
378        let pr = pr_from(serde_json::json!({
379            "id": 1,
380            "reviewers": [],
381            "participants": [
382                { "role": "PARTICIPANT", "state": "approved", "user": { "uuid": "{z}", "display_name": "Zed" } }
383            ]
384        }));
385
386        assert!(pr.reviewer_states().is_empty());
387    }
388
389    /// The same person appears in both arrays; they must be listed once, with the
390    /// participant state rather than a duplicate Pending row.
391    #[test]
392    fn reviewer_states_does_not_duplicate_across_both_arrays() {
393        let pr = pr_from(serde_json::json!({
394            "id": 1,
395            "reviewers": [{ "uuid": "{a}", "display_name": "Ana" }],
396            "participants": [
397                { "role": "REVIEWER", "state": "approved", "user": { "uuid": "{a}", "display_name": "Ana" } }
398            ]
399        }));
400
401        let states = pr.reviewer_states();
402        assert_eq!(states.len(), 1);
403        assert_eq!(states[0].state, ReviewState::Approved);
404    }
405
406    /// Dedup must survive a missing uuid on one side by falling back to the name.
407    #[test]
408    fn reviewer_states_dedups_by_name_when_a_uuid_is_absent() {
409        let pr = pr_from(serde_json::json!({
410            "id": 1,
411            "reviewers": [{ "display_name": "Ana" }],
412            "participants": [
413                { "role": "REVIEWER", "state": "approved", "user": { "display_name": "Ana" } }
414            ]
415        }));
416
417        assert_eq!(pr.reviewer_states().len(), 1);
418    }
419
420    #[test]
421    fn marks_are_stable_glyphs() {
422        assert_eq!(ReviewState::Approved.mark(), "✓");
423        assert_eq!(ReviewState::ChangesRequested.mark(), "✗");
424        assert_eq!(ReviewState::Pending.mark(), "·");
425    }
426
427    #[test]
428    fn review_state_serializes_in_snake_case() {
429        let json = serde_json::to_string(&ReviewState::ChangesRequested).unwrap();
430        assert_eq!(json, "\"changes_requested\"");
431    }
432
433    #[test]
434    fn draft_wins_over_open_state() {
435        let pr = pr_from(serde_json::json!({ "id": 1, "state": "OPEN", "draft": true }));
436        assert_eq!(pr.display_state(), "Draft");
437    }
438
439    #[test]
440    fn display_state_title_cases_the_api_value() {
441        let pr = pr_from(serde_json::json!({ "id": 1, "state": "DECLINED" }));
442        assert_eq!(pr.display_state(), "Declined");
443    }
444
445    #[test]
446    fn display_state_without_a_state_is_a_dash() {
447        let pr = pr_from(serde_json::json!({ "id": 1 }));
448        assert_eq!(pr.display_state(), "-");
449    }
450
451    #[test]
452    fn user_name_prefers_display_name_then_nickname() {
453        let full: User = serde_json::from_value(
454            serde_json::json!({ "display_name": "Ana Cruz", "nickname": "ana" }),
455        )
456        .unwrap();
457        assert_eq!(full.name(), "Ana Cruz");
458
459        let nick_only: User =
460            serde_json::from_value(serde_json::json!({ "nickname": "ana" })).unwrap();
461        assert_eq!(nick_only.name(), "ana");
462
463        let empty: User = serde_json::from_value(serde_json::json!({})).unwrap();
464        assert_eq!(empty.name(), "-");
465    }
466
467    /// The `{uuid}` escape hatch has no names at all; `name()` must still
468    /// identify the person rather than falling through to `"-"`.
469    #[test]
470    fn user_name_falls_back_to_uuid_when_no_names_are_set() {
471        let uuid_only: User =
472            serde_json::from_value(serde_json::json!({ "uuid": "{5f3a}" })).unwrap();
473        assert_eq!(uuid_only.name(), "{5f3a}");
474    }
475}