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
48 changes: 44 additions & 4 deletions src/github/app.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Octokit } from "@octokit/core";

Check notice on line 1 in src/github/app.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Check notice on line 1 in src/github/app.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

PR author has maintainer association

This PR appears to come from a maintainer-associated account.

Check notice on line 1 in src/github/app.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Check notice on line 1 in src/github/app.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

PR author has maintainer association

This PR appears to come from a maintainer-associated account.
import type { Advisory, GitHubWebhookPayload } from "../types";
import { signRs256Jwt } from "../utils/crypto";
import { evaluateGateCheck, formatCheckRunOutput, formatGateCheckOutput, type CheckRunAnnotationContext, type CheckRunOutput, type GateCheckConclusion, type GateCheckPolicy } from "../rules/advisory";
Expand Down Expand Up @@ -26,9 +26,20 @@
type GitHubCheckConclusion = Advisory["conclusion"] | GateCheckConclusion | "skipped";
type GitHubCheckStatus = "queued" | "in_progress" | "completed";

/** Hard cap on a single GitHub API request. Without it a slow/half-open GitHub connection can hang the
* Worker — e.g. the Gate's own completing PATCH stalling after the pending check was posted, which leaves
* the check in_progress forever. A bounded timeout turns a hang into a catchable error the caller can
* finalize. Applied to every raw fetch here and to the Octokit instances (via a timeout-injecting fetch). */
const GITHUB_FETCH_TIMEOUT_MS = 12_000;

function timeoutFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
if (init?.signal) return fetch(input, init);
return fetch(input, { ...(init ?? {}), signal: AbortSignal.timeout(GITHUB_FETCH_TIMEOUT_MS) });
}

