Files
leetcode/apps/api/src/mirror.ts
T

173 lines
6.0 KiB
TypeScript
Raw Normal View History

/**
* Best-effort mirror into the "Interview Prep" user Project — ported from
* scripts/srs-project.ts. D1 is the truth; a GraphQL failure here becomes a
* console warning and never blocks a state write. Only problem issues are
* ever passed in, so topic rows' Target Date is never written.
*
* Field/option IDs are resolved by NAME per invocation (a Worker isolate is
* short-lived; the two extra queries per mirror burst are irrelevant at this
* volume and survive field re-creation).
*/
import type { GitHub } from "./github.ts";
const PROJECT_TITLE = "Interview Prep";
interface SelectField {
id: string;
options: Record<string, string>;
}
interface ProjectInfo {
id: string;
targetDate: string;
srsStage: SelectField;
firstAttempt: SelectField;
}
export interface Mirror {
setTargetDate(issue: number, date: string | null): Promise<void>;
setStage(issue: number, stage: string): Promise<void>;
setFirstAttempt(issue: number, result: string): Promise<void>;
warnings: string[];
}
export function projectMirror(gh: GitHub, owner: string): Mirror {
const warnings: string[] = [];
let info: ProjectInfo | undefined;
const itemIds = new Map<number, string | undefined>();
async function resolve(): Promise<ProjectInfo> {
if (info) return info;
const data = (await gh.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`);
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`);
info = {
id: node.id,
targetDate: date.id,
srsStage: select("SRS Stage"),
firstAttempt: select("First Attempt"),
};
return info;
}
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 project = await resolve();
const [repoOwner, repoName] = gh.repo.split("/");
const data = (await gh.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 === project.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 project = await resolve();
await gh.graphql(
`mutation($project: ID!, $item: ID!, $field: ID!, $value: ProjectV2FieldValue!) {
updateProjectV2ItemFieldValue(
input: { projectId: $project, itemId: $item, fieldId: $field, value: $value }
) { projectV2Item { id } }
}`,
{ project: project.id, item: await itemId(issue), field: fieldId, value },
);
}
async function attempt(what: string, op: () => Promise<void>): Promise<void> {
try {
await op();
} catch (err) {
const message = `mirror ${what}: ${err instanceof Error ? err.message : String(err)}`;
warnings.push(message);
console.warn(message); // observability picks this up; never rethrow
}
}
return {
warnings,
setTargetDate: (issue, date) =>
attempt(`Target Date #${issue}`, async () => {
const project = await resolve();
if (date === null) {
await gh.graphql(
`mutation($project: ID!, $item: ID!, $field: ID!) {
clearProjectV2ItemFieldValue(
input: { projectId: $project, itemId: $item, fieldId: $field }
) { projectV2Item { id } }
}`,
{ project: project.id, item: await itemId(issue), field: project.targetDate },
);
} else {
await setField(issue, project.targetDate, { date });
}
}),
setStage: (issue, stage) =>
attempt(`SRS Stage #${issue}`, async () => {
const project = await resolve();
const option = project.srsStage.options[stage];
if (!option) throw new Error(`no option "${stage}"`);
await setField(issue, project.srsStage.id, { singleSelectOptionId: option });
}),
setFirstAttempt: (issue, result) =>
attempt(`First Attempt #${issue}`, async () => {
const project = await resolve();
const option = project.firstAttempt.options[result];
if (!option) throw new Error(`no option "${result}"`);
await setField(issue, project.firstAttempt.id, { singleSelectOptionId: option });
}),
};
}