#!/usr/bin/env bun /** * Reconcile the "Interview Prep" Project's Set and Difficulty fields from the * labels on each problem issue. * * The issue labels are the truth: `set:core|optional|deferred` and * `diff:easy|medium|hard` are fixed when a problem issue is filed, and the * Project's single-selects are a projection of them so the board can group and * filter. This recomputes every desired value from scratch and writes only the * ones that differ, so re-runs are no-ops and a backfill needs no special * casing — same reconcile-don't-react shape as close-solved / close-topics. * * Scope guard: only items whose issue carries the `problem` label are touched. * Topic, cadence and review issues have no set or difficulty, so they are * skipped outright — never written, never cleared. * * Deliberately NOT mirrored: SRS stage and first-attempt result. Those live * only in D1 (apps/api owns them; the charts, digest and /api/stats read them * from there). The Project mirror writes Target Date and nothing else. * * Auth: needs a token carrying the `project` scope — neither the default * `gh auth token` nor Actions' GITHUB_TOKEN has it. Pass one in GH_TOKEN: the * PROJECT_PAT secret in CI, or the Worker's GH_PAT locally: * GH_TOKEN=$GH_PAT bun run sync-project-fields -- --dry-run */ import { github, type GitHub } from "./github.ts"; import { markdownTable, printTable, writeStepSummary } from "./report.ts"; const PROJECT_TITLE = "Interview Prep"; /** Label → single-select option name, per field. Static tables, so Record. */ const FIELDS: Record> = { Set: { "set:core": "Core", "set:optional": "Optional", "set:deferred": "Deferred" }, Difficulty: { "diff:easy": "Easy", "diff:medium": "Medium", "diff:hard": "Hard" }, }; const DRY = process.argv.includes("--dry-run") || process.env.DRY_RUN === "1" || process.env.DRY_RUN === "true"; // ── project + field resolution ─────────────────────────────────── interface SelectField { id: string; /** option name → option id */ options: Record; } interface Project { id: string; fields: Record; } /** * Field and option IDs are resolved by NAME, so re-creating a field in the UI * does not strand this script on a dead id. */ async function readProject(gh: GitHub, owner: string): Promise { 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 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 }[] } | Record)[] }; }[]; }; }; }; 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 fields: Record = {}; for (const name of Object.keys(FIELDS)) { const field = node.fields.nodes.find((f) => "name" in f && f.name === name); if (!field || !("options" in field)) { throw new Error(`single-select field "${name}" missing from "${PROJECT_TITLE}"`); } fields[name] = { id: field.id, options: Object.fromEntries(field.options.map((o) => [o.name, o.id])), }; } return { id: node.id, fields }; } // ── items ──────────────────────────────────────────────────────── interface Item { id: string; issue: number; title: string; labels: string[]; /** field name → current option name */ current: Record; } async function* readItems(gh: GitHub, project: string): AsyncGenerator { let cursor: string | null = null; for (;;) { const data = (await gh.graphql( `query($id: ID!, $after: String) { node(id: $id) { ... on ProjectV2 { items(first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { id fieldValues(first: 25) { nodes { ... on ProjectV2ItemFieldSingleSelectValue { name field { ... on ProjectV2FieldCommon { name } } } } } content { ... on Issue { number title labels(first: 25) { nodes { name } } } } } } } } }`, { id: project, after: cursor }, )) as { node: { items: { pageInfo: { hasNextPage: boolean; endCursor: string }; nodes: { id: string; fieldValues: { nodes: ({ name: string; field: { name: string } } | Record)[] }; content: { number: number; title: string; labels: { nodes: { name: string }[] } } | Record; }[]; }; }; }; for (const node of data.node.items.nodes) { const content = node.content; // Draft items and PRs have no issue number; nothing to reconcile. if (!("number" in content) || typeof content.number !== "number") continue; const current: Record = {}; for (const value of node.fieldValues.nodes) { if ("name" in value && value.field?.name) current[value.field.name] = value.name; } yield { id: node.id, issue: content.number, title: content.title, labels: content.labels.nodes.map((l) => l.name), current, }; } const page = data.node.items.pageInfo; if (!page.hasNextPage) return; cursor = page.endCursor; } } // ── reconcile ──────────────────────────────────────────────────── interface Write { item: string; issue: number; title: string; field: string; from: string; to: string; } async function setField(gh: GitHub, project: string, w: Write, option: string): Promise { await gh.graphql( `mutation($project: ID!, $item: ID!, $field: ID!, $option: String!) { updateProjectV2ItemFieldValue( input: { projectId: $project, itemId: $item, fieldId: $field value: { singleSelectOptionId: $option } } ) { projectV2Item { id } } }`, { project, item: w.item, field: w.field, option }, ); } const gh = await github(); const owner = gh.repo.split("/")[0]!; const project = await readProject(gh, owner); const writes: Write[] = []; let problems = 0; let skipped = 0; for await (const item of readItems(gh, project.id)) { if (!item.labels.includes("problem")) { skipped++; continue; } problems++; for (const [field, mapping] of Object.entries(FIELDS)) { const want = item.labels.map((l) => mapping[l]).find(Boolean); if (!want) { console.warn(`#${item.issue} has no ${field.toLowerCase()} label — left untouched`); continue; } const from = item.current[field]; if (from === want) continue; if (!project.fields[field]!.options[want]) { throw new Error(`field "${field}" has no option "${want}"`); } writes.push({ item: item.id, issue: item.issue, title: item.title, field: project.fields[field]!.id, from: from ?? "(empty)", to: want, }); } } const rows: string[][] = [["ISSUE", "PROBLEM", "FIELD", "FROM", "TO"]]; for (const w of writes) { const name = Object.entries(project.fields).find(([, f]) => f.id === w.field)![0]; rows.push([`#${w.issue}`, w.title.slice(0, 46), name, w.from, w.to]); if (DRY) continue; await setField(gh, project.id, w, project.fields[name]!.options[w.to]!); } if (writes.length) printTable(rows); console.log( `\n${DRY ? "would update" : "updated"} ${writes.length} field value(s) across ${problems} problem item(s); ${skipped} non-problem item(s) skipped`, ); if (writes.length) await writeStepSummary(markdownTable(rows));