mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-16 23:16:26 +00:00
feat(cli): add GraphQL support and sync-project-fields script for Project field reconciliation
This commit is contained in:
Executable
+252
@@ -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<string, Record<string, string>> = {
|
||||
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<string, string>;
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
fields: Record<string, SelectField>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Project> {
|
||||
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<string, never>)[] };
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
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<string, SelectField> = {};
|
||||
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<string, string>;
|
||||
}
|
||||
|
||||
async function* readItems(gh: GitHub, project: string): AsyncGenerator<Item, void, void> {
|
||||
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<string, never>)[] };
|
||||
content: { number: number; title: string; labels: { nodes: { name: string }[] } } | Record<string, never>;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
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<string, string> = {};
|
||||
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<void> {
|
||||
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));
|
||||
Reference in New Issue
Block a user