mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 21:22:43 +00:00
feat(api): sync catalog with problems moved to review issues
This commit is contained in:
1 parent
f8ff12b55e
commit
b2dffa4615
2 files changed
+153
-5
No files matched your search
+17
-5
@@ -9,9 +9,16 @@
|
||||
* issue title — the title carries only the LC number and the problem's name,
|
||||
* so relabelling a problem is enough to move it and titles stay readable.
|
||||
* The `problem` label is what marks a sub-issue as curriculum.
|
||||
*
|
||||
* A topic's problems are its sub-issues PLUS those re-parented into a
|
||||
* `Review <N> — <Phase>` issue (reviews.ts recovers which topic each came
|
||||
* from). Walking topic sub-issues alone would silently freeze a moved
|
||||
* problem's row: upserts only, so nothing breaks — its labels just stop
|
||||
* syncing.
|
||||
*/
|
||||
import { DIFFICULTIES, SETS, workingDay } from "./srs.ts";
|
||||
import type { GitHub } from "./github.ts";
|
||||
import { movedFromTopics, readReviews } from "./reviews.ts";
|
||||
|
||||
const TITLE_RE = /^LC (\d+) · (.+)$/;
|
||||
|
||||
@@ -72,24 +79,29 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
|
||||
);
|
||||
}
|
||||
|
||||
const moved = movedFromTopics(await readReviews(gh));
|
||||
let problems = 0;
|
||||
let deferredSeen = 0;
|
||||
for (const t of topics) {
|
||||
const members: { number: number; title: string; labels: string[] }[] = [];
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${t.number}/sub_issues`)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
||||
if (!("title" in raw) || typeof raw.title !== "string") continue;
|
||||
const labels = labelNames(raw);
|
||||
members.push({ number: raw.number, title: raw.title, labels: labelNames(raw) });
|
||||
}
|
||||
members.push(...(moved.get(t.number) ?? []));
|
||||
for (const { number, title, labels } of members) {
|
||||
if (!labels.includes("problem")) continue; // non-curriculum sub-issue
|
||||
const m = raw.title.match(TITLE_RE);
|
||||
const m = title.match(TITLE_RE);
|
||||
if (!m) {
|
||||
console.warn(`catalog: #${raw.number} is a problem but not titled "LC <n> · <name>"`);
|
||||
console.warn(`catalog: #${number} is a problem but not titled "LC <n> · <name>"`);
|
||||
continue;
|
||||
}
|
||||
const difficulty = DIFFICULTIES.find((d) => labels.includes(`diff:${d}`));
|
||||
const set = SETS.find((s) => labels.includes(`set:${s}`));
|
||||
if (!difficulty || !set) {
|
||||
console.warn(`catalog: #${raw.number} is missing a diff:/set: label — skipped`);
|
||||
console.warn(`catalog: #${number} is missing a diff:/set: label — skipped`);
|
||||
continue;
|
||||
}
|
||||
const lc = Number(m[1]);
|
||||
@@ -106,7 +118,7 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
|
||||
ON CONFLICT(lc_number) DO UPDATE SET
|
||||
issue = ?2, topic_issue = ?3, title = ?4, difficulty = ?5, set_label = ?6`,
|
||||
)
|
||||
.bind(lc, raw.number, t.number, m[2]!, difficulty, set, defer),
|
||||
.bind(lc, number, t.number, m[2]!, difficulty, set, defer),
|
||||
);
|
||||
problems++;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* `Review <N> — <Phase>` issues: the transfer phase's day blocks. Problems were
|
||||
* re-parented into them from their topics (`replace_parent` — a GitHub issue
|
||||
* has exactly one parent), so a topic's sub-issue list no longer names every
|
||||
* problem it owns. This module is the ONE recovery of that ownership: each
|
||||
* moved problem's topic is its last `parent_issue_removed` event naming a
|
||||
* topic, which GitHub records and no human edits.
|
||||
*
|
||||
* Two consumers must agree on it: the Worker's catalog reconcile
|
||||
* (catalog.ts — else a moved problem's D1 row stops syncing its labels) and
|
||||
* the CLI's topic rules (apps/cli/topics.ts — else close-topics and the
|
||||
* release bodies change when a problem moves). Hence it lives here, the lower
|
||||
* layer, the way apps/cli already imports srs.ts.
|
||||
*
|
||||
* Library: side-effect-free on import; the caller passes its GitHub client —
|
||||
* the Worker's or the CLI's, which share the three members used here.
|
||||
*/
|
||||
import type { GitHub } from "./github.ts";
|
||||
|
||||
/** `Review 7 — Nodal & Grid`, never the Worker's weekly `Review — Week N` gate. */
|
||||
const REVIEW_TITLE = /^Review \d+ — /;
|
||||
const TOPIC_TITLE = /^Topic \d+ — /;
|
||||
|
||||
/** One problem issue as a topic or review lists it. */
|
||||
export interface Problem {
|
||||
number: number;
|
||||
/** `LC <num> · <Name>`. */
|
||||
title: string;
|
||||
/** ISO instant the issue closed; null while it is open. */
|
||||
closedAt: string | null;
|
||||
labels: string[];
|
||||
}
|
||||
|
||||
export interface Review {
|
||||
number: number;
|
||||
title: string;
|
||||
open: boolean;
|
||||
/** Sub-issues in the order GitHub lists them (the solve order). */
|
||||
problems: (Problem & { fromTopic: number | null })[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `Review <N> — <Phase>` issue, open and closed, with its sub-issues.
|
||||
* One GraphQL request per 50 `review`-labelled issues (the Worker's weekly
|
||||
* gate issues share the label and are dropped by title): the sub-issues and
|
||||
* the timeline filter ride along, so recovering each problem's source topic
|
||||
* costs no extra request. That matters in the Worker, whose nightly cron
|
||||
* spends its subrequest budget on the catalog walk and the digest.
|
||||
*/
|
||||
export async function readReviews(gh: Pick<GitHub, "repo" | "graphql">): Promise<Review[]> {
|
||||
const [owner, name] = gh.repo.split("/");
|
||||
const reviews: Review[] = [];
|
||||
for (let after: string | null = null; ; ) {
|
||||
// Shape fixed by the query below; GraphQL errors throw in gh.graphql().
|
||||
const data = (await gh.graphql(
|
||||
`query($owner: String!, $name: String!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
issues(labels: ["review"], first: 50, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
number title state
|
||||
subIssues(first: 50) {
|
||||
pageInfo { hasNextPage }
|
||||
nodes {
|
||||
number title state closedAt
|
||||
labels(first: 20) { nodes { name } }
|
||||
timelineItems(itemTypes: [PARENT_ISSUE_REMOVED_EVENT], last: 10) {
|
||||
nodes { ... on ParentIssueRemovedEvent { parent { number title } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ owner, name, after },
|
||||
)) as {
|
||||
repository: {
|
||||
issues: {
|
||||
pageInfo: { hasNextPage: boolean; endCursor: string | null };
|
||||
nodes: {
|
||||
number: number;
|
||||
title: string;
|
||||
state: "OPEN" | "CLOSED";
|
||||
subIssues: {
|
||||
pageInfo: { hasNextPage: boolean };
|
||||
nodes: {
|
||||
number: number;
|
||||
title: string;
|
||||
state: "OPEN" | "CLOSED";
|
||||
closedAt: string | null;
|
||||
labels: { nodes: { name: string }[] };
|
||||
timelineItems: { nodes: { parent: { number: number; title: string } | null }[] };
|
||||
}[];
|
||||
};
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
const page = data.repository.issues;
|
||||
for (const issue of page.nodes) {
|
||||
if (!REVIEW_TITLE.test(issue.title)) continue;
|
||||
// A review is one day's handful; more than a page means a mis-linked issue.
|
||||
if (issue.subIssues.pageInfo.hasNextPage) throw new Error(`review #${issue.number} has over 50 sub-issues`);
|
||||
reviews.push({
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
open: issue.state === "OPEN",
|
||||
problems: issue.subIssues.nodes.map((p) => ({
|
||||
number: p.number,
|
||||
title: p.title,
|
||||
closedAt: p.state === "CLOSED" ? p.closedAt : null,
|
||||
labels: p.labels.nodes.map((l) => l.name),
|
||||
// Oldest first, so the last topic it left is its owner; a later
|
||||
// review-to-review move names a review and is skipped.
|
||||
fromTopic:
|
||||
p.timelineItems.nodes.findLast((e) => e.parent && TOPIC_TITLE.test(e.parent.title))?.parent?.number ?? null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (!page.pageInfo.hasNextPage) break;
|
||||
after = page.pageInfo.endCursor;
|
||||
}
|
||||
return reviews.sort((a, b) => a.number - b.number);
|
||||
}
|
||||
|
||||
/** Topic -> the problems moved out of it into Review issues, in review order. */
|
||||
export function movedFromTopics(reviews: Review[]): Map<number, Problem[]> {
|
||||
const moved = new Map<number, Problem[]>();
|
||||
for (const review of reviews) {
|
||||
for (const { fromTopic, ...problem } of review.problems) {
|
||||
if (fromTopic !== null) moved.set(fromTopic, [...(moved.get(fromTopic) ?? []), problem]);
|
||||
}
|
||||
}
|
||||
return moved;
|
||||
}
|
||||
Reference in new issue
Block a user