export async function createInstallationToken(env: Env, installationId: number): Promise<string> {
const jwt = await createAppJwt(env);
const response = await fetch(`https://api.github.com/app/installations/${installationId}/access_tokens`, {
const response = await timeoutFetch(`https://api.github.com/app/installations/${installationId}/access_tokens`, {
method: "POST",
headers: githubHeaders(`Bearer ${jwt}`),
});
Expand All @@ -43,7 +54,7 @@

export async function getAppInstallation(env: Env, installationId: number): Promise<NonNullable<GitHubWebhookPayload["installation"]>> {
const jwt = await createAppJwt(env);
const response = await fetch(`https://api.github.com/app/installations/${installationId}`, {
const response = await timeoutFetch(`https://api.github.com/app/installations/${installationId}`, {
headers: githubHeaders(`Bearer ${jwt}`),
});
if (!response.ok) {
Expand All @@ -66,7 +77,7 @@
const [owner, name] = repoFullName.split("/");
if (!owner || !name || !login) return null;
const token = await createInstallationToken(env, installationId);
const response = await fetch(
const response = await timeoutFetch(
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/collaborators/${encodeURIComponent(login)}/permission`,
{ headers: githubHeaders(`Bearer ${token}`) },
);
Expand Down Expand Up @@ -163,6 +174,33 @@
});
}

/**
* Finalize a previously-posted pending Gate check to a NEUTRAL (non-blocking) terminal state when the
* evaluation could not finish (a transient error/timeout in the work between posting the pending check and
* completing it). This guarantees the "Gittensory Gate is evaluating" run never hangs in_progress forever;
* it does not block the PR and re-runs on the next push. Targets the known pending check_run id so it
* updates the SAME run rather than creating a second one.
*/
export async function createOrUpdateErroredGateCheckRun(
env: Env,
installationId: number,
repoFullName: string,
advisory: Advisory,
options: { checkRunId?: number | undefined } = {},
): Promise<CheckRunOutcome | null> {
return createOrUpdateNamedCheckRun(env, installationId, repoFullName, advisory, {
name: GITTENSORY_GATE_CHECK_NAME,
status: "completed",
conclusion: "neutral",
output: {
title: "Gittensory Gate — could not finish evaluating",
summary: "A transient error interrupted gate evaluation. This does NOT block the PR and re-runs automatically on the next push.",
text: "Gittensory finalizes the Gate to a neutral, non-blocking state when evaluation is interrupted, so the check never hangs in_progress. Push a new commit or use the 'Re-run Gittensory review' checkbox to re-evaluate.",
},
checkRunId: options.checkRunId,
});
}

async function createOrUpdateNamedCheckRun(
env: Env,
installationId: number,
Expand All @@ -181,7 +219,9 @@
if (!owner || !repo) throw new Error(`Invalid repository full name: ${repoFullName}`);

const token = await createInstallationToken(env, installationId);
const octokit = new Octokit({ auth: token });
// Inject a per-request timeout so a stalled GitHub API call (e.g. the Gate's completing PATCH) can never
// hang the Worker and orphan the in_progress check.
const octokit = new Octokit({ auth: token, request: { fetch: timeoutFetch } });

try {
if (check.checkRunId) {
Expand Down
6 changes: 6 additions & 0 deletions src/gittensor/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ContributorRepoStatRecord } from "../types";

Check notice on line 1 in src/gittensor/api.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Check notice on line 1 in src/gittensor/api.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

PR author has maintainer association

This PR appears to come from a maintainer-associated account.

Check notice on line 1 in src/gittensor/api.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Check notice on line 1 in src/gittensor/api.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

PR author has maintainer association

This PR appears to come from a maintainer-associated account.
import { errorMessage } from "../utils/json";

const GITTENSOR_API_BASE = "https://api.gittensor.io";
Expand Down Expand Up @@ -251,12 +251,18 @@
};
}

/** Hard cap on a single Gittensor API request so a slow/half-open upstream connection can never hang the
* Worker indefinitely (it would otherwise stall the webhook between posting and completing the Gate
* check, leaving the check in_progress forever — see the gate-finalization fix). */
const GITTENSOR_FETCH_TIMEOUT_MS = 10_000;

async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url, {
headers: {
accept: "application/json",
"user-agent": "gittensory/0.1",
},
signal: AbortSignal.timeout(GITTENSOR_FETCH_TIMEOUT_MS),
});
if (!response.ok) throw new Error(`Gittensor API failed for ${url} (${response.status})`);
return (await response.json()) as T;
Expand Down
135 changes: 82 additions & 53 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {

Check notice on line 1 in src/queue/processors.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Check notice on line 1 in src/queue/processors.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

PR author has maintainer association

This PR appears to come from a maintainer-associated account.

Check notice on line 1 in src/queue/processors.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Check notice on line 1 in src/queue/processors.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

PR author has maintainer association

This PR appears to come from a maintainer-associated account.
countOpenIssues,
countOpenPullRequests,
getAgentCommandAnswer,
Expand Down Expand Up @@ -61,7 +61,7 @@
refreshInstallationHealth,
} from "../github/backfill";
import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot, fetchOfficialGittensorMiner, type GittensorContributorSnapshot, type OfficialGittensorMinerDetection } from "../gittensor/api";
import { createOrUpdateCheckRun, createOrUpdateGateCheckRun, createOrUpdatePendingGateCheckRun, createOrUpdateSkippedGateCheckRun, getInstallationId, getRepositoryCollaboratorPermission } from "../github/app";
import { createOrUpdateCheckRun, createOrUpdateErroredGateCheckRun, createOrUpdateGateCheckRun, createOrUpdatePendingGateCheckRun, createOrUpdateSkippedGateCheckRun, getInstallationId, getRepositoryCollaboratorPermission } from "../github/app";
import { createOrUpdateAgentCommandComment, createOrUpdatePrIntelligenceComment, PR_PANEL_COMMENT_MARKER } from "../github/comments";
import { gittensoryFooter, gittensorRepoEarnUrl } from "../github/footer";
import {
Expand Down Expand Up @@ -944,62 +944,91 @@
}
}

const [repoIssues, repoPullRequests, repoBounties] = await Promise.all([
listIssues(env, repoFullName),
listPullRequests(env, repoFullName),
listBountiesByRepo(env, repoFullName),
]);
const collisions = buildCollisionReport(repoFullName, repoIssues, repoPullRequests);
const queueHealth = buildQueueHealth(repo, repoIssues, repoPullRequests, collisions);
const preflight = buildPreflightResult(
{
repoFullName,
contributorLogin: author ?? undefined,
title: pr.title,
body: pr.body ?? undefined,
labels: pr.labels,
linkedIssues: pr.linkedIssues,
authorAssociation: pr.authorAssociation ?? undefined,
},
repo,
repoIssues,
repoPullRequests,
repoBounties,
);
const readiness = buildPublicReadinessScore({
pr,
preflight,
queueHealth,
linkedDuplicatePrs: linkedIssueDuplicatePullRequestsForGate(pr, repoPullRequests),
scopedOverlapCount: unionScopedOverlapClusters(collisions, pr, preflight.collisions).length,
});

if (gateEnabled && author && !publicSurfaceSkipped && !official) {
official = await getCachedOfficialMinerDetection(env, author, {
targetKey: `${repoFullName}#${pr.number}`,
deliveryId: webhook.deliveryId,
});
}

// Only CONFIRMED gittensor contributors can be hard-blocked; everyone else (or an unavailable
// detection) gets a neutral, non-blocking gate. Gate-only runs still verify confirmation before
// evaluating blockers so confirmed contributors cannot bypass a required Gate check.
const confirmedContributor = official?.status === "confirmed";
const gateEvaluation = gateEnabled ? evaluateGateCheck(advisory, gateCheckPolicy(settings, readiness.total, confirmedContributor)) : undefined;
if (gateEnabled) {
const gateCheckResult = await createOrUpdateGateCheckRun(
env,
installationId,
repoFullName,
advisory,
gateCheckPolicy(settings, readiness.total, confirmedContributor),
// The pending Gate check is now posted (status in_progress). Everything from here until the gate is
// completed runs inside a try so that ANY failure/timeout (a slow Gittensor or GitHub call, a D1 error)
// still finalizes the check to a neutral, non-blocking state instead of orphaning it in_progress forever
// (the cause of the multi-hour stuck Gate). External calls in this window are bounded by request timeouts
// (GitHub App + Gittensor API), so a hang becomes a catchable error here.
let collisions!: ReturnType<typeof buildCollisionReport>;
let queueHealth!: ReturnType<typeof buildQueueHealth>;
let preflight!: ReturnType<typeof buildPreflightResult>;
let gateEvaluation: ReturnType<typeof evaluateGateCheck> | undefined;
let gateFinalized = false;
try {
const [repoIssues, repoPullRequests, repoBounties] = await Promise.all([
listIssues(env, repoFullName),
listPullRequests(env, repoFullName),
listBountiesByRepo(env, repoFullName),
]);
collisions = buildCollisionReport(repoFullName, repoIssues, repoPullRequests);
queueHealth = buildQueueHealth(repo, repoIssues, repoPullRequests, collisions);
preflight = buildPreflightResult(
{
checkRunId: pendingGateCheckRunId,
repoFullName,
contributorLogin: author ?? undefined,
title: pr.title,
body: pr.body ?? undefined,
labels: pr.labels,
linkedIssues: pr.linkedIssues,
authorAssociation: pr.authorAssociation ?? undefined,
},
repo,
repoIssues,
repoPullRequests,
repoBounties,
);
if (gateCheckResult?.kind === "permission_missing") {
await auditGateCheckPermissionMissing(env, author, repoFullName, pr.number, webhook.deliveryId, gateCheckResult.warning);
const readiness = buildPublicReadinessScore({
pr,
preflight,
queueHealth,
linkedDuplicatePrs: linkedIssueDuplicatePullRequestsForGate(pr, repoPullRequests),
scopedOverlapCount: unionScopedOverlapClusters(collisions, pr, preflight.collisions).length,
});

if (gateEnabled && author && !publicSurfaceSkipped && !official) {
official = await getCachedOfficialMinerDetection(env, author, {
targetKey: `${repoFullName}#${pr.number}`,
deliveryId: webhook.deliveryId,
});
}

// Only CONFIRMED gittensor contributors can be hard-blocked; everyone else (or an unavailable
// detection) gets a neutral, non-blocking gate. Gate-only runs still verify confirmation before
// evaluating blockers so confirmed contributors cannot bypass a required Gate check.
const confirmedContributor = official?.status === "confirmed";
gateEvaluation = gateEnabled ? evaluateGateCheck(advisory, gateCheckPolicy(settings, readiness.total, confirmedContributor)) : undefined;
if (gateEnabled) {
const gateCheckResult = await createOrUpdateGateCheckRun(
env,
installationId,
repoFullName,
advisory,
gateCheckPolicy(settings, readiness.total, confirmedContributor),
{
checkRunId: pendingGateCheckRunId,
},
);
if (gateCheckResult?.kind === "published") gateFinalized = true;
if (gateCheckResult?.kind === "permission_missing") {
await auditGateCheckPermissionMissing(env, author, repoFullName, pr.number, webhook.deliveryId, gateCheckResult.warning);
}
}
} catch (error) {
// The pending Gate check was posted but evaluation could not finish. Finalize it to a neutral
// (non-blocking) terminal state so it never hangs in_progress; it re-runs on the next push. Only when
// the gate was enabled, a pending check id exists, and a real conclusion was not already published.
if (gateEnabled && pendingGateCheckRunId !== undefined && !gateFinalized) {
await createOrUpdateErroredGateCheckRun(env, installationId, repoFullName, advisory, { checkRunId: pendingGateCheckRunId }).catch(() => undefined);
await recordAuditEvent(env, {
eventType: "github_app.gate_finalized_on_error",
actor: author,
targetKey: `${repoFullName}#${pr.number}`,
outcome: "error",
detail: errorMessage(error),
metadata: { deliveryId: webhook.deliveryId, repoFullName },
}).catch(() => undefined);
}
throw error;
}

if (!prelimHasPublicOutput) return;
Expand Down
63 changes: 63 additions & 0 deletions test/unit/queue.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

Check notice on line 1 in test/unit/queue.test.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Check notice on line 1 in test/unit/queue.test.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

PR author has maintainer association

This PR appears to come from a maintainer-associated account.

Check notice on line 1 in test/unit/queue.test.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Check notice on line 1 in test/unit/queue.test.ts

View check run for this annotation

Deleted GitHub App / Gittensory Context

PR author has maintainer association

This PR appears to come from a maintainer-associated account.
import {
listCollisionEdges,
createAgentRun,
Expand Down Expand Up @@ -968,6 +968,69 @@
expect(gatePatchBody.output?.title).toBe("Gittensory Gate: No linked issue detected");
});

it("finalizes the Gate to neutral instead of leaving it in_progress when gate completion fails", async () => {
const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() });
await persistRegistrySnapshot(
env,
normalizeRegistryPayload(
{ "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } },
{ kind: "raw-github", url: "https://example.test" },
"2026-05-23T00:00:00.000Z",
),
);
await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123);
await upsertRepositorySettings(env, {
repoFullName: "JSONbored/gittensory",
commentMode: "off",
publicSurface: "off",
autoLabelEnabled: false,
checkRunMode: "off",
gateCheckMode: "enabled",
linkedIssueGateMode: "off",
});
const patchBodies: Array<{ status?: string; conclusion?: string; output?: { title?: string } }> = [];
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
const method = init?.method ?? "GET";
if (url === "https://api.gittensor.io/miners") return Response.json([]);
if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" });
if (url.includes("/commits/finalize123/check-runs")) return Response.json({ total_count: 0, check_runs: [] });
if (url.includes("/check-runs") && method === "POST") return Response.json({ id: 970 }, { status: 201 }); // pending
if (url.includes("/check-runs/970") && method === "PATCH") {
const body = JSON.parse(String(init?.body ?? "{}")) as { status?: string; conclusion?: string; output?: { title?: string } };
patchBodies.push(body);
// First PATCH = the gate completion; fail it transiently so the catch must finalize the check.
if (patchBodies.length === 1) return new Response(JSON.stringify({ message: "server error" }), { status: 500 });
return Response.json({ id: 970 });
}
return new Response("not found", { status: 404 });
});

await processJob(env, {
type: "github-webhook",
deliveryId: "gate-finalize-on-error",
eventName: "pull_request",
payload: {
action: "opened",
installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } },
repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } },
pull_request: { number: 80, title: "Some change", state: "open", user: { login: "contributor" }, head: { sha: "finalize123" }, labels: [], body: "No issue link." },
},
});

// The completion PATCH failed (500), so the catch finalized the SAME check run (id 970) to a neutral,
// non-blocking terminal state — never left hanging in_progress.
expect(patchBodies.length).toBe(2);
const finalize = patchBodies[1];
expect(finalize?.status).toBe("completed");
expect(finalize?.conclusion).toBe("neutral");
expect(finalize?.output?.title).toBe("Gittensory Gate — could not finish evaluating");
const audit = await env.DB.prepare("select outcome from audit_events where event_type = ? and target_key = ?")
.bind("github_app.gate_finalized_on_error", "JSONbored/gittensory#80")
.first<{ outcome: string }>();
expect(audit?.outcome).toBe("error");
});

