Skip to main content

orch/github/
types.rs

1//! GitHub API response types — deserialized from `gh api` JSON output.
2
3use 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    /// Present when the item is actually a pull request (GitHub API returns PRs in /issues).
18    pub pull_request: Option<serde_json::Value>,
19    /// Author's relationship to the repo: OWNER, MEMBER, COLLABORATOR, CONTRIBUTOR,
20    /// FIRST_TIME_CONTRIBUTOR, FIRST_TIMER, MANNEQUIN, NONE.
21    #[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/// Pull request details from the GitHub API.
37/// The `mergeable` field indicates if the PR can be merged:
38/// - `true` - PR can be merged
39/// - `false` - PR has merge conflicts (CONFLICTING)
40/// - `null` - GitHub is still computing mergeability
41#[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/// A check run annotation returned by the GitHub Checks API.
64///
65/// Annotations appear on the "Annotations" tab of a check run in the GitHub UI.
66/// GitHub uses annotations for job-level messages, including billing failures
67/// ("The job was not started because recent account payments have failed …").
68#[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    /// Present when fetched from the repo-level /issues/comments endpoint.
91    /// Format: https://api.github.com/repos/owner/repo/issues/123
92    pub issue_url: Option<String>,
93    /// Author's relationship to the repo for comment payloads when present.
94    /// This field is present on repo-level comment endpoints and may be
95    /// missing for some GraphQL-derived nodes. See `author_association` on
96    /// issues for the corresponding field.
97    #[serde(default)]
98    pub author_association: Option<String>,
99}
100
101/// Extract issue/PR number from a GitHub API URL.
102///
103/// Parses the trailing numeric segment from URLs like:
104/// `https://api.github.com/repos/owner/repo/issues/123`
105pub 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/// PR review state - "APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"
113#[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/// PR review comment on a specific line of code.
125#[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/// A PR review with its associated comments.
143#[derive(Debug, Clone)]
144pub struct PullRequestReview {
145    pub review: GitHubReview,
146    pub comments: Vec<GitHubReviewComment>,
147}
148
149impl PullRequestReview {
150    /// Get actionable comments (non-empty, not replies).
151    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    /// Check if this review requests changes (test helper).
162    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(), // Empty - not actionable
311                    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), // Reply - not actionable as standalone
337                    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}