diff --git a/apps/cli/github.ts b/apps/cli/github.ts index 0472f93..32ddbd7 100644 --- a/apps/cli/github.ts +++ b/apps/cli/github.ts @@ -1,6 +1,6 @@ /** - * GitHub REST plumbing shared by the issue reconcilers (close-solved, - * close-topics). + * GitHub REST + GraphQL plumbing shared by the issue reconcilers (close-solved, + * close-topics) and the Project field reconciler (sync-project-fields). * * Importing this module is side-effect free — nothing resolves credentials or * touches the network until github() is awaited — so a script can import it @@ -22,6 +22,8 @@ export interface GitHub { repo: string; /** One authenticated request against api.github.com; throws on non-2xx. */ api(path: string, init?: RequestInit): Promise; + /** One GraphQL call (Projects v2 lives here); throws on transport or query errors. */ + graphql(query: string, variables?: Record): Promise; /** Every page of a list endpoint, flattened into one stream of elements. */ list(path: string): AsyncGenerator; /** Comment on an issue, then close it as completed. */ @@ -74,6 +76,24 @@ export async function github(): Promise { } } + 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; + } + /** * Comment before closing: if the PATCH fails, the issue still carries a * visible note of what the automation decided, instead of failing silently. @@ -89,5 +109,5 @@ export async function github(): Promise { }); } - return { repo, api, list, closeIssue }; + return { repo, api, graphql, list, closeIssue }; } diff --git a/apps/cli/sync-project-fields.ts b/apps/cli/sync-project-fields.ts new file mode 100755 index 0000000..5fc2d07 --- /dev/null +++ b/apps/cli/sync-project-fields.ts @@ -0,0 +1,252 @@ +#!/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));