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 CommentParent {
210    pub id: u64,
211}
212
213#[derive(Debug, Deserialize)]
214pub struct Comment {
215    pub id: u64,
216    pub content: Option<CommentContent>,
217    pub user: Option<User>,
218    pub created_on: Option<String>,
219    pub inline: Option<Inline>,
220    /// Set on a reply, holding the comment it answers.
221    pub parent: Option<CommentParent>,
222    #[serde(default)]
223    pub deleted: bool,
224    /// Present (even as `{}`) when the inline thread has been resolved.
225    pub resolution: Option<serde_json::Value>,
226}
227
228impl Comment {
229    pub fn is_inline(&self) -> bool {
230        self.inline
231            .as_ref()
232            .and_then(|i| i.path.as_deref())
233            .is_some_and(|p| !p.is_empty())
234    }
235
236    pub fn is_resolved(&self) -> bool {
237        self.resolution.is_some()
238    }
239
240    pub fn parent_id(&self) -> Option<u64> {
241        self.parent.as_ref().map(|p| p.id)
242    }
243
244    pub fn body(&self) -> String {
245        if self.deleted {
246            return "[deleted]".to_string();
247        }
248        self.content
249            .as_ref()
250            .and_then(|c| c.raw.clone())
251            .unwrap_or_default()
252    }
253
254    pub fn author(&self) -> &str {
255        self.user
256            .as_ref()
257            .and_then(|u| u.display_name.as_deref())
258            .unwrap_or("Unknown")
259    }
260}
261
262#[derive(Debug, Deserialize)]
263pub struct CommitAuthor {
264    pub user: Option<User>,
265    pub raw: Option<String>,
266}
267
268#[derive(Debug, Deserialize)]
269pub struct CommitTarget {
270    pub author: Option<CommitAuthor>,
271    pub date: Option<String>,
272}
273
274#[derive(Debug, Deserialize)]
275pub struct BranchRef {
276    pub name: String,
277    pub target: Option<CommitTarget>,
278}
279
280impl BranchRef {
281    pub fn owner(&self) -> String {
282        self.target
283            .as_ref()
284            .and_then(|t| t.author.as_ref())
285            .and_then(|a| {
286                a.user
287                    .as_ref()
288                    .and_then(|u| u.display_name.clone())
289                    .or_else(|| a.raw.clone())
290            })
291            .unwrap_or_else(|| "-".to_string())
292    }
293}
294
295#[derive(Debug, Deserialize)]
296pub struct CommitSummary {
297    pub raw: Option<String>,
298}
299
300#[derive(Debug, Deserialize)]
301pub struct Commit {
302    pub hash: Option<String>,
303    pub summary: Option<CommitSummary>,
304}
305
306#[derive(Debug, Deserialize)]
307pub struct DiffStatEntry {
308    pub status: Option<String>,
309    #[serde(rename = "new")]
310    pub new_file: Option<PathEntry>,
311    #[serde(rename = "old")]
312    pub old_file: Option<PathEntry>,
313}
314
315#[derive(Debug, Deserialize)]
316pub struct PathEntry {
317    pub path: Option<String>,
318}
319
320impl DiffStatEntry {
321    pub fn path(&self) -> &str {
322        self.new_file
323            .as_ref()
324            .and_then(|p| p.path.as_deref())
325            .or_else(|| self.old_file.as_ref().and_then(|p| p.path.as_deref()))
326            .unwrap_or("-")
327    }
328}
329
330#[derive(Debug, Serialize)]
331pub struct ReviewerRef {
332    pub uuid: String,
333}
334
335#[cfg(test)]
336#[allow(clippy::unwrap_used, clippy::expect_used)]
337mod tests {
338    use super::*;
339
340    fn pr_from(json: serde_json::Value) -> PullRequest {
341        serde_json::from_value(json).expect("fixture should deserialize")
342    }
343
344    #[test]
345    fn reviewer_states_reads_state_from_participants() {
346        let pr = pr_from(serde_json::json!({
347            "id": 1,
348            "reviewers": [
349                { "uuid": "{a}", "display_name": "Ana" },
350                { "uuid": "{b}", "display_name": "Bo" },
351                { "uuid": "{c}", "display_name": "Cy" }
352            ],
353            "participants": [
354                { "role": "REVIEWER", "state": "approved", "user": { "uuid": "{a}", "display_name": "Ana" } },
355                { "role": "REVIEWER", "state": "changes_requested", "user": { "uuid": "{b}", "display_name": "Bo" } },
356                { "role": "REVIEWER", "state": null, "user": { "uuid": "{c}", "display_name": "Cy" } }
357            ]
358        }));
359
360        let states = pr.reviewer_states();
361        assert_eq!(states.len(), 3);
362        assert_eq!(states[0].name, "Ana");
363        assert_eq!(states[0].state, ReviewState::Approved);
364        assert_eq!(states[1].state, ReviewState::ChangesRequested);
365        assert_eq!(states[2].state, ReviewState::Pending);
366    }
367
368    /// A tagged reviewer who has not opened the pull request at all is absent from
369    /// `participants`. They must still be listed, or the column under-reports who is
370    /// on the hook.
371    #[test]
372    fn reviewer_states_includes_a_reviewer_missing_from_participants() {
373        let pr = pr_from(serde_json::json!({
374            "id": 1,
375            "reviewers": [{ "uuid": "{a}", "display_name": "Ana" }],
376            "participants": []
377        }));
378
379        let states = pr.reviewer_states();
380        assert_eq!(states.len(), 1);
381        assert_eq!(states[0].name, "Ana");
382        assert_eq!(states[0].state, ReviewState::Pending);
383    }
384
385    /// Someone who merely commented has role PARTICIPANT. Counting them as a
386    /// reviewer would invent reviewers nobody tagged.
387    #[test]
388    fn reviewer_states_excludes_plain_participants() {
389        let pr = pr_from(serde_json::json!({
390            "id": 1,
391            "reviewers": [],
392            "participants": [
393                { "role": "PARTICIPANT", "state": "approved", "user": { "uuid": "{z}", "display_name": "Zed" } }
394            ]
395        }));
396
397        assert!(pr.reviewer_states().is_empty());
398    }
399
400    /// The same person appears in both arrays; they must be listed once, with the
401    /// participant state rather than a duplicate Pending row.
402    #[test]
403    fn reviewer_states_does_not_duplicate_across_both_arrays() {
404        let pr = pr_from(serde_json::json!({
405            "id": 1,
406            "reviewers": [{ "uuid": "{a}", "display_name": "Ana" }],
407            "participants": [
408                { "role": "REVIEWER", "state": "approved", "user": { "uuid": "{a}", "display_name": "Ana" } }
409            ]
410        }));
411
412        let states = pr.reviewer_states();
413        assert_eq!(states.len(), 1);
414        assert_eq!(states[0].state, ReviewState::Approved);
415    }
416
417    /// Dedup must survive a missing uuid on one side by falling back to the name.
418    #[test]
419    fn reviewer_states_dedups_by_name_when_a_uuid_is_absent() {
420        let pr = pr_from(serde_json::json!({
421            "id": 1,
422            "reviewers": [{ "display_name": "Ana" }],
423            "participants": [
424                { "role": "REVIEWER", "state": "approved", "user": { "display_name": "Ana" } }
425            ]
426        }));
427
428        assert_eq!(pr.reviewer_states().len(), 1);
429    }
430
431    #[test]
432    fn marks_are_stable_glyphs() {
433        assert_eq!(ReviewState::Approved.mark(), "✓");
434        assert_eq!(ReviewState::ChangesRequested.mark(), "✗");
435        assert_eq!(ReviewState::Pending.mark(), "·");
436    }
437
438    #[test]
439    fn review_state_serializes_in_snake_case() {
440        let json = serde_json::to_string(&ReviewState::ChangesRequested).unwrap();
441        assert_eq!(json, "\"changes_requested\"");
442    }
443
444    #[test]
445    fn draft_wins_over_open_state() {
446        let pr = pr_from(serde_json::json!({ "id": 1, "state": "OPEN", "draft": true }));
447        assert_eq!(pr.display_state(), "Draft");
448    }
449
450    #[test]
451    fn display_state_title_cases_the_api_value() {
452        let pr = pr_from(serde_json::json!({ "id": 1, "state": "DECLINED" }));
453        assert_eq!(pr.display_state(), "Declined");
454    }
455
456    #[test]
457    fn display_state_without_a_state_is_a_dash() {
458        let pr = pr_from(serde_json::json!({ "id": 1 }));
459        assert_eq!(pr.display_state(), "-");
460    }
461
462    #[test]
463    fn user_name_prefers_display_name_then_nickname() {
464        let full: User = serde_json::from_value(
465            serde_json::json!({ "display_name": "Ana Cruz", "nickname": "ana" }),
466        )
467        .unwrap();
468        assert_eq!(full.name(), "Ana Cruz");
469
470        let nick_only: User =
471            serde_json::from_value(serde_json::json!({ "nickname": "ana" })).unwrap();
472        assert_eq!(nick_only.name(), "ana");
473
474        let empty: User = serde_json::from_value(serde_json::json!({})).unwrap();
475        assert_eq!(empty.name(), "-");
476    }
477
478    /// The `{uuid}` escape hatch has no names at all; `name()` must still
479    /// identify the person rather than falling through to `"-"`.
480    #[test]
481    fn user_name_falls_back_to_uuid_when_no_names_are_set() {
482        let uuid_only: User =
483            serde_json::from_value(serde_json::json!({ "uuid": "{5f3a}" })).unwrap();
484        assert_eq!(uuid_only.name(), "{5f3a}");
485    }
486}