Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,14 @@ of them in a few seconds. The other eleven pull requests are not listed because
there was nothing to say about them. Nothing is written back to GitHub, and the
command needs no write access.

For private repositories, or to raise your API rate limit, set `GH_TOKEN`.
**`triage` needs `GH_TOKEN` set, even for a public repository.** It reads every
open pull request, and GitHub allows 60 unauthenticated requests an hour, which
one queue uses up. Any personal access token with no scopes at all is enough,
since nothing here needs write access. Without one the command tells you so and
exits non-zero rather than reporting a half-read queue.

`scan`, on a single public pull request, works without a token.

There is deliberately no way to pass a token as a command-line flag, because
flags end up in shell history and CI logs.

Expand Down
10 changes: 8 additions & 2 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,14 @@ hand:
npx --yes mergewarden@0.10.1 triage owner/repository
```

It needs no write access and writes nothing back. [Triage](triage.md) explains
what each row means and where the thresholds come from.
It needs no write access and writes nothing back, but it does need `GH_TOKEN`
set, even for a public repository: it reads every open pull request, and
GitHub's 60 unauthenticated requests an hour do not cover one queue. A personal
access token with no scopes selected is enough. Without one the command says so
and exits non-zero rather than reporting a queue it only half read.

[Triage](triage.md) explains what each row means and where the thresholds come
from.

Set `GH_TOKEN` for private repositories or for a higher API rate limit. There is
no command-line flag for the token, deliberately, because flags end up in shell
Expand Down
10 changes: 10 additions & 0 deletions docs/triage.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ Rows are ordered by how many facts each pull request trips, so the top of the li
reviewer's attention goes first. No installation, no configuration, and no write access: it
reads the same public API a browser does.

## It needs a token, including on a public repository

Set `GH_TOKEN` before running this. Reading a queue takes one request per pull request plus the
listing, and GitHub allows **60 unauthenticated requests an hour**, which a single queue uses
up. A personal access token with no scopes selected is enough, because nothing here writes.

Without one, the command says what it could not read and exits non-zero. It does not print a
queue it only half read: an unreadable pull request is a gap in the analysis, not a fact about
that pull request, and the two are counted separately for that reason.

`--limit N` reads more or fewer pull requests (default 20, maximum 100). `--format json` emits
the same data for scripting.

Expand Down
97 changes: 85 additions & 12 deletions packages/cli/src/triage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,12 @@ async function listOpenPullRequests(
// @mergewarden/github; this listing is a direct call and needs its own bounded backoff.
let response = await fetch(url, { headers });

for (let attempt = 1; attempt <= 3 && RETRYABLE.has(response.status); attempt++) {
// An exhausted hourly quota answers 403 with `x-ratelimit-remaining: 0`, and it does not
// recover inside the two minutes this backoff spends. Retrying it means an unauthenticated
// caller stares at nothing for two minutes and then gets the error anyway.
const quotaGone = response.headers.get("x-ratelimit-remaining") === "0";

for (let attempt = 1; attempt <= 3 && !quotaGone && RETRYABLE.has(response.status); attempt++) {
const retryAfter = Number(response.headers.get("retry-after"));
const waitMs =
Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : attempt * 20_000;
Expand All @@ -126,11 +131,16 @@ async function listOpenPullRequests(
}

if (!response.ok) {
const hint = RETRYABLE.has(response.status)
? " — GitHub is rate limiting this token; try again in a few minutes"
: "";
// Without a token this is the hourly quota, and waiting is the wrong advice: it resets in
// up to an hour and the fix is a token. Saying "this token" to somebody who has not set
// one also sends them looking for a problem they do not have.
const hint = !RETRYABLE.has(response.status)
? ""
: token
? " GitHub is rate limiting this token; try again in a few minutes."
: " GitHub allows 60 unauthenticated requests an hour, which one repository's queue exhausts. Set GH_TOKEN to a personal access token and run it again.";
throw new Error(
`Could not list pull requests for ${owner}/${repo}: HTTP ${response.status}${hint}`,
`Could not list pull requests for ${owner}/${repo}: HTTP ${response.status}.${hint}`,
);
}

Expand Down Expand Up @@ -181,6 +191,31 @@ function notesFor(result: AnalysisResult): string[] {
return notes;
}

/**
* Did the run hit a quota, rather than one pull request being unreadable?
*
* GitHub answers an exhausted quota with 403 and a reset timestamp. Once that fires, every
* remaining pull request fails the same way, so continuing prints a queue of "could not be read"
* that describes the tool's own state as though it were a fact about the pull requests. The
* cause chain is walked because the retry layer wraps the original error.
*/
function rateLimited(error: unknown): boolean {
let current: unknown = error;

for (let depth = 0; current !== undefined && current !== null && depth < 5; depth += 1) {
const candidate = current as { status?: unknown; rateLimitResetAt?: unknown; cause?: unknown };
const status = typeof candidate.status === "number" ? candidate.status : undefined;

if ((status === 403 || status === 429) && typeof candidate.rateLimitResetAt === "number") {
return true;
}

current = candidate.cause;
}

return false;
}

function truncate(value: string, max: number): string {
const compact = value.replace(/\s+/g, " ").trim();
return compact.length <= max ? compact : `${compact.slice(0, max - 1)}…`;
Expand Down Expand Up @@ -292,8 +327,18 @@ export async function runTriageCli(
notes: string[];
}[] = [];
const automation: OpenPullRequest[] = [];
// Kept out of `rows` on purpose. An unreadable pull request is a gap in the analysis, not a
// finding about the pull request, and counting the two together reported the tool's own
// failure as work waiting for a maintainer.
const unreadable: OpenPullRequest[] = [];
let quotaExhausted = false;

for (const [index, pull] of openPullRequests.entries()) {
if (quotaExhausted) {
unreadable.push(...openPullRequests.slice(index));
break;
}

for (const pull of openPullRequests) {
try {
const input = await loadGitHubAnalysis(
api,
Expand All @@ -320,10 +365,14 @@ export async function runTriageCli(

const result = await analyze(input);
rows.push({ ...pull, notes: notesFor(result) });
} catch {
// One unreadable pull request must not end the run — a deleted head repository is
// ordinary. It is reported as unreadable rather than silently dropped.
rows.push({ ...pull, notes: ["could not be read"] });
} catch (error) {
// One unreadable pull request must not end the run: a deleted head repository is
// ordinary. A quota is different, because everything after it fails identically.
if (rateLimited(error)) {
quotaExhausted = true;
}

unreadable.push(pull);
}
}

Expand All @@ -332,20 +381,32 @@ export async function runTriageCli(
// because it asked for JSON.
const partitioned = partitionUniformNotes(rows) as { uniform: string[]; rows: typeof rows };

// An incomplete read is reported as incomplete. The rest of this tool fails closed rather
// than presenting a partial pass, and the command people run first should not be the
// exception to that.
const incomplete = unreadable.length > 0;
const advice = quotaExhausted
? token
? "GitHub's rate limit for this token was exhausted. Wait for it to reset, or pass --limit with a smaller number."
: "GitHub allows 60 unauthenticated requests an hour, which one repository's queue exhausts. Set GH_TOKEN to a personal access token and run it again."
: "Those pull requests could not be read. A deleted or renamed head branch is the usual cause.";

if (options.format === "json") {
io.stdout(
`${JSON.stringify(
{
repository: `${options.owner}/${options.repo}`,
uniformNotes: partitioned.uniform,
automationPullRequests: automation.length,
unreadablePullRequests: unreadable.map((pull) => pull.number),
analysisComplete: !incomplete,
rows: partitioned.rows,
},
null,
2,
)}\n`,
);
return 0;
return incomplete ? 1 : 0;
}

// Most signals first: the point of the command is which pull request to open next.
Expand All @@ -354,6 +415,15 @@ export async function runTriageCli(
.sort((left, right) => right.notes.length - left.notes.length || left.number - right.number);

if (rows.length === 0) {
if (incomplete) {
// Nothing was analysed, so there is no queue to describe. Saying anything about the
// repository here would be describing a run that did not happen.
io.stderr(
`MergeWarden CLI error: none of the ${unreadable.length} open pull request(s) could be read. ${advice}\n`,
);
return 1;
}

io.stdout(
automation.length > 0
? `${options.owner}/${options.repo}: all ${automation.length} open pull request(s) read are maintenance automation. Nothing here is waiting on a human.\n`
Expand All @@ -367,6 +437,9 @@ export async function runTriageCli(
...(automation.length > 0
? [`${automation.length} more are maintenance automation and were not read.`]
: []),
...(incomplete
? [`${unreadable.length} could not be read, so this is a partial answer. ${advice}`]
: []),
"",
];

Expand Down Expand Up @@ -397,5 +470,5 @@ export async function runTriageCli(
);

io.stdout(lines.join("\n"));
return 0;
return incomplete ? 1 : 0;
}
Loading