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