@@ -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+
93819399async 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