Files
openidea/crates/ide-github/src/lib.rs
l0ng-ai a3bbbf90a4 feat(vcs): clone from the repositories of your GitHub account (#1125)
The Clone Repository dialog lists GitHub next to Repository URL: the repositories of the account gh is signed in to, searchable; picking one fills URL and directory, double-click or Enter clones. Clone URL follows gh's git_protocol. Clone sources are a workbench contribution (registerCloneSource); gh_account_repos needs no project.

Closes #750
2026-11-04 08:40:06 +08:00

381 lines
14 KiB
Rust

//! GitHub pull requests through the `gh` CLI. Authentication is gh's own
//! (`gh auth login`); the repository is resolved by gh from the project's remotes.
//!
//! Every function blocks; callers run them off the UI / command threads.
mod account;
mod model;
mod process;
pub use account::{GhAccountRepo, GhAccountRepos, account_repositories, parse_account_repos};
pub use model::*;
use ide_core::{CancellationToken, IdeError, IdeResult};
use ide_vcs::Repo;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::OnceLock;
use std::time::Duration;
/// API calls normally answer within a few seconds.
const QUICK: Duration = Duration::from_secs(60);
/// Locates the gh executable once per process (GUI apps on macOS have a minimal `PATH`).
pub fn gh_executable() -> Option<PathBuf> {
static GH: OnceLock<Option<PathBuf>> = OnceLock::new();
GH.get_or_init(|| {
if let Some(explicit) = std::env::var_os("OPENIDEA_GH").map(PathBuf::from).filter(|p| p.is_file()) {
return Some(explicit);
}
if let Ok(found) = which::which("gh") {
return Some(found);
}
let candidates: &[&str] = if cfg!(windows) {
&[r"C:\Program Files\GitHub CLI\gh.exe"]
} else {
&["/opt/homebrew/bin/gh", "/usr/local/bin/gh", "/usr/bin/gh", "/snap/bin/gh"]
};
candidates.iter().map(PathBuf::from).find(|p| p.is_file())
})
.clone()
}
fn command(root: &Path, args: &[&str]) -> IdeResult<Command> {
let exe =
gh_executable().ok_or_else(|| IdeError::unavailable("gh executable not found; install the GitHub CLI"))?;
let mut cmd = Command::new(exe);
cmd.current_dir(root)
.args(args)
.env("GH_PROMPT_DISABLED", "1")
.env("GH_NO_UPDATE_NOTIFIER", "1")
.env("GH_PAGER", "cat")
.env("NO_COLOR", "1")
.env("CLICOLOR", "0")
.env("GIT_TERMINAL_PROMPT", "0");
tracing::debug!(?args, "gh");
Ok(cmd)
}
fn failure(stderr: &str, stdout: &str) -> IdeError {
let msg = [stderr, stdout].iter().map(|s| s.trim()).find(|s| !s.is_empty()).unwrap_or("gh failed").to_owned();
match classify_failure(&msg) {
GhAvailability::NotLoggedIn { message } | GhAvailability::NotGitHub { message } => {
IdeError::unavailable(message)
}
_ => {
if msg.to_ascii_lowercase().contains("could not resolve to a pullrequest") || msg.contains("HTTP 404") {
IdeError::not_found(msg)
} else {
IdeError::internal(msg)
}
}
}
}
fn run(root: &Path, args: &[&str]) -> IdeResult<String> {
let out = process::run(command(root, args)?, QUICK, None)?;
if out.success { Ok(out.stdout) } else { Err(failure(&out.stderr, &out.stdout)) }
}
fn run_streaming(
root: &Path,
args: &[&str],
cancel: &CancellationToken,
on_output: &dyn Fn(String),
) -> IdeResult<String> {
let mut stdout = String::new();
let (ok, stderr) = process::stream(command(root, args)?, cancel, &mut |stream, line| {
if stream == process::Stream::Stdout {
stdout.push_str(&line);
stdout.push('\n');
}
if !line.trim().is_empty() {
on_output(line);
}
})?;
if ok { Ok(stdout) } else { Err(failure(&stderr.join("\n"), &stdout)) }
}
fn parse<T>(r: serde_json::Result<T>) -> IdeResult<T> {
r.map_err(|e| IdeError::new(ide_core::error::ErrorKind::Protocol, format!("unexpected gh output: {e}")))
}
/// Whether gh is installed and signed in, and which GitHub repository `root` maps to.
pub fn availability(root: &Path) -> GhAvailability {
if gh_executable().is_none() {
return GhAvailability::NotInstalled;
}
let (repo, login) = std::thread::scope(|s| {
let repo = s.spawn(|| {
let cmd = command(root, &["repo", "view", "--json", REPO_FIELDS])?;
process::run(cmd, QUICK, None)
});
let login = s.spawn(|| run(root, &["api", "user", "--jq", ".login"]));
(repo.join(), login.join())
});
let login = login.ok().and_then(Result::ok).map(|l| l.trim().to_owned()).filter(|l| !l.is_empty());
match repo {
Ok(Ok(out)) if out.success => match parse_repo(&out.stdout) {
Ok(repo) => GhAvailability::Ready { repo, login },
Err(e) => GhAvailability::Error { message: format!("unexpected gh output: {e}") },
},
Ok(Ok(out)) => classify_failure(if out.stderr.trim().is_empty() { &out.stdout } else { &out.stderr }),
Ok(Err(e)) => GhAvailability::Error { message: e.message },
Err(_) => GhAvailability::Error { message: "gh check panicked".into() },
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum PrStateFilter {
Open,
Closed,
Merged,
All,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PrListQuery {
pub state: PrStateFilter,
pub author_me: bool,
pub review_requested: bool,
pub assignee_me: bool,
/// Free text / GitHub search qualifiers.
pub search: String,
pub limit: u32,
}
/// `gh pr list` arguments for `q`.
pub fn list_args(q: &PrListQuery) -> Vec<String> {
let mut args: Vec<String> = ["pr", "list", "--json", LIST_FIELDS].iter().map(|s| s.to_string()).collect();
args.push("--limit".into());
args.push(q.limit.clamp(1, 500).to_string());
args.push("--state".into());
args.push(
match q.state {
PrStateFilter::Open => "open",
PrStateFilter::Closed => "closed",
PrStateFilter::Merged => "merged",
PrStateFilter::All => "all",
}
.into(),
);
if q.author_me {
args.push("--author".into());
args.push("@me".into());
}
if q.assignee_me {
args.push("--assignee".into());
args.push("@me".into());
}
let mut search: Vec<&str> = Vec::new();
if q.review_requested {
search.push("review-requested:@me");
}
if !q.search.trim().is_empty() {
search.push(q.search.trim());
}
if !search.is_empty() {
args.push("--search".into());
args.push(search.join(" "));
}
args
}
pub fn list(root: &Path, q: &PrListQuery) -> IdeResult<Vec<PullRequestSummary>> {
let args = list_args(q);
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
parse(parse_list(&run(root, &refs)?))
}
pub fn view(root: &Path, number: u32) -> IdeResult<PullRequestDetails> {
let n = number.to_string();
parse(parse_view(&run(root, &["pr", "view", &n, "--json", VIEW_FIELDS])?))
}
/// Inline review comments of a pull request (every page).
pub fn review_comments(root: &Path, number: u32) -> IdeResult<Vec<PrReviewComment>> {
let path = format!("repos/{{owner}}/{{repo}}/pulls/{number}/comments?per_page=100");
parse(parse_review_comments(&run(root, &["api", "--paginate", &path])?))
}
/// `gh pr checkout <n>`: creates / updates a local branch for the pull request and switches to it.
pub fn checkout(root: &Path, number: u32, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
let n = number.to_string();
run_streaming(root, &["pr", "checkout", &n], cancel, on_output).map(drop)
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CreatePullRequest {
pub title: String,
pub body: String,
pub base: String,
pub draft: bool,
/// Push the current branch (setting its upstream) before creating.
pub push: bool,
}
/// Creates a pull request from the current branch; returns its URL.
pub fn create(
root: &Path,
req: &CreatePullRequest,
cancel: &CancellationToken,
on_output: &dyn Fn(String),
) -> IdeResult<String> {
if req.title.trim().is_empty() {
return Err(IdeError::invalid("the title must not be empty"));
}
if req.base.trim().is_empty() || req.base.starts_with('-') {
return Err(IdeError::invalid("choose a base branch"));
}
if req.push {
let repo = Repo::discover(root)?.ok_or_else(|| IdeError::invalid("not a git repository"))?;
repo.push(false, true, cancel, on_output)?;
}
let mut args = vec!["pr", "create", "--title", req.title.trim(), "--body", &req.body, "--base", req.base.trim()];
if req.draft {
args.push("--draft");
}
let out = run_streaming(root, &args, cancel, on_output)?;
Ok(out.lines().map(str::trim).rfind(|l| l.starts_with("http")).unwrap_or_default().to_owned())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ReviewEvent {
Approve,
RequestChanges,
Comment,
}
/// Submits a review (`gh pr review`). Request changes and comment need a body.
pub fn submit_review(root: &Path, number: u32, event: ReviewEvent, body: &str) -> IdeResult<()> {
if !matches!(event, ReviewEvent::Approve) && body.trim().is_empty() {
return Err(IdeError::invalid("write a review comment first"));
}
let n = number.to_string();
let flag = match event {
ReviewEvent::Approve => "--approve",
ReviewEvent::RequestChanges => "--request-changes",
ReviewEvent::Comment => "--comment",
};
let mut args = vec!["pr", "review", &n, flag];
if !body.trim().is_empty() {
args.push("--body");
args.push(body);
}
run(root, &args).map(drop)
}
/// Adds a conversation comment.
pub fn add_comment(root: &Path, number: u32, body: &str) -> IdeResult<()> {
if body.trim().is_empty() {
return Err(IdeError::invalid("the comment is empty"));
}
let n = number.to_string();
run(root, &["pr", "comment", &n, "--body", body]).map(drop)
}
/// A GitHub API call through `gh api`: GraphQL (`endpoint` `graphql`, `body`
/// `{"query", "variables"}`) or REST (`repos/{owner}/{repo}/…`, gh fills in the
/// placeholders from the project's remote).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct GhApiRequest {
/// `GET`, `POST`, `PATCH`, `PUT`, `DELETE`.
pub method: String,
pub endpoint: String,
/// JSON request body, sent on stdin.
pub body: Option<String>,
/// Follow REST `Link` pages; the result is the array of all pages (`--slurp`).
pub paginate: bool,
}
const METHODS: &[&str] = &["GET", "POST", "PATCH", "PUT", "DELETE"];
/// `gh api` arguments for `req`.
pub fn api_args(req: &GhApiRequest) -> IdeResult<Vec<String>> {
let method = req.method.to_ascii_uppercase();
if !METHODS.contains(&method.as_str()) {
return Err(IdeError::invalid(format!("unsupported HTTP method {}", req.method)));
}
let endpoint = req.endpoint.trim();
if endpoint.is_empty() || endpoint.starts_with('-') || endpoint.contains("://") {
return Err(IdeError::invalid(format!("not a GitHub API endpoint: {endpoint}")));
}
let mut args: Vec<String> = vec!["api".into(), "--method".into(), method, endpoint.into()];
if req.body.is_some() {
args.push("--input".into());
args.push("-".into());
}
if req.paginate {
args.push("--paginate".into());
args.push("--slurp".into());
}
Ok(args)
}
/// Runs `req`; returns the response body (JSON text, empty for `204 No Content`).
pub fn api(root: &Path, req: &GhApiRequest) -> IdeResult<String> {
let args = api_args(req)?;
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
let out = process::run_with_input(command(root, &refs)?, req.body.as_deref(), QUICK, None)?;
if out.success { Ok(out.stdout) } else { Err(failure(&out.stderr, &out.stdout)) }
}
/// Revisions to diff a pull request's files with, present in the local repository.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PrDiffRevisions {
/// Merge base of base and head (what GitHub's "Files changed" compares with).
pub base: String,
pub head: String,
/// Changed files with rename information.
pub files: Vec<ide_vcs::ChangedFile>,
/// Worktree root the file paths are relative to.
pub root: ide_core::IpcPath,
}
/// Makes sure the pull request's head and base commits are available locally
/// (fetching `refs/pull/<n>/head` and the base branch from the matching remote),
/// then returns the revisions to compare.
#[allow(clippy::too_many_arguments)]
pub fn prepare_diff(
root: &Path,
number: u32,
head_oid: &str,
base_oid: &str,
base_ref: &str,
name_with_owner: &str,
cancel: &CancellationToken,
on_output: &dyn Fn(String),
) -> IdeResult<PrDiffRevisions> {
let repo = Repo::discover(root)?.ok_or_else(|| IdeError::invalid("not a git repository"))?;
if !repo.has_commit(head_oid) || !repo.has_commit(base_oid) {
let remotes = repo.remotes()?;
let remote = remote_for(&remotes, name_with_owner)
.or_else(|| remotes.iter().find(|r| r.name == "origin"))
.or(remotes.first())
.ok_or_else(|| IdeError::invalid("the repository has no remotes"))?;
let mut refspecs = vec![format!("refs/pull/{number}/head")];
if !base_ref.is_empty() {
refspecs.push(format!("refs/heads/{base_ref}"));
}
repo.fetch_refspecs(&remote.name, &refspecs, cancel, on_output)?;
if !repo.has_commit(head_oid) {
return Err(IdeError::not_found(format!("commit {head_oid} of #{number} could not be fetched")));
}
}
let base = if repo.has_commit(base_oid) {
repo.merge_base(base_oid, head_oid)?.unwrap_or_else(|| base_oid.to_owned())
} else {
format!("{head_oid}~1")
};
let files = repo.diff_files(&base, head_oid)?;
Ok(PrDiffRevisions { base, head: head_oid.to_owned(), files, root: ide_core::IpcPath(repo.root().to_path_buf()) })
}