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