Skip to content

Commit cbe243c

Browse files
authored
fix(review): never let an inconclusive linked-issue recheck downgrade a propagated label (#4816)
* fix(review): never let an inconclusive linked-issue recheck downgrade a propagated label A merge-time (or later sweep/webhook) recheck of a PR's linked-issue label propagation could not distinguish "the recheck genuinely couldn't be verified this pass" (a transient GitHub fetch/rate-limit failure) from "the issue confirms no propagation applies" -- both fell through to the blunt title heuristic and silently overwrote whatever correct label a prior pass had already applied. Confirmed live: 118 PRs mislabeled across gittensory and metagraphed in a 2-day sample, including contributor-reported PRs #4716 and #4783. - linked-issue-label-propagation-fetch.ts: fetchLinkedIssueLabelsForPropagation now returns {labels, inconclusive} instead of a bare label list; isRepoMaintainerLogin distinguishes a confirmed permission result from an errored (inconclusive) one, and logs the error instead of swallowing it. - processors.ts: the type-label block skips the mutation entirely (leaving existing labels untouched) when the recheck was inconclusive rather than confirmed-negative, and now claims the existing per-PR actuation lock so two concurrent passes for the same PR can no longer race each other here. - client.ts: excludes the two trust-deciding endpoints (linked-issue reads, collaborator-permission checks) from cross-caller request coalescing, so one caller's transient failure can no longer become a different concurrent caller's answer. - public.ts / rag-index.ts / grounding-wire.ts: three GitHub callers were computing their rate-limit admission key before a token fallback was applied (or never attributing one at all for a high-volume caller), which is what buried the live incident in an unattributed metric bucket. Full regression coverage added, including an end-to-end reproduction of the exact PR #4716/#4783 race in queue.test.ts. * fix(review): follow the reviewCheckMode migration in the new type-label tests Rebased onto main's gateCheckMode-deprecation migration (#4618); the two new regression tests added alongside it still need reviewCheckMode set directly, matching every sibling test in the same describe block.
1 parent 6af9d77 commit cbe243c

13 files changed

Lines changed: 683 additions & 178 deletions

src/github/client.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,20 @@ function isVolatileSingleFlightEligibleGithubUrl(url: string, headers: Headers):
317317
const path = githubApiPath(url);
318318
return (
319319
!/^\/repos\/[^/]+\/[^/]+\/contents(?:\/|$|[?#])/.test(path) &&
320-
!/^\/repos\/[^/]+\/[^/]+\/git\/(?:trees|blobs)\//.test(path)
320+
!/^\/repos\/[^/]+\/[^/]+\/git\/(?:trees|blobs)\//.test(path) &&
321+
// A bare single-issue read and a collaborator-permission check (#regression-safe-propagation) each gate a
322+
// TRUST decision (linked-issue label propagation's own-merge-closed check, and its maintainer-authored-issue
323+
// relaxation) rather than merely reducing redundant reads within one review pass, which is what this
324+
// coalescing mechanism was built for. Sharing one in-flight promise's outcome -- success OR a transient
325+
// failure -- across genuinely INDEPENDENT callers (a webhook re-review racing a sweep tick, or simply two
326+
// near-simultaneous webhook deliveries for the same PR merge) means one caller's momentary fetch/rate-limit
327+
// hiccup silently becomes every concurrent caller's answer too, not just its own -- exactly the mechanism
328+
// that let a transient GitHub hiccup permanently strip a correct propagated label (confirmed in production:
329+
// `sensitive`-class coalescing observed inside the exact incident window, see #regression-safe-propagation).
330+
// Excluding these two endpoint shapes costs at most one extra GitHub call when two truly-identical reads
331+
// genuinely overlap -- worth it for a check whose wrong answer silently corrupts gittensor scoring.
332+
!/^\/repos\/[^/]+\/[^/]+\/issues\/\d+(?:$|[?#])/.test(path) &&
333+
!/^\/repos\/[^/]+\/[^/]+\/collaborators\/[^/]+\/permission(?:$|[?#])/.test(path)
321334
);
322335
}
323336

@@ -436,6 +449,14 @@ async function fetchWithGitHubRetry(input: RequestInfo | URL, init?: GitHubTimeo
436449
// Retry a transient rate-limit (with backoff) instead of surfacing it; stop once exhausted or it's not a limit.
437450
const rateLimited = await isRateLimitedResponse(response);
438451
if (!rateLimited) break;
452+
// Deliberately UNCONDITIONAL, unlike observeGitHubRestRateLimit two lines above (#regression-safe-propagation):
453+
// an existing Grafana alert/runbook queries this exact metric BY key_scope specifically to catch a caller
454+
// that never opted into admission tracking -- key_scope="unknown" is the diagnostic signal that surfaces
455+
// exactly that class of bug (it's how the src/github/public.ts and src/review/rag-index.ts / grounding-
456+
// wire.ts wiring bugs landed alongside this fix were actually found in production). Gating this on
457+
// `admissionKey` would make a FUTURE unattributed caller's rate-limiting invisible instead of diagnosable --
458+
// strictly worse than a merely-imprecise "unknown" bucket. Fix the caller's wiring (as those three were),
459+
// don't hide the symptom here.
439460
recordGitHubRateLimitResponseMetric(
440461
response.status,
441462
admissionKey,

src/github/public.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { timeoutFetch } from "./client";
1+
import { githubRateLimitAdmissionKeyForPublicToken, timeoutFetch, type GitHubRateLimitAdmissionKey } from "./client";
22

33
export type PublicContributorProfile = {
44
login: string;
@@ -72,9 +72,22 @@ export async function fetchPublicContributorProfile(login: string, env?: Pick<En
7272
// loop doesn't exhaust it and silently degrade (mirrors fetchPublicRepoStats) (#790).
7373
...(env?.GITHUB_PUBLIC_TOKEN ? { authorization: `Bearer ${env.GITHUB_PUBLIC_TOKEN}` } : {}),
7474
};
75+
// #regression-safe-propagation: this is the single highest-volume unattributed GitHub caller found in a
76+
// fleet-wide audit -- the 500-login evidence batch (processors.ts) calls this once per login, each call
77+
// issuing up to 6 raw GETs, all on the SAME shared env.GITHUB_PUBLIC_TOKEN -- up to ~3000 requests/batch with
78+
// no rate-limit admission tracking at all, so a 403 anywhere in that batch silently fell into
79+
// `key_scope="unknown"` instead of the real "public" key_scope bucket (confirmed live: 837 scheduled + 279
80+
// exhausted secondary-rate-limit 403s in 90 minutes in production). Opting in costs nothing (no extra call)
81+
// and makes this loop's real impact on the shared public-token rate-limit bucket finally visible.
82+
const admissionKey: GitHubRateLimitAdmissionKey | undefined = env?.GITHUB_PUBLIC_TOKEN ? githubRateLimitAdmissionKeyForPublicToken() : undefined;
7583
try {
7684
const fetchWithTimeout = (url: string): Promise<Response> =>
77-
timeoutFetch(url, { headers, signal: AbortSignal.timeout(GITHUB_PUBLIC_FETCH_TIMEOUT_MS) });
85+
timeoutFetch(url, {
86+
headers,
87+
signal: AbortSignal.timeout(GITHUB_PUBLIC_FETCH_TIMEOUT_MS),
88+
githubRateLimitAdmission: admissionKey !== undefined,
89+
...(admissionKey ? { githubRateLimitAdmissionKey: admissionKey } : {}),
90+
});
7891
const [userResponse, reposResponse] = await Promise.all([
7992
fetchWithTimeout(`https://api.github.com/users/${safeLogin}`),
8093
fetchWithTimeout(`https://api.github.com/users/${safeLogin}/repos?per_page=100&sort=updated`),
@@ -173,13 +186,18 @@ function publicRepoFullName(env: Pick<Env, "PUBLIC_REPO_STATS_ALLOWLIST">, owner
173186

174187
async function fetchRepoStatsFromGitHub(env: Pick<Env, "GITHUB_PUBLIC_TOKEN">, repoFullName: string, nowMs: number): Promise<PublicRepoStats> {
175188
const [owner, repo] = repoFullName.split("/") as [string, string];
189+
// #regression-safe-propagation: same shared-public-token attribution gap as fetchPublicContributorProfile
190+
// above, fixed the same way -- a rate-limited response here previously fell into key_scope="unknown".
191+
const admissionKey: GitHubRateLimitAdmissionKey | undefined = env.GITHUB_PUBLIC_TOKEN ? githubRateLimitAdmissionKeyForPublicToken() : undefined;
176192
const response = await timeoutFetch(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, {
177193
headers: {
178194
accept: "application/vnd.github+json",
179195
"user-agent": "gittensory/0.1",
180196
"x-github-api-version": "2022-11-28",
181197
...(env.GITHUB_PUBLIC_TOKEN ? { authorization: `Bearer ${env.GITHUB_PUBLIC_TOKEN}` } : {}),
182198
},
199+
githubRateLimitAdmission: admissionKey !== undefined,
200+
...(admissionKey ? { githubRateLimitAdmissionKey: admissionKey } : {}),
183201
});
184202
if (!response.ok) throw new Error(`github_repo_stats_unavailable:${response.status}`);
185203
const body = (await response.json()) as GitHubPublicRepoResponse;

src/queue/processors.ts

Lines changed: 134 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -9378,6 +9378,24 @@ async function maybeApplyManifestPolicyGate(
93789378
}
93799379
}
93809380

9381+
/** Logs + audits a deliberate type-label no-op (#regression-safe-propagation): every reason this fires means
9382+
* "labels are left exactly as they are this pass," never "labels were cleared." Shared by every reason the
9383+
* type-label block below skips a pass -- the outer typeLabelsEnabled/gittensor_only gate, a contended
9384+
* per-PR actuation lock, and an inconclusive propagation recheck -- so all of them log/audit identically
9385+
* instead of duplicating the same two calls at each skip site. */
9386+
async function logTypeLabelSkip(env: Env, repoFullName: string, pullNumber: number, reason: string): Promise<void> {
9387+
console.log(
9388+
JSON.stringify({ event: "type_label_decision", repoFullName, pull: pullNumber, applied: false, reason }),
9389+
);
9390+
await recordAuditEvent(env, {
9391+
eventType: "github_app.type_label_decision",
9392+
targetKey: `${repoFullName}#${pullNumber}`,
9393+
outcome: "denied",
9394+
detail: reason,
9395+
metadata: { labels: [], source: null },
9396+
}).catch(() => undefined);
9397+
}
9398+
93819399
async function maybePublishPrPublicSurface(
93829400
env: Env,
93839401
installationId: number,
@@ -9628,121 +9646,134 @@ async function maybePublishPrPublicSurface(
96289646
decision.skipReason !== "miner_detection_unavailable" &&
96299647
decision.skipReason !== "not_official_gittensor_miner"
96309648
) {
9631-
try {
9632-
// Same reasoning as `typeLabelsEnabled` above: `settings.typeLabels` is optional only for
9633-
// RepositorySettings-fixture-construction backward compat -- getRepositorySettings always
9634-
// resolves it to a concrete, complete PrTypeLabelSet (parseTypeLabelSet never returns
9635-
// undefined), so the `?? DEFAULT_TYPE_LABELS` fallback is unreachable on this webhook-
9636-
// integration path.
9637-
/* v8 ignore next -- see the comment above */
9638-
const typeLabels = settings.typeLabels ?? DEFAULT_TYPE_LABELS;
9639-
const propagation = settings.linkedIssueLabelPropagation;
9640-
// Caller-gated (mirrors shouldCollectLinkedIssueEvidence/resolveLinkedIssueHardRule's own
9641-
// cheap-check-before-fetch precedent): zero extra GitHub calls when propagation is off, which
9642-
// is the default -- a repo that never opts in pays nothing for this feature.
9643-
const linkedIssueLabels =
9644-
propagation?.enabled && pr.linkedIssues.length > 0
9645-
? await fetchLinkedIssueLabelsForPropagation({
9649+
// Per-PR mutual exclusion (#regression-safe-propagation, mirrors the agent-maintenance claim at #2129
9650+
// below in maybeRunAgentMaintenance): a merge fans out into a BURST of near-simultaneous webhook
9651+
// deliveries for the SAME PR -- the merge event itself, the linked issue's own auto-close, and even an
9652+
// echo of THIS block's own label writes a moment earlier -- so a webhook re-review and a sweep-driven
9653+
// agent-regate-pr job (or simply two overlapping webhook deliveries) can each reach this block
9654+
// concurrently, each with its own independently-timed live linked-issue fetch. Confirmed in production:
9655+
// a correct propagation_exclusive decision, followed within 30-90s by a second concurrent pass computing
9656+
// a DIFFERENT (wrong) verdict that then overwrote the first. A losing pass must defer to the next tick,
9657+
// never compute-and-act on a stale/racing verdict for a PR another pass is actively deciding for.
9658+
const typeLabelLock = await claimPrActuationLock(env, repoFullName, pr.number);
9659+
if (!typeLabelLock.acquired) {
9660+
await logTypeLabelSkip(env, repoFullName, pr.number, "lock_contended");
9661+
} else {
9662+
try {
9663+
// Same reasoning as `typeLabelsEnabled` above: `settings.typeLabels` is optional only for
9664+
// RepositorySettings-fixture-construction backward compat -- getRepositorySettings always
9665+
// resolves it to a concrete, complete PrTypeLabelSet (parseTypeLabelSet never returns
9666+
// undefined), so the `?? DEFAULT_TYPE_LABELS` fallback is unreachable on this webhook-
9667+
// integration path.
9668+
/* v8 ignore next -- see the comment above */
9669+
const typeLabels = settings.typeLabels ?? DEFAULT_TYPE_LABELS;
9670+
const propagation = settings.linkedIssueLabelPropagation;
9671+
// Caller-gated (mirrors shouldCollectLinkedIssueEvidence/resolveLinkedIssueHardRule's own
9672+
// cheap-check-before-fetch precedent): zero extra GitHub calls when propagation is off, which
9673+
// is the default -- a repo that never opts in pays nothing for this feature.
9674+
const propagationResult =
9675+
propagation?.enabled && pr.linkedIssues.length > 0
9676+
? await fetchLinkedIssueLabelsForPropagation({
9677+
env,
9678+
repoFullName,
9679+
linkedIssues: pr.linkedIssues,
9680+
installationId,
9681+
prAuthorLogin: pr.authorLogin,
9682+
mappings: propagation.mappings,
9683+
// #4528: lets a closed linked issue still count when THIS PR's own merge is what closed it
9684+
// (the standard "Closes #N" auto-close), instead of losing propagation authority the instant
9685+
// the merge that's supposed to earn the label also closes its evidence.
9686+
prMergedAt: pr.mergedAt ?? null,
9687+
})
9688+
: { labels: [], inconclusive: false };
9689+
// #regression-safe-propagation: an INCONCLUSIVE recheck (the linked issue's facts or the
9690+
// maintainer-authored-issue permission check could not be verified this pass -- a transient GitHub
9691+
// fetch/rate-limit failure, never a confirmed "no") must NEVER be treated the same as a confirmed
9692+
// absence of propagation authority. Falling through to the title heuristic here would silently
9693+
// downgrade/remove a real, previously-applied propagation label the moment ANY transient hiccup hits
9694+
// this recheck -- exactly the bug #4528 was meant to close and didn't, because that fix only ever
9695+
// covered the CONFIRMED-closed-by-this-merge case, not an unrelated fetch failure. Leave existing
9696+
// labels untouched and defer; the next tick gets a fresh, hopefully-conclusive read.
9697+
if (propagationResult.labels.length === 0 && propagationResult.inconclusive) {
9698+
await logTypeLabelSkip(env, repoFullName, pr.number, "propagation_inconclusive");
9699+
} else {
9700+
const decisionResult = resolvePrTypeLabel({
9701+
title: pr.title,
9702+
linkedIssueLabels: propagationResult.labels,
9703+
labels: typeLabels,
9704+
propagation,
9705+
});
9706+
for (const label of decisionResult.applyLabels) {
9707+
await ensurePullRequestLabel(
96469708
env,
9709+
installationId,
96479710
repoFullName,
9648-
linkedIssues: pr.linkedIssues,
9711+
pr.number,
9712+
label,
9713+
{ createMissingLabel: true, mode },
9714+
);
9715+
}
9716+
for (const label of decisionResult.removeLabels) {
9717+
await removePullRequestLabel(
9718+
env,
96499719
installationId,
9650-
prAuthorLogin: pr.authorLogin,
9651-
mappings: propagation.mappings,
9652-
// #4528: lets a closed linked issue still count when THIS PR's own merge is what closed it
9653-
// (the standard "Closes #N" auto-close), instead of losing propagation authority the instant
9654-
// the merge that's supposed to earn the label also closes its evidence.
9655-
prMergedAt: pr.mergedAt ?? null,
9656-
})
9657-
: [];
9658-
const decisionResult = resolvePrTypeLabel({
9659-
title: pr.title,
9660-
linkedIssueLabels,
9661-
labels: typeLabels,
9662-
propagation,
9663-
});
9664-
for (const label of decisionResult.applyLabels) {
9665-
await ensurePullRequestLabel(
9666-
env,
9667-
installationId,
9668-
repoFullName,
9669-
pr.number,
9670-
label,
9671-
{ createMissingLabel: true, mode },
9672-
);
9673-
}
9674-
for (const label of decisionResult.removeLabels) {
9675-
await removePullRequestLabel(
9676-
env,
9677-
installationId,
9678-
repoFullName,
9679-
pr.number,
9680-
label,
9681-
mode,
9720+
repoFullName,
9721+
pr.number,
9722+
label,
9723+
mode,
9724+
);
9725+
}
9726+
console.log(
9727+
JSON.stringify({
9728+
event: "type_label_decision",
9729+
repoFullName,
9730+
pull: pr.number,
9731+
applied: true,
9732+
labels: decisionResult.applyLabels,
9733+
source: decisionResult.source,
9734+
}),
9735+
);
9736+
await recordAuditEvent(env, {
9737+
eventType: "github_app.type_label_decision",
9738+
targetKey: `${repoFullName}#${pr.number}`,
9739+
outcome: "completed",
9740+
// `|| "none"` is unreachable: resolvePrTypeLabel's "title" source always resolves a non-empty
9741+
// label (deriveKindFromTitle only ever returns "bug"/"feature", and parseTypeLabelSet always
9742+
// falls back a built-in category to its default rather than an empty string), and its
9743+
// propagation sources only ever use a mapping's `prLabel`, which normalizeMapping drops
9744+
// entirely when empty -- applyLabels can never be [] here.
9745+
/* v8 ignore next */
9746+
detail: `applied labels: ${decisionResult.applyLabels.join(", ") || "none"}`,
9747+
metadata: { labels: decisionResult.applyLabels, source: decisionResult.source },
9748+
}).catch(() => undefined);
9749+
}
9750+
} catch (error) {
9751+
console.log(
9752+
JSON.stringify({
9753+
event: "type_label_error",
9754+
repoFullName,
9755+
pull: pr.number,
9756+
message: errorMessage(error).slice(0, 150),
9757+
}),
96829758
);
9759+
await recordAuditEvent(env, {
9760+
eventType: "github_app.type_label_decision",
9761+
targetKey: `${repoFullName}#${pr.number}`,
9762+
outcome: "error",
9763+
detail: errorMessage(error).slice(0, 150),
9764+
metadata: { labels: [], source: null },
9765+
}).catch(() => undefined);
9766+
} finally {
9767+
await releasePrActuationLock(env, repoFullName, pr.number, typeLabelLock.ownerToken);
96839768
}
9684-
console.log(
9685-
JSON.stringify({
9686-
event: "type_label_decision",
9687-
repoFullName,
9688-
pull: pr.number,
9689-
applied: true,
9690-
labels: decisionResult.applyLabels,
9691-
source: decisionResult.source,
9692-
}),
9693-
);
9694-
await recordAuditEvent(env, {
9695-
eventType: "github_app.type_label_decision",
9696-
targetKey: `${repoFullName}#${pr.number}`,
9697-
outcome: "completed",
9698-
// `|| "none"` is unreachable: resolvePrTypeLabel's "title" source always resolves a non-empty
9699-
// label (deriveKindFromTitle only ever returns "bug"/"feature", and parseTypeLabelSet always
9700-
// falls back a built-in category to its default rather than an empty string), and its
9701-
// propagation sources only ever use a mapping's `prLabel`, which normalizeMapping drops
9702-
// entirely when empty -- applyLabels can never be [] here.
9703-
/* v8 ignore next */
9704-
detail: `applied labels: ${decisionResult.applyLabels.join(", ") || "none"}`,
9705-
metadata: { labels: decisionResult.applyLabels, source: decisionResult.source },
9706-
}).catch(() => undefined);
9707-
} catch (error) {
9708-
console.log(
9709-
JSON.stringify({
9710-
event: "type_label_error",
9711-
repoFullName,
9712-
pull: pr.number,
9713-
message: errorMessage(error).slice(0, 150),
9714-
}),
9715-
);
9716-
await recordAuditEvent(env, {
9717-
eventType: "github_app.type_label_decision",
9718-
targetKey: `${repoFullName}#${pr.number}`,
9719-
outcome: "error",
9720-
detail: errorMessage(error).slice(0, 150),
9721-
metadata: { labels: [], source: null },
9722-
}).catch(() => undefined);
97239769
}
97249770
} else {
97259771
const skipReason = settings.agentPaused
97269772
? "agent_paused"
97279773
: decision.skipReason === "miner_detection_unavailable" || decision.skipReason === "not_official_gittensor_miner"
97289774
? decision.skipReason
97299775
: "typeLabelsEnabled_false";
9730-
console.log(
9731-
JSON.stringify({
9732-
event: "type_label_decision",
9733-
repoFullName,
9734-
pull: pr.number,
9735-
applied: false,
9736-
reason: skipReason,
9737-
}),
9738-
);
9739-
await recordAuditEvent(env, {
9740-
eventType: "github_app.type_label_decision",
9741-
targetKey: `${repoFullName}#${pr.number}`,
9742-
outcome: "denied",
9743-
detail: skipReason,
9744-
metadata: { labels: [], source: null },
9745-
}).catch(() => undefined);
9776+
await logTypeLabelSkip(env, repoFullName, pr.number, skipReason);
97469777
}
97479778

97489779
// Respect the per-repo agent pause: suppress all public surface mutations (label, comment, context

0 commit comments

Comments
 (0)