mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 13:12:44 +00:00
137 lines
5.4 KiB
TypeScript
137 lines
5.4 KiB
TypeScript
/**
|
|
* `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;
|
|
}
|