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