1use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct GitHubIssue {
7 pub number: u64,
8 #[serde(default)]
10 pub id: u64,
11 pub title: String,
12 pub body: Option<String>,
13 pub state: String,
14 pub labels: Vec<GitHubLabel>,
15 pub user: GitHubUser,
16 pub created_at: String,
17 pub updated_at: String,
18 pub html_url: String,
19 pub node_id: Option<String>,
20 pub pull_request: Option<serde_json::Value>,
22 #[serde(default)]
25 pub author_association: Option<String>,
26 #[serde(default)]
28 pub state_reason: Option<String>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct GitHubLabel {
33 pub name: String,
34 pub color: Option<String>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct GitHubUser {
39 pub login: String,
40}
41
42#[allow(dead_code)]
46#[derive(Debug, Clone, Deserialize)]
47pub struct IssueDependency {
48 pub id: u64,
49 pub number: u64,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct GitHubPullRequest {
59 pub number: u64,
60 pub title: String,
61 pub body: Option<String>,
62 pub state: String,
63 pub head: GitHubBranchRef,
64 pub base: GitHubBranchRef,
65 pub mergeable: Option<bool>,
66 pub mergeable_state: Option<String>,
67 pub merged: Option<bool>,
68 pub html_url: String,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct GitHubCheckRun {
73 pub id: u64,
74 pub name: String,
75 pub status: String,
76 pub conclusion: Option<String>,
77 #[serde(default)]
79 pub app: Option<GitHubApp>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct GitHubApp {
85 pub id: i64,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Hash)]
90pub struct RequiredStatusCheck {
91 pub context: String,
92 pub integration_id: Option<i64>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct GitHubAnnotation {
103 pub annotation_level: Option<String>,
104 pub message: Option<String>,
105 pub title: Option<String>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct GitHubBranchRef {
110 #[serde(rename = "ref")]
111 pub ref_: String,
112 pub sha: String,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct GitHubComment {
117 pub id: u64,
118 pub body: String,
119 pub user: GitHubUser,
120 pub created_at: String,
121 pub updated_at: Option<String>,
122 pub html_url: Option<String>,
123 pub issue_url: Option<String>,
126 #[serde(default)]
131 pub author_association: Option<String>,
132}
133
134pub fn extract_issue_number_from_url(url: &str) -> Option<String> {
139 url.rsplit('/')
140 .next()
141 .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
142 .map(String::from)
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct GitHubReview {
148 pub id: u64,
149 pub user: GitHubUser,
150 pub body: Option<String>,
151 pub state: String,
152 pub html_url: Option<String>,
153 pub submitted_at: String,
154 pub commit_id: Option<String>,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct GitHubReviewComment {
160 pub id: u64,
161 pub user: GitHubUser,
162 pub body: String,
163 pub path: String,
164 pub line: Option<u32>,
165 pub original_line: Option<u32>,
166 pub commit_id: String,
167 pub original_commit_id: String,
168 pub html_url: String,
169 pub created_at: String,
170 pub updated_at: String,
171 pub in_reply_to_id: Option<u64>,
172 pub diff_hunk: Option<String>,
173}
174
175#[derive(Debug, Clone)]
177pub struct PullRequestReview {
178 pub review: GitHubReview,
179 pub comments: Vec<GitHubReviewComment>,
180}
181
182impl PullRequestReview {
183 pub fn actionable_comments(&self) -> Vec<&GitHubReviewComment> {
185 self.comments
186 .iter()
187 .filter(|c| !c.body.trim().is_empty() && c.in_reply_to_id.is_none())
188 .collect()
189 }
190}
191
192#[cfg(test)]
193impl PullRequestReview {
194 pub fn requests_changes(&self) -> bool {
196 self.review.state == "CHANGES_REQUESTED"
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 #[test]
205 fn test_github_review_deserialization() {
206 let json = r#"{
207 "id": 789,
208 "user": {"login": "reviewer"},
209 "body": "Please fix this",
210 "state": "CHANGES_REQUESTED",
211 "html_url": "https://github.com/test/repo/pull/123#pullrequestreview-789",
212 "submitted_at": "2024-01-01T12:00:00Z",
213 "commit_id": "abc123"
214 }"#;
215
216 let review: GitHubReview = serde_json::from_str(json).unwrap();
217 assert_eq!(review.id, 789);
218 assert_eq!(review.user.login, "reviewer");
219 assert_eq!(review.body, Some("Please fix this".to_string()));
220 assert_eq!(review.state, "CHANGES_REQUESTED");
221 assert_eq!(review.submitted_at, "2024-01-01T12:00:00Z");
222 assert_eq!(review.commit_id, Some("abc123".to_string()));
223 }
224
225 #[test]
226 fn test_github_review_comment_deserialization() {
227 let json = r#"{
228 "id": 101112,
229 "user": {"login": "reviewer"},
230 "body": "This line needs fixing",
231 "path": "src/main.rs",
232 "line": 42,
233 "original_line": 42,
234 "commit_id": "abc123",
235 "original_commit_id": "abc123",
236 "html_url": "https://github.com/test/repo/pull/123#discussion_r101112",
237 "created_at": "2024-01-01T12:00:00Z",
238 "updated_at": "2024-01-01T12:00:00Z",
239 "in_reply_to_id": null,
240 "diff_hunk": "@@ -40,5 +40,5 @@ fn main() {\n let x = 1;\n- let y = 2;\n+ let y = 3;"
241 }"#;
242
243 let comment: GitHubReviewComment = serde_json::from_str(json).unwrap();
244 assert_eq!(comment.id, 101112);
245 assert_eq!(comment.user.login, "reviewer");
246 assert_eq!(comment.body, "This line needs fixing");
247 assert_eq!(comment.path, "src/main.rs");
248 assert_eq!(comment.line, Some(42));
249 assert_eq!(comment.commit_id, "abc123");
250 assert_eq!(comment.in_reply_to_id, None);
251 assert!(comment.diff_hunk.is_some());
252 }
253
254 #[test]
255 fn github_check_run_deserializes_app_id() {
256 let json = r#"{
257 "id": 4,
258 "name": "ci/test",
259 "status": "completed",
260 "conclusion": "success",
261 "app": { "id": 42, "slug": "shipcheck", "name": "shipcheck" }
262 }"#;
263 let run: GitHubCheckRun = serde_json::from_str(json).unwrap();
264 assert_eq!(run.name, "ci/test");
265 assert_eq!(run.app.unwrap().id, 42);
266 }
267
268 #[test]
269 fn github_check_run_deserializes_without_app() {
270 let json = r#"{
271 "id": 4,
272 "name": "ci/test",
273 "status": "completed",
274 "conclusion": "success"
275 }"#;
276 let run: GitHubCheckRun = serde_json::from_str(json).unwrap();
277 assert!(run.app.is_none());
278 }
279
280 #[test]
281 fn test_github_branch_ref_deserialization() {
282 let json = r#"{
283 "ref": "main",
284 "sha": "abc123"
285 }"#;
286
287 let branch_ref: GitHubBranchRef = serde_json::from_str(json).unwrap();
288 assert_eq!(branch_ref.ref_, "main");
289 assert_eq!(branch_ref.sha, "abc123");
290 }
291
292 #[test]
293 fn test_pull_request_review_requests_changes() {
294 let review = PullRequestReview {
295 review: GitHubReview {
296 id: 1,
297 user: GitHubUser {
298 login: "reviewer".to_string(),
299 },
300 body: Some("Please fix".to_string()),
301 state: "CHANGES_REQUESTED".to_string(),
302 html_url: None,
303 submitted_at: "2024-01-01T00:00:00Z".to_string(),
304 commit_id: None,
305 },
306 comments: vec![],
307 };
308
309 assert!(review.requests_changes());
310 }
311
312 #[test]
313 fn test_pull_request_review_approved() {
314 let review = PullRequestReview {
315 review: GitHubReview {
316 id: 1,
317 user: GitHubUser {
318 login: "reviewer".to_string(),
319 },
320 body: Some("LGTM".to_string()),
321 state: "APPROVED".to_string(),
322 html_url: None,
323 submitted_at: "2024-01-01T00:00:00Z".to_string(),
324 commit_id: None,
325 },
326 comments: vec![],
327 };
328
329 assert!(!review.requests_changes());
330 }
331
332 #[test]
333 fn test_pull_request_review_actionable_comments() {
334 let review = PullRequestReview {
335 review: GitHubReview {
336 id: 1,
337 user: GitHubUser {
338 login: "reviewer".to_string(),
339 },
340 body: None,
341 state: "CHANGES_REQUESTED".to_string(),
342 html_url: None,
343 submitted_at: "2024-01-01T00:00:00Z".to_string(),
344 commit_id: None,
345 },
346 comments: vec![
347 GitHubReviewComment {
348 id: 1,
349 user: GitHubUser {
350 login: "reviewer".to_string(),
351 },
352 body: "Fix this".to_string(),
353 path: "src/main.rs".to_string(),
354 line: Some(10),
355 original_line: Some(10),
356 commit_id: "abc".to_string(),
357 original_commit_id: "abc".to_string(),
358 html_url: "url".to_string(),
359 created_at: "2024-01-01T00:00:00Z".to_string(),
360 updated_at: "2024-01-01T00:00:00Z".to_string(),
361 in_reply_to_id: None,
362 diff_hunk: None,
363 },
364 GitHubReviewComment {
365 id: 2,
366 user: GitHubUser {
367 login: "reviewer".to_string(),
368 },
369 body: "".to_string(), path: "src/lib.rs".to_string(),
371 line: Some(20),
372 original_line: Some(20),
373 commit_id: "abc".to_string(),
374 original_commit_id: "abc".to_string(),
375 html_url: "url".to_string(),
376 created_at: "2024-01-01T00:00:00Z".to_string(),
377 updated_at: "2024-01-01T00:00:00Z".to_string(),
378 in_reply_to_id: None,
379 diff_hunk: None,
380 },
381 GitHubReviewComment {
382 id: 3,
383 user: GitHubUser {
384 login: "reviewer".to_string(),
385 },
386 body: "Reply to this".to_string(),
387 path: "src/lib.rs".to_string(),
388 line: Some(30),
389 original_line: Some(30),
390 commit_id: "abc".to_string(),
391 original_commit_id: "abc".to_string(),
392 html_url: "url".to_string(),
393 created_at: "2024-01-01T00:00:00Z".to_string(),
394 updated_at: "2024-01-01T00:00:00Z".to_string(),
395 in_reply_to_id: Some(1), diff_hunk: None,
397 },
398 ],
399 };
400
401 let actionable = review.actionable_comments();
402 assert_eq!(actionable.len(), 1);
403 assert_eq!(actionable[0].id, 1);
404 assert_eq!(actionable[0].body, "Fix this");
405 }
406
407 #[test]
408 fn extract_issue_number_from_url_works() {
409 assert_eq!(
410 extract_issue_number_from_url("https://api.github.com/repos/owner/repo/issues/123"),
411 Some("123".into())
412 );
413 }
414
415 #[test]
416 fn extract_issue_number_from_url_empty() {
417 assert_eq!(extract_issue_number_from_url(""), None);
418 }
419}