Files
leetcode/apps/api/src/reviews.ts
T

136 lines
5.4 KiB
TypeScript
Raw Normal View History

/**
* `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;
}