Skip to main content

torii_lib/platforms/github/
pr.rs

1//! GitHub — pr client.
2
3use crate::error::{Result, ToriiError};
4use crate::platforms::pr::*;
5use reqwest::blocking::Client;
6
7pub struct GitHubPrClient {
8    token: String,
9    base_url: String,
10}
11
12impl GitHubPrClient {
13    pub fn new() -> Result<Self> {
14        let token = crate::auth::resolve_token("github", ".")
15            .value
16            .ok_or_else(|| ToriiError::Auth {
17                provider: "github".into(),
18                message: "GitHub token not found. Run: torii auth set github YOUR_TOKEN"
19                    .to_string(),
20            })?;
21        Ok(Self {
22            token,
23            base_url: "https://api.github.com".to_string(),
24        })
25    }
26
27    fn client(&self) -> Client {
28        crate::http::make_client()
29    }
30
31    fn auth(&self) -> String {
32        format!("token {}", self.token)
33    }
34}
35
36impl PrClient for GitHubPrClient {
37    fn create(&self, owner: &str, repo: &str, opts: CreatePrOptions) -> Result<PullRequest> {
38        let url = format!("{}/repos/{}/{}/pulls", self.base_url, owner, repo);
39        let body = serde_json::json!({
40            "title": opts.title,
41            "body":  opts.body.unwrap_or_default(),
42            "head":  opts.head,
43            "base":  opts.base,
44            "draft": opts.draft,
45        });
46        let req = self
47            .client()
48            .post(&url)
49            .header("Authorization", self.auth())
50            .header("Accept", "application/vnd.github.v3+json")
51            .json(&body);
52        let json = crate::http::send_json(req, "GitHub create PR")?;
53        parse_github_pr(&json)
54    }
55
56    fn list(&self, owner: &str, repo: &str, state: &str) -> Result<Vec<PullRequest>> {
57        let url = format!(
58            "{}/repos/{}/{}/pulls?state={}&per_page=50",
59            self.base_url, owner, repo, state
60        );
61        let req = self
62            .client()
63            .get(&url)
64            .header("Authorization", self.auth())
65            .header("Accept", "application/vnd.github.v3+json");
66        let json = crate::http::send_json(req, &format!("GitHub (url: {})", url))?;
67        crate::http::extract_array(&json, &url)?
68            .iter()
69            .map(parse_github_pr)
70            .collect()
71    }
72
73    fn get(&self, owner: &str, repo: &str, number: u64) -> Result<PullRequest> {
74        let url = format!(
75            "{}/repos/{}/{}/pulls/{}",
76            self.base_url, owner, repo, number
77        );
78        let req = self
79            .client()
80            .get(&url)
81            .header("Authorization", self.auth())
82            .header("Accept", "application/vnd.github.v3+json");
83        let json = crate::http::send_json(req, &format!("GitHub PR #{}", number))?;
84        parse_github_pr(&json)
85    }
86
87    fn merge(&self, owner: &str, repo: &str, number: u64, method: MergeMethod) -> Result<()> {
88        let url = format!(
89            "{}/repos/{}/{}/pulls/{}/merge",
90            self.base_url, owner, repo, number
91        );
92        let body = serde_json::json!({ "merge_method": method.to_string() });
93        let req = self
94            .client()
95            .put(&url)
96            .header("Authorization", self.auth())
97            .header("Accept", "application/vnd.github.v3+json")
98            .json(&body);
99        crate::http::send_empty(req, "GitHub merge PR")
100    }
101
102    fn close(&self, owner: &str, repo: &str, number: u64) -> Result<()> {
103        let url = format!(
104            "{}/repos/{}/{}/pulls/{}",
105            self.base_url, owner, repo, number
106        );
107        let body = serde_json::json!({ "state": "closed" });
108        let req = self
109            .client()
110            .patch(&url)
111            .header("Authorization", self.auth())
112            .header("Accept", "application/vnd.github.v3+json")
113            .json(&body);
114        crate::http::send_empty(req, "GitHub close PR")
115    }
116
117    fn update(&self, owner: &str, repo: &str, number: u64, opts: UpdatePrOptions) -> Result<()> {
118        let url = format!(
119            "{}/repos/{}/{}/pulls/{}",
120            self.base_url, owner, repo, number
121        );
122        let mut body = serde_json::Map::new();
123        if let Some(t) = opts.title {
124            body.insert("title".into(), t.into());
125        }
126        if let Some(b) = opts.body {
127            body.insert("body".into(), b.into());
128        }
129        if let Some(b) = opts.base {
130            body.insert("base".into(), b.into());
131        }
132        let req = self
133            .client()
134            .patch(&url)
135            .header("Authorization", self.auth())
136            .header("Accept", "application/vnd.github.v3+json")
137            .json(&serde_json::Value::Object(body));
138        crate::http::send_empty(req, "GitHub update PR")
139    }
140
141    fn delete_branch(&self, owner: &str, repo: &str, branch: &str) -> Result<()> {
142        let url = format!(
143            "{}/repos/{}/{}/git/refs/heads/{}",
144            self.base_url, owner, repo, branch
145        );
146        let req = self
147            .client()
148            .delete(&url)
149            .header("Authorization", self.auth())
150            .header("Accept", "application/vnd.github.v3+json");
151        crate::http::send_empty(req, "GitHub delete branch")
152    }
153
154    fn checkout_branch(&self, pr: &PullRequest) -> String {
155        pr.head.clone()
156    }
157}
158
159fn parse_github_pr(json: &serde_json::Value) -> Result<PullRequest> {
160    Ok(PullRequest {
161        number: json["number"].as_u64().unwrap_or(0),
162        title: json["title"].as_str().unwrap_or("").to_string(),
163        body: json["body"].as_str().map(|s| s.to_string()),
164        state: json["state"].as_str().unwrap_or("").to_string(),
165        head: json["head"]["ref"].as_str().unwrap_or("").to_string(),
166        base: json["base"]["ref"].as_str().unwrap_or("").to_string(),
167        author: json["user"]["login"].as_str().unwrap_or("").to_string(),
168        url: json["html_url"].as_str().unwrap_or("").to_string(),
169        draft: json["draft"].as_bool().unwrap_or(false),
170        mergeable: json["mergeable"].as_bool(),
171        created_at: json["created_at"].as_str().unwrap_or("").to_string(),
172    })
173}
174
175// ============================================================================
176// GitLab (Merge Requests)
177// ============================================================================
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use httpmock::prelude::*;
183
184    fn client_for(server: &MockServer) -> GitHubPrClient {
185        GitHubPrClient {
186            token: "test-token".into(),
187            base_url: server.base_url(),
188        }
189    }
190
191    #[test]
192    fn parse_github_pr_maps_all_fields() {
193        let json = serde_json::json!({
194            "number": 42u64,
195            "title": "Add feature",
196            "body": "Long description",
197            "state": "open",
198            "head": { "ref": "feature/x" },
199            "base": { "ref": "main" },
200            "user": { "login": "octocat" },
201            "html_url": "https://github.com/o/r/pull/42",
202            "draft": true,
203            "mergeable": false,
204            "created_at": "2026-01-02T03:04:05Z",
205        });
206        let pr = parse_github_pr(&json).unwrap();
207        assert_eq!(pr.number, 42);
208        assert_eq!(pr.title, "Add feature");
209        assert_eq!(pr.body.as_deref(), Some("Long description"));
210        assert_eq!(pr.state, "open");
211        assert_eq!(pr.head, "feature/x");
212        assert_eq!(pr.base, "main");
213        assert_eq!(pr.author, "octocat");
214        assert_eq!(pr.url, "https://github.com/o/r/pull/42");
215        assert!(pr.draft);
216        assert_eq!(pr.mergeable, Some(false));
217        assert_eq!(pr.created_at, "2026-01-02T03:04:05Z");
218    }
219
220    #[test]
221    fn parse_github_pr_defaults_when_fields_missing() {
222        let pr = parse_github_pr(&serde_json::json!({})).unwrap();
223        assert_eq!(pr.number, 0);
224        assert_eq!(pr.title, "");
225        assert_eq!(pr.body, None);
226        assert_eq!(pr.head, "");
227        assert_eq!(pr.base, "");
228        assert!(!pr.draft);
229        assert_eq!(pr.mergeable, None);
230    }
231
232    #[test]
233    fn list_parses_pull_requests_from_api() {
234        let server = MockServer::start();
235        let m = server.mock(|when, then| {
236            when.method(GET)
237                .path("/repos/octo/demo/pulls")
238                .query_param("state", "open")
239                .query_param("per_page", "50")
240                .header("Authorization", "token test-token");
241            then.status(200).json_body(serde_json::json!([{
242                "number": 7,
243                "title": "First",
244                "state": "open",
245                "head": { "ref": "topic" },
246                "base": { "ref": "main" },
247                "user": { "login": "alice" },
248                "html_url": "https://x/pull/7",
249                "draft": false,
250                "created_at": "2026-01-01T00:00:00Z",
251            }]));
252        });
253        let prs = client_for(&server).list("octo", "demo", "open").unwrap();
254        m.assert();
255        assert_eq!(prs.len(), 1);
256        assert_eq!(prs[0].number, 7);
257        assert_eq!(prs[0].head, "topic");
258        assert_eq!(prs[0].author, "alice");
259    }
260
261    #[test]
262    fn merge_puts_to_merge_endpoint_with_auth_and_method() {
263        let server = MockServer::start();
264        let m = server.mock(|when, then| {
265            when.method(PUT)
266                .path("/repos/octo/demo/pulls/5/merge")
267                .header("Authorization", "token test-token")
268                .json_body(serde_json::json!({ "merge_method": "squash" }));
269            then.status(200)
270                .json_body(serde_json::json!({ "merged": true }));
271        });
272        client_for(&server)
273            .merge("octo", "demo", 5, MergeMethod::Squash)
274            .unwrap();
275        m.assert();
276    }
277
278    #[test]
279    fn get_maps_404_to_platform_api_error() {
280        let server = MockServer::start();
281        server.mock(|when, then| {
282            when.method(GET).path("/repos/octo/demo/pulls/99");
283            then.status(404)
284                .json_body(serde_json::json!({ "message": "Not Found" }));
285        });
286        let err = client_for(&server).get("octo", "demo", 99).unwrap_err();
287        assert!(
288            matches!(err, ToriiError::PlatformApi { status: 404, .. }),
289            "expected PlatformApi 404, got: {err:?}"
290        );
291    }
292}