feat(github): pull request tool window, timeline and review flow (#477)

Replaces the Pull Requests tool window with a full review workflow on top of gh's authentication.

- List with search, quick filters and paging; a tab per pull request and a New Pull Request tab.
- Details: commit picker, changes tree with viewed state, CI and reviewer status, merge, close and reopen.
- Timeline editor: comments, review threads, reactions and suggested changes.
- Review in the diff viewer and, for the checked-out branch, in the editor (review mode).
- New generic vcs extension points: registerDiffViewerProvider, lineInlays, reviewGutter, branch widget extensions; new vcs_merge_base and gh_api commands.
- CSP: img-src allows https://avatars.githubusercontent.com for avatars.

Fixed while testing against a real repository: remotes behind an SSH host alias, mergeability stuck on UNKNOWN, Apply Locally staying disabled after a checkout, relative links navigating the app's page, and review inlays wider than the pane.

Co-authored-by: imacross <[email protected]>
Цей коміт міститься в:
imacross authored and GitHub committed 2026-10-31 07:50:43 +08:00
1 джерело 696377acc2
коміт 059fd8a69b
192 files changed
+23668 -2076

No files matched your search

+48 -1
Переглянути файл
@@ -107,7 +107,7 @@ pub fn availability(root: &Path) -> GhAvailability {
}
let (repo, login) = std::thread::scope(|s| {
let repo = s.spawn(|| {
let cmd = command(root, &["repo", "view", "--json", "nameWithOwner,url,defaultBranchRef"])?;
let cmd = command(root, &["repo", "view", "--json", REPO_FIELDS])?;
process::run(cmd, QUICK, None)
});
let login = s.spawn(|| run(root, &["api", "user", "--jq", ".login"]));
@@ -278,6 +278,53 @@ pub fn add_comment(root: &Path, number: u32, body: &str) -> IdeResult<()> {
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")]
+24 -2
Переглянути файл
@@ -34,6 +34,12 @@ pub struct GhRepo {
pub name_with_owner: String,
pub url: String,
pub default_branch: Option<String>,
/// Merge methods the repository allows (all of them when gh does not say).
pub merge_commit_allowed: bool,
pub squash_merge_allowed: bool,
pub rebase_merge_allowed: bool,
/// The signed-in user's permission: `ADMIN`, `MAINTAIN`, `WRITE`, `TRIAGE`, `READ`; empty when unknown.
pub viewer_permission: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -152,6 +158,8 @@ pub struct PullRequestDetails {
pub base_oid: String,
/// `MERGEABLE`, `CONFLICTING`, `UNKNOWN`.
pub mergeable: String,
/// GitHub's merge box state: `CLEAN`, `UNSTABLE`, `HAS_HOOKS`, `BLOCKED`, `BEHIND`, `DIRTY`, `DRAFT`, `UNKNOWN`.
pub merge_state_status: String,
pub is_cross_repository: bool,
pub merged_at: Option<String>,
pub closed_at: Option<String>,
@@ -242,7 +250,7 @@ impl RawSummary {
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 const VIEW_FIELDS: &str = "number,title,author,state,isDraft,headRefName,baseRefName,createdAt,updatedAt,labels,reviewDecision,url,additions,deletions,body,headRefOid,baseRefOid,mergeable,mergeStateStatus,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)?;
@@ -348,6 +356,7 @@ struct RawDetails {
head_ref_oid: String,
base_ref_oid: String,
mergeable: String,
merge_state_status: String,
is_cross_repository: bool,
merged_at: Option<String>,
closed_at: Option<String>,
@@ -442,6 +451,7 @@ pub fn parse_view(json: &str) -> serde_json::Result<PullRequestDetails> {
head_oid: raw.head_ref_oid,
base_oid: raw.base_ref_oid,
mergeable: raw.mergeable,
merge_state_status: raw.merge_state_status,
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()),
@@ -534,6 +544,10 @@ struct RawRepo {
name_with_owner: String,
url: String,
default_branch_ref: Option<RawBranchRef>,
merge_commit_allowed: Option<bool>,
squash_merge_allowed: Option<bool>,
rebase_merge_allowed: Option<bool>,
viewer_permission: Option<String>,
}
#[derive(Deserialize, Default)]
@@ -542,13 +556,21 @@ struct RawBranchRef {
name: String,
}
/// `gh repo view --json nameWithOwner,url,defaultBranchRef`.
/// Fields requested from `gh repo view`.
pub const REPO_FIELDS: &str =
"nameWithOwner,url,defaultBranchRef,mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,viewerPermission";
/// `gh repo view --json` with [`REPO_FIELDS`].
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()),
merge_commit_allowed: raw.merge_commit_allowed.unwrap_or(true),
squash_merge_allowed: raw.squash_merge_allowed.unwrap_or(true),
rebase_merge_allowed: raw.rebase_merge_allowed.unwrap_or(true),
viewer_permission: raw.viewer_permission.unwrap_or_default(),
})
}
+22 -1
Переглянути файл
@@ -60,9 +60,30 @@ fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<Str
/// 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> {
pub(crate) fn run(cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult<Output> {
run_with_input(cmd, None, timeout, cancel)
}
/// [`run`] with `input` written to the child's stdin (then closed).
pub(crate) fn run_with_input(
mut cmd: Command,
input: Option<&str>,
timeout: Duration,
cancel: Option<&CancellationToken>,
) -> IdeResult<Output> {
prepare(&mut cmd);
if input.is_some() {
cmd.stdin(Stdio::piped());
}
let mut child = spawn(&mut cmd)?;
if let (Some(input), Some(mut stdin)) = (input, child.stdin.take()) {
let input = input.to_owned();
// A writer thread: a large body must not block on a full pipe while gh is not reading yet.
std::thread::spawn(move || {
use std::io::Write;
let _ = stdin.write_all(input.as_bytes());
});
}
let out = child.stdout.take().map(read_all);
let err = child.stderr.take().map(read_all);
let deadline = Instant::now() + timeout;