Initial open-source release

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
Цей коміт міститься в:
l0ng-ai committed 2026-10-22 18:55:10 +08:00
коміт 9a2025b76e
2034 files changed
+243381

No files matched your search

+331
Переглянути файл
@@ -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()) })
}
+641
Переглянути файл
@@ -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")]
);
}
}
+185
Переглянути файл
@@ -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))
}