1use review_protocol::{
25 CommentSide, ListQuery, NewComment, PrRef, PrState, PrSummary, ProviderResult, PullRequest,
26 ReviewComment, ReviewError, ReviewProvider, ReviewVerdict, WorktreeHandle,
27};
28use serde::{Deserialize, Serialize};
29use std::path::Path;
30use std::process::Command;
31
32pub fn make_provider() -> Box<dyn ReviewProvider> {
33 Box::new(GithubProvider)
34}
35
36struct GithubProvider;
37
38impl ReviewProvider for GithubProvider {
39 fn id(&self) -> &'static str {
40 "github"
41 }
42
43 fn check_ready(&self) -> ProviderResult<()> {
44 let out = Command::new("gh")
45 .args(["auth", "status"])
46 .output()
47 .map_err(|e| {
48 ReviewError::NotAuthenticated(format!(
49 "could not run `gh` (is the GitHub CLI installed?): {e}"
50 ))
51 })?;
52 if !out.status.success() {
53 return Err(ReviewError::NotAuthenticated(
54 String::from_utf8_lossy(&out.stderr).trim().to_string(),
55 ));
56 }
57 Ok(())
58 }
59
60 fn list_pull_requests(&self, query: ListQuery) -> ProviderResult<Vec<PrSummary>> {
61 let fields = "number,title,author,headRefName,baseRefName,state,url";
62 let mut args = vec!["pr", "list", "--json", fields];
63 if query.assigned_to_me {
64 args.extend_from_slice(&["--search", "review-requested:@me state:open"]);
65 }
66 if let Some(state) = query.state {
67 let s = match state {
69 PrState::Open => "open",
70 PrState::Closed => "closed",
71 PrState::Merged => "merged",
72 PrState::All => "all",
73 };
74 args.extend_from_slice(&["--state", s]);
75 }
76 let raw = run_gh(&args)?;
77 let parsed: Vec<RawPrSummary> = serde_json::from_slice(&raw)
78 .map_err(|e| ReviewError::Backend(format!("parse gh pr list: {e}")))?;
79 Ok(parsed.into_iter().map(RawPrSummary::into_protocol).collect())
80 }
81
82 fn get_pull_request(&self, r: PrRef) -> ProviderResult<PullRequest> {
83 let arg = match &r {
84 PrRef::Number(n) => n.to_string(),
85 PrRef::Branch(b) => b.clone(),
86 PrRef::Url(u) => u.clone(),
87 };
88 let fields = "number,title,body,author,headRefName,baseRefName,headRefOid,state,url";
94 let raw = run_gh(&["pr", "view", &arg, "--json", fields])?;
95 let parsed: RawPrFull = serde_json::from_slice(&raw)
96 .map_err(|e| ReviewError::Backend(format!("parse gh pr view: {e}")))?;
97 Ok(parsed.into_protocol())
98 }
99
100 fn ensure_worktree(
101 &self,
102 pr: &PullRequest,
103 cache_root: &str,
104 ) -> ProviderResult<WorktreeHandle> {
105 if let Ok(cur) = git_current_branch() {
108 if cur == pr.branch {
109 let cwd = std::env::current_dir()
110 .map(|p| p.display().to_string())
111 .unwrap_or_else(|_| ".".into());
112 return Ok(WorktreeHandle {
113 path: cwd,
114 cleanup_on_drop: false,
115 });
116 }
117 }
118
119 let dest_dir =
124 format!("{}-{}-{}", pr.repo_owner, pr.repo_name, pr.number);
125 let dest = Path::new(cache_root).join(&dest_dir);
126 if let Some(parent) = dest.parent() {
127 std::fs::create_dir_all(parent).map_err(|e| {
128 ReviewError::Backend(format!("create cache dir {}: {}", parent.display(), e))
129 })?;
130 }
131
132 let pr_ref = format!("pull/{}/head", pr.number);
133 let local_ref = format!("refs/lziff/review/{}", pr.number);
134 eprintln!(
141 "lziff: git fetch origin +{pr_ref}:{local_ref}…"
142 );
143 run_git_inherit(&[
144 "fetch",
145 "origin",
146 &format!("+{pr_ref}:{local_ref}"),
147 ])?;
148 let _ = run_git(&["worktree", "remove", "--force", dest.to_str().unwrap_or("")]);
150 eprintln!(
151 "lziff: git worktree add {} {}…",
152 dest.display(),
153 short_sha(&pr.head_sha)
154 );
155 run_git_inherit(&[
156 "worktree",
157 "add",
158 "--detach",
159 dest.to_str().unwrap_or_default(),
160 &pr.head_sha,
161 ])?;
162 Ok(WorktreeHandle {
163 path: dest.to_string_lossy().into_owned(),
164 cleanup_on_drop: true,
165 })
166 }
167
168 fn list_review_comments(&self, _pr: &PullRequest) -> ProviderResult<Vec<ReviewComment>> {
169 Ok(Vec::new())
174 }
175
176 fn submit_review(
177 &self,
178 pr: &PullRequest,
179 body: &str,
180 verdict: ReviewVerdict,
181 comments: Vec<NewComment>,
182 ) -> ProviderResult<()> {
183 if pr.repo_owner.is_empty() || pr.repo_name.is_empty() {
184 return Err(ReviewError::Backend(
185 "PR is missing repo owner/name (cannot submit)".into(),
186 ));
187 }
188 let payload = ReviewPayload {
191 commit_id: pr.head_sha.clone(),
192 body: body.to_string(),
193 event: match verdict {
194 ReviewVerdict::Comment => "COMMENT",
195 ReviewVerdict::Approve => "APPROVE",
196 ReviewVerdict::RequestChanges => "REQUEST_CHANGES",
197 }
198 .to_string(),
199 comments: comments.into_iter().map(NewCommentJson::from).collect(),
200 };
201 let body_json = serde_json::to_string(&payload)
202 .map_err(|e| ReviewError::Backend(format!("encode review body: {e}")))?;
203 let endpoint =
204 format!("repos/{}/{}/pulls/{}/reviews", pr.repo_owner, pr.repo_name, pr.number);
205 let mut child = Command::new("gh")
207 .args(["api", "-X", "POST", &endpoint, "--input", "-"])
208 .stdin(std::process::Stdio::piped())
209 .stdout(std::process::Stdio::piped())
210 .stderr(std::process::Stdio::piped())
211 .spawn()
212 .map_err(|e| ReviewError::Backend(format!("spawn gh: {e}")))?;
213 if let Some(stdin) = child.stdin.as_mut() {
214 use std::io::Write;
215 stdin
216 .write_all(body_json.as_bytes())
217 .map_err(|e| ReviewError::Backend(format!("write gh stdin: {e}")))?;
218 }
219 let out = child
220 .wait_with_output()
221 .map_err(|e| ReviewError::Backend(format!("wait gh: {e}")))?;
222 if !out.status.success() {
223 let msg = String::from_utf8_lossy(&out.stderr).trim().to_string();
224 return Err(classify_gh_error(&msg));
225 }
226 Ok(())
227 }
228}
229
230#[derive(Serialize)]
231struct ReviewPayload {
232 commit_id: String,
233 body: String,
234 event: String,
235 comments: Vec<NewCommentJson>,
236}
237
238#[derive(Serialize)]
239struct NewCommentJson {
240 path: String,
241 line: u32,
242 side: &'static str,
243 body: String,
244}
245
246impl From<NewComment> for NewCommentJson {
247 fn from(c: NewComment) -> Self {
248 Self {
249 path: c.path,
250 line: c.line,
251 side: match c.side {
252 CommentSide::Old => "LEFT",
253 CommentSide::New => "RIGHT",
254 },
255 body: c.body,
256 }
257 }
258}
259
260fn run_gh(args: &[&str]) -> ProviderResult<Vec<u8>> {
264 let out = Command::new("gh")
265 .args(args)
266 .output()
267 .map_err(|e| ReviewError::Backend(format!("spawn gh: {e}")))?;
268 if !out.status.success() {
269 let msg = String::from_utf8_lossy(&out.stderr).trim().to_string();
270 return Err(classify_gh_error(&msg));
271 }
272 Ok(out.stdout)
273}
274
275fn run_git(args: &[&str]) -> ProviderResult<Vec<u8>> {
276 let out = Command::new("git")
277 .args(args)
278 .output()
279 .map_err(|e| ReviewError::Backend(format!("spawn git: {e}")))?;
280 if !out.status.success() {
281 return Err(ReviewError::Backend(format!(
282 "git {} failed: {}",
283 args.join(" "),
284 String::from_utf8_lossy(&out.stderr).trim()
285 )));
286 }
287 Ok(out.stdout)
288}
289
290fn run_git_inherit(args: &[&str]) -> ProviderResult<()> {
295 let status = Command::new("git")
296 .args(args)
297 .status()
298 .map_err(|e| ReviewError::Backend(format!("spawn git: {e}")))?;
299 if !status.success() {
300 return Err(ReviewError::Backend(format!(
301 "git {} failed (exit {})",
302 args.join(" "),
303 status.code().unwrap_or(-1)
304 )));
305 }
306 Ok(())
307}
308
309fn short_sha(sha: &str) -> String {
310 sha.chars().take(8).collect()
311}
312
313fn git_current_branch() -> Result<String, ReviewError> {
314 let out = run_git(&["rev-parse", "--abbrev-ref", "HEAD"])?;
315 Ok(String::from_utf8_lossy(&out).trim().to_string())
316}
317
318fn classify_gh_error(msg: &str) -> ReviewError {
319 let lower = msg.to_ascii_lowercase();
320 if lower.contains("not authenticated") || lower.contains("authenticate") {
321 ReviewError::NotAuthenticated(msg.into())
322 } else if lower.contains("no pull requests found")
323 || lower.contains("not found")
324 || lower.contains("could not find")
325 {
326 ReviewError::NotFound(msg.into())
327 } else if lower.contains("network") || lower.contains("timeout") {
328 ReviewError::Network(msg.into())
329 } else {
330 ReviewError::Backend(msg.into())
331 }
332}
333
334#[derive(Deserialize, Default)]
338struct RawAuthor {
339 #[serde(default)]
340 login: String,
341}
342
343#[derive(Deserialize)]
344struct RawPrSummary {
345 number: u64,
346 title: String,
347 #[serde(default)]
348 author: RawAuthor,
349 #[serde(rename = "headRefName", default)]
350 head_ref_name: String,
351 #[serde(rename = "baseRefName", default)]
352 base_ref_name: String,
353 #[serde(default)]
354 state: String,
355 #[serde(default)]
356 url: String,
357}
358
359impl RawPrSummary {
360 fn into_protocol(self) -> PrSummary {
361 PrSummary {
362 number: self.number,
363 title: self.title,
364 author: self.author.login,
365 branch: self.head_ref_name,
366 base: self.base_ref_name,
367 state: parse_state(&self.state),
368 url: self.url,
369 }
370 }
371}
372
373#[derive(Deserialize)]
374struct RawPrFull {
375 number: u64,
376 title: String,
377 #[serde(default)]
378 body: String,
379 #[serde(default)]
380 author: RawAuthor,
381 #[serde(rename = "headRefName", default)]
382 head_ref_name: String,
383 #[serde(rename = "baseRefName", default)]
384 base_ref_name: String,
385 #[serde(rename = "headRefOid", default)]
386 head_ref_oid: String,
387 #[serde(default)]
388 state: String,
389 #[serde(default)]
390 url: String,
391}
392
393impl RawPrFull {
394 fn into_protocol(self) -> PullRequest {
395 let (mut owner, mut name) = (String::new(), String::new());
398 if let Some((o, n)) = parse_owner_name_from_url(&self.url) {
399 owner = o;
400 name = n;
401 }
402 PullRequest {
403 number: self.number,
404 title: self.title,
405 body: self.body,
406 author: self.author.login,
407 branch: self.head_ref_name,
408 base: self.base_ref_name,
409 head_sha: self.head_ref_oid,
410 base_sha: String::new(),
413 state: parse_state(&self.state),
414 url: self.url,
415 repo_owner: owner,
416 repo_name: name,
417 }
418 }
419}
420
421
422fn parse_state(s: &str) -> PrState {
423 match s.to_ascii_uppercase().as_str() {
424 "OPEN" => PrState::Open,
425 "CLOSED" => PrState::Closed,
426 "MERGED" => PrState::Merged,
427 _ => PrState::All,
428 }
429}
430
431fn parse_owner_name_from_url(url: &str) -> Option<(String, String)> {
435 let stripped = url
436 .trim_start_matches("https://")
437 .trim_start_matches("http://");
438 let parts: Vec<&str> = stripped.split('/').collect();
439 if parts.len() >= 3 && parts.first().map(|h| h.contains("github")).unwrap_or(false) {
440 return Some((parts[1].to_string(), parts[2].to_string()));
441 }
442 None
443}