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