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