#!/usr/bin/env bun /** * Close the GitHub issue for every topic whose required (core) problems are * done, and for every `Review — ` issue whose problems all are. * * Each `topic`-labelled issue owns its problems as GitHub sub-issues, and each * problem carries exactly one `set:` label — `set:core` is required, while * `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"). * Problems re-parented into Review issues still count for their topic. The * rules live in topics.ts, shared with release-topics.ts. * * A Review issue is a fixed day's assignment, so it closes when every one of * its sub-issues is closed, whatever their set. Closing it fires the Worker's * `review`-label webhook, which scores only issues with a gate record — these * have none, so that is a no-op. * * 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 * open topics/reviews are touched, so reopening a problem never reopens either. * * bun apps/cli/close-topics.ts # close finished topics and reviews * bun apps/cli/close-topics.ts --dry-run # report only, touch nothing * * Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`. */ import { movedFromTopics, readReviews } from "../api/src/reviews.ts"; import { github } from "./github.ts"; import { markdownTable, printTable, writeStepSummary } from "./report.ts"; import { completion, coreSet, readProblems } from "./topics.ts"; const DRY = process.argv.includes("--dry-run") || process.env.DRY_RUN === "1" || process.env.DRY_RUN === "true"; const gh = await github(); // ── open topic issues ──────────────────────────────────────────── interface Topic { number: number; title: string; } /** * Narrow one element of the /issues payload to the fields this script needs. * The `topic` label filter cannot exclude pull requests, so drop those here. */ function readTopic(value: unknown): Topic | undefined { if (!value || typeof value !== "object") return; if ("pull_request" in value) return; // the /issues route also lists PRs if (!("number" in value) || typeof value.number !== "number") return; if (!("title" in value) || typeof value.title !== "string") return; return { number: value.number, title: value.title }; } const topics: Topic[] = []; for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=open`)) { const topic = readTopic(raw); if (topic) topics.push(topic); } topics.sort((a, b) => a.number - b.number); const reviews = await readReviews(gh); const moved = movedFromTopics(reviews); // ── reconcile ──────────────────────────────────────────────────── const report: string[][] = []; let closed = 0; for (const topic of topics) { const core = coreSet(await readProblems(gh, topic.number, moved)); 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, // since that would be indistinguishable from a mis-labelled problem set. if (core.all.length === 0) { report.push([`#${topic.number}`, topic.title, progress, "no core set"]); continue; } if (core.pending.length > 0) { report.push([ `#${topic.number}`, topic.title, progress, `open: ${core.pending.map((n) => `#${n}`).join(" ")}`, ]); continue; } if (DRY) { report.push([`#${topic.number}`, topic.title, progress, "would close"]); continue; } await gh.closeIssue( topic.number, `Core set complete — all ${core.all.length} core problems closed ` + `(${core.all.map((n) => `#${n}`).join(", ")}).\n\n` + "Closed automatically by `close-topics`. Optional and deferred problems " + "stay open as extra credit.", ); report.push([`#${topic.number}`, topic.title, progress, "closed"]); closed++; } // ── reviews ────────────────────────────────────────────────────── const reviewReport: string[][] = []; let reviewsClosed = 0; for (const review of reviews.filter((r) => r.open)) { const set = completion(review.problems); const progress = `${set.all.length - set.pending.length}/${set.all.length}`; // An empty review would read as done; leave it for a human, like a topic // with no core set. if (set.all.length === 0) { reviewReport.push([`#${review.number}`, review.title, progress, "no sub-issues"]); continue; } if (set.pending.length > 0) { reviewReport.push([`#${review.number}`, review.title, progress, `open: ${set.pending.map((n) => `#${n}`).join(" ")}`]); continue; } if (DRY) { reviewReport.push([`#${review.number}`, review.title, progress, "would close"]); continue; } await gh.closeIssue( review.number, `Review complete — all ${set.all.length} problems closed ` + `(${set.all.map((n) => `#${n}`).join(", ")}).\n\n` + "Closed automatically by `close-topics`.", ); reviewReport.push([`#${review.number}`, review.title, progress, "closed"]); reviewsClosed++; } // ── report ─────────────────────────────────────────────────────── const rows = [["topic", "title", "core", "status"], ...report]; printTable(rows); const reviewRows = [["review", "title", "closed", "status"], ...reviewReport]; console.log(); printTable(reviewRows); const finished = new Set(["closed", "would close"]); const topicsDone = report.filter((r) => finished.has(r[3]!)).length; const reviewsDone = reviewReport.filter((r) => finished.has(r[3]!)).length; console.log( `\n${topics.length} open topics · ${DRY ? `would close ${topicsDone}` : `closed ${closed}`}` + `\n${reviewReport.length} open reviews · ${DRY ? `would close ${reviewsDone}` : `closed ${reviewsClosed}`}`, ); await writeStepSummary( `### close-topics${DRY ? " (dry run)" : ""}\n\n` + `${topics.length} open topic issues, ${topicsDone} with a complete core set.\n\n` + (report.length ? `${markdownTable(rows)}\n` : "No open topics.\n") + `\n${reviewReport.length} open review issues, ${reviewsDone} with every problem closed.\n\n` + (reviewReport.length ? `${markdownTable(reviewRows)}\n` : "No open reviews.\n"), );