/** * 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; // 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; setStage(issue: number, stage: string): Promise; setFirstAttempt(issue: number, result: string): Promise; /** Accumulated failures; print + step-summarize these, never throw. */ warnings: string[]; } async function resolveToken(): Promise { 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 { const warnings: string[] = []; let token = ""; let project: ProjectInfo | undefined; const itemIds = new Map(); async function graphql(query: string, variables: Record): Promise { 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 { 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 { 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 { 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 { 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): Promise { 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 { 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`, ); } }