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