Skip to main content

lziff_github/
lib.rs

1//! GitHub review backend for lziff.
2//!
3//! ════════════════════════════════════════════════════════════════════════
4//!  FUTURE PLUGIN — DO NOT IMPORT FROM `lziff` INTERNALS
5//! ════════════════════════════════════════════════════════════════════════
6//!
7//! This crate is the GitHub-specific [`ReviewProvider`] implementation.
8//! It will eventually be split out into a separate process speaking the
9//! `review-protocol` JSON-RPC over stdio. To keep that future viable:
10//!
11//! - The crate's `Cargo.toml` depends on `review-protocol` and
12//!   `serde_json` only. **Adding `lziff = ...` here is a hard policy
13//!   violation** — nothing in the GitHub plugin should know what's
14//!   inside the host.
15//! - All public types and methods are reachable through the
16//!   [`ReviewProvider`] trait. The host
17//!   (`crates/lziff/src/main.rs` via `crates/lziff/src/review.rs`)
18//!   only ever obtains a `Box<dyn ReviewProvider>` from
19//!   [`make_provider`] and never names `GithubProvider` directly.
20//! - We shell out to the user's `gh` CLI (already authenticated).
21//!   Going through HTTP+OAuth is left for the eventual stand-alone
22//!   plugin once we hit a perf/feature wall.
23
24use 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            // gh accepts open|closed|merged|all — map our enum.
68            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        // Note: gh's `pr view --json` does NOT currently expose `baseRefOid`
89        // or `baseRepository` (verified against gh 2.x — the field list its
90        // error message prints out includes neither). We resolve the base
91        // SHA ourselves after fetching the base branch in the host, and we
92        // derive owner/name from the URL when needed.
93        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 the user is already on the PR's branch in the cwd repo, work
106        // there. Cheap check: `git rev-parse --abbrev-ref HEAD`.
107        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        // Otherwise: fetch the PR head into a refspec we own and add a
120        // worktree at <cache_root>/<owner>-<repo>-<num>. Using the
121        // `pull/<n>/head` refspec works on github.com without us needing
122        // to know whether the PR comes from a fork.
123        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        // Inherit stdio for fetch + worktree add so the user sees git's
135        // own progress output, and — critically — so SSH/credential
136        // prompts (passphrase, host-key confirmation, askpass helpers)
137        // are routed to the user's terminal. Capturing them, as
138        // `run_git` does, is what made `--review` look hung when origin
139        // needed a credential.
140        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        // If a stale worktree exists at this path, remove it first.
149        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        // Wire-up plan: combine
170        //   `gh api repos/{owner}/{repo}/pulls/{n}/comments` (line-anchored)
171        //   `gh api repos/{owner}/{repo}/issues/{n}/comments`  (PR-level)
172        // Skipping for the first cut — the diff UX works without comments.
173        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        // Build the JSON body matching GitHub's
189        //   POST /repos/{owner}/{repo}/pulls/{n}/reviews
190        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        // `gh api -X POST <endpoint> --input -` reads JSON from stdin.
206        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
260// ---------------------------------------------------------------------------
261// Subprocess helpers
262
263fn 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
290/// Run `git` with stdio inherited from the parent process. Use this for
291/// commands that may prompt the user (fetch over SSH/HTTPS, worktree
292/// commands that touch the filesystem) so progress and prompts flow to
293/// the user's terminal instead of being silently captured.
294fn 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// ---------------------------------------------------------------------------
335// gh JSON shapes
336
337#[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        // owner/name come from the URL — `gh pr view --json` doesn't
396        // currently expose `baseRepository`, so we parse them ourselves.
397        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            // Resolved by the host (lziff::review) after the base branch
411            // has been fetched into the worktree.
412            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
431/// Parse owner/repo out of a typical PR URL:
432///   https://github.com/<owner>/<repo>/pull/<n>
433/// Used as a fallback when `gh` doesn't surface the repository fields.
434fn 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}