it("disables the gate from .gittensory.yml (gate.enabled: false) even when repo settings enable it", async () => {
const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() });
await persistRegistrySnapshot(
Expand Down
5 changes: 5 additions & 0 deletions wrangler.jsonc
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{

Check notice on line 1 in wrangler.jsonc

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Check notice on line 1 in wrangler.jsonc

View check run for this annotation

Deleted GitHub App / Gittensory Context

PR author has maintainer association

This PR appears to come from a maintainer-associated account.

Check notice on line 1 in wrangler.jsonc

View check run for this annotation

Deleted GitHub App / Gittensory Context

Issue discovery is disabled for this repo

This repo is configured for direct contribution review rather than issue-discovery flow.

Check notice on line 1 in wrangler.jsonc

View check run for this annotation

Deleted GitHub App / Gittensory Context

PR author has maintainer association

This PR appears to come from a maintainer-associated account.
"$schema": "node_modules/wrangler/config-schema.json",
// Auto-deploys to production via Cloudflare Workers Builds on push to `main`
// (deploy command: `npm run deploy:api` — applies pending D1 migrations, then `wrangler deploy`).
Expand Down Expand Up @@ -84,6 +84,11 @@
"queue": "gittensory-jobs",
"max_batch_size": 10,
"max_batch_timeout": 5,
// After max_retries failed attempts a job is dead-lettered instead of silently dropped, so a
// persistently-failing webhook is observable (inspect via `wrangler queues` / the dashboard)
// rather than vanishing. The DLQ is a landing pad with no consumer (house pattern).
"max_retries": 3,
"dead_letter_queue": "gittensory-jobs-dlq",
},
],
},
Expand Down