дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
Initial open-source release
Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
Цей коміт міститься в:
коміт
9a2025b76e
2034 files changed
+243381
No files matched your search
@@ -0,0 +1,331 @@
|
||||
//! 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 model;
|
||||
mod process;
|
||||
|
||||
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", "nameWithOwner,url,defaultBranchRef"])?;
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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()) })
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
//! IPC types for pull requests and their parsers from `gh … --json` output.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
/// Whether pull requests can be shown for the project, and why not.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum GhAvailability {
|
||||
Ready {
|
||||
repo: GhRepo,
|
||||
/// The signed-in user.
|
||||
login: Option<String>,
|
||||
},
|
||||
/// No `gh` executable was found.
|
||||
NotInstalled,
|
||||
/// `gh auth login` has not been run (or the token expired).
|
||||
NotLoggedIn {
|
||||
message: String,
|
||||
},
|
||||
/// The project has no GitHub remote (or is not a git repository).
|
||||
NotGitHub {
|
||||
message: String,
|
||||
},
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GhRepo {
|
||||
/// `owner/name`.
|
||||
pub name_with_owner: String,
|
||||
pub url: String,
|
||||
pub default_branch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PrState {
|
||||
Open,
|
||||
Closed,
|
||||
Merged,
|
||||
}
|
||||
|
||||
impl PrState {
|
||||
fn parse(s: &str) -> Self {
|
||||
match s.to_ascii_uppercase().as_str() {
|
||||
"MERGED" => Self::Merged,
|
||||
"CLOSED" => Self::Closed,
|
||||
_ => Self::Open,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PrLabel {
|
||||
pub name: String,
|
||||
/// Hex colour without `#`, as GitHub stores it.
|
||||
pub color: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PullRequestSummary {
|
||||
pub number: u32,
|
||||
pub title: String,
|
||||
pub author: String,
|
||||
pub state: PrState,
|
||||
pub is_draft: bool,
|
||||
pub head_ref: String,
|
||||
pub base_ref: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub labels: Vec<PrLabel>,
|
||||
/// `APPROVED`, `CHANGES_REQUESTED`, `REVIEW_REQUIRED`, or `None`.
|
||||
pub review_decision: Option<String>,
|
||||
pub url: String,
|
||||
pub additions: u32,
|
||||
pub deletions: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum CheckStatus {
|
||||
Pending,
|
||||
Success,
|
||||
Failure,
|
||||
Cancelled,
|
||||
Skipped,
|
||||
Neutral,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PrCheck {
|
||||
pub name: String,
|
||||
/// Workflow of a check run (GitHub Actions), if any.
|
||||
pub workflow: Option<String>,
|
||||
pub status: CheckStatus,
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PrReviewer {
|
||||
pub login: String,
|
||||
/// `APPROVED`, `CHANGES_REQUESTED`, `COMMENTED`, `DISMISSED`, or `REQUESTED` (no review yet).
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PrCommit {
|
||||
pub oid: String,
|
||||
pub headline: String,
|
||||
pub body: String,
|
||||
pub author: String,
|
||||
pub date: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PrFile {
|
||||
pub path: String,
|
||||
pub additions: u32,
|
||||
pub deletions: u32,
|
||||
/// `ADDED`, `MODIFIED`, `DELETED`, `RENAMED`, `COPIED`, `CHANGED`.
|
||||
pub change_type: String,
|
||||
}
|
||||
|
||||
/// A conversation comment or a submitted review (the timeline).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PrTimelineItem {
|
||||
pub author: String,
|
||||
pub body: String,
|
||||
pub created_at: String,
|
||||
/// `None` for comments; the review state for reviews.
|
||||
pub review_state: Option<String>,
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PullRequestDetails {
|
||||
pub summary: PullRequestSummary,
|
||||
pub body: String,
|
||||
pub head_oid: String,
|
||||
pub base_oid: String,
|
||||
/// `MERGEABLE`, `CONFLICTING`, `UNKNOWN`.
|
||||
pub mergeable: String,
|
||||
pub is_cross_repository: bool,
|
||||
pub merged_at: Option<String>,
|
||||
pub closed_at: Option<String>,
|
||||
pub checks: Vec<PrCheck>,
|
||||
pub reviewers: Vec<PrReviewer>,
|
||||
pub assignees: Vec<String>,
|
||||
pub commits: Vec<PrCommit>,
|
||||
pub files: Vec<PrFile>,
|
||||
/// Comments and reviews, oldest first.
|
||||
pub timeline: Vec<PrTimelineItem>,
|
||||
}
|
||||
|
||||
/// An inline review comment on a diff line.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PrReviewComment {
|
||||
pub id: u64,
|
||||
pub in_reply_to: Option<u64>,
|
||||
pub path: String,
|
||||
/// 1-based line in the current diff; `None` when the comment is outdated.
|
||||
pub line: Option<u32>,
|
||||
pub original_line: Option<u32>,
|
||||
/// `LEFT` (base) or `RIGHT` (head).
|
||||
pub side: String,
|
||||
pub body: String,
|
||||
pub author: String,
|
||||
pub created_at: String,
|
||||
pub url: String,
|
||||
pub diff_hunk: String,
|
||||
}
|
||||
|
||||
// ---- raw gh shapes ----
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
struct RawActor {
|
||||
login: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
struct RawLabel {
|
||||
name: String,
|
||||
color: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
struct RawSummary {
|
||||
number: u32,
|
||||
title: String,
|
||||
author: Option<RawActor>,
|
||||
state: String,
|
||||
is_draft: bool,
|
||||
head_ref_name: String,
|
||||
base_ref_name: String,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
labels: Vec<RawLabel>,
|
||||
review_decision: Option<String>,
|
||||
url: String,
|
||||
additions: u32,
|
||||
deletions: u32,
|
||||
}
|
||||
|
||||
impl RawSummary {
|
||||
fn into_summary(self) -> PullRequestSummary {
|
||||
PullRequestSummary {
|
||||
number: self.number,
|
||||
title: self.title,
|
||||
author: self.author.map(|a| a.login).unwrap_or_else(|| "ghost".into()),
|
||||
state: PrState::parse(&self.state),
|
||||
is_draft: self.is_draft,
|
||||
head_ref: self.head_ref_name,
|
||||
base_ref: self.base_ref_name,
|
||||
created_at: self.created_at,
|
||||
updated_at: self.updated_at,
|
||||
labels: self.labels.into_iter().map(|l| PrLabel { name: l.name, color: l.color }).collect(),
|
||||
review_decision: self.review_decision.filter(|d| !d.is_empty()),
|
||||
url: self.url,
|
||||
additions: self.additions,
|
||||
deletions: self.deletions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fields requested from `gh pr list`.
|
||||
pub const LIST_FIELDS: &str = "number,title,author,state,isDraft,headRefName,baseRefName,createdAt,updatedAt,labels,reviewDecision,url,additions,deletions";
|
||||
|
||||
/// Fields requested from `gh pr view`.
|
||||
pub const VIEW_FIELDS: &str = "number,title,author,state,isDraft,headRefName,baseRefName,createdAt,updatedAt,labels,reviewDecision,url,additions,deletions,body,headRefOid,baseRefOid,mergeable,isCrossRepository,mergedAt,closedAt,statusCheckRollup,reviewRequests,reviews,assignees,commits,files,comments";
|
||||
|
||||
pub fn parse_list(json: &str) -> serde_json::Result<Vec<PullRequestSummary>> {
|
||||
let raw: Vec<RawSummary> = serde_json::from_str(json)?;
|
||||
Ok(raw.into_iter().map(RawSummary::into_summary).collect())
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
struct RawCheck {
|
||||
#[serde(rename = "__typename")]
|
||||
typename: String,
|
||||
// CheckRun
|
||||
name: String,
|
||||
status: String,
|
||||
conclusion: String,
|
||||
details_url: String,
|
||||
workflow_name: String,
|
||||
// StatusContext
|
||||
context: String,
|
||||
state: String,
|
||||
target_url: String,
|
||||
}
|
||||
|
||||
fn check_status(c: &RawCheck) -> CheckStatus {
|
||||
if c.typename == "StatusContext" || (!c.context.is_empty() && c.name.is_empty()) {
|
||||
return match c.state.as_str() {
|
||||
"SUCCESS" => CheckStatus::Success,
|
||||
"FAILURE" | "ERROR" => CheckStatus::Failure,
|
||||
_ => CheckStatus::Pending,
|
||||
};
|
||||
}
|
||||
if c.status != "COMPLETED" {
|
||||
return CheckStatus::Pending;
|
||||
}
|
||||
match c.conclusion.as_str() {
|
||||
"SUCCESS" => CheckStatus::Success,
|
||||
"FAILURE" | "TIMED_OUT" | "STARTUP_FAILURE" | "ACTION_REQUIRED" => CheckStatus::Failure,
|
||||
"CANCELLED" => CheckStatus::Cancelled,
|
||||
"SKIPPED" => CheckStatus::Skipped,
|
||||
_ => CheckStatus::Neutral,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
struct RawRequest {
|
||||
login: Option<String>,
|
||||
/// Team requests carry a name / slug instead of a login.
|
||||
name: Option<String>,
|
||||
slug: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
struct RawReview {
|
||||
author: Option<RawActor>,
|
||||
body: String,
|
||||
state: String,
|
||||
submitted_at: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
struct RawComment {
|
||||
author: Option<RawActor>,
|
||||
body: String,
|
||||
created_at: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
struct RawCommit {
|
||||
oid: String,
|
||||
message_headline: String,
|
||||
message_body: String,
|
||||
committed_date: String,
|
||||
authors: Vec<RawCommitAuthor>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
struct RawCommitAuthor {
|
||||
login: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
struct RawFile {
|
||||
path: String,
|
||||
additions: u32,
|
||||
deletions: u32,
|
||||
change_type: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
struct RawDetails {
|
||||
#[serde(flatten)]
|
||||
summary: RawSummary,
|
||||
body: String,
|
||||
head_ref_oid: String,
|
||||
base_ref_oid: String,
|
||||
mergeable: String,
|
||||
is_cross_repository: bool,
|
||||
merged_at: Option<String>,
|
||||
closed_at: Option<String>,
|
||||
status_check_rollup: Vec<RawCheck>,
|
||||
review_requests: Vec<RawRequest>,
|
||||
reviews: Vec<RawReview>,
|
||||
assignees: Vec<RawActor>,
|
||||
commits: Vec<RawCommit>,
|
||||
files: Vec<RawFile>,
|
||||
comments: Vec<RawComment>,
|
||||
}
|
||||
|
||||
/// The reviewers panel: each reviewer's latest significant review (a later plain
|
||||
/// comment does not hide an approval), then requested reviewers who have not
|
||||
/// reviewed yet. The author's own comments are left out.
|
||||
fn reviewers(author: &str, reviews: &[RawReview], requests: &[RawRequest]) -> Vec<PrReviewer> {
|
||||
let mut out: Vec<PrReviewer> = Vec::new();
|
||||
for r in reviews {
|
||||
let Some(login) = r.author.as_ref().map(|a| a.login.as_str()).filter(|l| !l.is_empty() && *l != author) else {
|
||||
continue;
|
||||
};
|
||||
if r.state == "PENDING" {
|
||||
continue;
|
||||
}
|
||||
match out.iter_mut().find(|x| x.login == login) {
|
||||
Some(x) => {
|
||||
if r.state != "COMMENTED" || x.state == "COMMENTED" {
|
||||
x.state = r.state.clone();
|
||||
}
|
||||
}
|
||||
None => out.push(PrReviewer { login: login.to_owned(), state: r.state.clone() }),
|
||||
}
|
||||
}
|
||||
for q in requests {
|
||||
let Some(login) = q.login.clone().or_else(|| q.slug.clone()).or_else(|| q.name.clone()) else { continue };
|
||||
// A re-requested review supersedes the earlier one.
|
||||
match out.iter_mut().find(|x| x.login == login) {
|
||||
Some(x) => x.state = "REQUESTED".into(),
|
||||
None => out.push(PrReviewer { login, state: "REQUESTED".into() }),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn parse_view(json: &str) -> serde_json::Result<PullRequestDetails> {
|
||||
let raw: RawDetails = serde_json::from_str(json)?;
|
||||
let author = raw.summary.author.as_ref().map(|a| a.login.clone()).unwrap_or_default();
|
||||
let reviewers = reviewers(&author, &raw.reviews, &raw.review_requests);
|
||||
let mut timeline: Vec<PrTimelineItem> = raw
|
||||
.comments
|
||||
.into_iter()
|
||||
.map(|c| PrTimelineItem {
|
||||
author: c.author.map(|a| a.login).unwrap_or_else(|| "ghost".into()),
|
||||
body: c.body,
|
||||
created_at: c.created_at,
|
||||
review_state: None,
|
||||
url: Some(c.url).filter(|u| !u.is_empty()),
|
||||
})
|
||||
.chain(
|
||||
raw.reviews
|
||||
.into_iter()
|
||||
// Empty "commented" reviews only carry inline comments, listed separately.
|
||||
.filter(|r| r.state != "PENDING" && !(r.state == "COMMENTED" && r.body.trim().is_empty()))
|
||||
.map(|r| PrTimelineItem {
|
||||
author: r.author.map(|a| a.login).unwrap_or_else(|| "ghost".into()),
|
||||
body: r.body,
|
||||
created_at: r.submitted_at,
|
||||
review_state: Some(r.state),
|
||||
url: None,
|
||||
}),
|
||||
)
|
||||
.collect();
|
||||
timeline.sort_by(|a, b| a.created_at.cmp(&b.created_at));
|
||||
let mut checks: Vec<PrCheck> = raw
|
||||
.status_check_rollup
|
||||
.iter()
|
||||
.map(|c| PrCheck {
|
||||
name: if c.name.is_empty() { c.context.clone() } else { c.name.clone() },
|
||||
workflow: Some(c.workflow_name.clone()).filter(|w| !w.is_empty()),
|
||||
status: check_status(c),
|
||||
url: [&c.details_url, &c.target_url].into_iter().find(|u| !u.is_empty()).cloned(),
|
||||
})
|
||||
.collect();
|
||||
// Failures first, then running, then the rest (GitHub's merge box order).
|
||||
checks.sort_by_key(|c| match c.status {
|
||||
CheckStatus::Failure => 0,
|
||||
CheckStatus::Pending => 1,
|
||||
_ => 2,
|
||||
});
|
||||
Ok(PullRequestDetails {
|
||||
body: raw.body,
|
||||
head_oid: raw.head_ref_oid,
|
||||
base_oid: raw.base_ref_oid,
|
||||
mergeable: raw.mergeable,
|
||||
is_cross_repository: raw.is_cross_repository,
|
||||
merged_at: raw.merged_at.filter(|s| !s.is_empty()),
|
||||
closed_at: raw.closed_at.filter(|s| !s.is_empty()),
|
||||
checks,
|
||||
reviewers,
|
||||
assignees: raw.assignees.into_iter().map(|a| a.login).collect(),
|
||||
commits: raw
|
||||
.commits
|
||||
.into_iter()
|
||||
.map(|c| PrCommit {
|
||||
author: c
|
||||
.authors
|
||||
.first()
|
||||
.map(|a| if a.login.is_empty() { a.name.clone() } else { a.login.clone() })
|
||||
.unwrap_or_default(),
|
||||
oid: c.oid,
|
||||
headline: c.message_headline,
|
||||
body: c.message_body,
|
||||
date: c.committed_date,
|
||||
})
|
||||
.collect(),
|
||||
files: raw
|
||||
.files
|
||||
.into_iter()
|
||||
.map(|f| PrFile {
|
||||
path: f.path,
|
||||
additions: f.additions,
|
||||
deletions: f.deletions,
|
||||
change_type: f.change_type,
|
||||
})
|
||||
.collect(),
|
||||
timeline,
|
||||
summary: raw.summary.into_summary(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
struct RawReviewComment {
|
||||
id: u64,
|
||||
in_reply_to_id: Option<u64>,
|
||||
path: String,
|
||||
line: Option<u32>,
|
||||
original_line: Option<u32>,
|
||||
side: Option<String>,
|
||||
body: String,
|
||||
user: Option<RawActor>,
|
||||
created_at: String,
|
||||
html_url: String,
|
||||
diff_hunk: String,
|
||||
}
|
||||
|
||||
/// `gh api repos/{owner}/{repo}/pulls/<n>/comments --paginate`: one JSON array per page,
|
||||
/// concatenated (or an array of pages with `--slurp`).
|
||||
pub fn parse_review_comments(text: &str) -> serde_json::Result<Vec<PrReviewComment>> {
|
||||
let mut raw: Vec<RawReviewComment> = Vec::new();
|
||||
for value in serde_json::Deserializer::from_str(text).into_iter::<serde_json::Value>() {
|
||||
let value = value?;
|
||||
let pages = match value {
|
||||
serde_json::Value::Array(items) if items.iter().all(|i| i.is_array()) && !items.is_empty() => items,
|
||||
other => vec![other],
|
||||
};
|
||||
for page in pages {
|
||||
raw.extend(serde_json::from_value::<Vec<RawReviewComment>>(page)?);
|
||||
}
|
||||
}
|
||||
let mut out: Vec<PrReviewComment> = raw
|
||||
.into_iter()
|
||||
.map(|c| PrReviewComment {
|
||||
id: c.id,
|
||||
in_reply_to: c.in_reply_to_id,
|
||||
path: c.path,
|
||||
line: c.line,
|
||||
original_line: c.original_line,
|
||||
side: c.side.unwrap_or_else(|| "RIGHT".into()),
|
||||
body: c.body,
|
||||
author: c.user.map(|u| u.login).unwrap_or_else(|| "ghost".into()),
|
||||
created_at: c.created_at,
|
||||
url: c.html_url,
|
||||
diff_hunk: c.diff_hunk,
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.created_at.cmp(&b.created_at));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
struct RawRepo {
|
||||
name_with_owner: String,
|
||||
url: String,
|
||||
default_branch_ref: Option<RawBranchRef>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
struct RawBranchRef {
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// `gh repo view --json nameWithOwner,url,defaultBranchRef`.
|
||||
pub fn parse_repo(json: &str) -> serde_json::Result<GhRepo> {
|
||||
let raw: RawRepo = serde_json::from_str(json)?;
|
||||
Ok(GhRepo {
|
||||
name_with_owner: raw.name_with_owner,
|
||||
url: raw.url,
|
||||
default_branch: raw.default_branch_ref.map(|b| b.name).filter(|n| !n.is_empty()),
|
||||
})
|
||||
}
|
||||
|
||||
/// What a failed gh command's message means for the Pull Requests view.
|
||||
pub fn classify_failure(message: &str) -> GhAvailability {
|
||||
let lower = message.to_ascii_lowercase();
|
||||
let message = message.trim().to_owned();
|
||||
if lower.contains("gh auth login")
|
||||
|| lower.contains("not logged in")
|
||||
|| lower.contains("authentication required")
|
||||
|| lower.contains("bad credentials")
|
||||
|| lower.contains("http 401")
|
||||
{
|
||||
GhAvailability::NotLoggedIn { message }
|
||||
} else if lower.contains("none of the git remotes")
|
||||
|| lower.contains("no git remotes")
|
||||
|| lower.contains("not a git repository")
|
||||
|| lower.contains("could not determine base repo")
|
||||
|| lower.contains("unable to determine base repository")
|
||||
{
|
||||
GhAvailability::NotGitHub { message }
|
||||
} else {
|
||||
GhAvailability::Error { message }
|
||||
}
|
||||
}
|
||||
|
||||
/// The remote whose URL points at `owner/name` (any GitHub URL form), if any.
|
||||
pub fn remote_for<'a>(remotes: &'a [ide_vcs::Remote], name_with_owner: &str) -> Option<&'a ide_vcs::Remote> {
|
||||
let want = name_with_owner.to_ascii_lowercase();
|
||||
remotes.iter().find(|r| {
|
||||
let url = r.url.trim_end_matches('/').to_ascii_lowercase();
|
||||
let url = url.strip_suffix(".git").unwrap_or(&url);
|
||||
url.ends_with(&format!("/{want}")) || url.ends_with(&format!(":{want}"))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classifies_failures() {
|
||||
assert!(matches!(
|
||||
classify_failure("To get started with GitHub CLI, please run: gh auth login"),
|
||||
GhAvailability::NotLoggedIn { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
classify_failure("none of the git remotes configured for this repository point to a known GitHub host."),
|
||||
GhAvailability::NotGitHub { .. }
|
||||
));
|
||||
assert!(matches!(classify_failure("HTTP 502: bad gateway"), GhAvailability::Error { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_remote_by_repo() {
|
||||
let remotes = vec![
|
||||
ide_vcs::Remote { name: "origin".into(), url: "[email protected]:me/App.git".into() },
|
||||
ide_vcs::Remote { name: "upstream".into(), url: "https://github.com/org/app/".into() },
|
||||
];
|
||||
assert_eq!(remote_for(&remotes, "me/app").map(|r| r.name.as_str()), Some("origin"));
|
||||
assert_eq!(remote_for(&remotes, "org/app").map(|r| r.name.as_str()), Some("upstream"));
|
||||
assert_eq!(remote_for(&remotes, "x/app"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reviewers_keep_significant_state() {
|
||||
let rv = |who: &str, state: &str| RawReview {
|
||||
author: Some(RawActor { login: who.into() }),
|
||||
state: state.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let reviews = [
|
||||
rv("ann", "APPROVED"),
|
||||
rv("ann", "COMMENTED"),
|
||||
rv("bob", "COMMENTED"),
|
||||
rv("me", "COMMENTED"),
|
||||
rv("bob", "CHANGES_REQUESTED"),
|
||||
];
|
||||
let requests = [
|
||||
RawRequest { login: Some("cat".into()), ..Default::default() },
|
||||
RawRequest { slug: Some("core".into()), ..Default::default() },
|
||||
];
|
||||
let got = reviewers("me", &reviews, &requests);
|
||||
let pairs: Vec<(&str, &str)> = got.iter().map(|r| (r.login.as_str(), r.state.as_str())).collect();
|
||||
assert_eq!(
|
||||
pairs,
|
||||
[("ann", "APPROVED"), ("bob", "CHANGES_REQUESTED"), ("cat", "REQUESTED"), ("core", "REQUESTED")]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Child processes of the gh CLI: bounded runs and cancellable line streams.
|
||||
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use std::io::Read;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub(crate) struct Output {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Stream {
|
||||
Stdout,
|
||||
Stderr,
|
||||
}
|
||||
|
||||
fn prepare(cmd: &mut Command) {
|
||||
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
cmd.process_group(0);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x0800_0000);
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn(cmd: &mut Command) -> IdeResult<Child> {
|
||||
cmd.spawn().map_err(|e| IdeError::unavailable(format!("could not run gh: {e}")))
|
||||
}
|
||||
|
||||
/// Kills the child and, on Unix, its whole process group (gh spawns git and helpers).
|
||||
pub(crate) fn kill(child: &mut Child) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let pid = child.id() as libc::pid_t;
|
||||
// SAFETY: plain syscall; the child leads its own process group (`prepare`).
|
||||
unsafe {
|
||||
libc::kill(-pid, libc::SIGTERM);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
unsafe {
|
||||
libc::kill(-pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<String> {
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = Vec::new();
|
||||
let _ = pipe.read_to_end(&mut buf);
|
||||
String::from_utf8_lossy(&buf).into_owned()
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs `cmd` to completion. Fails when it outlives `timeout` or `cancel` fires;
|
||||
/// a non-zero exit is reported in [`Output::success`], not as an error.
|
||||
pub(crate) fn run(mut cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult<Output> {
|
||||
prepare(&mut cmd);
|
||||
let mut child = spawn(&mut cmd)?;
|
||||
let out = child.stdout.take().map(read_all);
|
||||
let err = child.stderr.take().map(read_all);
|
||||
let deadline = Instant::now() + timeout;
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
break status;
|
||||
}
|
||||
if cancel.is_some_and(CancellationToken::is_cancelled) {
|
||||
kill(&mut child);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
kill(&mut child);
|
||||
return Err(IdeError::unavailable("gh did not respond in time"));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
Ok(Output {
|
||||
stdout: out.map(|t| t.join().unwrap_or_default()).unwrap_or_default(),
|
||||
stderr: err.map(|t| t.join().unwrap_or_default()).unwrap_or_default(),
|
||||
success: status.success(),
|
||||
})
|
||||
}
|
||||
|
||||
enum Event {
|
||||
Line(Stream, String),
|
||||
Closed,
|
||||
}
|
||||
|
||||
fn spawn_reader(mut pipe: impl Read + Send + 'static, tx: mpsc::Sender<Event>, stream: Stream) {
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = [0u8; 8192];
|
||||
let mut pending: Vec<u8> = Vec::new();
|
||||
let flush = |pending: &mut Vec<u8>| {
|
||||
if !pending.is_empty() {
|
||||
let line = String::from_utf8_lossy(pending).trim_end().to_string();
|
||||
pending.clear();
|
||||
let _ = tx.send(Event::Line(stream, line));
|
||||
}
|
||||
};
|
||||
loop {
|
||||
match pipe.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
for &b in &buf[..n] {
|
||||
// `\r` too: git progress rewrites its line in place.
|
||||
if b == b'\n' || b == b'\r' {
|
||||
flush(&mut pending);
|
||||
} else {
|
||||
pending.push(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
flush(&mut pending);
|
||||
let _ = tx.send(Event::Closed);
|
||||
});
|
||||
}
|
||||
|
||||
/// Runs `cmd`, handing every output line to `on_line` until it exits or `cancel`
|
||||
/// fires. Returns whether it exited successfully plus the last stderr lines.
|
||||
pub(crate) fn stream(
|
||||
mut cmd: Command,
|
||||
cancel: &CancellationToken,
|
||||
on_line: &mut dyn FnMut(Stream, String),
|
||||
) -> IdeResult<(bool, Vec<String>)> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
prepare(&mut cmd);
|
||||
let mut child = spawn(&mut cmd)?;
|
||||
let (tx, rx) = mpsc::channel::<Event>();
|
||||
let mut open = 0;
|
||||
if let Some(out) = child.stdout.take() {
|
||||
spawn_reader(out, tx.clone(), Stream::Stdout);
|
||||
open += 1;
|
||||
}
|
||||
if let Some(err) = child.stderr.take() {
|
||||
spawn_reader(err, tx.clone(), Stream::Stderr);
|
||||
open += 1;
|
||||
}
|
||||
drop(tx);
|
||||
let mut stderr_tail: Vec<String> = Vec::new();
|
||||
while open > 0 {
|
||||
if cancel.is_cancelled() {
|
||||
kill(&mut child);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
match rx.recv_timeout(Duration::from_millis(50)) {
|
||||
Ok(Event::Line(stream, line)) => {
|
||||
if stream == Stream::Stderr && !line.trim().is_empty() {
|
||||
stderr_tail.push(line.clone());
|
||||
if stderr_tail.len() > 20 {
|
||||
stderr_tail.remove(0);
|
||||
}
|
||||
}
|
||||
on_line(stream, line);
|
||||
}
|
||||
Ok(Event::Closed) => open -= 1,
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
break status;
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
kill(&mut child);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
Ok((status.success(), stderr_tail))
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача