дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
fix(vcs): show GitHub commit checks in the Git Log (#1861)
Цей коміт міститься в:
1 джерело
0ac3f82dfb
коміт
5b6dd007c6
12 files changed
+480
-11
No files matched your search
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Заблокувати користувача