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    /// GitHub database id, the value the dependencies API calls issue_id
9    #[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    /// Present when the item is actually a pull request (GitHub API returns PRs in /issues).
21    pub pull_request: Option<serde_json::Value>,
22    /// Author's relationship to the repo: OWNER, MEMBER, COLLABORATOR, CONTRIBUTOR,
23    /// FIRST_TIME_CONTRIBUTOR, FIRST_TIMER, MANNEQUIN, NONE.
24    #[serde(default)]
25    pub author_association: Option<String>,
26    /// `completed`, `not_planned`, `duplicate`, `reopened`, or absent
27    #[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/// Pull request details from the GitHub API.
43/// The `mergeable` field indicates if the PR can be merged:
44/// - `true` - PR can be merged
45/// - `false` - PR has merge conflicts (CONFLICTING)
46/// - `null` - GitHub is still computing mergeability
47#[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    /// GitHub App that posted the run, used to match a ruleset `integration_id`
68    #[serde(default)]
69    pub app: Option<GitHubApp>,
70}
71
72/// The GitHub App that created a check run (`check_run.app`)
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct GitHubApp {
75    pub id: i64,
76}
77
78/// A required status check from classic branch protection or a ruleset
79#[derive(Debug, Clone, PartialEq, Eq, Hash)]
80pub struct RequiredStatusCheck {
81    pub context: String,
82    /// GitHub App that must post this check. `None` means any app's check with this name counts
83    pub integration_id: Option<i64>,
84}
85
86/// A check run annotation returned by the GitHub Checks API.
87///
88/// Annotations appear on the "Annotations" tab of a check run in the GitHub UI.
89/// GitHub uses annotations for job-level messages, including billing failures
90/// ("The job was not started because recent account payments have failed …").
91#[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}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct GitHubComment {
107    pub id: u64,
108    pub body: String,
109    pub user: GitHubUser,
110    pub created_at: String,
111    pub updated_at: Option<String>,
112    pub html_url: Option<String>,
113    /// Present when fetched from the repo-level /issues/comments endpoint.
114    /// Format: https://api.github.com/repos/owner/repo/issues/123
115    pub issue_url: Option<String>,
116    /// Author's relationship to the repo for comment payloads when present.
117    /// This field is present on repo-level comment endpoints and may be
118    /// missing for some GraphQL-derived nodes. See `author_association` on
119    /// issues for the corresponding field.
120    #[serde(default)]
121    pub author_association: Option<String>,
122}
123
124/// Extract issue/PR number from a GitHub API URL.
125///
126/// Parses the trailing numeric segment from URLs like:
127/// `https://api.github.com/repos/owner/repo/issues/123`
128pub fn extract_issue_number_from_url(url: &str) -> Option<String> {
129    url.rsplit('/')
130        .next()
131        .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
132        .map(String::from)
133}
134
135/// PR review state - "APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct GitHubReview {
138    pub id: u64,
139    pub user: GitHubUser,
140    pub body: Option<String>,
141    pub state: String,
142    pub html_url: Option<String>,
143    pub submitted_at: String,
144    pub commit_id: Option<String>,
145}
146
147/// PR review comment on a specific line of code.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct GitHubReviewComment {
150    pub id: u64,
151    pub user: GitHubUser,
152    pub body: String,
153    pub path: String,
154    pub line: Option<u32>,
155    pub original_line: Option<u32>,
156    pub commit_id: String,
157    pub original_commit_id: String,
158    pub html_url: String,
159    pub created_at: String,
160    pub updated_at: String,
161    pub in_reply_to_id: Option<u64>,
162    pub diff_hunk: Option<String>,
163}
164
165/// A PR review with its associated comments.
166#[derive(Debug, Clone)]
167pub struct PullRequestReview {
168    pub review: GitHubReview,
169    pub comments: Vec<GitHubReviewComment>,
170}
171
172impl PullRequestReview {
173    /// Get actionable comments (non-empty, not replies).
174    pub fn actionable_comments(&self) -> Vec<&GitHubReviewComment> {
175        self.comments
176            .iter()
177            .filter(|c| !c.body.trim().is_empty() && c.in_reply_to_id.is_none())
178            .collect()
179    }
180}
181
182#[cfg(test)]
183impl PullRequestReview {
184    /// Check if this review requests changes (test helper).
185    pub fn requests_changes(&self) -> bool {
186        self.review.state == "CHANGES_REQUESTED"
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn test_github_review_deserialization() {
196        let json = r#"{
197            "id": 789,
198            "user": {"login": "reviewer"},
199            "body": "Please fix this",
200            "state": "CHANGES_REQUESTED",
201            "html_url": "https://github.com/test/repo/pull/123#pullrequestreview-789",
202            "submitted_at": "2024-01-01T12:00:00Z",
203            "commit_id": "abc123"
204        }"#;
205
206        let review: GitHubReview = serde_json::from_str(json).unwrap();
207        assert_eq!(review.id, 789);
208        assert_eq!(review.user.login, "reviewer");
209        assert_eq!(review.body, Some("Please fix this".to_string()));
210        assert_eq!(review.state, "CHANGES_REQUESTED");
211        assert_eq!(review.submitted_at, "2024-01-01T12:00:00Z");
212        assert_eq!(review.commit_id, Some("abc123".to_string()));
213    }
214
215    #[test]
216    fn test_github_review_comment_deserialization() {
217        let json = r#"{
218            "id": 101112,
219            "user": {"login": "reviewer"},
220            "body": "This line needs fixing",
221            "path": "src/main.rs",
222            "line": 42,
223            "original_line": 42,
224            "commit_id": "abc123",
225            "original_commit_id": "abc123",
226            "html_url": "https://github.com/test/repo/pull/123#discussion_r101112",
227            "created_at": "2024-01-01T12:00:00Z",
228            "updated_at": "2024-01-01T12:00:00Z",
229            "in_reply_to_id": null,
230            "diff_hunk": "@@ -40,5 +40,5 @@ fn main() {\n     let x = 1;\n-    let y = 2;\n+    let y = 3;"
231        }"#;
232
233        let comment: GitHubReviewComment = serde_json::from_str(json).unwrap();
234        assert_eq!(comment.id, 101112);
235        assert_eq!(comment.user.login, "reviewer");
236        assert_eq!(comment.body, "This line needs fixing");
237        assert_eq!(comment.path, "src/main.rs");
238        assert_eq!(comment.line, Some(42));
239        assert_eq!(comment.commit_id, "abc123");
240        assert_eq!(comment.in_reply_to_id, None);
241        assert!(comment.diff_hunk.is_some());
242    }
243
244    #[test]
245    fn github_check_run_deserializes_app_id() {
246        let json = r#"{
247            "id": 4,
248            "name": "ci/test",
249            "status": "completed",
250            "conclusion": "success",
251            "app": { "id": 42, "slug": "shipcheck", "name": "shipcheck" }
252        }"#;
253        let run: GitHubCheckRun = serde_json::from_str(json).unwrap();
254        assert_eq!(run.name, "ci/test");
255        assert_eq!(run.app.unwrap().id, 42);
256    }
257
258    #[test]
259    fn github_check_run_deserializes_without_app() {
260        let json = r#"{
261            "id": 4,
262            "name": "ci/test",
263            "status": "completed",
264            "conclusion": "success"
265        }"#;
266        let run: GitHubCheckRun = serde_json::from_str(json).unwrap();
267        assert!(run.app.is_none());
268    }
269
270    #[test]
271    fn test_github_branch_ref_deserialization() {
272        let json = r#"{
273            "ref": "main",
274            "sha": "abc123"
275        }"#;
276
277        let branch_ref: GitHubBranchRef = serde_json::from_str(json).unwrap();
278        assert_eq!(branch_ref.ref_, "main");
279        assert_eq!(branch_ref.sha, "abc123");
280    }
281
282    #[test]
283    fn test_pull_request_review_requests_changes() {
284        let review = PullRequestReview {
285            review: GitHubReview {
286                id: 1,
287                user: GitHubUser {
288                    login: "reviewer".to_string(),
289                },
290                body: Some("Please fix".to_string()),
291                state: "CHANGES_REQUESTED".to_string(),
292                html_url: None,
293                submitted_at: "2024-01-01T00:00:00Z".to_string(),
294                commit_id: None,
295            },
296            comments: vec![],
297        };
298
299        assert!(review.requests_changes());
300    }
301
302    #[test]
303    fn test_pull_request_review_approved() {
304        let review = PullRequestReview {
305            review: GitHubReview {
306                id: 1,
307                user: GitHubUser {
308                    login: "reviewer".to_string(),
309                },
310                body: Some("LGTM".to_string()),
311                state: "APPROVED".to_string(),
312                html_url: None,
313                submitted_at: "2024-01-01T00:00:00Z".to_string(),
314                commit_id: None,
315            },
316            comments: vec![],
317        };
318
319        assert!(!review.requests_changes());
320    }
321
322    #[test]
323    fn test_pull_request_review_actionable_comments() {
324        let review = PullRequestReview {
325            review: GitHubReview {
326                id: 1,
327                user: GitHubUser {
328                    login: "reviewer".to_string(),
329                },
330                body: None,
331                state: "CHANGES_REQUESTED".to_string(),
332                html_url: None,
333                submitted_at: "2024-01-01T00:00:00Z".to_string(),
334                commit_id: None,
335            },
336            comments: vec![
337                GitHubReviewComment {
338                    id: 1,
339                    user: GitHubUser {
340                        login: "reviewer".to_string(),
341                    },
342                    body: "Fix this".to_string(),
343                    path: "src/main.rs".to_string(),
344                    line: Some(10),
345                    original_line: Some(10),
346                    commit_id: "abc".to_string(),
347                    original_commit_id: "abc".to_string(),
348                    html_url: "url".to_string(),
349                    created_at: "2024-01-01T00:00:00Z".to_string(),
350                    updated_at: "2024-01-01T00:00:00Z".to_string(),
351                    in_reply_to_id: None,
352                    diff_hunk: None,
353                },
354                GitHubReviewComment {
355                    id: 2,
356                    user: GitHubUser {
357                        login: "reviewer".to_string(),
358                    },
359                    body: "".to_string(), // Empty - not actionable
360                    path: "src/lib.rs".to_string(),
361                    line: Some(20),
362                    original_line: Some(20),
363                    commit_id: "abc".to_string(),
364                    original_commit_id: "abc".to_string(),
365                    html_url: "url".to_string(),
366                    created_at: "2024-01-01T00:00:00Z".to_string(),
367                    updated_at: "2024-01-01T00:00:00Z".to_string(),
368                    in_reply_to_id: None,
369                    diff_hunk: None,
370                },
371                GitHubReviewComment {
372                    id: 3,
373                    user: GitHubUser {
374                        login: "reviewer".to_string(),
375                    },
376                    body: "Reply to this".to_string(),
377                    path: "src/lib.rs".to_string(),
378                    line: Some(30),
379                    original_line: Some(30),
380                    commit_id: "abc".to_string(),
381                    original_commit_id: "abc".to_string(),
382                    html_url: "url".to_string(),
383                    created_at: "2024-01-01T00:00:00Z".to_string(),
384                    updated_at: "2024-01-01T00:00:00Z".to_string(),
385                    in_reply_to_id: Some(1), // Reply - not actionable as standalone
386                    diff_hunk: None,
387                },
388            ],
389        };
390
391        let actionable = review.actionable_comments();
392        assert_eq!(actionable.len(), 1);
393        assert_eq!(actionable[0].id, 1);
394        assert_eq!(actionable[0].body, "Fix this");
395    }
396
397    #[test]
398    fn extract_issue_number_from_url_works() {
399        assert_eq!(
400            extract_issue_number_from_url("https://api.github.com/repos/owner/repo/issues/123"),
401            Some("123".into())
402        );
403    }
404
405    #[test]
406    fn extract_issue_number_from_url_empty() {
407        assert_eq!(extract_issue_number_from_url(""), None);
408    }
409}