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