/** * 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; } interface ProjectInfo { id: string; targetDate: string; srsStage: SelectField; firstAttempt: SelectField; } export interface Mirror { setTargetDate(issue: number, date: string | null): Promise; setStage(issue: number, stage: string): Promise; setFirstAttempt(issue: number, result: string): Promise; warnings: string[]; } export function projectMirror(gh: GitHub, owner: string): Mirror { const warnings: string[] = []; let info: ProjectInfo | undefined; const itemIds = new Map(); async function resolve(): Promise { 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 { 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 { 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): Promise { 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 }); }), }; }