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 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 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 _ => 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 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 pub parent: Option<CommentParent>,
222 #[serde(default)]
223 pub deleted: bool,
224 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 #[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 #[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 #[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 #[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 #[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}