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