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