1use 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#[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}