mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 13:12:44 +00:00
chore: clean up content phase organization
This commit is contained in:
1 parent
5dc2265ee5
commit
6f9e4ecb3b
14 files changed
+706
-45
No files matched your search
@@ -7,6 +7,7 @@
|
||||
* `set:optional` and `set:deferred` are extra credit. A topic is finished when
|
||||
* every one of its core sub-issues is closed; optional/deferred state is
|
||||
* ignored, matching the day rule in each topic body ("Core problems first").
|
||||
* The rule lives in topics.ts, shared with release-topics.ts.
|
||||
*
|
||||
* Like close-solved.ts this reconciles state instead of reacting to an event
|
||||
* payload: re-runs are no-ops, backfilling needs no special casing, and only
|
||||
@@ -19,6 +20,7 @@
|
||||
*/
|
||||
import { github } from "./github.ts";
|
||||
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||
import { coreSet, readProblems } from "./topics.ts";
|
||||
|
||||
const DRY =
|
||||
process.argv.includes("--dry-run") ||
|
||||
@@ -53,52 +55,13 @@ for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=ope
|
||||
}
|
||||
topics.sort((a, b) => a.number - b.number);
|
||||
|
||||
// ── core sub-issue state per topic ───────────────────────────────
|
||||
|
||||
interface Core {
|
||||
/** Core sub-issues, closed and open alike, lowest number first. */
|
||||
all: number[];
|
||||
/** The core sub-issues still open — non-empty means the topic stays open. */
|
||||
pending: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a topic's core sub-issue state. The sub_issues payload carries the full
|
||||
* issue objects (state + labels), so no per-problem follow-up request is needed.
|
||||
*/
|
||||
async function readCore(topic: number): Promise<Core> {
|
||||
const all: number[] = [];
|
||||
const pending: number[] = [];
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${topic}/sub_issues`)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
||||
if (!("state" in raw) || typeof raw.state !== "string") continue;
|
||||
if (!("labels" in raw) || !Array.isArray(raw.labels)) continue;
|
||||
|
||||
const core = raw.labels.some(
|
||||
(label) =>
|
||||
label &&
|
||||
typeof label === "object" &&
|
||||
"name" in label &&
|
||||
label.name === "set:core",
|
||||
);
|
||||
if (!core) continue;
|
||||
|
||||
all.push(raw.number);
|
||||
if (raw.state === "open") pending.push(raw.number);
|
||||
}
|
||||
all.sort((a, b) => a - b);
|
||||
pending.sort((a, b) => a - b);
|
||||
return { all, pending };
|
||||
}
|
||||
|
||||
// ── reconcile ────────────────────────────────────────────────────
|
||||
|
||||
const report: string[][] = [];
|
||||
let closed = 0;
|
||||
|
||||
for (const topic of topics) {
|
||||
const core = await readCore(topic.number);
|
||||
const core = coreSet(await readProblems(gh, topic.number));
|
||||
const progress = `${core.all.length - core.pending.length}/${core.all.length}`;
|
||||
|
||||
// A topic with no core sub-issues has nothing to complete: never close it,
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"sync": "bun ./sync.ts",
|
||||
"videos": "bun ./videos.ts",
|
||||
"close-solved": "bun ./close-solved.ts",
|
||||
"close-topics": "bun ./close-topics.ts"
|
||||
"close-topics": "bun ./close-topics.ts",
|
||||
"release-topics": "bun ./release-topics.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clack/prompts": "^1.7.0",
|
||||
|
||||
Executable
+333
@@ -0,0 +1,333 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Publish one GitHub release per finished topic, versioned by timever
|
||||
* (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC — https://gist.github.com/twolfson/de1b004dd22536b8e668).
|
||||
*
|
||||
* "Finished" is topics.ts's rule — every core sub-issue closed — the same one
|
||||
* close-topics.ts closes the topic issue on, and the version is the instant
|
||||
* the LAST core problem closed. Keying off the sub-issues rather than the
|
||||
* topic issue lets this run as the tail of Close Solved: the push that closes
|
||||
* a topic's final core problem releases it in the same run, without waiting
|
||||
* for close-topics to chain after it. The topic issue itself closes seconds
|
||||
* later, on the same day.
|
||||
*
|
||||
* Each release is a changelog snapshot at that instant: the topic's problem
|
||||
* sub-issues grouped as closed on the finish day (ET), closed earlier, and
|
||||
* open at release, each with its set, difficulty, and the solution files as
|
||||
* they stood in the tagged commit — the last main commit before the finish.
|
||||
*
|
||||
* The tag is annotated and backdated (tagger date = finish), so
|
||||
* `git tag --sort=taggerdate` reads as the campaign's history even for tags
|
||||
* minted by a backfill. The release's own published date cannot be backdated
|
||||
* (the API has no field for it), and the docs changelog (githubReleases in
|
||||
* apps/docs/blume.config.ts) dates entries by it — so a backfilled entry
|
||||
* carries its real finish time in the version and the body's first sentence.
|
||||
*
|
||||
* Reconciles instead of reacting: every finished topic without a release gets
|
||||
* one, so a first run backfills the whole campaign; an existing release keeps
|
||||
* its tag and commit and only has its body rewritten when render() would say
|
||||
* something else. Re-runs with nothing new are no-ops.
|
||||
* A release is found by the marker comment in its body, never by tag name, so
|
||||
* reopening and re-closing a core problem can never mint a second release.
|
||||
* A tag that exists without its release (a run that died between the two) is
|
||||
* reused, not re-created.
|
||||
*
|
||||
* bun apps/cli/release-topics.ts # tag, push, release, fix bodies
|
||||
* bun apps/cli/release-topics.ts --dry-run # print the would-be releases
|
||||
*
|
||||
* Auth: GH_TOKEN / GITHUB_TOKEN, else `gh auth token` (needs contents: write).
|
||||
* Git: a clone with full history, an `origin` it may push tags to, and a
|
||||
* committer identity (the tagger).
|
||||
*/
|
||||
import { etDate } from "../api/src/srs.ts";
|
||||
import { github } from "./github.ts";
|
||||
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
|
||||
import { coreSet, labelValue, readProblems, type Problem } from "./topics.ts";
|
||||
import { ROOT, parseWorkPath } from "./work.ts";
|
||||
|
||||
const DRY =
|
||||
process.argv.includes("--dry-run") ||
|
||||
process.env.DRY_RUN === "1" ||
|
||||
process.env.DRY_RUN === "true";
|
||||
|
||||
/** The docs site (apps/docs/blume.config.ts `deployment.site`); sync publishes `/<num>` short links. */
|
||||
const DOCS_SITE = "https://lc.prd.lk";
|
||||
const TIMEVER = "https://gist.github.com/twolfson/de1b004dd22536b8e668";
|
||||
|
||||
const gh = await github();
|
||||
|
||||
function git(...args: string[]): string {
|
||||
const run = Bun.spawnSync(["git", ...args], { cwd: ROOT });
|
||||
if (run.exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${run.stderr.toString().trim()}`);
|
||||
return run.stdout.toString().trim();
|
||||
}
|
||||
|
||||
// ── topics ───────────────────────────────────────────────────────
|
||||
|
||||
interface Topic {
|
||||
number: number;
|
||||
title: string;
|
||||
milestone: string | null;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** Narrow one /issues element; the `topic` label filter cannot exclude PRs. */
|
||||
function readTopic(value: unknown): Topic | undefined {
|
||||
if (!value || typeof value !== "object") return;
|
||||
if ("pull_request" in value) return;
|
||||
if (!("number" in value) || typeof value.number !== "number") return;
|
||||
if (!("title" in value) || typeof value.title !== "string") return;
|
||||
const milestone =
|
||||
"milestone" in value &&
|
||||
value.milestone &&
|
||||
typeof value.milestone === "object" &&
|
||||
"title" in value.milestone &&
|
||||
typeof value.milestone.title === "string"
|
||||
? value.milestone.title
|
||||
: null;
|
||||
const body = "body" in value && typeof value.body === "string" ? value.body : "";
|
||||
return { number: value.number, title: value.title, milestone, body };
|
||||
}
|
||||
|
||||
interface Finished {
|
||||
topic: Topic;
|
||||
problems: Problem[];
|
||||
core: number[];
|
||||
finishedAt: string;
|
||||
}
|
||||
|
||||
// Closed and open topics alike: the rule reads the sub-issues, so a topic
|
||||
// finished moments ago (close-topics not yet run) releases now.
|
||||
const finished: Finished[] = [];
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) {
|
||||
const topic = readTopic(raw);
|
||||
if (!topic) continue;
|
||||
const problems = await readProblems(gh, topic.number);
|
||||
const core = coreSet(problems);
|
||||
if (core.finishedAt) finished.push({ topic, problems, core: core.all, finishedAt: core.finishedAt });
|
||||
}
|
||||
// Oldest first, so a backfill creates releases in campaign order.
|
||||
finished.sort((a, b) => a.finishedAt.localeCompare(b.finishedAt) || a.topic.number - b.topic.number);
|
||||
|
||||
// ── existing releases ────────────────────────────────────────────
|
||||
|
||||
/** Topic -> its release. The body is kept in sync with render() on every run. */
|
||||
const released = new Map<number, { id: number; tag: string; body: string }>();
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/releases`)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
if (!("id" in raw) || typeof raw.id !== "number") continue;
|
||||
if (!("tag_name" in raw) || typeof raw.tag_name !== "string") continue;
|
||||
if (!("body" in raw) || typeof raw.body !== "string") continue;
|
||||
const m = raw.body.match(/<!-- release-topics: #(\d+) -->/);
|
||||
if (m) released.set(Number(m[1]), { id: raw.id, tag: raw.tag_name, body: raw.body });
|
||||
}
|
||||
|
||||
// ── timever ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Timever of an instant. The API reports whole seconds, so SUBTIME is the
|
||||
* tiebreak: two topics finishing in the same second (one push closing both
|
||||
* last core problems) get consecutive nanoseconds, in (instant, topic) order.
|
||||
*/
|
||||
const tags = new Map<number, string>(); // topic -> tag
|
||||
{
|
||||
let prev = "";
|
||||
let ns = 0;
|
||||
for (const f of finished) {
|
||||
const iso = new Date(f.finishedAt).toISOString(); // 2026-09-24T18:36:38.000Z
|
||||
const base = `${iso.slice(0, 10).replaceAll("-", "")}.${iso.slice(11, 19).replaceAll(":", "")}`;
|
||||
ns = base === prev ? ns + 1 : Number(iso.slice(20, 23)) * 1_000_000;
|
||||
prev = base;
|
||||
tags.set(f.topic.number, `${base}.${String(ns).padStart(9, "0")}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── release body ─────────────────────────────────────────────────
|
||||
|
||||
const ET_WHEN = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "America/New_York",
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const ET_DAY = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "America/New_York",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
/** Topic body sections worth carrying into the changelog (skipped while still `TODO`). */
|
||||
const NOTES = ["Trigger", "Invariant", "Trap"] as const;
|
||||
|
||||
function bodyField(body: string, field: string): string | undefined {
|
||||
return body.match(new RegExp(`^\\*\\*${field}:\\*\\*\\s*(.+)$`, "m"))?.[1]?.trim();
|
||||
}
|
||||
|
||||
function bodySection(body: string, heading: string): string | undefined {
|
||||
const m = body.match(new RegExp(`^### ${heading}\\n([\\s\\S]*?)(?=^### |(?![\\s\\S]))`, "m"));
|
||||
const text = m?.[1]?.trim();
|
||||
return text && text !== "TODO" ? text : undefined;
|
||||
}
|
||||
|
||||
function render(f: Finished, tag: string, sha: string, files: Map<number, string[]>): string {
|
||||
const { topic, problems } = f;
|
||||
const finishDay = etDate(new Date(f.finishedAt));
|
||||
// Snapshot at the finish instant: a problem closed later was open then.
|
||||
const closedBy = (p: Problem) => p.closedAt !== null && p.closedAt <= f.finishedAt;
|
||||
const onDay = problems.filter((p) => closedBy(p) && etDate(new Date(p.closedAt!)) === finishDay);
|
||||
const earlier = problems.filter((p) => closedBy(p) && etDate(new Date(p.closedAt!)) !== finishDay);
|
||||
const open = problems.filter((p) => !closedBy(p));
|
||||
|
||||
const row = (p: Problem): string[] => {
|
||||
const lc = Number(p.title.match(/^LC (\d+)/)?.[1]);
|
||||
const code = closedBy(p)
|
||||
? (files.get(lc) ?? []).map(
|
||||
(path) => `[${path.slice(path.lastIndexOf(".") + 1)}](https://github.com/${gh.repo}/blob/${tag}/${encodeURI(path)})`,
|
||||
)
|
||||
: [];
|
||||
return [
|
||||
`#${p.number}`,
|
||||
p.title,
|
||||
labelValue(p, "set:") ?? "—",
|
||||
labelValue(p, "diff:") ?? "—",
|
||||
closedBy(p) ? ET_DAY.format(new Date(p.closedAt!)) : "—",
|
||||
code.length ? [...code, `[docs](${DOCS_SITE}/${lc})`].join(" · ") : "—",
|
||||
];
|
||||
};
|
||||
const table = (list: Problem[]) =>
|
||||
markdownTable([["Issue", "Problem", "Set", "Difficulty", "Closed (ET)", "Solution"], ...list.map(row)]);
|
||||
|
||||
// Blume's changelog derives each page's meta description from the opening
|
||||
// prose, stripping markdown and line breaks without adding a space — so lead
|
||||
// with one self-contained sentence and keep the fields on one `·` line.
|
||||
const when = ET_WHEN.format(new Date(f.finishedAt));
|
||||
const summary =
|
||||
`Finished ${when} ET: all ${f.core.length} core problems closed, ` +
|
||||
`${onDay.length} ${onDay.length === 1 ? "problem" : "problems"} closed that day.`;
|
||||
const fields: string[] = [];
|
||||
const scheduled = bodyField(topic.body, "Scheduled");
|
||||
if (scheduled) fields.push(`**Scheduled:** ${scheduled}`);
|
||||
if (topic.milestone) fields.push(`**Phase:** ${topic.milestone}`);
|
||||
fields.push(`**Topic:** #${topic.number}`);
|
||||
const buildsOn = bodyField(topic.body, "Builds on");
|
||||
if (buildsOn) fields.push(`**Builds on:** ${buildsOn}`);
|
||||
// The marker is how a later run finds this release; see the file header.
|
||||
const sections = [`<!-- release-topics: #${topic.number} -->\n${summary}`, fields.join(" · ")];
|
||||
sections.push(`### Closed ${ET_DAY.format(new Date(f.finishedAt))} — the day the topic finished\n\n${table(onDay)}`);
|
||||
if (earlier.length) sections.push(`### Closed earlier\n\n${table(earlier)}`);
|
||||
if (open.length) sections.push(`### Open at release\n\n${table(open)}`);
|
||||
const notes = NOTES.flatMap((h) => {
|
||||
const text = bodySection(topic.body, h);
|
||||
return text ? [`**${h}.** ${text}`] : [];
|
||||
});
|
||||
if (notes.length) sections.push(`### Notes\n\n${notes.join("\n\n")}`);
|
||||
sections.push(
|
||||
`---\n\n\`${tag}\` is [timever](${TIMEVER}) for the UTC instant the core set completed; ` +
|
||||
`it tags ${sha.slice(0, 7)}, the last main commit before it.`,
|
||||
);
|
||||
return `${sections.join("\n\n")}\n`;
|
||||
}
|
||||
|
||||
// ── reconcile ────────────────────────────────────────────────────
|
||||
|
||||
const latest = finished.at(-1)?.topic.number;
|
||||
const report: string[][] = [];
|
||||
const previews: string[] = [];
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
|
||||
for (const f of finished) {
|
||||
const { topic } = f;
|
||||
const finishedEt = etDate(new Date(f.finishedAt));
|
||||
const existing = released.get(topic.number);
|
||||
// An existing release keeps its tag and commit; only its body is reconciled.
|
||||
const tag = existing?.tag ?? tags.get(topic.number)!;
|
||||
const sha = existing
|
||||
? git("rev-parse", `${tag}^{commit}`)
|
||||
: git("rev-list", "-1", "--first-parent", `--before=${f.finishedAt}`, "HEAD");
|
||||
if (!sha) {
|
||||
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "ERROR: no commit before finish"]);
|
||||
continue;
|
||||
}
|
||||
// LC number -> its first-solve files in the tagged tree. Keyed by file name,
|
||||
// like firstSolved(): tags before the bucket split see `work/<Difficulty>/…`,
|
||||
// and the work/3 + work/7 re-solves are not the solve that closed the issue.
|
||||
const files = new Map<number, string[]>();
|
||||
for (const path of git("ls-tree", "-r", "--name-only", sha, "--", "work").split("\n")) {
|
||||
const bucket = parseWorkPath(path.slice("work/".length))?.bucket;
|
||||
const lc = path.match(/\/(\d+)\.[^/]+\.(?:js|py)$/)?.[1];
|
||||
if (!lc || (bucket && bucket !== 1)) continue;
|
||||
files.set(Number(lc), [...(files.get(Number(lc)) ?? []), path]);
|
||||
}
|
||||
const body = render(f, tag, sha, files);
|
||||
|
||||
if (existing) {
|
||||
if (existing.body === body) {
|
||||
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "released"]);
|
||||
} else if (DRY) {
|
||||
previews.push(`## ${tag} — ${topic.title}\n\n${body}`);
|
||||
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "would update"]);
|
||||
} else {
|
||||
await gh.api(`/repos/${gh.repo}/releases/${existing.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "updated"]);
|
||||
updated++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (DRY) {
|
||||
previews.push(`## ${tag} — ${topic.title}\n\n${body}`);
|
||||
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "would release"]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const remote = git("ls-remote", "--tags", "origin", `refs/tags/${tag}`);
|
||||
if (!remote) {
|
||||
const local = Bun.spawnSync(["git", "rev-parse", "-q", "--verify", `refs/tags/${tag}`], { cwd: ROOT });
|
||||
if (local.exitCode !== 0) {
|
||||
const tagged = Bun.spawnSync(
|
||||
["git", "tag", "-a", tag, sha, "-m", `${topic.title} (#${topic.number})\n\nCore set complete: ${f.core.map((n) => `#${n}`).join(", ")}`],
|
||||
{ cwd: ROOT, env: { ...process.env, GIT_COMMITTER_DATE: f.finishedAt } },
|
||||
);
|
||||
if (tagged.exitCode !== 0) throw new Error(`git tag ${tag}: ${tagged.stderr.toString().trim()}`);
|
||||
}
|
||||
git("push", "origin", `refs/tags/${tag}`);
|
||||
}
|
||||
await gh.api(`/repos/${gh.repo}/releases`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
tag_name: tag,
|
||||
name: topic.title,
|
||||
body,
|
||||
make_latest: topic.number === latest ? "true" : "false",
|
||||
}),
|
||||
});
|
||||
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "released now"]);
|
||||
created++;
|
||||
}
|
||||
|
||||
// ── report ───────────────────────────────────────────────────────
|
||||
|
||||
if (previews.length) console.log(`${previews.join("\n")}\n`);
|
||||
const rows = [["topic", "title", "finished (ET)", "tag", "status"], ...report];
|
||||
printTable(rows);
|
||||
const would = (status: string) => report.filter((r) => r[4] === status).length;
|
||||
console.log(
|
||||
`\n${finished.length} finished topics · ` +
|
||||
(DRY
|
||||
? `would release ${would("would release")}, update ${would("would update")}`
|
||||
: `released ${created}, updated ${updated}`),
|
||||
);
|
||||
|
||||
await writeStepSummary(
|
||||
`### release-topics${DRY ? " (dry run)" : ""}\n\n` +
|
||||
`${finished.length} finished topics, ${released.size} already released.\n\n` +
|
||||
(report.length ? `${markdownTable(rows)}\n` : "No finished topics.\n") +
|
||||
(previews.length ? `\n${previews.join("\n")}` : ""),
|
||||
);
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Topic issues and their problem sub-issues: the ONE reading of "is this topic
|
||||
* finished", shared by close-topics.ts (closes the topic issue) and
|
||||
* release-topics.ts (publishes the topic's timever release) so the two can
|
||||
* never disagree about when a topic was done.
|
||||
*
|
||||
* Rule: a topic is finished when it has a core set (`set:core` sub-issues) and
|
||||
* every one of them is closed. Optional and deferred problems are extra credit
|
||||
* and never hold a topic open. A topic with no core sub-issues is never
|
||||
* finished — that would be indistinguishable from a mis-labelled problem set.
|
||||
*
|
||||
* Library: side-effect-free on import; every call takes the caller's github().
|
||||
*/
|
||||
import type { GitHub } from "./github.ts";
|
||||
|
||||
/** One problem sub-issue, narrowed from the sub_issues payload. */
|
||||
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 Core {
|
||||
/** Core sub-issues, closed and open alike, lowest number first. */
|
||||
all: number[];
|
||||
/** The core sub-issues still open — non-empty means the topic stays open. */
|
||||
pending: number[];
|
||||
/** When the last core sub-issue closed; null until the topic is finished. */
|
||||
finishedAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every sub-issue of a topic. The sub_issues payload carries the full issue
|
||||
* objects (state + labels), so no per-problem follow-up request is needed.
|
||||
*/
|
||||
export async function readProblems(gh: GitHub, topic: number): Promise<Problem[]> {
|
||||
const problems: Problem[] = [];
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${topic}/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;
|
||||
if (!("state" in raw) || typeof raw.state !== "string") continue;
|
||||
if (!("labels" in raw) || !Array.isArray(raw.labels)) continue;
|
||||
const closedAt =
|
||||
raw.state === "closed" && "closed_at" in raw && typeof raw.closed_at === "string"
|
||||
? raw.closed_at
|
||||
: null;
|
||||
const labels = raw.labels.flatMap((label: unknown) =>
|
||||
label && typeof label === "object" && "name" in label && typeof label.name === "string"
|
||||
? [label.name]
|
||||
: [],
|
||||
);
|
||||
problems.push({ number: raw.number, title: raw.title, closedAt, labels });
|
||||
}
|
||||
return problems.sort((a, b) => a.number - b.number);
|
||||
}
|
||||
|
||||
/** The value of a `prefix:value` label (`set:` → `core`), if the problem has one. */
|
||||
export function labelValue(problem: Problem, prefix: string): string | undefined {
|
||||
return problem.labels.find((l) => l.startsWith(prefix))?.slice(prefix.length);
|
||||
}
|
||||
|
||||
export function coreSet(problems: Problem[]): Core {
|
||||
const core = problems.filter((p) => labelValue(p, "set:") === "core");
|
||||
const pending = core.filter((p) => !p.closedAt).map((p) => p.number);
|
||||
// ISO instants from the API share one format, so string order is time order.
|
||||
const finishedAt =
|
||||
core.length && !pending.length
|
||||
? core.map((p) => p.closedAt!).reduce((a, b) => (a > b ? a : b))
|
||||
: null;
|
||||
return { all: core.map((p) => p.number), pending, finishedAt };
|
||||
}
|
||||
Reference in new issue
Block a user