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 <30753807+imacross@users.noreply.github.com>
This commit is contained in:
imacross
2026-10-31 07:50:43 +08:00
committed by GitHub
parent 696377acc2
commit 059fd8a69b
192 changed files with 23668 additions and 2076 deletions
+112
View File
@@ -0,0 +1,112 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fragmentsOf, withFragments } from "./document";
import { FRAGMENTS } from "./fragments";
import * as Q from "./queries";
import { GHGraphQLError, graphqlData, parseRest } from "./client";
import { timelineItems } from "./requests";
const definitions = (text: string) => [...text.matchAll(/^\s*fragment\s+(\S+)\s+on\s/gm)].map((m) => m[1]!);
const spreads = (text: string) => new Set([...text.matchAll(/^\s*\.\.\.([A-Za-z_]\w*)/gm)].map((m) => m[1]!));
describe("GraphQL documents", () => {
it("ports every IntelliJ fragment and query", () => {
expect(Object.keys(FRAGMENTS)).toHaveLength(39);
expect(Object.keys(Q)).toHaveLength(40);
for (const [name, text] of Object.entries(FRAGMENTS)) expect(definitions(text), name).toEqual([name]);
});
it.each(Object.entries(Q))("sends %s with every fragment it needs, once", (_name, doc) => {
const text = withFragments(doc);
const defined = definitions(text);
expect(new Set(defined).size).toBe(defined.length);
for (const s of spreads(text)) expect(defined, s).toContain(s);
// Only fragments that are spread somewhere are added.
for (const d of fragmentsOf(doc)) expect(spreads(text).has(d), d).toBe(true);
});
it("resolves fragments transitively, like GHGQLQueryLoader", () => {
const names = fragmentsOf(Q.issueSearch);
expect(names).toEqual(
expect.arrayContaining([
"pageInfo",
"pullRequestInfoShort",
"rates",
"nodeInfo",
"actor",
"actorInfo",
"userInfo",
"user",
"labelInfo",
"pullRequestReviewer",
"teamInfo",
"pullRequestReview",
"reactions",
"reaction",
"enterpriseUserAccountInfo",
"organizationInfo",
]),
);
expect(names).not.toContain("repository");
// Fragments go first, the operation last.
expect(withFragments(Q.issueSearch).trim()).toMatch(/^fragment[\s\S]*\nquery issueSearch[\s\S]*\}$/);
});
it("does not add fragments a document defines itself", () => {
const text = withFragments(Q.pullRequestTimeline);
expect(definitions(text).filter((d) => d === "gitRefName")).toHaveLength(1);
expect(fragmentsOf(Q.pullRequestTimeline)).not.toContain("gitRefName");
expect(fragmentsOf(Q.pullRequestTimeline)).not.toContain("issueCommentInfo");
});
it("treats inline fragments as selections, not spreads", () => {
expect(fragmentsOf(Q.findPendingReview)).toEqual(expect.arrayContaining(["pullRequestPendingReview", "nodeInfo"]));
expect(fragmentsOf(`query { node(id: "x") { ... on User { id } } }`)).toEqual([]);
});
it("fails on an unknown fragment", () => {
expect(() => withFragments(`query {\n viewer {\n ...nope\n }\n}`)).toThrow(/nope/);
});
});
describe("client", () => {
afterEach(() => vi.restoreAllMocks());
it("returns data and throws GraphQL errors", () => {
expect(graphqlData<{ a: number }>('{"data":{"a":1}}')).toEqual({ a: 1 });
expect(() => graphqlData('{"errors":[{"message":"bad"},{"message":"worse"}]}')).toThrow(GHGraphQLError);
expect(() => graphqlData('{"errors":[{"message":"bad"}]}')).toThrow("bad");
});
it("flattens slurped REST pages and maps empty bodies to null", () => {
expect(parseRest("[[1,2],[3]]", true)).toEqual([1, 2, 3]);
expect(parseRest('[{"a":1}]', false)).toEqual([{ a: 1 }]);
expect(parseRest(" ", false)).toBeNull();
});
it("sends documents with their fragments through gh", async () => {
const ipc = await import("@/platform/ipc");
const ghApi = vi
.spyOn(ipc.commands, "ghApi")
.mockResolvedValue({ status: "ok", data: '{"data":{"repository":null}}' } as never);
const { graphql } = await import("./client");
await graphql(Q.findRepository, { repoOwner: "o", repoName: "r", cursor: undefined });
const req = ghApi.mock.calls[0]![0];
expect(req).toMatchObject({ method: "POST", endpoint: "graphql", paginate: false });
const body = JSON.parse(req.body!) as { query: string; variables: Record<string, unknown> };
expect(body.variables).toEqual({ repoOwner: "o", repoName: "r" });
expect(definitions(body.query)).toEqual(expect.arrayContaining(["repository", "repositoryOwnerName", "rates"]));
});
});
describe("timeline items", () => {
it("drops IntelliJ's ignored types and marks unknown ones", () => {
const items = timelineItems([
{ __typename: "IssueComment" },
{ __typename: "MentionedEvent" },
{ __typename: "SubscribedEvent" },
{ __typename: "AutoMergeEnabledEvent" },
]);
expect(items.map((i) => i.__typename)).toEqual(["IssueComment", "Unknown"]);
expect(items[1]).toEqual({ __typename: "Unknown", type: "AutoMergeEnabledEvent" });
});
});