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;
+1 -1
Переглянути файл
@@ -1 +1 @@
{"additions":42,"assignees":[],"author":{"id":"MDQ6VXNlcjM2NzI4OTMx","is_bot":false,"login":"babakks","name":"Babak K. Shandiz"},"baseRefName":"trunk","baseRefOid":"ae7b9715976c5508398b11dc8546e1279e10f6fc","body":"### Description\n\nThis PR moves gh to the tagged version of `cli/oauth`.","closedAt":"2026-10-21T12:29:58Z","comments":[{"id":"IC_1","author":{"login":"babakks"},"authorAssociation":"MEMBER","body":"Rebased on trunk.","createdAt":"2026-10-21T08:00:00Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/cli/cli/pull/14768#issuecomment-1","viewerDidAuthor":false}],"commits":[{"authoredDate":"2026-10-20T16:37:24Z","authors":[{"email":"[email protected]","id":"MDQ6VXNlcjM2NzI4OTMx","login":"babakks","name":"Babak K. Shandiz"}],"committedDate":"2026-10-21T12:06:51Z","messageBody":"cli/oauth v1.3.0 exposes the token expiry fields as time.Duration values.","messageHeadline":"chore(deps): bump github.com/cli/oauth to v1.3.0","oid":"fd1b1feb222dde885d00262b2c21b107c6c6e416"}],"createdAt":"2026-10-20T16:40:41Z","deletions":48,"files":[{"path":"go.mod","additions":1,"deletions":1,"changeType":"MODIFIED"},{"path":"pkg/cmd/status/status.go","additions":3,"deletions":2,"changeType":"MODIFIED"}],"headRefName":"babakks/refresh-token-bump-deps","headRefOid":"6ec1ba54542baca6cc5932582634104c83f36a28","isCrossRepository":false,"isDraft":false,"labels":[],"mergeable":"UNKNOWN","mergedAt":"2026-10-21T12:29:58Z","number":14768,"reviewDecision":"APPROVED","reviewRequests":[{"__typename":"User","login":"niik"}],"reviews":[{"id":"PRR_1","author":{"login":"copilot-pull-request-reviewer"},"authorAssociation":"CONTRIBUTOR","body":"","submittedAt":"2026-10-21T09:46:01Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"831b9343fa77c2f11f521a49fd01da2d2d007b8e"}},{"id":"PRR_2","author":{"login":"williammartin"},"authorAssociation":"MEMBER","body":"LGTM","submittedAt":"2026-10-21T12:20:00Z","includesCreatedEdit":false,"reactionGroups":[],"state":"APPROVED","commit":{"oid":"6ec1ba54542baca6cc5932582634104c83f36a28"}}],"state":"MERGED","statusCheckRollup":[{"__typename":"CheckRun","completedAt":"2026-10-21T12:09:03Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/1","name":"build (ubuntu)","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"0001-01-01T00:00:00Z","conclusion":"","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/2","name":"build (windows)","startedAt":"2026-10-21T12:07:01Z","status":"IN_PROGRESS","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"FAILURE","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/3","name":"lint","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Lint"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"SKIPPED","detailsUrl":"","name":"docs","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":""},{"__typename":"StatusContext","context":"ci/legacy","state":"PENDING","targetUrl":"https://ci.example.com/1","startedAt":"2026-10-21T12:07:01Z"}],"title":"Refreshable tokens: bump cli/oauth to v1.3.0 and fix `gh status` column width","updatedAt":"2026-10-21T12:30:00Z","url":"https://github.com/cli/cli/pull/14768"}
{"additions":42,"assignees":[],"author":{"id":"MDQ6VXNlcjM2NzI4OTMx","is_bot":false,"login":"babakks","name":"Babak K. Shandiz"},"baseRefName":"trunk","baseRefOid":"ae7b9715976c5508398b11dc8546e1279e10f6fc","body":"### Description\n\nThis PR moves gh to the tagged version of `cli/oauth`.","closedAt":"2026-10-21T12:29:58Z","comments":[{"id":"IC_1","author":{"login":"babakks"},"authorAssociation":"MEMBER","body":"Rebased on trunk.","createdAt":"2026-10-21T08:00:00Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/cli/cli/pull/14768#issuecomment-1","viewerDidAuthor":false}],"commits":[{"authoredDate":"2026-10-20T16:37:24Z","authors":[{"email":"[email protected]","id":"MDQ6VXNlcjM2NzI4OTMx","login":"babakks","name":"Babak K. Shandiz"}],"committedDate":"2026-10-21T12:06:51Z","messageBody":"cli/oauth v1.3.0 exposes the token expiry fields as time.Duration values.","messageHeadline":"chore(deps): bump github.com/cli/oauth to v1.3.0","oid":"fd1b1feb222dde885d00262b2c21b107c6c6e416"}],"createdAt":"2026-10-20T16:40:41Z","deletions":48,"files":[{"path":"go.mod","additions":1,"deletions":1,"changeType":"MODIFIED"},{"path":"pkg/cmd/status/status.go","additions":3,"deletions":2,"changeType":"MODIFIED"}],"headRefName":"babakks/refresh-token-bump-deps","headRefOid":"6ec1ba54542baca6cc5932582634104c83f36a28","isCrossRepository":false,"isDraft":false,"labels":[],"mergeable":"UNKNOWN","mergeStateStatus":"UNKNOWN","mergedAt":"2026-10-21T12:29:58Z","number":14768,"reviewDecision":"APPROVED","reviewRequests":[{"__typename":"User","login":"niik"}],"reviews":[{"id":"PRR_1","author":{"login":"copilot-pull-request-reviewer"},"authorAssociation":"CONTRIBUTOR","body":"","submittedAt":"2026-10-21T09:46:01Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"831b9343fa77c2f11f521a49fd01da2d2d007b8e"}},{"id":"PRR_2","author":{"login":"williammartin"},"authorAssociation":"MEMBER","body":"LGTM","submittedAt":"2026-10-21T12:20:00Z","includesCreatedEdit":false,"reactionGroups":[],"state":"APPROVED","commit":{"oid":"6ec1ba54542baca6cc5932582634104c83f36a28"}}],"state":"MERGED","statusCheckRollup":[{"__typename":"CheckRun","completedAt":"2026-10-21T12:09:03Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/1","name":"build (ubuntu)","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"0001-01-01T00:00:00Z","conclusion":"","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/2","name":"build (windows)","startedAt":"2026-10-21T12:07:01Z","status":"IN_PROGRESS","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"FAILURE","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/3","name":"lint","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Lint"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"SKIPPED","detailsUrl":"","name":"docs","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":""},{"__typename":"StatusContext","context":"ci/legacy","state":"PENDING","targetUrl":"https://ci.example.com/1","startedAt":"2026-10-21T12:07:01Z"}],"title":"Refreshable tokens: bump cli/oauth to v1.3.0 and fix `gh status` column width","updatedAt":"2026-10-21T12:30:00Z","url":"https://github.com/cli/cli/pull/14768"}
+32
Переглянути файл
@@ -22,6 +22,7 @@ fn pr_view() {
assert_eq!(pr.summary.number, 14768);
assert_eq!(pr.summary.head_ref, "babakks/refresh-token-bump-deps");
assert_eq!(pr.head_oid, "6ec1ba54542baca6cc5932582634104c83f36a28");
assert_eq!(pr.merge_state_status, "UNKNOWN");
assert_eq!(pr.files.len(), 2);
assert_eq!(pr.files[0].change_type, "MODIFIED");
assert_eq!(pr.commits[0].headline, "chore(deps): bump github.com/cli/oauth to v1.3.0");
@@ -69,6 +70,16 @@ fn repo_view() {
.unwrap();
assert_eq!(r.name_with_owner, "l0ng-ai/openidea");
assert_eq!(r.default_branch.as_deref(), Some("main"));
assert!(r.merge_commit_allowed && r.squash_merge_allowed && r.rebase_merge_allowed, "unknown: allowed");
assert_eq!(r.viewer_permission, "");
let r = parse_repo(
r#"{"mergeCommitAllowed":true,"nameWithOwner":"o/r","url":"","rebaseMergeAllowed":false,"squashMergeAllowed":false,"viewerPermission":"WRITE"}"#,
)
.unwrap();
assert!(r.merge_commit_allowed);
assert!(!r.squash_merge_allowed && !r.rebase_merge_allowed);
assert_eq!(r.viewer_permission, "WRITE");
}
#[test]
@@ -87,3 +98,24 @@ fn list_arguments() {
assert!(joined.contains("--limit 500 --state merged --author @me"));
assert_eq!(args.last().map(String::as_str), Some("review-requested:@me fix login"));
}
#[test]
fn api_arguments() {
let req =
GhApiRequest { method: "post".into(), endpoint: "graphql".into(), body: Some("{}".into()), paginate: false };
assert_eq!(api_args(&req).unwrap(), ["api", "--method", "POST", "graphql", "--input", "-"]);
let req = GhApiRequest {
method: "GET".into(),
endpoint: "repos/{owner}/{repo}/labels".into(),
body: None,
paginate: true,
};
assert_eq!(
api_args(&req).unwrap(),
["api", "--method", "GET", "repos/{owner}/{repo}/labels", "--paginate", "--slurp"]
);
for bad in ["--hostname", "https://evil.example/x", ""] {
assert!(api_args(&GhApiRequest { endpoint: bad.into(), ..req.clone() }).is_err(), "{bad}");
}
assert!(api_args(&GhApiRequest { method: "TRACE".into(), ..req }).is_err());
}