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 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 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 #[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 #[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 #[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 #[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 #[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}