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