mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-17 07:26:27 +00:00
223 lines
8.1 KiB
TypeScript
223 lines
8.1 KiB
TypeScript
/**
|
|||
|
|
* Best-effort mirror of SRS state into the user-level GitHub Project
|
||
|
|
* ("Interview Prep").
|
||
|
|
*
|
||
|
|
* The JSON state file is the truth; these fields only make the project's
|
||
|
|
* table and roadmap views double as a live review calendar. Every operation
|
||
|
|
* is wrapped: a failure records a warning and the run continues — a workflow
|
||
|
|
* must never lose an srs.json update because a GraphQL mutation failed.
|
||
|
|
*
|
||
|
|
* Auth: PROJECT_PAT (the default GITHUB_TOKEN cannot touch user projects),
|
||
|
|
* falling back to GH_TOKEN / `gh auth token` for local runs.
|
||
|
|
*
|
||
|
|
* Guardrail: only problem issues are ever passed in here, so topic rows'
|
||
|
|
* Target Date is never written.
|
||
|
|
*/
|
||
|
|
import { $ } from "bun";
|
||
|
|
|
||
|
|
const PROJECT_TITLE = "Interview Prep";
|
||
|
|
|
||
|
|
interface SelectField {
|
||
|
|
id: string;
|
||
|
|
options: Record<string, string>; // option name -> option id
|
||
|
|
}
|
||
|
|
|
||
|
|
interface ProjectInfo {
|
||
|
|
id: string;
|
||
|
|
targetDate: string; // field id
|
||
|
|
srsStage: SelectField;
|
||
|
|
firstAttempt: SelectField;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface ProjectMirror {
|
||
|
|
/** Set (or clear, with null) a problem row's Target Date. */
|
||
|
|
setTargetDate(issue: number, date: string | null): Promise<void>;
|
||
|
|
setStage(issue: number, stage: string): Promise<void>;
|
||
|
|
setFirstAttempt(issue: number, result: string): Promise<void>;
|
||
|
|
/** Accumulated failures; print + step-summarize these, never throw. */
|
||
|
|
warnings: string[];
|
||
|
|
}
|
||
|
|
|
||
|
|
async function resolveToken(): Promise<string> {
|
||
|
|
const env = process.env.PROJECT_PAT || process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
|
||
|
|
if (env) return env;
|
||
|
|
return (await $`gh auth token`.text()).trim();
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function projectMirror(owner: string, repo: string): Promise<ProjectMirror> {
|
||
|
|
const warnings: string[] = [];
|
||
|
|
let token = "";
|
||
|
|
let project: ProjectInfo | undefined;
|
||
|
|
const itemIds = new Map<number, string | undefined>();
|
||
|
|
|
||
|
|
async function graphql(query: string, variables: Record<string, unknown>): Promise<unknown> {
|
||
|
|
const res = await fetch("https://api.github.com/graphql", {
|
||
|
|
method: "POST",
|
||
|
|
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
||
|
|
body: JSON.stringify({ query, variables }),
|
||
|
|
});
|
||
|
|
if (!res.ok) throw new Error(`graphql -> ${res.status} ${await res.text()}`);
|
||
|
|
const payload = (await res.json()) as { data?: unknown; errors?: { message: string }[] };
|
||
|
|
if (payload.errors?.length) {
|
||
|
|
throw new Error(payload.errors.map((e) => e.message).join("; "));
|
||
|
|
}
|
||
|
|
return payload.data;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Resolve project + field/option ids once; by name, never hardcoded. */
|
||
|
|
async function resolve(): Promise<ProjectInfo> {
|
||
|
|
if (project) return project;
|
||
|
|
token ||= await resolveToken();
|
||
|
|
if (!token) throw new Error("no PROJECT_PAT / token available");
|
||
|
|
|
||
|
|
const data = (await graphql(
|
||
|
|
`query($owner: String!, $title: String!) {
|
||
|
|
user(login: $owner) {
|
||
|
|
projectsV2(first: 10, query: $title) {
|
||
|
|
nodes {
|
||
|
|
id title
|
||
|
|
fields(first: 30) {
|
||
|
|
nodes {
|
||
|
|
... on ProjectV2FieldCommon { id name dataType }
|
||
|
|
... on ProjectV2SingleSelectField { id name options { id name } }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}`,
|
||
|
|
{ owner, title: PROJECT_TITLE },
|
||
|
|
)) as {
|
||
|
|
user: {
|
||
|
|
projectsV2: {
|
||
|
|
nodes: {
|
||
|
|
id: string;
|
||
|
|
title: string;
|
||
|
|
fields: {
|
||
|
|
nodes: { id: string; name: string; options?: { id: string; name: string }[] }[];
|
||
|
|
};
|
||
|
|
}[];
|
||
|
|
};
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
const node = data.user.projectsV2.nodes.find((n) => n.title === PROJECT_TITLE);
|
||
|
|
if (!node) throw new Error(`project "${PROJECT_TITLE}" not found for @${owner}`);
|
||
|
|
|
||
|
|
const select = (name: string): SelectField => {
|
||
|
|
const f = node.fields.nodes.find((n) => n.name === name);
|
||
|
|
if (!f?.options) throw new Error(`single-select field "${name}" missing — run srs-setup`);
|
||
|
|
return { id: f.id, options: Object.fromEntries(f.options.map((o) => [o.name, o.id])) };
|
||
|
|
};
|
||
|
|
const date = node.fields.nodes.find((n) => n.name === "Target Date");
|
||
|
|
if (!date) throw new Error(`date field "Target Date" missing from project`);
|
||
|
|
|
||
|
|
project = {
|
||
|
|
id: node.id,
|
||
|
|
targetDate: date.id,
|
||
|
|
srsStage: select("SRS Stage"),
|
||
|
|
firstAttempt: select("First Attempt"),
|
||
|
|
};
|
||
|
|
return project;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** The issue's item id in THIS project (an issue can be in several). */
|
||
|
|
async function itemId(issue: number): Promise<string> {
|
||
|
|
if (itemIds.has(issue)) {
|
||
|
|
const cached = itemIds.get(issue);
|
||
|
|
if (!cached) throw new Error(`issue #${issue} is not in the project`);
|
||
|
|
return cached;
|
||
|
|
}
|
||
|
|
const info = await resolve();
|
||
|
|
const [repoOwner, repoName] = repo.split("/");
|
||
|
|
const data = (await graphql(
|
||
|
|
`query($owner: String!, $name: String!, $issue: Int!) {
|
||
|
|
repository(owner: $owner, name: $name) {
|
||
|
|
issue(number: $issue) {
|
||
|
|
projectItems(first: 10, includeArchived: true) {
|
||
|
|
nodes { id project { id } }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}`,
|
||
|
|
{ owner: repoOwner, name: repoName, issue },
|
||
|
|
)) as {
|
||
|
|
repository: { issue: { projectItems: { nodes: { id: string; project: { id: string } }[] } } };
|
||
|
|
};
|
||
|
|
const item = data.repository.issue.projectItems.nodes.find((n) => n.project.id === info.id);
|
||
|
|
itemIds.set(issue, item?.id);
|
||
|
|
if (!item) throw new Error(`issue #${issue} is not in the project`);
|
||
|
|
return item.id;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function setField(issue: number, fieldId: string, value: object): Promise<void> {
|
||
|
|
const info = await resolve();
|
||
|
|
await graphql(
|
||
|
|
`mutation($project: ID!, $item: ID!, $field: ID!, $value: ProjectV2FieldValue!) {
|
||
|
|
updateProjectV2ItemFieldValue(
|
||
|
|
input: { projectId: $project, itemId: $item, fieldId: $field, value: $value }
|
||
|
|
) { projectV2Item { id } }
|
||
|
|
}`,
|
||
|
|
{ project: info.id, item: await itemId(issue), field: fieldId, value },
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function clearField(issue: number, fieldId: string): Promise<void> {
|
||
|
|
const info = await resolve();
|
||
|
|
await graphql(
|
||
|
|
`mutation($project: ID!, $item: ID!, $field: ID!) {
|
||
|
|
clearProjectV2ItemFieldValue(
|
||
|
|
input: { projectId: $project, itemId: $item, fieldId: $field }
|
||
|
|
) { projectV2Item { id } }
|
||
|
|
}`,
|
||
|
|
{ project: info.id, item: await itemId(issue), field: fieldId },
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Run op; on failure record a warning instead of propagating. */
|
||
|
|
async function attempt(what: string, op: () => Promise<void>): Promise<void> {
|
||
|
|
try {
|
||
|
|
await op();
|
||
|
|
} catch (err) {
|
||
|
|
warnings.push(`${what}: ${err instanceof Error ? err.message : String(err)}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
warnings,
|
||
|
|
setTargetDate: (issue, date) =>
|
||
|
|
attempt(`Target Date #${issue}`, async () => {
|
||
|
|
const info = await resolve();
|
||
|
|
if (date === null) await clearField(issue, info.targetDate);
|
||
|
|
else await setField(issue, info.targetDate, { date });
|
||
|
|
}),
|
||
|
|
setStage: (issue, stage) =>
|
||
|
|
attempt(`SRS Stage #${issue}`, async () => {
|
||
|
|
const info = await resolve();
|
||
|
|
const option = info.srsStage.options[stage];
|
||
|
|
if (!option) throw new Error(`no option "${stage}"`);
|
||
|
|
await setField(issue, info.srsStage.id, { singleSelectOptionId: option });
|
||
|
|
}),
|
||
|
|
setFirstAttempt: (issue, result) =>
|
||
|
|
attempt(`First Attempt #${issue}`, async () => {
|
||
|
|
const info = await resolve();
|
||
|
|
const option = info.firstAttempt.options[result];
|
||
|
|
if (!option) throw new Error(`no option "${result}"`);
|
||
|
|
await setField(issue, info.firstAttempt.id, { singleSelectOptionId: option });
|
||
|
|
}),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Print mirror warnings and surface them in the Actions run summary. */
|
||
|
|
export async function reportMirror(mirror: ProjectMirror): Promise<void> {
|
||
|
|
if (mirror.warnings.length === 0) return;
|
||
|
|
console.warn(`\nproject mirror: ${mirror.warnings.length} warning(s) — srs.json is the truth`);
|
||
|
|
for (const w of mirror.warnings) console.warn(` ⚠ ${w}`);
|
||
|
|
if (process.env.GITHUB_STEP_SUMMARY) {
|
||
|
|
await Bun.write(
|
||
|
|
process.env.GITHUB_STEP_SUMMARY,
|
||
|
|
`### ⚠ project mirror warnings\n\nsrs.json was updated; these project mutations failed:\n\n${mirror.warnings.map((w) => `- ${w}`).join("\n")}\n`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|