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

+78
Переглянути файл
@@ -0,0 +1,78 @@
/**
* Incrementally loaded lists (IntelliJ's `BatchesLoader` / `IncrementallyComputedValue`):
* pages are appended as they arrive, so choosers show the first users while the rest load.
* A list loads once and stays cached until `reset()`.
*/
import { create, type StoreApi, type UseBoundStore } from "zustand";
import { errorMessage } from "../api/client";
export interface BatchesState<T> {
items: T[];
/** A page is being fetched. */
loading: boolean;
/** Every page arrived. */
done: boolean;
error: string | null;
}
export interface BatchesLoader<T> {
store: UseBoundStore<StoreApi<BatchesState<T>>>;
/** Starts loading unless loaded or loading; resolves with every item (rejects on failure). */
load(): Promise<T[]>;
/** Forgets the items; the next `load()` starts over. */
reset(): void;
}
const initial = <T>(): BatchesState<T> => ({ items: [], loading: false, done: false, error: null });
/** A loader fetching `page(cursor)` from `first` until `next` is null. */
export function createBatchesLoader<T, C>(
first: C,
page: (cursor: C) => Promise<{ items: T[]; next: C | null }>,
): BatchesLoader<T> {
const store = create<BatchesState<T>>(() => initial<T>());
let run: Promise<T[]> | null = null;
let generation = 0;
const load = () => {
if (run) return run;
const gen = ++generation;
const alive = () => gen === generation;
store.setState({ ...initial<T>(), loading: true });
run = (async () => {
let cursor: C | null = first;
try {
while (cursor !== null) {
const r: { items: T[]; next: C | null } = await page(cursor);
if (!alive()) return store.getState().items;
store.setState((s) => ({ items: [...s.items, ...r.items] }));
cursor = r.next;
}
if (alive()) store.setState({ loading: false, done: true });
return store.getState().items;
} catch (e) {
if (alive()) {
store.setState({ loading: false, error: errorMessage(e) });
run = null;
}
throw e;
}
})();
return run;
};
const reset = () => {
generation++;
run = null;
store.setState(initial<T>());
};
return { store, load, reset };
}
/** Merges several loaders' states (potential reviewers: teams + users), keeping their order. */
export function mergeBatches<T>(states: ReadonlyArray<BatchesState<T>>): BatchesState<T> {
return {
items: states.flatMap((s) => s.items),
loading: states.some((s) => s.loading),
done: states.every((s) => s.done),
error: states.find((s) => s.error)?.error ?? null,
};
}
+470
Переглянути файл
@@ -0,0 +1,470 @@
import { beforeEach, describe, expect, it } from "vitest";
import type {
GHActor,
GHPullRequestMergeabilityData,
GHPullRequestReview,
GHPullRequestShort,
GHTeam,
GHUser,
} from "../api/types";
import { createBatchesLoader, mergeBatches } from "./batches";
import {
buildMergeabilityState,
filterCount,
hasPermission,
isSeen,
listSearchQuery,
mergeCommitMessage,
normalizeSearch,
parseGitHubRemote,
remoteOfRepository,
pullRequestPermissions,
pullRequestStateIcon,
repositoryDisplayName,
reviewersStates,
reviewerStateOf,
searchTerms,
sortedReviewersStates,
splitCommitMessage,
stateTagText,
unresolvedThreadsCount,
} from "./model";
import { githubRemotes } from "./repository";
import { withSearch } from "./searchHistory";
const user = (login: string): GHUser => ({
__typename: "User",
id: `id-${login}`,
login,
url: `https://github.com/${login}`,
avatarUrl: "",
name: null,
});
const team: GHTeam = {
__typename: "Team",
id: "T1",
slug: "core",
url: "",
teamAvatarUrl: null,
teamName: "Core",
combinedSlug: "org/core",
};
let reviewSeq = 0;
const review = (author: GHActor | null, state: GHPullRequestReview["state"], minute: number): GHPullRequestReview => ({
__typename: "PullRequestReview",
id: `R${++reviewSeq}`,
url: "",
author,
body: "",
state,
createdAt: `2026-10-01T10:${String(minute).padStart(2, "0")}:00Z`,
viewerCanUpdate: false,
});
function pr(over: Partial<GHPullRequestShort> = {}): GHPullRequestShort {
return {
id: "PR1",
url: "https://github.com/o/r/pull/1",
number: 1,
title: "Fix",
state: "OPEN",
isDraft: false,
author: user("ann"),
createdAt: "2026-10-01T09:00:00Z",
updatedAt: "2026-10-01T12:00:00Z",
isReadByViewer: false,
assignees: { nodes: [] },
labels: { nodes: [] },
reviewRequests: { nodes: [] },
reviewThreads: { nodes: [] },
reviews: { nodes: [] },
reactions: {
pageInfo: { startCursor: null, endCursor: null, hasNextPage: false, hasPreviousPage: false },
nodes: [],
},
mergeable: "MERGEABLE",
viewerCanUpdate: false,
viewerCanReact: true,
viewerDidAuthor: false,
...over,
};
}
describe("list search", () => {
it("builds IntelliJ's terms in order", () => {
expect(
searchTerms({
searchQuery: "foo bar",
state: "MERGED",
assignee: "me",
reviewState: "NOT_REVIEWED_BY_ME",
author: "ann",
label: "good first issue",
sort: "LEAST_RECENTLY_UPDATED",
}),
).toBe('(foo bar) is:merged assignee:me -reviewed-by:@me author:ann label:"good first issue" sort:updated-asc');
expect(searchTerms({ reviewState: "AWAITING_REVIEW" })).toBe("review-requested:@me");
expect(searchTerms({})).toBeNull();
});
it("prefixes the repository for issueSearch", () => {
expect(listSearchQuery({ owner: "o", name: "r" }, { state: "OPEN" })).toBe("type:pr repo:o/r is:open");
expect(listSearchQuery({ owner: "o", name: "r" }, {})).toBe("type:pr repo:o/r");
});
it("keeps GitHub's boolean syntax inside the repository", () => {
const q = "is:pr is:open (review-requested:ann OR assignee:@ann)";
expect(listSearchQuery({ owner: "o", name: "r" }, { searchQuery: q })).toBe(`type:pr repo:o/r (${q})`);
expect(searchTerms({ searchQuery: " " })).toBeNull();
});
it("counts filters and normalizes stored values", () => {
expect(filterCount({ searchQuery: "x", state: "OPEN", sort: "NEWEST" })).toBe(3);
expect(normalizeSearch({ state: "OPEN", sort: "SIDEWAYS", label: "", author: "ann", extra: 1 })).toEqual({
state: "OPEN",
author: "ann",
});
expect(normalizeSearch("nope")).toBeNull();
});
it("keeps searches with filters in the history, newest first, at most 10", () => {
let h = withSearch([], { state: "OPEN" });
expect(h).toEqual([]); // the default
h = withSearch(h, {});
expect(h).toEqual([]); // no filters
h = withSearch(h, { author: "a" });
h = withSearch(h, { author: "b" });
h = withSearch(h, { author: "a" });
expect(h).toEqual([{ author: "a" }, { author: "b" }]);
for (let i = 0; i < 12; i++) h = withSearch(h, { label: `l${i}` });
expect(h).toHaveLength(10);
expect(h[0]).toEqual({ label: "l11" });
});
});
describe("reviewer states", () => {
it("lets the newest opinionated review win over later comments", () => {
expect(reviewerStateOf([review(user("bo"), "APPROVED", 1), review(user("bo"), "COMMENTED", 2)])).toBe("APPROVED");
expect(reviewerStateOf([review(user("bo"), "APPROVED", 1), review(user("bo"), "CHANGES_REQUESTED", 2)])).toBe(
"CHANGES_REQUESTED",
);
});
it("surfaces a dismissed latest review as a comment", () => {
expect(reviewerStateOf([review(user("bo"), "APPROVED", 1), review(user("bo"), "DISMISSED", 2)])).toBe("COMMENTED");
});
it("skips reviewers with only pending reviews", () => {
expect(reviewerStateOf([review(user("bo"), "PENDING", 1)])).toBeNull();
expect(reviewerStateOf([review(user("bo"), "PENDING", 2), review(user("bo"), "COMMENTED", 1)])).toBe("COMMENTED");
});
it("excludes the author and marks requests as re-requested or needing review", () => {
const states = reviewersStates(
pr({
reviews: {
nodes: [
review(user("ann"), "COMMENTED", 1),
review(user("bo"), "APPROVED", 2),
review(user("cy"), "COMMENTED", 3),
],
},
reviewRequests: {
nodes: [{ requestedReviewer: user("cy") }, { requestedReviewer: team }, { requestedReviewer: null }],
},
}),
);
expect(
states.map((s) => [s.reviewer.__typename === "Team" ? "team" : (s.reviewer as GHUser).login, s.state]),
).toEqual([
["bo", "APPROVED"],
["cy", "RE_REQUESTED"],
["team", "NEEDS_REVIEW"],
]);
});
it("counts reviews of deleted users under the ghost user", () => {
const states = reviewersStates(pr({ reviews: { nodes: [review(null, "APPROVED", 1)] } }));
expect(states).toHaveLength(1);
expect((states[0]!.reviewer as GHUser).login).toBe("ghost");
});
it("orders the row's avatars approved → changes → commented → re-requested → needs review", () => {
const states = sortedReviewersStates(
pr({
reviews: {
nodes: [
review(user("c"), "COMMENTED", 1),
review(user("x"), "CHANGES_REQUESTED", 2),
review(user("a"), "APPROVED", 3),
],
},
reviewRequests: { nodes: [{ requestedReviewer: user("n") }] },
}),
);
expect(states.map((s) => s.state)).toEqual(["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "NEEDS_REVIEW"]);
});
it("counts unresolved, current threads", () => {
expect(
unresolvedThreadsCount(
pr({
reviewThreads: {
nodes: [
{ isResolved: false, isOutdated: false },
{ isResolved: true, isOutdated: false },
{ isResolved: false, isOutdated: true },
],
},
}),
),
).toBe(1);
});
});
describe("unread", () => {
const me = { id: "id-me" };
const updated = Date.parse("2026-10-01T12:00:00Z");
it("only applies to pull requests the user wrote or is asked to review", () => {
expect(isSeen(pr(), me, null)).toBe(true);
expect(isSeen(pr({ author: user("me") }), me, null)).toBe(false);
expect(isSeen(pr({ reviewRequests: { nodes: [{ requestedReviewer: user("me") }] } }), me, null)).toBe(false);
});
it("compares the last seen time, with a 5 s margin, with the update", () => {
const mine = pr({ author: user("me") });
expect(isSeen(mine, me, updated - 4000)).toBe(true);
expect(isSeen(mine, me, updated - 6000)).toBe(false);
expect(isSeen(mine, me, updated + 1)).toBe(true);
});
});
describe("mergeability", () => {
const data = (over: Partial<GHPullRequestMergeabilityData> = {}): GHPullRequestMergeabilityData => ({
mergeable: "MERGEABLE",
canBeRebased: true,
mergeStateStatus: "CLEAN",
baseRef: null,
commits: {
pageInfo: { startCursor: null, endCursor: null, hasNextPage: false, hasPreviousPage: false },
nodes: [
{
id: "c",
commit: {
id: "c",
oid: "abc",
abbreviatedOid: "abc",
status: {
contexts: [
{ context: "ci/a", state: "SUCCESS", isRequired: true, targetUrl: "u1" },
{ context: "ci/b", state: "ERROR", isRequired: false, targetUrl: null },
],
},
checkSuites: {
pageInfo: { startCursor: null, endCursor: null, hasNextPage: false, hasPreviousPage: false },
nodes: [
{
checkRuns: {
pageInfo: { startCursor: null, endCursor: null, hasNextPage: false, hasPreviousPage: false },
nodes: [
{ name: "build", conclusion: null, isRequired: false, detailsUrl: null, url: "run" },
{ name: "lint", conclusion: "SKIPPED", isRequired: false, detailsUrl: "d", url: "run2" },
{ name: "test", conclusion: "TIMED_OUT", isRequired: true, detailsUrl: null, url: "run3" },
],
},
},
],
},
},
},
],
},
...over,
});
const rule = {
allowsDeletions: false,
allowsForcePushes: false,
pattern: "main",
requiredApprovingReviewCount: 2,
requiredStatusCheckContexts: ["ci/a"],
requiresLinearHistory: false,
requiresSignatures: false,
viewerCanPush: true,
};
it("maps statuses and check runs to CI jobs", () => {
const s = buildMergeabilityState(data(), false);
expect(s.ciJobs.map((j) => [j.name, j.status, j.detailsUrl])).toEqual([
["ci/a", "SUCCESS", "u1"],
["ci/b", "FAILED", null],
["build", "PENDING", "run"],
["lint", "SKIPPED", "d"],
["test", "FAILED", "run3"],
]);
expect(s).toMatchObject({
hasConflicts: false,
canBeMerged: true,
checksState: null,
requiredApprovingReviewsCount: 0,
});
});
it("follows the merge state and branch protection", () => {
expect(buildMergeabilityState(data({ mergeable: "UNKNOWN" }), false).hasConflicts).toBeNull();
expect(buildMergeabilityState(data({ mergeable: "CONFLICTING", mergeStateStatus: "DIRTY" }), false)).toMatchObject({
hasConflicts: true,
canBeMerged: false,
});
const blocked = data({ mergeStateStatus: "BLOCKED", baseRef: { refUpdateRule: rule } });
expect(buildMergeabilityState(blocked, false)).toMatchObject({
canBeMerged: false,
checksState: null,
requiredApprovingReviewsCount: 2,
});
expect(buildMergeabilityState(blocked, true).canBeMerged).toBe(true);
const failing = data({
mergeStateStatus: "BLOCKED",
baseRef: { refUpdateRule: { ...rule, requiredStatusCheckContexts: ["ci/b"] } },
});
expect(buildMergeabilityState(failing, false)).toMatchObject({
checksState: "BLOCKING_FAILING",
requiredApprovingReviewsCount: 0,
});
expect(buildMergeabilityState(data({ mergeStateStatus: "BEHIND" }), false).checksState).toBe("BLOCKING_BEHIND");
const restricted = data({
mergeStateStatus: "BLOCKED",
baseRef: { refUpdateRule: { ...rule, viewerCanPush: false } },
});
expect(buildMergeabilityState(restricted, false)).toMatchObject({
isRestricted: true,
requiredApprovingReviewsCount: 0,
});
});
it("decides the merge actions from permission, settings and state", () => {
const repo = {
viewerPermission: "WRITE" as const,
mergeCommitAllowed: true,
squashMergeAllowed: false,
rebaseMergeAllowed: true,
};
const state = buildMergeabilityState(data(), false);
expect(pullRequestPermissions({ viewerDidAuthor: false, state: "OPEN" }, repo, state)).toEqual({
canManage: true,
canMerge: true,
canSquashMerge: false,
canRebaseMerge: true,
});
expect(pullRequestPermissions({ viewerDidAuthor: false, state: "OPEN" }, repo, state, true).canMerge).toBe(false);
const reader = { ...repo, viewerPermission: "READ" as const };
expect(pullRequestPermissions({ viewerDidAuthor: true, state: "OPEN" }, reader, state)).toMatchObject({
canManage: true,
canMerge: false,
});
expect(pullRequestPermissions({ viewerDidAuthor: false, state: "OPEN" }, reader, state).canManage).toBe(false);
expect(hasPermission("MAINTAIN", "WRITE")).toBe(true);
expect(hasPermission(null, "READ")).toBe(false);
});
});
describe("pull request helpers", () => {
it("names states like IntelliJ", () => {
expect(stateTagText("OPEN", false)).toBeNull();
expect(stateTagText("OPEN", true)).toBe("DRAFT");
expect(stateTagText("MERGED", true)).toBe("MERGED");
expect(pullRequestStateIcon("OPEN", true)).toBe("github-pr-draft");
expect(pullRequestStateIcon("CLOSED", false)).toBe("github-pr-closed");
});
it("prefills and splits merge commit messages", () => {
expect(mergeCommitMessage({ number: 7, title: "Fix it" })).toBe("Merge pull request #7\n\nFix it");
expect(mergeCommitMessage({ number: 7, title: "x" }, ["a", "b"])).toBe("Merge pull request #7\n\n* a\n\n* b");
expect(splitCommitMessage("Subject\n\nBody\nmore")).toEqual({ title: "Subject", body: "Body\nmore" });
expect(splitCommitMessage("Only")).toEqual({ title: "Only", body: "" });
});
it("shows the owner only when owners differ", () => {
const a = { owner: "me", name: "app" };
const b = { owner: "org", name: "app" };
expect(repositoryDisplayName([a], a)).toBe("app");
expect(repositoryDisplayName([a, b], a)).toBe("me/app");
expect(repositoryDisplayName([a, { owner: "me", name: "lib" }], a)).toBe("app");
expect(repositoryDisplayName([a], a, true)).toBe("me/app");
});
it("recognizes github.com remotes", () => {
for (const url of [
"https://github.com/o/r.git",
"https://[email protected]/o/r",
"[email protected]:o/r.git",
"ssh://[email protected]/o/r.git",
"ssh://[email protected]:22/o/r.git",
"git://github.com/o/r",
])
expect(parseGitHubRemote(url), url).toEqual({ owner: "o", name: "r" });
expect(parseGitHubRemote("https://gitlab.com/o/r.git")).toBeNull();
expect(parseGitHubRemote("/local/repo")).toBeNull();
expect(
githubRemotes([
{ name: "origin", url: "[email protected]:me/r.git" },
{ name: "upstream", url: "https://github.com/org/r.git" },
{ name: "dup", url: "https://github.com/ORG/r" },
{ name: "other", url: "https://example.com/x/y" },
]).map((r) => `${r.remote}=${r.owner}/${r.name}`),
).toEqual(["origin=me/r", "upstream=org/r"]);
});
});
describe("batches", () => {
let pages: Array<{ items: number[]; next: number | null }>;
beforeEach(() => {
pages = [
{ items: [1, 2], next: 1 },
{ items: [3], next: null },
];
});
it("appends pages as they arrive and loads once", async () => {
let calls = 0;
const loader = createBatchesLoader<number, number>(0, async (p) => {
calls++;
return pages[p]!;
});
const all = await loader.load();
expect(all).toEqual([1, 2, 3]);
expect(loader.store.getState()).toMatchObject({ items: [1, 2, 3], loading: false, done: true });
await loader.load();
expect(calls).toBe(2);
loader.reset();
expect(loader.store.getState().items).toEqual([]);
});
it("keeps what loaded before a failure and retries on the next load", async () => {
let fail = true;
const loader = createBatchesLoader<number, number>(0, async (p) => {
if (p === 1 && fail) throw new Error("boom");
return pages[p]!;
});
await expect(loader.load()).rejects.toThrow("boom");
expect(loader.store.getState()).toMatchObject({ items: [1, 2], loading: false, done: false, error: "boom" });
fail = false;
expect(await loader.load()).toEqual([1, 2, 3]);
expect(
mergeBatches([
{ items: [1], loading: false, done: true, error: null },
{ items: [2], loading: true, done: false, error: null },
]),
).toEqual({ items: [1, 2], loading: true, done: false, error: null });
});
});
describe("remoteOfRepository", () => {
const repo = { owner: "Org", name: "app" };
it("finds the remote behind an SSH host alias by the URL's path", () => {
const alias = { name: "origin", url: "git@work-github:org/app.git" };
expect(remoteOfRepository([{ name: "other", url: "git@work-github:org/app2.git" }, alias], repo)).toBe(alias);
expect(remoteOfRepository([{ name: "x", url: "ssh://work-github/org/app/" }], repo)?.name).toBe("x");
expect(remoteOfRepository([{ name: "x", url: "git@work-github:notorg/app.git" }], repo)).toBeNull();
});
});
+86
Переглянути файл
@@ -0,0 +1,86 @@
/**
* When each pull request was last seen (IntelliJ's `GHPRPersistentInteractionState`),
* stored per project; the list's unread dots compare it with `updatedAt` (`isSeen` in
* `model.ts`). Entries of pull requests updated since and older than
* `CLEAR_LAST_SEEN_AFTER_DAYS` are dropped.
*/
import { create } from "zustand";
import { useProject } from "@/platform/project";
import type { GHPullRequestShort, GHUser } from "../api/types";
import { CLEAR_LAST_SEEN_AFTER_DAYS, isSeen } from "./model";
const key = (root: string) => `openidea.github.lastSeen.v1:${root}`;
const projectRoot = () => useProject.getState().project?.root ?? null;
function load(root: string | null): Record<string, number> {
if (!root) return {};
try {
const v = JSON.parse(globalThis.localStorage?.getItem(key(root)) ?? "{}") as unknown;
if (!v || typeof v !== "object") return {};
return Object.fromEntries(Object.entries(v).filter(([, t]) => typeof t === "number")) as Record<string, number>;
} catch {
return {};
}
}
function save(root: string | null, lastSeen: Record<string, number>) {
if (!root) return;
try {
globalThis.localStorage?.setItem(key(root), JSON.stringify(lastSeen));
} catch {
/* storage unavailable */
}
}
/** Last-seen times (ms) by pull request node id, for the open project. */
export const useLastSeen = create<{ root: string | null; lastSeen: Record<string, number> }>(() => ({
root: projectRoot(),
lastSeen: load(projectRoot()),
}));
function current(): Record<string, number> {
const root = projectRoot();
if (useLastSeen.getState().root !== root) useLastSeen.setState({ root, lastSeen: load(root) });
return useLastSeen.getState().lastSeen;
}
/** Records that the pull request with node id `prId` was seen now. */
export function markSeen(prId: string, at = Date.now()) {
const lastSeen = { ...current(), [prId]: at };
useLastSeen.setState({ lastSeen });
save(projectRoot(), lastSeen);
}
/**
* `isSeen` with the stored time; forgets entries that are stale (unseen updates, older than 14 days).
* @public
*/
export function isPullRequestSeen(
pr: Pick<GHPullRequestShort, "id" | "author" | "reviewRequests" | "updatedAt">,
user: Pick<GHUser, "id">,
now = Date.now(),
): boolean {
const all = current();
const lastSeen = all[pr.id];
const seen = isSeen(pr, user, lastSeen);
if (!seen && lastSeen !== undefined && lastSeen < now - CLEAR_LAST_SEEN_AFTER_DAYS * 86_400_000) {
const { [pr.id]: _, ...rest } = all;
useLastSeen.setState({ lastSeen: rest });
save(projectRoot(), rest);
}
return seen;
}
/** React hook: whether `pr` is seen; re-renders when the last-seen times change. */
export function usePullRequestSeen(
pr: Pick<GHPullRequestShort, "id" | "author" | "reviewRequests" | "updatedAt">,
user: Pick<GHUser, "id"> | null | undefined,
): boolean {
const lastSeen = useLastSeen((s) => s.lastSeen[pr.id]);
return !user || isSeen(pr, user, lastSeen);
}
useProject.subscribe((s, prev) => {
const root = s.project?.root ?? null;
if (root !== (prev.project?.root ?? null)) useLastSeen.setState({ root, lastSeen: load(root) });
});
+159
Переглянути файл
@@ -0,0 +1,159 @@
/**
* The pull request list (IntelliJ's `GHPRListLoader` over `issueSearch`): pages of 50,
* cursor-paged, for the current search value. `loadMore()` fetches the next page (the
* list calls it while scrolling past 70%); `reloadList()` starts over, keeping the rows
* visible until the first page arrives. Rows are refreshed in place when a pull request's
* details load (`updateListItem`).
*/
import { create } from "zustand";
import { errorMessage } from "../api/client";
import * as api from "../api/requests";
import type { GHPullRequest, GHPullRequestShort } from "../api/types";
import { listSearchQuery, sameSearch, type PrSearchValue } from "./model";
import { useGhRepository } from "./repository";
interface ListState {
search: PrSearchValue;
items: GHPullRequestShort[];
cursor: string | null;
hasMore: boolean;
loading: boolean;
error: string | null;
/** The first page of the current search arrived (`items` are for `search`). */
loaded: boolean;
}
export const useGhPrList = create<ListState>(() => ({
search: {},
items: [],
cursor: null,
hasMore: true,
loading: false,
error: null,
loaded: false,
}));
let seq = 0;
async function fetchPage(reset: boolean): Promise<void> {
const repo = useGhRepository.getState().connection?.coordinates;
if (!repo) return;
const my = ++seq;
const s = useGhPrList.getState();
useGhPrList.setState({ loading: true, error: null });
try {
const page = await api.searchPullRequests(listSearchQuery(repo, s.search), reset ? null : s.cursor);
if (my !== seq) return;
useGhPrList.setState((cur) => {
const items = reset
? page.nodes
: [...cur.items, ...page.nodes.filter((n) => !cur.items.some((i) => i.id === n.id))];
return {
items,
cursor: page.pageInfo.endCursor,
hasMore: page.pageInfo.hasNextPage,
loading: false,
loaded: true,
};
});
} catch (e) {
if (my !== seq) return;
useGhPrList.setState({
loading: false,
error: errorMessage(e),
hasMore: false,
loaded: true,
...(reset ? { items: [] } : {}),
});
}
}
/** Shows the pull requests matching `search` (from the first page). */
export function setListSearch(search: PrSearchValue): Promise<void> {
const same = sameSearch(useGhPrList.getState().search, search);
useGhPrList.setState({ search, ...(same ? {} : { items: [], loaded: false }), cursor: null, hasMore: true });
return fetchPage(true);
}
/** Loads the next page, if there is one and none is loading. */
export function loadMore(): Promise<void> {
const s = useGhPrList.getState();
if (s.loading || !s.hasMore || !s.loaded) return Promise.resolve();
return fetchPage(false);
}
/** "Refresh List": the current search from the first page. */
export function reloadList(): Promise<void> {
useGhPrList.setState({ cursor: null, hasMore: true });
return fetchPage(true);
}
/** Replaces the row of `pr` with fresher data (details loaded, a mutation's result). */
export function updateListItem(pr: GHPullRequestShort | GHPullRequest) {
useGhPrList.setState((s) => {
const i = s.items.findIndex((x) => x.id === pr.id);
if (i < 0) return s;
const items = [...s.items];
items[i] = { ...items[i]!, ...pickShort(pr) };
return { items };
});
}
function pickShort(pr: GHPullRequestShort): GHPullRequestShort {
const {
id,
url,
number,
title,
state,
isDraft,
author,
createdAt,
updatedAt,
isReadByViewer,
assignees,
labels,
reviewRequests,
reviewThreads,
reviews,
reactions,
mergeable,
viewerCanUpdate,
viewerCanReact,
viewerDidAuthor,
} = pr;
return {
id,
url,
number,
title,
state,
isDraft,
author,
createdAt,
updatedAt,
isReadByViewer,
assignees,
labels,
reviewRequests,
reviewThreads,
reviews,
reactions,
mergeable,
viewerCanUpdate,
viewerCanReact,
viewerDidAuthor,
};
}
/** Forgets the rows (disconnect, another repository). */
export function clearList() {
seq++;
useGhPrList.setState({ items: [], cursor: null, hasMore: true, loading: false, error: null, loaded: false });
}
useGhRepository.subscribe((s, prev) => {
const a = s.connection?.coordinates;
const b = prev.connection?.coordinates;
if (a?.owner !== b?.owner || a?.name !== b?.name) clearList();
});
+192
Переглянути файл
@@ -0,0 +1,192 @@
/**
* Local git work for pull requests: checkout (`gh pr checkout`), fetching what a diff
* needs (`ghPrPrepareDiff`, cached per head / base commit) and opening a changed file in
* the diff viewer (`vcs.openDiff`; `diff/` shows its review threads). Long operations run
* one at a time and are shown in the tool window's banner (`useGhBusy`), cancellable.
*/
import { Channel } from "@tauri-apps/api/core";
import { create } from "zustand";
import { withChain, type DiffData } from "@/features/vcs/diffInput";
import { executeAction } from "@/platform/actions";
import type { PrDiffRevisions, RequestId } from "@/platform/ipc";
import { call, commands, isCancelled, nextRequestId } from "@/platform/ipc";
import { openExternalUrl } from "@/platform/openUrl";
import { tx } from "@/platform/i18n";
import { notifications } from "@/workbench/contributions";
import { errorMessage } from "../api/client";
import type { GHCommitFile, GHPullRequest } from "../api/types";
import { loadPullRequestData } from "./pullRequest";
import { nameWithOwner, requireConnection } from "./repository";
/** The running operation (checkout, fetch for a diff) with its last output line. */
export const useGhBusy = create<{ busy: { title: string; line: string; request: RequestId } | null }>(() => ({
busy: null,
}));
/** Runs a cancellable, streaming gh / git operation; null when it failed, was cancelled or another one runs. */
export async function withBusy<T>(
title: string,
run: (request: RequestId, out: Channel<string>) => Promise<T>,
): Promise<T | null> {
if (useGhBusy.getState().busy) {
notifications().notify({
group: "vcs",
level: "info",
title: "Another GitHub operation is still running",
key: "github-busy",
});
return null;
}
const request = nextRequestId();
const out = new Channel<string>();
out.onmessage = (line) => useGhBusy.setState((s) => (s.busy ? { busy: { ...s.busy, line } } : s));
useGhBusy.setState({ busy: { title, line: "", request } });
try {
return await run(request, out);
} catch (e) {
if (!isCancelled(e))
notifications().notify({
group: "vcs",
level: "error",
title: `${title.replace(/…$/, "")} failed`,
body: errorMessage(e),
key: "github-op",
});
return null;
} finally {
useGhBusy.setState({ busy: null });
}
}
export function cancelBusy() {
const b = useGhBusy.getState().busy;
if (b) void commands.cancelRequest(b.request);
}
/** "Checkout Branch": fetches and checks out the pull request's branch (gh's local name). */
export async function checkoutPullRequest(number: number): Promise<boolean> {
// The command resolves with nothing (`null`), which `withBusy` reports as a failure.
const done = await withBusy(`Checking out #${number}…`, async (r, out) => {
await call(commands.ghPrCheckout(r, number, out));
return true;
});
if (done === null) return false;
notifications().notify({
group: "vcs",
level: "info",
title: `Checked out pull request #${number}`,
key: "github-checkout",
});
return true;
}
const diffCache = new Map<string, PrDiffRevisions>();
/** Fetches what the local repository lacks to diff `pr` (once per head / base commit). */
export async function prepareDiff(pr: GHPullRequest): Promise<PrDiffRevisions | null> {
const key = `${pr.number}:${pr.headRefOid}:${pr.baseRefOid}`;
const hit = diffCache.get(key);
if (hit) return hit;
const repo = nameWithOwner(requireConnection().coordinates);
const revs = await withBusy(`Fetching #${pr.number}…`, (r, out) =>
call(commands.ghPrPrepareDiff(r, pr.number, pr.headRefOid, pr.baseRefOid, pr.baseRefName, repo, out)),
);
if (revs) diffCache.set(key, revs);
return revs;
}
export function clearDiffCache() {
diffCache.clear();
}
/** The diff context of a pull request's file (`diff/` shows its review there); `commit` for one commit's diff. */
export const PULL_REQUEST_DIFF = "github.pullRequest";
/**
* `DiffData` for a changed file of `pr`: merge base ↔ head, its context (`PULL_REQUEST_DIFF`)
* naming the pull request and the file.
*/
export function fileDiffData(
pr: Pick<GHPullRequest, "number" | "baseRefName" | "headRefName">,
file: Pick<GHCommitFile, "filename" | "status" | "previousFilename">,
revs: PrDiffRevisions,
): DiffData {
const root = revs.root;
const changed = revs.files.find((f) => f.path === file.filename);
const added = file.status === "added" || changed?.kind === "added";
const deleted = file.status === "removed" || changed?.kind === "deleted";
const oldPath = file.previousFilename ?? changed?.oldPath ?? null;
const n = pr.number;
const name = file.filename.slice(file.filename.lastIndexOf("/") + 1);
return {
path: `${root}/${file.filename}`,
left: added
? { type: "text", text: "", label: tx("(does not exist)") }
: {
type: "rev",
rev: revs.base,
path: oldPath ? `${root}/${oldPath}` : undefined,
label: `${pr.baseRefName} (${revs.base.slice(0, 8)})`,
},
right: deleted
? { type: "text", text: "", label: tx("(deleted)") }
: { type: "rev", rev: revs.head, label: `#${n} ${pr.headRefName}` },
title: `#${n}: ${name}`,
context: { kind: PULL_REQUEST_DIFF, number: n, path: file.filename, commit: null },
};
}
/**
* Opens `path` of pull request `number` in the diff viewer (fetching what it needs first),
* revealing the 0-based `line` of `side` (default right) when given (a review thread's line).
* The pull request's other files follow in the chain (F7 past the last change).
*/
export async function openPullRequestFileDiff(
number: number,
path: string,
opts: { line?: number; side?: "left" | "right" } = {},
): Promise<void> {
const [pr, files] = await Promise.all([
loadPullRequestData(number, "details"),
loadPullRequestData(number, "changes"),
]);
const revs = await prepareDiff(pr);
if (!revs) return;
const file = files.find((f) => f.filename === path) ?? {
filename: path,
status: "modified" as const,
previousFilename: null,
};
const all = files.some((f) => f.filename === path) ? files : [...files, file];
const data = withChain(
all.map((f) => fileDiffData(pr, f, revs)),
all.findIndex((f) => f.filename === path),
);
await executeAction("vcs.openDiff", opts.line == null ? data : { ...data, line: opts.line, lineSide: opts.side });
}
/** "View Pull Request in Browser" and other links. */
export function openInBrowser(url: string): Promise<void> {
return openExternalUrl(url);
}
/** "Copy Pull Request URL". */
export async function copyUrl(url: string): Promise<void> {
await navigator.clipboard?.writeText(url);
}
/**
* "Open in Worktree": fetches the pull request's head, adds the worktree
* `<repository root>_PR_<n>` on it (detached) and opens it in a new window.
*/
export async function openInWorktree(number: number): Promise<void> {
const pr = await loadPullRequestData(number, "details");
const revs = await prepareDiff(pr);
if (!revs) return;
const path = `${String(revs.root).replace(/[\\/]+$/, "")}_PR_${number}`;
const added = await withBusy(`Creating worktree for #${number}…`, async () => {
await call(commands.vcsAddWorktree(null, path, pr.headRefOid, null));
return true;
});
if (added) await executeAction("file.openInNewWindow", path);
}
+444
Переглянути файл
@@ -0,0 +1,444 @@
/**
* Pure rules of the pull request data, ported from IntelliJ: the list search value and
* its query (`GHPRListSearchValue`, `GHPRSearchQuery`), reviewer states
* (`GHReviewersUtils`), the unread rule (`GHPRPersistentInteractionState`), the merge box
* state (`GHPRMergeabilityStateBuilder`), permissions (`GHPRSecurityService`) and the
* merge commit messages (`GHPRReviewFlowViewModelImpl`).
*/
import type {
CodeReviewCIJob,
CodeReviewCIJobState,
GHActor,
GHCommitCheckSuiteConclusion,
GHCommitStatusContextState,
GHPRMergeabilityState,
GHPRReviewerState,
GHPullRequest,
GHPullRequestMergeabilityData,
GHPullRequestMergeStateStatus,
GHPullRequestRequestedReviewer,
GHPullRequestReview,
GHPullRequestShort,
GHPullRequestState,
GHRepository,
GHRepositoryCoordinates,
GHRepositoryPermissionLevel,
GHTeam,
GHUser,
} from "../api/types";
// ---------------------------------------------------------------------------
// List search (`GHPRListSearchValue`)
export type PrSearchState = "OPEN" | "CLOSED" | "MERGED";
export type PrSearchReviewState =
| "NO_REVIEW"
| "REQUIRED"
| "APPROVED"
| "CHANGES_REQUESTED"
| "REVIEWED_BY_ME"
| "NOT_REVIEWED_BY_ME"
| "AWAITING_REVIEW";
export type PrSearchSort =
| "NEWEST"
| "OLDEST"
| "MOST_COMMENTED"
| "LEAST_COMMENTED"
| "RECENTLY_UPDATED"
| "LEAST_RECENTLY_UPDATED";
/** The list filter: free text plus the chips (author / assignee are logins). Unset = absent. */
export interface PrSearchValue {
searchQuery?: string;
state?: PrSearchState;
assignee?: string;
reviewState?: PrSearchReviewState;
author?: string;
label?: string;
sort?: PrSearchSort;
}
/** `GHPRListSearchValue.DEFAULT`: the Quick Filters' "Open" (`EMPTY` is `{}`). */
export const DEFAULT_SEARCH: PrSearchValue = { state: "OPEN" };
const SEARCH_KEYS = ["searchQuery", "state", "assignee", "reviewState", "author", "label", "sort"] as const;
const STATE_TERMS: Record<PrSearchState, string> = { OPEN: "is:open", CLOSED: "is:closed", MERGED: "is:merged" };
const REVIEW_TERMS: Record<PrSearchReviewState, string> = {
NO_REVIEW: "review:none",
REQUIRED: "review:required",
APPROVED: "review:approved",
CHANGES_REQUESTED: "review:changes-requested",
REVIEWED_BY_ME: "reviewed-by:@me",
NOT_REVIEWED_BY_ME: "-reviewed-by:@me",
AWAITING_REVIEW: "review-requested:@me",
};
const SORT_TERMS: Record<PrSearchSort, string> = {
NEWEST: "sort:created-desc",
OLDEST: "sort:created-asc",
MOST_COMMENTED: "sort:comments-desc",
LEAST_COMMENTED: "sort:comments-asc",
RECENTLY_UPDATED: "sort:updated-desc",
LEAST_RECENTLY_UPDATED: "sort:updated-asc",
};
/** How many filters are set (the text counts as one), as the Quick Filters badge and "Clear {n} Filters". */
export function filterCount(v: PrSearchValue): number {
return SEARCH_KEYS.filter((k) => v[k] !== undefined).length;
}
export function sameSearch(a: PrSearchValue, b: PrSearchValue): boolean {
return SEARCH_KEYS.every((k) => a[k] === b[k]);
}
/** A search value with only known, non-empty fields (from storage or a patch). */
export function normalizeSearch(v: unknown): PrSearchValue | null {
if (!v || typeof v !== "object") return null;
const r = v as Record<string, unknown>;
const str = (x: unknown) => (typeof x === "string" && x !== "" ? x : undefined);
const oneOf = <T extends string>(x: unknown, all: Record<T, unknown>) =>
typeof x === "string" && x in all ? (x as T) : undefined;
const out: PrSearchValue = {
searchQuery: str(r.searchQuery),
state: oneOf(r.state, STATE_TERMS),
assignee: str(r.assignee),
reviewState: oneOf(r.reviewState, REVIEW_TERMS),
author: str(r.author),
label: str(r.label),
sort: oneOf(r.sort, SORT_TERMS),
};
for (const k of SEARCH_KEYS) if (out[k] === undefined) delete out[k];
return out;
}
/**
* The filter's search terms (`GHPRListSearchValue.toQuery`), or null when nothing is set.
* The typed text is grouped in parentheses: GitHub's advanced search gives `OR` the lowest
* precedence, so `a OR b` would otherwise escape the repository and the other filters.
*/
export function searchTerms(v: PrSearchValue): string | null {
const terms: string[] = [];
const text = v.searchQuery?.trim();
if (text) terms.push(`(${text})`);
if (v.state) terms.push(STATE_TERMS[v.state]);
if (v.assignee !== undefined) terms.push(`assignee:${v.assignee}`);
if (v.reviewState) terms.push(REVIEW_TERMS[v.reviewState]);
if (v.author !== undefined) terms.push(`author:${v.author}`);
if (v.label !== undefined) terms.push(`label:"${v.label}"`);
if (v.sort) terms.push(SORT_TERMS[v.sort]);
return terms.length ? terms.join(" ") : null;
}
/** The `issueSearch` query of the list: `type:pr repo:owner/name` and the filter's terms. */
export function listSearchQuery(repo: GHRepositoryCoordinates, v: PrSearchValue): string {
const terms = searchTerms(v);
return [`type:pr repo:${repo.owner}/${repo.name}`, terms].filter(Boolean).join(" ");
}
// ---------------------------------------------------------------------------
// Reviewers (`GHReviewersUtils`)
export interface ReviewerWithState {
reviewer: GHPullRequestRequestedReviewer;
state: GHPRReviewerState;
}
export const isTeam = (r: GHPullRequestRequestedReviewer | GHActor | null | undefined): r is GHTeam =>
r?.__typename === "Team";
/** Login of a user / actor, `org/slug` of a team. */
export function reviewerKey(r: GHPullRequestRequestedReviewer): string {
return isTeam(r) ? r.combinedSlug : r.login;
}
/** A reviewer's effective state from their reviews, newest first; null for no submitted review. */
export function reviewerStateOf(reviews: readonly GHPullRequestReview[]): GHPRReviewerState | null {
let commented = false;
const newestFirst = [...reviews].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
for (const review of newestFirst) {
switch (review.state) {
case "APPROVED":
return "APPROVED";
case "CHANGES_REQUESTED":
return "CHANGES_REQUESTED";
case "DISMISSED":
return "COMMENTED";
case "COMMENTED":
commented = true;
break;
case "PENDING":
break;
}
}
return commented ? "COMMENTED" : null;
}
/** Placeholder author of reviews whose user was deleted (IntelliJ's ghost user). */
export const GHOST_USER: GHUser = {
__typename: "User",
id: "",
login: "ghost",
url: "https://github.com/ghost",
avatarUrl: "https://avatars.githubusercontent.com/u/10137?v=4",
name: "Deleted user",
};
/**
* Every reviewer of `pr` with their state, as GitHub shows them: reviewers by their
* reviews (the author's own excluded), then the pending requests, which make a reviewer
* who already reviewed RE_REQUESTED and one who did not NEEDS_REVIEW.
*/
export function reviewersStates(
pr: Pick<GHPullRequestShort, "author" | "reviews" | "reviewRequests">,
): ReviewerWithState[] {
const result = new Map<string, ReviewerWithState>();
const byReviewer = new Map<string, { reviewer: GHPullRequestRequestedReviewer; reviews: GHPullRequestReview[] }>();
for (const review of pr.reviews.nodes) {
const reviewer = review.author ?? GHOST_USER;
const key = reviewerKey(reviewer);
const group = byReviewer.get(key) ?? { reviewer, reviews: [] };
group.reviews.push(review);
byReviewer.set(key, group);
}
const authorKey = pr.author ? pr.author.login : null;
for (const [key, { reviewer, reviews }] of byReviewer) {
if (key === authorKey) continue;
const state = reviewerStateOf(reviews);
if (state) result.set(key, { reviewer, state });
}
for (const { requestedReviewer } of pr.reviewRequests.nodes) {
if (!requestedReviewer) continue;
const key = reviewerKey(requestedReviewer);
result.set(key, { reviewer: requestedReviewer, state: result.has(key) ? "RE_REQUESTED" : "NEEDS_REVIEW" });
}
return [...result.values()];
}
const REVIEWER_ORDER: Record<GHPRReviewerState, number> = {
APPROVED: 0,
CHANGES_REQUESTED: 1,
COMMENTED: 2,
RE_REQUESTED: 3,
NEEDS_REVIEW: 4,
};
/** `reviewersStates` ordered like the list row's avatars: approved → changes requested → commented → re-requested → needs review. */
export function sortedReviewersStates(pr: Pick<GHPullRequestShort, "author" | "reviews" | "reviewRequests">) {
return reviewersStates(pr).sort((a, b) => REVIEWER_ORDER[a.state] - REVIEWER_ORDER[b.state]);
}
/** Review threads that are neither resolved nor outdated (the list row's comment counter). */
export function unresolvedThreadsCount(pr: Pick<GHPullRequestShort, "reviewThreads">): number {
return pr.reviewThreads.nodes.filter((t) => !t.isResolved && !t.isOutdated).length;
}
// ---------------------------------------------------------------------------
// Unread (`GHPRPersistentInteractionState`)
/** Registry `github.last.seen.state.margin.millis`. */
export const LAST_SEEN_MARGIN_MS = 5000;
/** Registry `github.clear.last.seen.state.days`. */
export const CLEAR_LAST_SEEN_AFTER_DAYS = 14;
/**
* Whether `pr` counts as seen by `user`: always, unless the user wrote it or their review
* is requested; then when it was last seen (ms, stored locally) no earlier than its update.
*/
export function isSeen(
pr: Pick<GHPullRequestShort, "author" | "reviewRequests" | "updatedAt">,
user: Pick<GHUser, "id">,
lastSeen: number | null | undefined,
): boolean {
const involved =
pr.author?.id === user.id || pr.reviewRequests.nodes.some((r) => r.requestedReviewer?.id === user.id);
if (!involved) return true;
return lastSeen != null && lastSeen + LAST_SEEN_MARGIN_MS >= Date.parse(pr.updatedAt);
}
// ---------------------------------------------------------------------------
// Pull request state
/** `GHUIUtil.getPullRequestStateIcon`. */
export function pullRequestStateIcon(state: GHPullRequestState, isDraft: boolean): string {
if (state === "MERGED") return "github-pr-merged";
if (state === "CLOSED") return "github-pr-closed";
return isDraft ? "github-pr-draft" : "github-pr-open";
}
/** The state tag's text, or null for an open pull request (draft wins over open). */
export function stateTagText(state: GHPullRequestState, isDraft: boolean): "DRAFT" | "CLOSED" | "MERGED" | null {
if (state === "MERGED") return "MERGED";
if (state === "CLOSED") return "CLOSED";
return isDraft ? "DRAFT" : null;
}
// ---------------------------------------------------------------------------
// Mergeability (`GHPRMergeabilityStateBuilder`)
const CAN_MERGE: ReadonlySet<GHPullRequestMergeStateStatus> = new Set(["CLEAN", "HAS_HOOKS", "UNSTABLE"]);
const ADMIN_CAN_MERGE: ReadonlySet<GHPullRequestMergeStateStatus> = new Set(["BLOCKED", "BEHIND"]);
function contextJobState(s: GHCommitStatusContextState): CodeReviewCIJobState {
if (s === "PENDING") return "PENDING";
if (s === "SUCCESS") return "SUCCESS";
return "FAILED";
}
function checkRunJobState(c: GHCommitCheckSuiteConclusion | null): CodeReviewCIJobState {
if (c === null) return "PENDING";
if (c === "SKIPPED") return "SKIPPED";
if (c === "SUCCESS") return "SUCCESS";
return "FAILED";
}
export function buildMergeabilityState(
data: GHPullRequestMergeabilityData,
currentUserIsAdmin: boolean,
): GHPRMergeabilityState {
const rule = data.baseRef?.refUpdateRule ?? null;
const canOverrideAsAdmin = rule ? currentUserIsAdmin : false;
const requiredContexts = rule ? rule.requiredStatusCheckContexts.filter((c): c is string => c !== null) : [];
const isRestricted = rule ? !rule.viewerCanPush : false;
const requiredReviews = rule?.requiredApprovingReviewCount ?? 0;
const hasConflicts = data.mergeable === "MERGEABLE" ? false : data.mergeable === "CONFLICTING" ? true : null;
const lastCommit = data.commits.nodes.at(-1)?.commit;
const contexts = lastCommit?.status?.contexts ?? [];
const ciJobs: CodeReviewCIJob[] = [
...contexts.map((c) => ({
name: c.context,
status: contextJobState(c.state),
isRequired: c.isRequired,
detailsUrl: c.targetUrl,
})),
...(lastCommit?.checkSuites?.nodes ?? [])
.flatMap((s) => s.checkRuns?.nodes ?? [])
.map((r) => ({
name: r.name,
status: checkRunJobState(r.conclusion),
isRequired: r.isRequired,
detailsUrl: r.detailsUrl ?? r.url,
})),
];
const status = data.mergeStateStatus;
const canBeMerged = CAN_MERGE.has(status) || (ADMIN_CAN_MERGE.has(status) && canOverrideAsAdmin);
let checksState: GHPRMergeabilityState["checksState"] = null;
if (status === "BEHIND") checksState = "BLOCKING_BEHIND";
else if (status === "BLOCKED") {
const passed = new Set(contexts.filter((c) => c.state === "SUCCESS").map((c) => c.context));
if (requiredContexts.length && !requiredContexts.every((c) => passed.has(c))) checksState = "BLOCKING_FAILING";
}
const requiredApprovingReviewsCount =
status === "BLOCKED" && !isRestricted && checksState !== "BLOCKING_FAILING" ? requiredReviews : 0;
return {
hasConflicts,
ciJobs,
canBeMerged,
canBeRebased: data.canBeRebased,
isRestricted,
requiredApprovingReviewsCount,
checksState,
mergeable: data.mergeable,
mergeStateStatus: status,
};
}
// ---------------------------------------------------------------------------
// Permissions (`GHPRSecurityService`)
const PERMISSION_ORDER: GHRepositoryPermissionLevel[] = ["READ", "TRIAGE", "WRITE", "MAINTAIN", "ADMIN"];
/** Whether `permission` is at least `level` (an unknown permission has none). */
export function hasPermission(
permission: GHRepositoryPermissionLevel | null | undefined,
level: GHRepositoryPermissionLevel,
): boolean {
return (permission ? PERMISSION_ORDER.indexOf(permission) : -1) >= PERMISSION_ORDER.indexOf(level);
}
/**
* Which pull request actions the user may run (IntelliJ's review flow enablement): managing
* (request reviewers, close, reopen, post a draft) needs triage access or authorship; merging
* needs write access, a method the repository allows, a mergeable state and an unrestricted
* base branch, and `mergeForbidden` (the project setting) off.
*/
export function pullRequestPermissions(
pr: Pick<GHPullRequest, "viewerDidAuthor" | "state">,
repo: Pick<GHRepository, "viewerPermission" | "mergeCommitAllowed" | "squashMergeAllowed" | "rebaseMergeAllowed">,
mergeability: GHPRMergeabilityState | null,
mergeForbidden = false,
) {
const canManage = hasPermission(repo.viewerPermission, "TRIAGE") || pr.viewerDidAuthor;
const canWrite = hasPermission(repo.viewerPermission, "WRITE") && !mergeForbidden;
const open = pr.state === "OPEN";
const mergeable = open && canWrite && !!mergeability && !mergeability.isRestricted;
return {
canManage,
canMerge: mergeable && repo.mergeCommitAllowed && mergeability!.canBeMerged,
canSquashMerge: mergeable && repo.squashMergeAllowed && mergeability!.canBeMerged,
canRebaseMerge: mergeable && repo.rebaseMergeAllowed && mergeability!.canBeRebased,
};
}
// ---------------------------------------------------------------------------
// Merge commit messages
/** The merge dialog's message: subject `Merge pull request #n`, body the title, or `* headline` per commit when squashing. */
export function mergeCommitMessage(
pr: Pick<GHPullRequest, "number" | "title">,
squashCommitHeadlines?: readonly string[],
): string {
const subject = `Merge pull request #${pr.number}`;
const body = squashCommitHeadlines ? `* ${squashCommitHeadlines.join("\n\n* ")}` : pr.title;
return `${subject}\n\n${body}`;
}
/** Splits a commit message into its subject (first line) and body (`splitCommitMessage`). */
export function splitCommitMessage(message: string): { title: string; body: string } {
const i = message.indexOf("\n");
if (i < 0) return { title: message.trim(), body: "" };
return { title: message.slice(0, i).trim(), body: message.slice(i + 1).trim() };
}
// ---------------------------------------------------------------------------
// Repositories
/** `GHUIUtil.getRepositoryDisplayName`: `name`, or `owner/name` when the owners differ (or always). */
export function repositoryDisplayName(
all: readonly GHRepositoryCoordinates[],
repo: GHRepositoryCoordinates,
alwaysShowOwner = false,
): string {
const showOwner = alwaysShowOwner || (all.length > 1 && all.some((r) => r.owner !== all[0]!.owner));
return showOwner ? `${repo.owner}/${repo.name}` : repo.name;
}
/** `owner/name` of a github.com remote URL (https, ssh or scp-like), else null. */
export function parseGitHubRemote(url: string): GHRepositoryCoordinates | null {
const m =
/^(?:https?:\/\/(?:[^@/]+@)?|ssh:\/\/(?:[^@/]+@)?|git:\/\/|(?:[^@/]+@)?)(?:www\.)?github\.com(?::\d+(?=\/))?[:/]+([^/]+)\/([^/]+?)(?:\.git)?\/?$/i.exec(
url.trim(),
);
return m ? { owner: m[1]!, name: m[2]! } : null;
}
/**
* The remote of `repo` among `remotes`, by the URL's path: gh also resolves a remote behind
* an SSH host alias (`git@work-github:o/r.git`), which `parseGitHubRemote` can't tell is GitHub.
*/
export function remoteOfRepository(
remotes: ReadonlyArray<{ name: string; url: string }>,
repo: GHRepositoryCoordinates,
): { name: string; url: string } | null {
const path = `${repo.owner}/${repo.name}`.toLowerCase();
return (
remotes.find((r) => {
const url = r.url
.trim()
.toLowerCase()
.replace(/\/$/, "")
.replace(/\.git$/, "");
return url.endsWith(`/${path}`) || url.endsWith(`:${path}`);
}) ?? null
);
}
+400
Переглянути файл
@@ -0,0 +1,400 @@
/**
* Pull request changes (IntelliJ's `GHPRDetailsDataProvider`, `GHPRReviewDataProvider`,
* `GHPRViewedStateDataProvider`, `GHPRCommentsDataProvider` mutations). Each sends the
* request, then refreshes what it changed — details, mergeability, timeline, threads,
* pending review, the list row — so every view of the pull request follows. Errors
* propagate to the caller, which shows them where IntelliJ does.
*/
import * as api from "../api/requests";
import type {
GHDiffSide,
GHPullRequestDraftReviewThread,
GHPullRequestPendingReview,
GHPullRequestRequestedReviewer,
GHPullRequestReviewEvent,
GHPullRequestReviewThread,
GHPullRequestShort,
GHReactionContent,
} from "../api/types";
import { reloadList, updateListItem } from "./list";
import { isTeam } from "./model";
import {
loadPullRequestData,
pullRequestId,
reloadPullRequestData,
reloadTimeline,
setPullRequestData,
updateTimeline,
} from "./pullRequest";
import { requireConnection } from "./repository";
const settle = (ps: Array<Promise<unknown>>) => Promise.allSettled(ps).then(() => undefined);
/** After a change of the pull request itself: details, mergeability and new timeline items. */
function refreshState(number: number) {
return settle([
reloadPullRequestData(number, "details"),
reloadPullRequestData(number, "mergeability"),
updateTimeline(number),
]);
}
/** After a review change: pending review, threads, details (reviews) and the timeline. */
function refreshReview(number: number) {
return settle([
reloadPullRequestData(number, "pendingReview"),
reloadPullRequestData(number, "threads"),
reloadPullRequestData(number, "details"),
reloadTimeline(number),
]);
}
// ---------------------------------------------------------------------------
// Details
/**
* Edits the title and / or description.
*/
export async function updateDetails(number: number, patch: { title?: string; body?: string }) {
const pr = await api.updatePullRequest(await pullRequestId(number), patch);
setPullRequestData(number, "details", () => pr);
updateListItem(pr);
await updateTimeline(number);
return pr;
}
/** Adds / removes requested reviewers (users and teams), as the reviewer picker's delta. */
export async function adjustReviewers(
number: number,
delta: { added: GHPullRequestRequestedReviewer[]; removed: GHPullRequestRequestedReviewer[] },
) {
const split = (rs: GHPullRequestRequestedReviewer[]) => ({
logins: rs.filter((r) => !isTeam(r)).map((r) => (r as { login: string }).login),
teamSlugs: rs.filter(isTeam).map((t) => t.slug),
});
if (delta.removed.length) await api.changeRequestedReviewers(number, "DELETE", split(delta.removed));
if (delta.added.length) await api.changeRequestedReviewers(number, "POST", split(delta.added));
await refreshState(number);
}
/** "Close Pull Request". */
export async function closePullRequest(number: number) {
await api.updatePullRequestState(number, "closed");
await refreshState(number);
}
/** "Reopen Pull Request". */
export async function reopenPullRequest(number: number) {
await api.updatePullRequestState(number, "open");
await refreshState(number);
}
/** "Post Pull Request": marks a draft ready for review. */
export async function markReadyForReview(number: number) {
await api.markPullRequestReadyForReview(await pullRequestId(number));
await refreshState(number);
}
/** Merge… / Squash and Merge… (with the dialog's message) / Rebase, at head `headRefOid`. */
export async function mergePullRequest(
number: number,
method: "merge" | "squash" | "rebase",
headRefOid: string,
message?: { title: string; body: string },
) {
await api.mergePullRequest(number, method, headRefOid, method === "rebase" ? undefined : message);
await refreshState(number);
}
/** Deletes the merged head branch (the timeline's merged banner). */
export async function deleteMergedBranch(number: number, refId: string) {
await api.deleteRef(refId);
await settle([reloadPullRequestData(number, "details"), updateTimeline(number)]);
}
/**
* Replaces the assignees (logins).
* @public
*/
export async function setAssignees(number: number, logins: string[]) {
await api.updateAssignees(number, logins);
await settle([reloadPullRequestData(number, "details"), updateTimeline(number)]);
}
/**
* Replaces the labels (names).
* @public
*/
export async function setLabels(number: number, names: string[]) {
await api.replaceLabels(number, names);
await settle([reloadPullRequestData(number, "details"), updateTimeline(number)]);
}
// ---------------------------------------------------------------------------
// Viewed state
/**
* Marks `paths` viewed / not viewed: the cache changes at once, one request per path runs
* in parallel, and the state is reloaded when any fails.
*/
export async function setViewed(number: number, paths: readonly string[], viewed: boolean) {
setPullRequestData(number, "viewedState", (v) => {
const next = { ...v };
for (const p of paths) next[p] = viewed ? "VIEWED" : "UNVIEWED";
return next;
});
const id = await pullRequestId(number);
const results = await Promise.allSettled(
paths.map((p) => (viewed ? api.markFileAsViewed(id, p) : api.unmarkFileAsViewed(id, p))),
);
const failed = results.find((r): r is PromiseRejectedResult => r.status === "rejected");
if (failed) {
await reloadPullRequestData(number, "viewedState").catch(() => undefined);
throw failed.reason;
}
}
// ---------------------------------------------------------------------------
// Reviews
/**
* `createReview`: a pending review (no `event`), or a submitted one; `threads` are the
* new comments ("Start Review" / "Add Single Comment" with `event: "COMMENT"`).
*/
export async function createReview(
number: number,
input: {
event?: GHPullRequestReviewEvent;
body?: string;
commitOid?: string;
threads?: GHPullRequestDraftReviewThread[];
} = {},
): Promise<GHPullRequestPendingReview> {
const review = await api.createReview({ pullRequestId: await pullRequestId(number), ...input });
await refreshReview(number);
return review;
}
/** Submits the pending review `reviewId`. */
export async function submitReview(number: number, reviewId: string, event: GHPullRequestReviewEvent, body?: string) {
await api.submitReview(reviewId, event, body || null);
await refreshReview(number);
await reloadPullRequestData(number, "mergeability").catch(() => undefined);
}
/** Submit Review popup: submits the pending review, or creates a submitted one. */
export async function submitOrCreateReview(number: number, event: GHPullRequestReviewEvent, body: string) {
const pending = await loadPullRequestData(number, "pendingReview");
if (pending) await submitReview(number, pending.id, event, body);
else await createReview(number, { event, body: body || undefined });
}
/** Discards a pending review with its comments. */
export async function deleteReview(number: number, reviewId: string) {
await api.deleteReview(reviewId);
await refreshReview(number);
}
/**
* Edits a review's body; resolves with the new body.
*/
export async function updateReviewBody(number: number, reviewId: string, body: string) {
const review = await api.updateReview(reviewId, body);
await reloadTimeline(number);
return review.body;
}
/** Replaces one thread in the cache (resolve / unresolve / new thread) and refreshes the list counter. */
function putThread(number: number, thread: GHPullRequestReviewThread) {
setPullRequestData(number, "threads", (ts) => {
const list = ts ?? [];
return list.some((t) => t.id === thread.id)
? list.map((t) => (t.id === thread.id ? thread : t))
: [...list, thread];
});
}
/**
* A new thread in the pending review `reviewId` (lines are 1-based, both ends on one side).
* @public
*/
export async function createThread(
number: number,
input: {
reviewId: string;
body: string;
path: string;
line: number;
side: GHDiffSide;
startLine?: number;
startSide?: GHDiffSide;
},
) {
const thread = await api.addReviewThread({
pullRequestReviewId: input.reviewId,
body: input.body,
path: input.path,
line: input.line,
side: input.side,
startLine: input.startLine ?? null,
startSide: input.startSide ?? null,
});
putThread(number, thread);
await settle([reloadPullRequestData(number, "pendingReview"), reloadTimeline(number)]);
return thread;
}
/**
* A comment on a single commit's diff (`position` in its patch) in the pending review `reviewId`.
* @public
*/
export async function addCommitComment(
number: number,
input: { reviewId: string; body: string; commitSha: string; path: string; position: number },
) {
const comment = await api.addReviewComment({
reviewId: input.reviewId,
body: input.body,
commit: input.commitSha,
file: input.path,
position: input.position,
});
await refreshReview(number);
return comment;
}
/**
* Replies to the review comment `replyToCommentId` (in the pending review, else a new one).
*/
export async function replyToComment(number: number, replyToCommentId: string, body: string) {
const pending = await loadPullRequestData(number, "pendingReview");
const reviewId = pending?.id ?? (await api.createReview({ pullRequestId: await pullRequestId(number) })).id;
const comment = await api.addReviewComment({ reviewId, body, inReplyTo: replyToCommentId });
// A reply outside a review is posted at once, like GitHub's "Reply".
if (!pending) await api.submitReview(reviewId, "COMMENT", null);
await refreshReview(number);
return comment;
}
/**
* Edits a review comment.
*/
export async function updateReviewComment(number: number, commentId: string, body: string) {
const comment = await api.updateReviewComment(commentId, body);
await settle([reloadPullRequestData(number, "threads"), reloadTimeline(number)]);
return comment;
}
/**
* Deletes a review comment (its thread goes with its last comment).
*/
export async function deleteReviewComment(number: number, commentId: string) {
await api.deleteReviewComment(commentId);
await refreshReview(number);
}
/**
* Resolve: marks a review thread resolved.
*/
export async function resolveThread(number: number, threadId: string) {
const thread = await api.resolveReviewThread(threadId);
putThread(number, thread);
await settle([reloadPullRequestData(number, "details"), reloadTimeline(number)]);
return thread;
}
/**
* Unresolve: reopens a review thread.
*/
export async function unresolveThread(number: number, threadId: string) {
const thread = await api.unresolveReviewThread(threadId);
putThread(number, thread);
await settle([reloadPullRequestData(number, "details"), reloadTimeline(number)]);
return thread;
}
// ---------------------------------------------------------------------------
// Conversation comments and reactions
/**
* Adds a conversation comment (the timeline's composer).
*/
export async function addIssueComment(number: number, body: string) {
await api.createIssueComment(number, body);
await updateTimeline(number);
}
/**
* Edits a conversation comment.
*/
export async function updateIssueComment(number: number, commentId: string, body: string) {
const comment = await api.updateIssueComment(commentId, body);
await reloadTimeline(number);
return comment;
}
/**
* Deletes a conversation comment.
*/
export async function deleteIssueComment(number: number, commentId: string) {
await api.deleteIssueComment(commentId);
await reloadTimeline(number);
}
/**
* Adds or removes the viewer's reaction on `subjectId` (the description, a comment, a
* review comment); `on` says which data holds the subject, to refresh it.
*/
export async function toggleReaction(
number: number,
subjectId: string,
reaction: GHReactionContent,
add: boolean,
on: "details" | "timeline" | "threads",
) {
if (add) await api.addReaction(subjectId, reaction);
else await api.removeReaction(subjectId, reaction);
if (on === "timeline") await reloadTimeline(number);
else await reloadPullRequestData(number, on);
}
// ---------------------------------------------------------------------------
// New pull requests
/**
* Creates a pull request in the connected repository (`GHPRCreationService`): `head` is
* the branch (`owner:branch` for a fork's). A blank title falls back to the branch name,
* a blank body is sent as null. Reviewers, assignees and labels are set afterwards
* (`onSettingMetadata` is called first: "Updating pull request fields").
*/
export async function createPullRequest(input: {
base: string;
head: string;
title: string;
body: string;
draft: boolean;
reviewers?: GHPullRequestRequestedReviewer[];
assignees?: string[];
labels?: string[];
onSettingMetadata?: () => void;
}): Promise<GHPullRequestShort> {
const { repository } = requireConnection();
const branch = input.head.slice(input.head.indexOf(":") + 1);
const pr = await api.createPullRequest({
repositoryId: repository.id,
baseRefName: input.base,
headRefName: input.head,
title: input.title.trim() || branch,
body: input.body.trim() ? input.body : null,
draft: input.draft,
});
const n = pr.number;
if (input.reviewers?.length || input.assignees?.length || input.labels?.length) input.onSettingMetadata?.();
await settle([
...(input.reviewers?.length ? [adjustReviewers(n, { added: input.reviewers, removed: [] })] : []),
...(input.assignees?.length ? [api.updateAssignees(n, input.assignees)] : []),
...(input.labels?.length ? [api.replaceLabels(n, input.labels)] : []),
]);
void reloadList();
return pr;
}
+352
Переглянути файл
@@ -0,0 +1,352 @@
/**
* Per pull request data cache, like IntelliJ's `GHPRDataProvider` (details, changes,
* viewed state, review, timeline providers): each slice loads on first use, keeps its
* value while reloading, and is shared by every view of the pull request (its tab, the
* timeline editor, the review diff).
*
* const details = usePullRequestData(number, "details"); // { value, loading, error }
* await reloadPullRequestData(number, "mergeability");
*
* Slices: details (`findPullRequest`), mergeability (`findPullRequestMergeability`, as
* `GHPRMergeabilityState`), commits, changes (REST files with patches), viewedState (per
* path), threads (review threads), pendingReview (the viewer's unsubmitted review) and
* participants. The timeline is cursor-paged separately (`usePullRequestTimeline`).
*/
import { useEffect } from "react";
import { create } from "zustand";
import { errorMessage } from "../api/client";
import * as api from "../api/requests";
import type {
GHCommitFile,
GHPRMergeabilityState,
GHPRTimelineItem,
GHPullRequest,
GHPullRequestCommit,
GHPullRequestFileViewedState,
GHPullRequestPendingReview,
GHPullRequestReviewThread,
GHUser,
} from "../api/types";
import { markSeen } from "./interaction";
import { updateListItem } from "./list";
import { buildMergeabilityState } from "./model";
import { currentUserHasPermission, useGhRepository } from "./repository";
export interface Loadable<T> {
/** Last loaded value; kept while reloading. */
value: T | undefined;
loading: boolean;
error: string | null;
}
interface Slices {
details: GHPullRequest;
mergeability: GHPRMergeabilityState;
commits: GHPullRequestCommit[];
changes: GHCommitFile[];
viewedState: Record<string, GHPullRequestFileViewedState>;
threads: GHPullRequestReviewThread[];
pendingReview: GHPullRequestPendingReview | null;
participants: GHUser[];
}
export type PullRequestSlice = keyof Slices;
export interface TimelineState {
items: GHPRTimelineItem[];
/** Cursor after the last loaded item. */
cursor: string | null;
/** More pages exist after `cursor` (as far as is known). */
hasMore: boolean;
/** Nothing requested yet. */
initial: boolean;
loading: boolean;
error: string | null;
}
type PrData = { [K in PullRequestSlice]: Loadable<Slices[K]> } & { timeline: TimelineState };
const EMPTY: Loadable<never> = { value: undefined, loading: false, error: null };
const EMPTY_TIMELINE: TimelineState = {
items: [],
cursor: null,
hasMore: true,
initial: true,
loading: false,
error: null,
};
const usePullRequests = create<{ prs: Record<number, Partial<PrData>> }>(() => ({ prs: {} }));
function patch<K extends keyof PrData>(number: number, key: K, value: Partial<PrData[K]>) {
usePullRequests.setState((s) => {
const pr = s.prs[number] ?? {};
const prev = (pr[key] ?? (key === "timeline" ? EMPTY_TIMELINE : EMPTY)) as PrData[K];
return { prs: { ...s.prs, [number]: { ...pr, [key]: { ...prev, ...value } } } };
});
}
function patchSlice<K extends PullRequestSlice>(number: number, key: K, value: Partial<Loadable<Slices[K]>>) {
patch(number, key, value as Partial<PrData[K]>);
}
function slice<K extends PullRequestSlice>(number: number, key: K): Loadable<Slices[K]> {
return (usePullRequests.getState().prs[number]?.[key] as Loadable<Slices[K]> | undefined) ?? EMPTY;
}
/** The node id of pull request `number` (from its details, else looked up). */
export async function pullRequestId(number: number): Promise<string> {
const known = slice(number, "details").value?.id;
if (known) return known;
const id = await api.findPullRequestId(number);
if (!id) throw new Error(`Pull request #${number} not found`);
return id;
}
const fetchers: { [K in PullRequestSlice]: (number: number) => Promise<Slices[K]> } = {
details: async (n) => {
const pr = await api.findPullRequest(n);
if (!pr) throw new Error(`Pull request #${n} not found`);
updateListItem(pr);
return pr;
},
mergeability: async (n) => {
const data = await api.findPullRequestMergeability(n);
if (!data) throw new Error(`Pull request #${n} not found`);
scheduleMergeabilityCheck(n, data.mergeable);
return buildMergeabilityState(data, currentUserHasPermission("ADMIN"));
},
commits: (n) => api.allPages((c) => api.pullRequestCommitsPage(n, c)),
changes: (n) => api.pullRequestChangedFiles(n),
viewedState: async (n) => {
const files = await api.allPages((c) => api.pullRequestFilesPage(n, c));
return Object.fromEntries(files.map((f) => [f.path, f.viewerViewedState]));
},
threads: (n) => api.allPages((c) => api.pullRequestReviewThreadsPage(n, c)),
pendingReview: async (n) => api.findPendingReview(await pullRequestId(n)),
participants: (n) => api.allPages((c) => api.pullRequestParticipantsPage(n, c)),
};
/** GitHub computes mergeability in the background: `UNKNOWN` is asked again a few times. */
const MERGEABILITY_RECHECK_MS = 3000;
const MERGEABILITY_RECHECKS = 10;
const mergeabilityRechecks = new Map<number, number>();
function scheduleMergeabilityCheck(number: number, mergeable: string) {
if (mergeable !== "UNKNOWN") {
mergeabilityRechecks.delete(number);
return;
}
const done = mergeabilityRechecks.get(number) ?? 0;
if (done >= MERGEABILITY_RECHECKS) return;
mergeabilityRechecks.set(number, done + 1);
const started = epoch;
setTimeout(() => {
if (started === epoch) void reloadPullRequestData(number, "mergeability").catch(() => undefined);
}, MERGEABILITY_RECHECK_MS);
}
const inFlight = new Map<string, { seq: number; promise: Promise<unknown> }>();
let seqCounter = 0;
/** Bumped on disconnect / another repository: results of older requests are dropped. */
let epoch = 0;
/** Loads `key` of pull request `number` again (the value stays visible meanwhile). */
export function reloadPullRequestData<K extends PullRequestSlice>(number: number, key: K): Promise<Slices[K]> {
const id = `${number}:${key}`;
const seq = ++seqCounter;
const started = epoch;
patchSlice(number, key, { loading: true, error: null });
const promise = fetchers[key](number).then(
(value) => {
if (inFlight.get(id)?.seq === seq && started === epoch) {
inFlight.delete(id);
patchSlice(number, key, { value, loading: false, error: null });
}
return value;
},
(e: unknown) => {
if (inFlight.get(id)?.seq === seq && started === epoch) {
inFlight.delete(id);
patchSlice(number, key, { loading: false, error: errorMessage(e) });
}
throw e;
},
);
inFlight.set(id, { seq, promise });
return promise;
}
/** Loads `key` unless it is loaded or loading; resolves with the value. */
export function loadPullRequestData<K extends PullRequestSlice>(number: number, key: K): Promise<Slices[K]> {
const running = inFlight.get(`${number}:${key}`);
if (running) return running.promise as Promise<Slices[K]>;
const s = slice(number, key);
if (s.value !== undefined) return Promise.resolve(s.value);
return reloadPullRequestData(number, key);
}
/** The current state of a slice, without loading it. */
export function pullRequestData<K extends PullRequestSlice>(number: number, key: K): Loadable<Slices[K]> {
return slice(number, key);
}
/** React hook: slice `key` of pull request `number`, loaded on first use. */
export function usePullRequestData<K extends PullRequestSlice>(number: number, key: K): Loadable<Slices[K]> {
const connected = useGhRepository((s) => !!s.connection);
useEffect(() => {
if (connected) void loadPullRequestData(number, key).catch(() => undefined);
}, [number, key, connected]);
return usePullRequests((s) => (s.prs[number]?.[key] as Loadable<Slices[K]> | undefined) ?? EMPTY);
}
/** Replaces a loaded value (a mutation returned the new state, an optimistic update). */
export function setPullRequestData<K extends PullRequestSlice>(
number: number,
key: K,
update: (value: Slices[K] | undefined) => Slices[K],
) {
patchSlice(number, key, { value: update(slice(number, key).value) });
}
/** Calls `listener` after any data of pull request `number` changed (outside React); returns the unsubscriber. */
export function subscribePullRequestData(number: number, listener: () => void): () => void {
return usePullRequests.subscribe((s, prev) => {
if (s.prs[number] !== prev.prs[number]) listener();
});
}
/** Pull request `number` as far as it is cached (the list row while details load). */
export function cachedDetails(number: number): GHPullRequest | undefined {
return slice(number, "details").value;
}
// ---------------------------------------------------------------------------
// Timeline (`GHPRTimelineDataProvider`: pages of 100)
function timelineOf(number: number): TimelineState {
return usePullRequests.getState().prs[number]?.timeline ?? EMPTY_TIMELINE;
}
const timelineRuns = new Map<number, Promise<void>>();
async function fetchTimelinePages(number: number, from: string | null, all: boolean) {
const items: GHPRTimelineItem[] = [];
let cursor = from;
let hasMore = true;
do {
const page = await api.pullRequestTimelinePage(number, cursor);
if (!page) throw new Error(`Pull request #${number} not found`);
items.push(...page.nodes);
hasMore = page.pageInfo.hasNextPage;
cursor = page.pageInfo.endCursor ?? cursor;
} while (all && hasMore);
return { items, cursor, hasMore };
}
function runTimeline(number: number, run: () => Promise<void>): Promise<void> {
const running = timelineRuns.get(number);
if (running) return running;
const started = epoch;
patch(number, "timeline", { loading: true, error: null, initial: false });
const promise = run()
.catch((e: unknown) => {
if (started === epoch) patch(number, "timeline", { error: errorMessage(e) });
})
.finally(() => {
timelineRuns.delete(number);
if (started === epoch) patch(number, "timeline", { loading: false });
const t = timelineOf(number);
const details = slice(number, "details").value;
// IntelliJ stores "last seen" once the timeline is fully loaded.
if (!t.hasMore && !t.error && details) markSeen(details.id);
});
timelineRuns.set(number, promise);
return promise;
}
/** Loads the next timeline page (the first one on first use). */
export function loadTimelinePage(number: number): Promise<void> {
const t = timelineOf(number);
if (!t.hasMore && !t.initial) return Promise.resolve();
const started = epoch;
return runTimeline(number, async () => {
const page = await fetchTimelinePages(number, t.cursor, false);
if (started !== epoch) return;
patch(number, "timeline", {
items: [...timelineOf(number).items, ...page.items],
cursor: page.cursor,
hasMore: page.hasMore,
});
});
}
/** New items after the loaded ones (`timelineUpdateSignal`); loads the first page when nothing is loaded. */
export function updateTimeline(number: number): Promise<void> {
const t = timelineOf(number);
if (t.initial) return loadTimelinePage(number);
if (t.hasMore) return Promise.resolve();
const started = epoch;
return runTimeline(number, async () => {
const page = await fetchTimelinePages(number, t.cursor, true);
if (started !== epoch) return;
patch(number, "timeline", {
items: [...timelineOf(number).items, ...page.items],
cursor: page.cursor,
hasMore: false,
});
});
}
/**
* Reloads the whole timeline (`signalTimelineNeedsReload`): fully loaded timelines fetch
* every page before replacing the items, partly loaded ones start over from the first page.
*/
export function reloadTimeline(number: number): Promise<void> {
const t = timelineOf(number);
const complete = !t.initial && !t.hasMore;
const started = epoch;
return runTimeline(number, async () => {
const page = await fetchTimelinePages(number, null, complete);
if (started !== epoch) return;
patch(number, "timeline", { items: page.items, cursor: page.cursor, hasMore: page.hasMore });
});
}
/** React hook: the timeline of `number`, its first page loaded on first use. */
export function usePullRequestTimeline(number: number): TimelineState {
const connected = useGhRepository((s) => !!s.connection);
useEffect(() => {
if (connected && timelineOf(number).initial) void loadTimelinePage(number);
}, [number, connected]);
return usePullRequests((s) => s.prs[number]?.timeline ?? EMPTY_TIMELINE);
}
// ---------------------------------------------------------------------------
/** `updateAll()` of the timeline / "Refresh Review Data": details, mergeability, timeline and threads. */
export async function reloadPullRequest(number: number): Promise<void> {
const loaded = usePullRequests.getState().prs[number] ?? {};
const keys = (Object.keys(fetchers) as PullRequestSlice[]).filter(
(k) => k === "details" || loaded[k]?.value !== undefined,
);
await Promise.allSettled([
...keys.map((k) => reloadPullRequestData(number, k)),
...(loaded.timeline && !loaded.timeline.initial ? [reloadTimeline(number)] : []),
]);
}
/** Forgets every pull request (disconnect, another repository). */
export function clearPullRequests() {
epoch++;
mergeabilityRechecks.clear();
inFlight.clear();
timelineRuns.clear();
usePullRequests.setState({ prs: {} });
}
useGhRepository.subscribe((s, prev) => {
const a = s.connection?.coordinates;
const b = prev.connection?.coordinates;
if (a?.owner !== b?.owner || a?.name !== b?.name) clearPullRequests();
});
+342
Переглянути файл
@@ -0,0 +1,342 @@
/**
* The project's GitHub connection and repository data, like IntelliJ's `GHPRProjectViewModel`
* (repository selection, auto-connect) plus `GHPRRepositoryDataService` and
* `GHPRSecurityService`: gh availability, the GitHub remotes, the connected repository
* (permission, merge settings, default branch), the current user, and the cached,
* incrementally loaded collaborators, contributors, assignees, labels, teams and
* mentionable users.
*
* Auto-connect: a project with one GitHub remote connects at once; with several, the
* repository chosen last (saved per project) is used, else the selector is shown.
*/
import { useEffect } from "react";
import { create } from "zustand";
import type { GhAvailability } from "@/platform/ipc";
import { call, commands, isTauri } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { errorMessage, setCurrentRepository } from "../api/client";
import * as api from "../api/requests";
import type {
GHCollaborator,
GHLabel,
GHPullRequestRequestedReviewer,
GHRepository,
GHRepositoryCoordinates,
GHRepositoryPermissionLevel,
GHTeam,
GHUser,
} from "../api/types";
import { createBatchesLoader, mergeBatches, type BatchesLoader, type BatchesState } from "./batches";
import { hasPermission, parseGitHubRemote, remoteOfRepository } from "./model";
/** A GitHub remote of the project. */
export interface GHRemote extends GHRepositoryCoordinates {
/** Git remote name (`origin`). */
remote: string;
url: string;
}
export interface GHConnection {
coordinates: GHRepositoryCoordinates;
repository: GHRepository;
currentUser: GHUser;
}
interface RepositoryState {
/** gh's state; null until checked. */
availability: GhAvailability | null;
checking: boolean;
/** GitHub remotes of the project, deduplicated by repository. */
remotes: GHRemote[];
/** gh's own choice among them (`gh repo view`), preselected in the selector. */
defaultRemote: GHRepositoryCoordinates | null;
connection: GHConnection | null;
connecting: boolean;
/** "Could not connect to repository …" */
connectionError: string | null;
/** Bumped whenever the repository data is reloaded (IntelliJ's `dataReloadSignal`). */
revision: number;
}
export const useGhRepository = create<RepositoryState>(() => ({
availability: null,
checking: false,
remotes: [],
defaultRemote: null,
connection: null,
connecting: false,
connectionError: null,
revision: 0,
}));
const projectRoot = () => useProject.getState().project?.root ?? null;
const selectionKey = (root: string) => `openidea.github.repository.v1:${root}`;
function loadSelection(root: string | null): string | null {
if (!root) return null;
try {
return globalThis.localStorage?.getItem(selectionKey(root)) ?? null;
} catch {
return null;
}
}
function saveSelection(root: string | null, nameWithOwner: string | null) {
if (!root) return;
try {
if (nameWithOwner) globalThis.localStorage?.setItem(selectionKey(root), nameWithOwner);
else globalThis.localStorage?.removeItem(selectionKey(root));
} catch {
/* storage unavailable */
}
}
export const sameRepository = (a: GHRepositoryCoordinates | null | undefined, b: GHRepositoryCoordinates | null) =>
!!a && !!b && a.owner.toLowerCase() === b.owner.toLowerCase() && a.name.toLowerCase() === b.name.toLowerCase();
export const nameWithOwner = (r: GHRepositoryCoordinates) => `${r.owner}/${r.name}`;
/** The GitHub remotes among `remotes`, one per repository. */
export function githubRemotes(remotes: ReadonlyArray<{ name: string; url: string }>): GHRemote[] {
const out: GHRemote[] = [];
for (const r of remotes) {
const c = parseGitHubRemote(r.url);
if (c && !out.some((o) => sameRepository(o, c))) out.push({ ...c, remote: r.name, url: r.url });
}
return out;
}
function parseNameWithOwner(s: string | null | undefined): GHRepositoryCoordinates | null {
const m = s ? /^([^/\s]+)\/([^/\s]+)$/.exec(s) : null;
return m ? { owner: m[1]!, name: m[2]! } : null;
}
let checkSeq = 0;
/**
* Checks gh and the remotes, then connects as IntelliJ does on start: the only GitHub
* repository, else the saved choice. Keeps the current connection when it still applies.
*/
export async function refreshConnection(): Promise<void> {
if (!isTauri()) return;
const seq = ++checkSeq;
useGhRepository.setState({ checking: true });
let availability: GhAvailability;
let remotes: GHRemote[] = [];
let allRemotes: Array<{ name: string; url: string }> = [];
try {
const [a, r] = await Promise.all([
call(commands.ghAvailability()),
call(commands.vcsRemotes(null)).catch(() => []),
]);
availability = a;
allRemotes = r;
remotes = githubRemotes(r);
} catch (e) {
availability = { kind: "error", message: errorMessage(e) };
}
if (seq !== checkSeq) return;
const defaultRemote = availability.kind === "ready" ? parseNameWithOwner(availability.repo.nameWithOwner) : null;
if (defaultRemote && !remotes.some((r) => sameRepository(r, defaultRemote))) {
const own = remoteOfRepository(allRemotes, defaultRemote);
remotes.unshift({
...defaultRemote,
remote: own?.name ?? "",
url: own?.url ?? (availability.kind === "ready" ? availability.repo.url : ""),
});
}
useGhRepository.setState({ availability, remotes, defaultRemote, checking: false });
if (availability.kind !== "ready") {
disconnect({ forget: false });
return;
}
const current = useGhRepository.getState().connection?.coordinates ?? null;
const saved = parseNameWithOwner(loadSelection(projectRoot()));
const target =
remotes.length === 1
? remotes[0]!
: (remotes.find((r) => sameRepository(r, current)) ?? remotes.find((r) => sameRepository(r, saved)) ?? null);
// Only a choice among several repositories is remembered.
if (target) await connect(target, { remember: remotes.length > 1 });
else disconnect({ forget: false });
}
let connectSeq = 0;
/** Connects to `repo` (the selector's "View Pull Requests"), remembering the choice. */
export async function connect(repo: GHRepositoryCoordinates, opts: { remember?: boolean } = {}): Promise<boolean> {
const seq = ++connectSeq;
const coordinates = { owner: repo.owner, name: repo.name };
useGhRepository.setState({ connecting: true, connectionError: null });
try {
const [repository, currentUser] = await Promise.all([api.findRepository(coordinates), api.currentUser()]);
if (seq !== connectSeq) return false;
if (!repository) throw new Error(`Repository ${nameWithOwner(coordinates)} not found`);
const same = sameRepository(useGhRepository.getState().connection?.coordinates, coordinates);
if (!same) resetRepositoryData();
setCurrentRepository({ ...coordinates, id: repository.id });
useGhRepository.setState((s) => ({
connection: { coordinates, repository, currentUser },
connecting: false,
revision: s.revision + 1,
}));
if (opts.remember ?? true) saveSelection(projectRoot(), nameWithOwner(coordinates));
return true;
} catch (e) {
if (seq !== connectSeq) return false;
useGhRepository.setState({
connecting: false,
connectionError: `Could not connect to repository ${nameWithOwner(coordinates)}: ${errorMessage(e)}`,
});
return false;
}
}
/** Drops the connection; `forget` also clears the saved choice ("Change Repository or Account…"). */
export function disconnect({ forget }: { forget: boolean }) {
connectSeq++;
if (forget) saveSelection(projectRoot(), null);
if (!useGhRepository.getState().connection && !useGhRepository.getState().connecting) return;
setCurrentRepository(null);
resetRepositoryData();
useGhRepository.setState({ connection: null, connecting: false });
}
/** Another project: start over (the next `refreshConnection` checks again). */
export function resetConnection() {
checkSeq++;
disconnect({ forget: false });
useGhRepository.setState({ availability: null, remotes: [], defaultRemote: null, connectionError: null });
}
/** The connection, or throws (for operations that only run while connected). */
export function requireConnection(): GHConnection {
const c = useGhRepository.getState().connection;
if (!c) throw new Error("Not connected to a GitHub repository");
return c;
}
/** `GHPRSecurityService.currentUserHasPermissionLevel`. */
export function currentUserHasPermission(level: GHRepositoryPermissionLevel): boolean {
return hasPermission(useGhRepository.getState().connection?.repository.viewerPermission, level);
}
/**
* Whether `user` is the signed-in user (by node id, else login).
* @public
*/
export function isCurrentUser(user: { id?: string; login?: string } | null | undefined): boolean {
const me = useGhRepository.getState().connection?.currentUser;
return !!me && !!user && (user.id ? user.id === me.id : user.login === me.login);
}
/**
* Reloads the repository (permission, merge settings) and drops the cached lists.
* @public
*/
export async function reloadRepositoryData(): Promise<void> {
const c = useGhRepository.getState().connection;
resetRepositoryData();
if (c) await connect(c.coordinates);
}
// ---------------------------------------------------------------------------
// Cached lists
type Cursor = { after: string | null };
const FIRST: Cursor = { after: null };
function gqlPage<T>(fetch: (cursor: string | null) => ReturnType<typeof api.mentionableUsersPage>) {
return async (c: Cursor) => {
const page = (await fetch(c.after)) as {
nodes: T[];
pageInfo: { hasNextPage: boolean; endCursor: string | null };
} | null;
if (!page) return { items: [] as T[], next: null };
return {
items: page.nodes,
next: page.pageInfo.hasNextPage && page.pageInfo.endCursor ? { after: page.pageInfo.endCursor } : null,
};
};
}
const loaders = {
collaborators: createBatchesLoader<GHCollaborator, number>(1, (p) => api.collaboratorsPage(p)),
contributors: createBatchesLoader<GHUser, number>(1, (p) => api.contributorsPage(p)),
assignees: createBatchesLoader<GHUser, number>(1, (p) => api.assigneesPage(p)),
labels: createBatchesLoader<GHLabel, number>(1, (p) => api.labelsPage(p)),
mentionableUsers: createBatchesLoader<GHUser, Cursor>(FIRST, gqlPage<GHUser>(api.mentionableUsersPage)),
teams: createBatchesLoader<GHTeam, Cursor>(FIRST, async (c) => {
const repo = useGhRepository.getState().connection?.repository;
// Teams exist for organizations only; listing them needs `read:org`, which gh may lack.
if (repo?.owner.__typename !== "Organization") return { items: [], next: null };
const page = await api.organizationTeamsPage(repo.owner.login, c.after).catch(() => null);
if (!page) return { items: [], next: null };
return {
items: page.nodes,
next: page.pageInfo.hasNextPage && page.pageInfo.endCursor ? { after: page.pageInfo.endCursor } : null,
};
}),
} satisfies Record<string, BatchesLoader<unknown>>;
export type RepositoryListName = keyof typeof loaders;
type ItemOf<K extends RepositoryListName> = (typeof loaders)[K] extends BatchesLoader<infer T> ? T : never;
let template: Promise<string | null> | null = null;
function resetRepositoryData() {
for (const l of Object.values(loaders)) l.reset();
template = null;
}
/**
* Loads (once) one of the repository lists; resolves with all its items.
* @public
*/
export function loadRepositoryList<K extends RepositoryListName>(name: K): Promise<ItemOf<K>[]> {
return (loaders[name] as BatchesLoader<ItemOf<K>>).load();
}
/** React hook: a repository list, loading it on first use (incremental: items grow while `loading`). */
export function useRepositoryList<K extends RepositoryListName>(name: K): BatchesState<ItemOf<K>> {
const loader = loaders[name] as BatchesLoader<ItemOf<K>>;
const connected = useGhRepository((s) => !!s.connection);
useEffect(() => {
if (connected) void loader.load().catch(() => undefined);
}, [loader, connected]);
return loader.store();
}
/**
* Users that can be chosen as authors in the list filter: collaborators with write
* access, else contributors (users without push access cannot list collaborators).
*/
export function useAuthorCandidates(): BatchesState<GHUser> {
const write = useGhRepository((s) => hasPermission(s.connection?.repository.viewerPermission, "WRITE"));
const collaborators = useRepositoryList(write ? "collaborators" : "contributors");
return collaborators;
}
/**
* Potential reviewers (`getBatchedPotentialReviewers`): teams, then collaborators with push (else contributors).
*/
export function usePotentialReviewers(): BatchesState<GHPullRequestRequestedReviewer> {
const write = useGhRepository((s) => hasPermission(s.connection?.repository.viewerPermission, "WRITE"));
const teams = useRepositoryList("teams");
const users = useRepositoryList(write ? "collaborators" : "contributors");
const pushers = write
? { ...users, items: (users.items as GHCollaborator[]).filter((u) => u.permissions.push) }
: users;
return mergeBatches<GHPullRequestRequestedReviewer>([teams, pushers]);
}
/**
* The first non-blank pull request template of the repository (cached).
*/
export function loadPullRequestTemplate(): Promise<string | null> {
template ??= api
.pullRequestTemplates()
.then((ts) => ts.find((t) => t.body?.trim())?.body ?? null)
.catch(() => null);
return template;
}
+69
Переглянути файл
@@ -0,0 +1,69 @@
/**
* IntelliJ's pull request search history (`GHPRListPersistentSearchHistory`), per project:
* the last 10 searches (newest first) and the last search used, which the list restores.
* On first run the list shows the Quick Filter "Assigned to you" (`initialSearch`).
*/
import { useProject } from "@/platform/project";
import { DEFAULT_SEARCH, filterCount, normalizeSearch, sameSearch, type PrSearchValue } from "./model";
const MAX_HISTORY = 10;
const historyKey = (root: string) => `openidea.github.prSearchHistory.v2:${root}`;
const lastKey = (root: string) => `openidea.github.prLastSearch.v2:${root}`;
const projectRoot = () => useProject.getState().project?.root ?? null;
function load(key: string): unknown {
try {
const raw = globalThis.localStorage?.getItem(key);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
function save(key: string, value: unknown) {
try {
globalThis.localStorage?.setItem(key, JSON.stringify(value));
} catch {
/* storage unavailable */
}
}
/**
* `history` with `v` first (moved up when already there). Searches without filters and
* `defaultSearch` are not kept (the list passes its first-run filter, "Assigned to you").
*/
export function withSearch(
history: readonly PrSearchValue[],
v: PrSearchValue,
defaultSearch: PrSearchValue = DEFAULT_SEARCH,
): PrSearchValue[] {
if (filterCount(v) === 0 || sameSearch(v, defaultSearch)) return [...history];
return [v, ...history.filter((h) => !sameSearch(h, v))].slice(0, MAX_HISTORY);
}
/** Recent searches of the project, newest first. */
export function loadSearchHistory(root = projectRoot()): PrSearchValue[] {
if (!root) return [];
const v = load(historyKey(root));
return Array.isArray(v) ? v.flatMap((x) => normalizeSearch(x) ?? []) : [];
}
/** Adds `v` to the history (see `withSearch`). */
export function rememberSearch(v: PrSearchValue, root = projectRoot(), defaultSearch: PrSearchValue = DEFAULT_SEARCH) {
if (root) save(historyKey(root), withSearch(loadSearchHistory(root), v, defaultSearch));
}
/** The search the project's list showed last, if any. */
export function loadLastSearch(root = projectRoot()): PrSearchValue | null {
return root ? normalizeSearch(load(lastKey(root))) : null;
}
/** Remembers `v` as the search to restore. */
export function saveLastSearch(v: PrSearchValue, root = projectRoot()) {
if (root) save(lastKey(root), v);
}
/** The list's search when the tool window opens: the last one, else "Assigned to you". */
export function initialSearch(currentUserLogin: string, root = projectRoot()): PrSearchValue {
return loadLastSearch(root) ?? { state: "OPEN", assignee: currentUserLogin };
}