fix(vcs): show GitHub commit checks in the Git Log (#1861)

Цей коміт міститься в:
Shohruh Ismoilov authored and GitHub committed 2026-11-07 00:26:42 +08:00
1 джерело 0ac3f82dfb
коміт 5b6dd007c6
12 files changed
+480 -11

No files matched your search

+142
Переглянути файл
@@ -0,0 +1,142 @@
import { useEffect, useRef, useState, type RefObject } from "react";
import { isTauri } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { IconButton } from "@/ui";
import { commitStatusContextsPage } from "./api/requests";
import type { GHCommitStatusRollupContext } from "./api/types";
import { watchLogCommitCheck, type LogCommitCheck } from "./data/logCommitChecks";
import { openInBrowser } from "./data/local";
import { STATUS_ICON } from "./details/model";
import { ChooserPopup } from "./ui/ChooserPopup";
const statuses = {
SUCCESS: { label: "Checks passed", icon: "success" },
FAILURE: { label: "Checks failed", icon: "failed" },
ERROR: { label: "Checks failed", icon: "failed" },
PENDING: { label: "Checks in progress…", icon: "pending" },
EXPECTED: { label: "Checks in progress…", icon: "pending" },
} as const satisfies Record<LogCommitCheck["state"], { label: string; icon: keyof typeof STATUS_ICON }>;
function checkPresentation(context: GHCommitStatusRollupContext) {
if (context.__typename === "StatusContext") {
return { text: context.context, icon: STATUS_ICON[statuses[context.state].icon] };
}
const conclusion = context.conclusion;
const icon =
conclusion === "SUCCESS"
? "success"
: conclusion === "NEUTRAL" || conclusion === "SKIPPED"
? "skipped"
: conclusion
? "failed"
: "pending";
return { text: context.name, detail: context.checkSuite?.workflowRun?.workflow.name, icon: STATUS_ICON[icon] };
}
const checkUrl = (context: GHCommitStatusRollupContext) =>
context.__typename === "CheckRun" ? context.url : context.targetUrl;
function CheckDetails({
check,
revision,
anchor,
onClose,
}: {
check: LogCommitCheck;
revision: string;
anchor: RefObject<HTMLButtonElement | null>;
onClose: () => void;
}) {
const [items, setItems] = useState<GHCommitStatusRollupContext[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const { root, owner, name } = check.target;
useEffect(() => {
let active = true;
void (async () => {
let cursor: string | null = null;
do {
const page = await commitStatusContextsPage(revision, cursor, 100, { root, owner, name });
if (!active || !page) break;
setItems((prev) => [...prev, ...page.nodes]);
cursor = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null;
} while (cursor);
})()
.catch(() => active && setError(true))
.finally(() => active && setLoading(false));
return () => {
active = false;
};
}, [revision, root, owner, name]);
return (
<ChooserPopup
mode="single"
aria-label={tx("GitHub Commit Checks")}
anchor={anchor}
placement="top-start"
items={items}
getKey={(context) => `${checkPresentation(context).text}\0${checkUrl(context) ?? ""}`}
present={checkPresentation}
onChoose={(context) => {
const url = checkUrl(context);
if (url && /^https?:\/\//i.test(url)) void openInBrowser(url);
}}
loading={loading}
error={error ? tx("Could not load commit checks") : null}
height={Math.min(300, 40 + Math.max(1, items.length) * 28)}
onClose={onClose}
/>
);
}
export function LogCommitChecks({ root, revision }: { root: string; revision: string }) {
const [check, setCheck] = useState<LogCommitCheck | null>(null);
const [open, setOpen] = useState(false);
const anchor = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!isTauri() || !/^[a-f0-9]{40}$/i.test(revision)) return;
let timer: ReturnType<typeof setTimeout> | undefined;
let unsubscribe: (() => void) | undefined;
const load = () => {
unsubscribe?.();
unsubscribe = watchLogCommitCheck(root, revision, ({ check, expires }) => {
setCheck(check);
if (check) timer = setTimeout(load, Math.max(0, expires - Date.now()));
});
};
load();
return () => {
clearTimeout(timer);
unsubscribe?.();
};
}, [root, revision]);
if (!check) return null;
const status = statuses[check.state];
return (
<>
<IconButton
ref={anchor}
size="small"
icon={STATUS_ICON[status.icon]}
title={status.label}
aria-haspopup="dialog"
aria-expanded={open}
onMouseDown={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === " " || e.key === "Enter") e.stopPropagation();
}}
onClick={() => setOpen(!open)}
/>
{open && (
<CheckDetails
key={`${check.target.owner}/${check.target.name}`}
check={check}
revision={revision}
anchor={anchor}
onClose={() => setOpen(false)}
/>
)}
</>
);
}
+6 -4
Переглянути файл
@@ -37,11 +37,13 @@ export function graphqlData<T>(text: string): T {
}
/** Runs a document from `queries.ts` with `variables` (undefined values are dropped). */
export async function graphql<T>(doc: string, variables: Record<string, unknown> = {}): Promise<T> {
export async function graphql<T>(
doc: string,
variables: Record<string, unknown> = {},
root: string | null = current?.root ?? null,
): Promise<T> {
const body = JSON.stringify({ query: withFragments(doc), variables: definedOnly(variables) });
const text = await call(
commands.ghApi(current?.root ?? null, { method: "POST", endpoint: "graphql", body, paginate: false }),
);
const text = await call(commands.ghApi(root, { method: "POST", endpoint: "graphql", body, paginate: false }));
return graphqlData<T>(text);
}
+9 -4
Переглянути файл
@@ -181,13 +181,18 @@ export async function pullRequestTemplates(): Promise<GHRepositoryPullRequestTem
* A page of the commit's status check rollup (`getRepositoryCommitStatusContexts`).
* @public
*/
export async function commitStatusContextsPage(oid: string, cursor: string | null, pageSize = 100) {
export async function commitStatusContextsPage(
oid: string,
cursor: string | null,
pageSize = 100,
target?: GHRepositoryCoordinates & { root: string },
) {
const data = await graphql<{
repository: {
object: { statusCheckRollup: { contexts: GHConnection<GHCommitStatusRollupContext> } | null } | null;
};
}>(Q.getRepositoryCommitStatusContexts, { ...repoVariables(), oid, pageSize, cursor });
return data.repository.object?.statusCheckRollup?.contexts ?? null;
} | null;
}>(Q.getRepositoryCommitStatusContexts, { ...repoVariables(target), oid, pageSize, cursor }, target?.root);
return data.repository?.object?.statusCheckRollup?.contexts ?? null;
}
/**
+127
Переглянути файл
@@ -0,0 +1,127 @@
/** Fetch rollups only for mounted log rows. Each GraphQL batch is scoped to one Git root. */
import { call, commands } from "@/platform/ipc";
import { graphql, repoVariables } from "../api/client";
import type { GHCommitStatusContextState, GHRepositoryCoordinates } from "../api/types";
import { githubRemotes } from "./repository";
type Target = GHRepositoryCoordinates & { root: string };
export interface LogCommitCheck {
state: GHCommitStatusContextState;
target: Target;
}
const BATCH_SIZE = 20;
const CACHE_SIZE = 2_000;
const REMOTE_TTL = 5 * 60_000;
const remoteCache = new Map<string, { targets: Target[]; expires: number }>();
async function targets(root: string): Promise<Target[]> {
const previous = remoteCache.get(root);
if (previous && previous.expires > Date.now()) return previous.targets;
const remotes = githubRemotes(await call(commands.vcsRemotes(root)).catch(() => []), root);
remotes.sort((a, b) => Number(b.remote === "origin") - Number(a.remote === "origin"));
const resolved = remotes.map(({ owner, name }) => ({ owner, name, root }));
if (remoteCache.size >= 128) remoteCache.delete(remoteCache.keys().next().value!);
remoteCache.set(root, { targets: resolved, expires: Date.now() + REMOTE_TTL });
return resolved;
}
async function queryBatch(target: Target, revisions: string[]) {
const fields = revisions
.map((_, i) => `c${i}: object(oid: $oid${i}) { ... on Commit { statusCheckRollup { state } } }`)
.join("\n");
const params = revisions.map((_, i) => `$oid${i}: GitObjectID!`).join(", ");
const doc = `query($repoOwner: String!, $repoName: String!, ${params}) {
repository(owner: $repoOwner, name: $repoName) { ${fields} }
}`;
const variables = Object.fromEntries(revisions.map((oid, i) => [`oid${i}`, oid]));
const data = await graphql<{
repository: Record<string, { statusCheckRollup: { state: GHCommitStatusContextState } | null } | null> | null;
}>(doc, { ...repoVariables(target), ...variables }, target.root);
return revisions.map((_, i) => data.repository?.[`c${i}`]?.statusCheckRollup?.state ?? null);
}
interface CachedCheck {
check: LogCommitCheck | null;
expires: number;
}
type Listener = (result: CachedCheck) => void;
interface Request {
key: string;
root: string;
revision: string;
listeners: Set<Listener>;
started: boolean;
}
const cache = new Map<string, CachedCheck>();
const requests = new Map<string, Request>();
let scheduled: ReturnType<typeof setTimeout> | undefined;
let running = false;
function isPendingCheck(check: LogCommitCheck | null): boolean {
return check?.state === "PENDING" || check?.state === "EXPECTED";
}
function schedule() {
if (running || scheduled !== undefined || !requests.size) return;
scheduled = setTimeout(() => {
scheduled = undefined;
void flush();
}, 40);
}
async function flush() {
const first = requests.values().next().value;
if (!first) return;
const batch = [...requests.values()].filter((r) => r.root === first.root).slice(0, BATCH_SIZE);
batch.forEach((r) => (r.started = true));
running = true;
const results = new Map<string, LogCommitCheck>();
try {
// Checks on an upstream can be absent from a fork, even when it contains the same commit.
for (const target of await targets(first.root)) {
const remaining = batch.filter((r) => r.listeners.size && !results.has(r.revision));
if (!remaining.length) break;
const states = await queryBatch(
target,
remaining.map((r) => r.revision),
).catch(() => []);
remaining.forEach(({ revision }, i) => {
const state = states[i];
if (state) results.set(revision, { state, target });
});
}
} finally {
for (const r of batch) {
requests.delete(r.key);
const check = results.get(r.revision) ?? null;
if (cache.size >= CACHE_SIZE) cache.delete(cache.keys().next().value!);
const ttl = isPendingCheck(check) ? 30_000 : check ? 5 * 60_000 : 60_000;
const result = { check, expires: Date.now() + ttl };
cache.set(r.key, result);
r.listeners.forEach((listener) => listener(result));
}
running = false;
schedule();
}
}
export function watchLogCommitCheck(root: string, revision: string, listener: Listener): () => void {
const key = `${root}\0${revision}`;
const previous = cache.get(key);
if (previous && previous.expires > Date.now()) {
listener(previous);
return () => {};
}
cache.delete(key);
const request = requests.get(key) ?? { key, root, revision, listeners: new Set<Listener>(), started: false };
request.listeners.add(listener);
requests.set(key, request);
schedule();
return () => {
request.listeners.delete(listener);
if (!request.started && !request.listeners.size) requests.delete(key);
};
}
+2
Переглянути файл
@@ -17,6 +17,7 @@ import {
maybeEditorService,
registerCloneSource,
registerEditorKind,
registerGitLogChecks,
registerOverlay,
registerToolWindow,
} from "@/workbench/contributions";
@@ -318,6 +319,7 @@ export function activate(): () => void {
order: 10,
component: lazyComponent(() => import("./clone/GitHubCloneSource").then((m) => m.GitHubCloneSource)),
}),
registerGitLogChecks(lazyComponent(() => import("./LogCommitChecks").then((m) => m.LogCommitChecks))),
registerToolWindow({
id: WINDOW,
title: "Pull Requests",