mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* feat(tools-pr): add maintainer PR-duty workspace
Adds `tools/pr` as the maintainer-only control plane for PR-duty work on
this repo. Thin `gh` wrapper that encodes repo-specific knowledge:
review lanes, forbidden surfaces, lane-specific checklists, validation
command derivation from touched packages.
Subcommands:
- `list` — triage open queue by lane and review-state bucket.
- `view <num>` — agent-friendly review brief for a single PR.
- `classify [num]` — emit script-level tags for one PR or the whole
open queue; full-queue JSON output lands under `.tmp/tools-pr/classify/`
with rate-limit telemetry per run.
- `assignment` — assigner-perspective view of PR ownership, idle time,
and blockers (derived from existing tags; no new judgments).
Tag dictionary (13 tags) covers: bot-only-approval, needs-rebase,
forbidden-surface, unlabeled, duplicate-title, non-ascii-slug,
maintainer-edits-disabled, org-member, unresolved-changes-requested,
stale-approval, and three awaiting-* timing tags. Each rule is
expressible as one factual sentence over `gh` data + repo paths — see
`tools/pr/AGENTS.md` for the full dictionary plus precision rules.
Templates in `tools/pr/templates/*.md` are aesthetic references for
recurring maintainer comments (duplicate-title ask, awaiting-author
nudge, agent-review brief shape). `templates/examples/` holds
frozen-in-time agent-review snapshots for three PR shapes.
Infrastructure:
- `gh()` wraps `execFile` with minimum-touch retry (2 attempts at 1s + 2s
backoff) on transient 5xx / network errors. Persistent failures still
surface — retry is anti-jitter, not an exponential-backoff resilience
layer.
- Heavy chunks (`reviews`, `comments`, `commits`, assignment timelines)
use cursor-paginated `gh api graphql` via `fetchPaginatedPrList` to
stay under GitHub's GraphQL server-side timeout. Light chunks stay on
`gh pr list --json`.
- `fetchOrgMembers` cached per process via `gh api orgs/<owner>/members
--paginate`.
Wiring:
- Root `package.json` adds `pnpm tools-pr` to the allowed root entry
points.
- `scripts/postinstall.mjs` builds `tools/pr` alongside other workspace
packages.
- `scripts/guard.ts` allowlists `tools/pr/bin/tools-pr.mjs` and
`tools/pr/esbuild.config.mjs`, and adds `pr/` to the `tools/` top-level
layout allowlist.
- Root `AGENTS.md` and `tools/AGENTS.md` document the new command
surface, root-command-boundary update, and per-tool ownership.
* docs(agents): brief tools-pr in root AGENTS.md, link to tools/pr/AGENTS.md
Adds a `PR-duty tooling` section to the root AGENTS.md summarising what
`pnpm tools-pr` is, listing the four common subcommands (list / view /
classify / assignment), and pointing readers to `tools/pr/AGENTS.md` for
the full tag dictionary, operational playbook, templates, and design
rules. The section keeps root-level guidance to high-level orientation
while details stay local to the tool's own AGENTS.md.
* fix(tools-pr): drop overly broad touches-root-package.json forbidden hit
`deriveForbidden` was flagging any change to root `package.json` as a
forbidden-surface hit, but AGENTS.md §Root command boundary only forbids
specific *lifecycle* aliases (pnpm dev / test / build / daemon / preview
/ start) — tools-control-plane entrypoints like `pnpm tools-pr` are
explicitly allowed. Distinguishing "forbidden alias" from "allowed
entry" requires reading the diff content, which is `pnpm guard`'s job
rather than a path-derived classify tag.
Dogfooded on this branch's own PR (#1259), which added the `pnpm
tools-pr` script and was incorrectly flagged. Removing the hit aligns
the `forbidden-surface` tag with what tools-pr can mechanically detect
from file paths alone (apps/nextjs/, packages/shared/).
* fix(tools-pr): paginate commits fetch, recognise ready-to-merge, escape title-index separator
Three review follow-ups on #1259, all factual fixes:
- `fetchOpenPrCommits` now uses `fetchPaginatedPrList` instead of a
one-shot `pullRequests(first: $first)` query. GitHub GraphQL caps
connection page size at 100, so the previous implementation would
fail at runtime when callers passed `--limit > 100`. The paginated
path makes the commits fetch consistent with the other heavy chunks
(reviews, comments, assignment timelines) and removes the artificial
ceiling entirely. The `limit` parameter is dropped from
`fetchOpenPrCommits`; the CLI `--limit` continues to bound the
`gh pr list --json` chunks.
- `deriveStatus` in `assignment.ts` now reads `facts.reviewDecision`
and `facts.mergeStateStatus`. When the PR is `APPROVED` with merge
state `CLEAN` or `UNSTABLE` and carries no blockers, status renders
as `ready to merge` instead of falling through to `in review`. The
assignment view loses its main triage signal without this — a clean
human-approved PR rendered identical to a REVIEW_REQUIRED one.
- `tags.ts:tagDuplicateTitle` and `tags.ts:buildContext` both
constructed the title-index key with a literal NUL byte between
author and title, which made the file appear as binary in `git diff`
/ review tooling. Replaced the literal byte with a Unicode escape
sequence in source; the runtime string value is identical, the
source stays plain text and round-trips through review tooling
cleanly.
* fix(tools-pr): raise default --limit to 1000 to cover the live open queue
mrcfps flagged that `tools-pr list` (and `classify --all`, `assignment`)
defaults to `--limit 100`, which silently drops every PR past the first
100 in the open queue. The repo currently sits at 104 open PRs, so the
out-of-the-box run was already omitting four PRs.
Raise the default to 1000 in `list.ts`, `classify.ts`, and `assignment.ts`,
and remove the now-pointless 200 ceiling — `gh pr list --limit N` paginates
internally, so a high cap is cheap. Users can still pass `--limit <small>`
for a truncated preview. CLI help text on the three subcommands updated to
match.
* fix(web): pass designTemplates to ProjectView render helper
#955 made `designTemplates` a required Prop on ProjectView, but the test
helper added in #1244 (`renderProjectView` in
`ProjectView.api-empty-response.test.tsx`) was never updated. The two
PRs landed on main without conflicting, leaving `apps/web` typecheck red
for every PR that rebases past b5eb8c16.
Pass `designTemplates={[] as SkillSummary[]}` alongside the existing
`skills={[] as SkillSummary[]}` so the helper compiles. The component
already treats the array shape (empty included) as a no-op fallback in
the empty-response paths the test exercises.
* fix(tools-pr): correct author signal + merge inline review comments
Two correctness gaps in the awaiting-* signal pipeline surfaced during
review of the new tools-pr commands:
1. `authorSignalAt` iterated every PR commit unconditionally. On
`maintainerCanModify=true` PRs a maintainer's follow-up push would
advance the author timestamp, masking a stalled author response.
Filter commits to those whose `authorLogin` matches `facts.author`,
mirroring the same filter already applied to comments.
2. `fetchOpenPrComments` (and `fetchView`) only fetched
`pullRequest.comments` / `gh pr view --json comments`, which is the
issue-conversation thread. Inline review-thread replies — where
authors and reviewers actually exchange most fix-up replies — live in
`reviewThreads.comments` / REST `pulls/{n}/comments`. Missing them let
`humanReviewerSignalAt` / `authorSignalAt` and the `view` brief point
at the wrong side after someone replied inline. Extend the list-mode
GraphQL to also sweep `reviewThreads(last: 20).comments(first: 20)`,
and add a parallel REST inline-comments fetch in `fetchView` that
merges into `GhView.comments`.
279 lines
9.6 KiB
TypeScript
279 lines
9.6 KiB
TypeScript
/**
|
|
* `tools-pr classify` — emit script-level tags for one PR or the full open
|
|
* queue. Output is strictly factual: each tag carries a name + factual
|
|
* reason + source token; no severity, no judgment, no merge guidance.
|
|
*
|
|
* Tag dictionary lives in `tools/pr/src/tags.ts` and is documented in
|
|
* `tools/pr/AGENTS.md` §Tag dictionary.
|
|
*/
|
|
|
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
import { reduceLatestReviewsByAuthor } from "./bot.js";
|
|
import {
|
|
detectRepoSlug,
|
|
fetchOpenPrs,
|
|
fetchOrgMembers,
|
|
fetchRateLimit,
|
|
fetchView,
|
|
type FetchOpenPrsResult,
|
|
type RateLimitSnapshot,
|
|
} from "./gh.js";
|
|
import { buildContext, classifyPr, KNOWN_TAGS } from "./tags.js";
|
|
import type {
|
|
ClassifyContext,
|
|
ClassifyReport,
|
|
GhView,
|
|
PrFacts,
|
|
Tag,
|
|
} from "./types.js";
|
|
|
|
// ---- fact bridging -------------------------------------------------------
|
|
|
|
function factsFromList(input: FetchOpenPrsResult, orgMembers: ReadonlySet<string>): PrFacts[] {
|
|
const statsBy = new Map(input.stats.map((row) => [row.number, row] as const));
|
|
const filesBy = new Map(input.files.map((row) => [row.number, row] as const));
|
|
const reviewsBy = new Map(input.reviews.map((row) => [row.number, row] as const));
|
|
const commitsBy = new Map((input.commits ?? []).map((row) => [row.number, row] as const));
|
|
const commentsBy = new Map((input.comments ?? []).map((row) => [row.number, row] as const));
|
|
|
|
return input.meta.map((meta) => {
|
|
const stats = statsBy.get(meta.number);
|
|
const filesRow = filesBy.get(meta.number);
|
|
const reviewsRow = reviewsBy.get(meta.number);
|
|
const commitsRow = commitsBy.get(meta.number);
|
|
const commentsRow = commentsBy.get(meta.number);
|
|
return {
|
|
number: meta.number,
|
|
author: meta.author.login,
|
|
title: meta.title,
|
|
createdAt: meta.createdAt,
|
|
updatedAt: meta.updatedAt,
|
|
isDraft: meta.isDraft,
|
|
reviewDecision: meta.reviewDecision,
|
|
mergeStateStatus: stats?.mergeStateStatus ?? "UNKNOWN",
|
|
maintainerCanModify: meta.maintainerCanModify,
|
|
isOrgMember: orgMembers.has(meta.author.login),
|
|
headRefOid: stats?.headRefOid ?? "",
|
|
assignees: meta.assignees.map((a) => a.login),
|
|
labels: meta.labels,
|
|
filePaths: filesRow ? filesRow.files.map((f) => f.path) : [],
|
|
reviews: reviewsRow
|
|
? reduceLatestReviewsByAuthor(reviewsRow.reviews).map((r) => ({
|
|
author: r.author,
|
|
body: r.body,
|
|
state: r.state,
|
|
submittedAt: r.submittedAt,
|
|
commit: r.commit ?? null,
|
|
}))
|
|
: [],
|
|
comments: commentsRow ? commentsRow.comments : [],
|
|
commits: commitsRow
|
|
? commitsRow.commits.map((c) => ({
|
|
committedDate: c.committedDate,
|
|
authorLogin: c.authors[0]?.login ?? null,
|
|
}))
|
|
: [],
|
|
};
|
|
});
|
|
}
|
|
|
|
function factsFromView(num: number, view: GhView, orgMembers: ReadonlySet<string>): PrFacts {
|
|
return {
|
|
number: num,
|
|
author: view.author.login,
|
|
title: view.title,
|
|
createdAt: view.createdAt,
|
|
updatedAt: view.updatedAt,
|
|
isDraft: view.isDraft,
|
|
reviewDecision: view.reviewDecision,
|
|
mergeStateStatus: view.mergeStateStatus,
|
|
maintainerCanModify: view.maintainerCanModify,
|
|
isOrgMember: orgMembers.has(view.author.login),
|
|
headRefOid: view.headRefOid,
|
|
assignees: view.assignees.map((a) => a.login),
|
|
labels: view.labels,
|
|
filePaths: view.files.map((f) => f.path),
|
|
reviews: reduceLatestReviewsByAuthor(view.reviews).map((r) => ({
|
|
author: r.author,
|
|
body: r.body,
|
|
state: r.state,
|
|
submittedAt: r.submittedAt,
|
|
commit: r.commit ?? null,
|
|
})),
|
|
comments: view.comments.map((c) => ({
|
|
author: c.author,
|
|
body: c.body,
|
|
createdAt: c.createdAt,
|
|
})),
|
|
commits: view.commits.map((c) => ({
|
|
committedDate: c.committedDate,
|
|
authorLogin: c.authors[0]?.login ?? null,
|
|
})),
|
|
};
|
|
}
|
|
|
|
// ---- output paths --------------------------------------------------------
|
|
|
|
function timestampStem(): string {
|
|
// YYYY-MM-DDTHHmmssZ — colon-free for filesystem portability
|
|
const d = new Date();
|
|
const iso = d.toISOString().replace(/[-:]/g, "").replace(/\..+/, "");
|
|
// iso is "20260511T034500Z"; reinsert dashes for readability
|
|
return `${iso.slice(0, 4)}-${iso.slice(4, 6)}-${iso.slice(6, 8)}T${iso.slice(9, 15)}Z`;
|
|
}
|
|
|
|
function classifyOutputDir(): string {
|
|
// Per AGENTS.md §Default runtime files convention:
|
|
// <project-root>/.tmp/<source>/...; we own `tools-pr` as our source slot.
|
|
// import.meta.dirname is the dist directory when bundled; walk up to repo
|
|
// root via dist/.. = tools/pr -> tools -> root.
|
|
const here = import.meta.dirname;
|
|
// when bundled: tools/pr/dist/index.mjs -> dirname = tools/pr/dist
|
|
// when tsx-run: tools/pr/src/classify.ts -> dirname = tools/pr/src
|
|
const repoRoot = path.resolve(here, "..", "..", "..");
|
|
return path.join(repoRoot, ".tmp", "tools-pr", "classify");
|
|
}
|
|
|
|
// ---- report assembly -----------------------------------------------------
|
|
|
|
function buildReport(
|
|
allFacts: PrFacts[],
|
|
ctx: ClassifyContext,
|
|
rateBefore: RateLimitSnapshot,
|
|
rateAfter: RateLimitSnapshot,
|
|
): ClassifyReport {
|
|
const byTag: Record<string, number[]> = {};
|
|
const byNumber: Record<string, Tag[]> = {};
|
|
for (const tagName of KNOWN_TAGS) byTag[tagName] = [];
|
|
|
|
for (const facts of allFacts) {
|
|
const tags = classifyPr(facts, ctx);
|
|
byNumber[String(facts.number)] = tags;
|
|
for (const tag of tags) {
|
|
const bucket = byTag[tag.name] ?? [];
|
|
bucket.push(facts.number);
|
|
byTag[tag.name] = bucket;
|
|
}
|
|
}
|
|
|
|
// Drop tag buckets that ended up empty so the JSON stays tight.
|
|
for (const tagName of Object.keys(byTag)) {
|
|
if ((byTag[tagName] ?? []).length === 0) delete byTag[tagName];
|
|
}
|
|
|
|
const cost =
|
|
rateBefore.resetAt === rateAfter.resetAt
|
|
? rateBefore.remaining - rateAfter.remaining
|
|
: null;
|
|
|
|
return {
|
|
generatedAt: new Date().toISOString(),
|
|
openPrTotal: allFacts.length,
|
|
classifiedCount: allFacts.length,
|
|
byTag,
|
|
byNumber,
|
|
rate: {
|
|
before: rateBefore,
|
|
after: rateAfter,
|
|
cost,
|
|
},
|
|
};
|
|
}
|
|
|
|
// ---- human single-PR output ---------------------------------------------
|
|
|
|
function formatAwaitingDuration(hours: number): string {
|
|
const days = Math.floor(hours / 24);
|
|
const rem = hours - days * 24;
|
|
if (days === 0) return `${rem}h`;
|
|
if (rem === 0) return `${days}d`;
|
|
return `${days}d ${rem}h`;
|
|
}
|
|
|
|
function formatSinglePr(num: number, tags: Tag[]): string {
|
|
const lines: string[] = [];
|
|
lines.push(`PR #${num} — ${tags.length} tag${tags.length === 1 ? "" : "s"}`);
|
|
if (tags.length === 0) {
|
|
lines.push(" (no tags matched)");
|
|
} else {
|
|
for (const tag of tags) {
|
|
const suffix =
|
|
tag.awaitingHours !== undefined
|
|
? ` (awaiting ${formatAwaitingDuration(tag.awaitingHours)})`
|
|
: "";
|
|
lines.push(` • ${tag.name}${suffix}`);
|
|
lines.push(` reason: ${tag.reason}`);
|
|
lines.push(` source: ${tag.source}`);
|
|
}
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
|
|
// ---- command entries -----------------------------------------------------
|
|
|
|
export type ClassifyOptions = {
|
|
json?: boolean;
|
|
all?: boolean;
|
|
name?: string;
|
|
print?: boolean;
|
|
limit?: number | string;
|
|
};
|
|
|
|
export async function runClassifyOne(num: number, options: ClassifyOptions): Promise<void> {
|
|
if (!Number.isFinite(num) || num <= 0) {
|
|
throw new Error("classify <num> requires a positive PR number");
|
|
}
|
|
const { owner } = await detectRepoSlug();
|
|
const [view, orgMembers] = await Promise.all([fetchView(num), fetchOrgMembers(owner)]);
|
|
const facts = factsFromView(num, view, orgMembers);
|
|
// Single-PR mode has no cross-PR title corpus; duplicate-title cannot fire.
|
|
const ctx: ClassifyContext = { titleIndexByAuthor: new Map() };
|
|
const tags = classifyPr(facts, ctx);
|
|
|
|
if (options.json) {
|
|
process.stdout.write(`${JSON.stringify({ number: num, tags }, null, 2)}\n`);
|
|
return;
|
|
}
|
|
process.stdout.write(`${formatSinglePr(num, tags)}\n`);
|
|
}
|
|
|
|
export async function runClassifyAll(options: ClassifyOptions): Promise<void> {
|
|
// Default covers the whole open queue with growth headroom; `gh pr list`
|
|
// paginates internally so a high cap is cheap.
|
|
const limit = Math.max(1, Number(options.limit ?? 1000) || 1000);
|
|
const { owner } = await detectRepoSlug();
|
|
const rateBefore = await fetchRateLimit();
|
|
const [fetched, orgMembers] = await Promise.all([
|
|
fetchOpenPrs(limit, { includeCommits: true, includeComments: true }),
|
|
fetchOrgMembers(owner),
|
|
]);
|
|
const rateAfter = await fetchRateLimit();
|
|
const allFacts = factsFromList(fetched, orgMembers);
|
|
const ctx = buildContext(allFacts);
|
|
const report = buildReport(allFacts, ctx, rateBefore, rateAfter);
|
|
|
|
const dir = classifyOutputDir();
|
|
await mkdir(dir, { recursive: true });
|
|
const stem = options.name && options.name.length > 0 ? options.name : timestampStem();
|
|
const outPath = path.join(dir, `${stem}.json`);
|
|
await writeFile(outPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
|
|
|
const tagSummary = Object.entries(report.byTag)
|
|
.map(([name, nums]) => `${name}=${nums.length}`)
|
|
.join(" ");
|
|
const rateSummary =
|
|
report.rate.cost !== null
|
|
? `rate cost=${report.rate.cost} remaining=${report.rate.after.remaining}/${report.rate.after.limit} reset=${report.rate.after.resetAt}`
|
|
: `rate remaining=${report.rate.after.remaining}/${report.rate.after.limit} (window rolled over; cost N/A)`;
|
|
process.stdout.write(
|
|
`wrote ${report.openPrTotal} entries to ${outPath}` +
|
|
(tagSummary ? ` [${tagSummary}]` : " [no tags matched]") +
|
|
` ${rateSummary}\n`,
|
|
);
|
|
if (options.print) {
|
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
}
|
|
}
|