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
1 change: 1 addition & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export {
computeLaneFit,
type GoalModelInput,
} from "./goal-model.js";
export {
classifyContributorFit,
type ContributorFit,
type ContributorFitCheck,
Expand Down
4 changes: 2 additions & 2 deletions packages/gittensory-engine/src/opportunity-freshness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ function pickTimestamp(issue: FreshnessIssue): string | null {
}

function issueAgeDays(value: string | null, nowMs: number): number {
if (!value) return 0;
if (!value) return Number.POSITIVE_INFINITY;
const parsed = Date.parse(value);
if (!Number.isFinite(parsed)) return 0;
if (!Number.isFinite(parsed)) return Number.POSITIVE_INFINITY;
return Math.floor((nowMs - parsed) / 86_400_000);
}

Expand Down
4 changes: 3 additions & 1 deletion src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2983,9 +2983,11 @@ export function createApp() {
const auth = c.req.header("authorization") ?? "";
const secret = auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
if (!secret) return c.json({ error: "missing_enrollment_secret" }, 401);
const body = await c.req.json().catch(() => null);
const forceRefresh = typeof body === "object" && body !== null && (body as { forceRefresh?: unknown }).forceRefresh === true;
let result: Awaited<ReturnType<typeof brokerOrbToken>>;
try {
result = await brokerOrbToken(c.env, secret);
result = await brokerOrbToken(c.env, secret, { forceRefresh });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(JSON.stringify({ level: "error", event: "orb_broker_mint_failed", message: message.slice(0, 200) }));
Expand Down
6 changes: 6 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,12 @@ export async function getInstallation(env: Env, installationId: number): Promise
return row ? toInstallationRecord(row) : null;
}

export async function updateInstallationPermissions(env: Env, installationId: number, permissions: Record<string, string>): Promise<void> {
if (Object.keys(permissions).length === 0) return;
const db = getDb(env.DB);
await db.update(installations).set({ permissionsJson: jsonString(permissions), updatedAt: nowIso() }).where(eq(installations.id, installationId));
}

export async function listInstallations(env: Env): Promise<InstallationRecord[]> {
const db = getDb(env.DB);
const rows = await db.select().from(installations).orderBy(desc(installations.updatedAt)).limit(100);
Expand Down
32 changes: 27 additions & 5 deletions src/github/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
fetchBrokeredInstallationToken,
isOrbBrokerMode,
} from "../orb/broker-client";
import { updateInstallationPermissions } from "../db/repositories";
import {
clearGitHubResponseCacheForTest,
githubRateLimitAdmissionKeyForInstallation,
Expand Down Expand Up @@ -131,10 +132,12 @@ const inFlightMints = new Map<number, Promise<string>>();
export async function createInstallationToken(
env: Env,
installationId: number,
options: { forceRefresh?: boolean } = {},
): Promise<string> {
const cached = await readCachedToken(installationId);
if (cached && cached.expiresAtMs - TOKEN_SAFETY_MARGIN_MS > Date.now())
if (!options.forceRefresh && cached && cached.expiresAtMs - TOKEN_SAFETY_MARGIN_MS > Date.now())
return cached.token;
if (options.forceRefresh) return mintInstallationToken(env, installationId, cached, true);
const existing = inFlightMints.get(installationId);
if (existing) return existing; // a concurrent caller is already minting for this install — join it
const mint = mintInstallationToken(env, installationId, cached).finally(() => {
Expand All @@ -157,6 +160,10 @@ export function isGitHubBadCredentialsError(error: unknown): boolean {
return status === 401 || /bad credentials/i.test(errorMessage(error));
}

function isGitHubInstallationPermissionError(error: unknown): boolean {
return githubErrorStatus(error) === 403 && /resource not accessible by integration|not have permission/i.test(errorMessage(error));
}

async function expireCachedInstallationToken(
installationId: number,
rejectedToken: string,
Expand All @@ -175,7 +182,8 @@ export async function withInstallationTokenRetry<T>(
try {
return await operation(token);
} catch (error) {
if (!isGitHubBadCredentialsError(error)) throw error;
const refreshForPermission = isGitHubInstallationPermissionError(error);
if (!isGitHubBadCredentialsError(error) && !refreshForPermission) throw error;
await expireCachedInstallationToken(installationId, token).catch(
() => undefined,
);
Expand All @@ -185,10 +193,11 @@ export async function withInstallationTokenRetry<T>(
event: "github_installation_token_rejected",
installationId,
status: githubErrorStatus(error),
reason: refreshForPermission ? "permission_scope" : "bad_credentials",
message: errorMessage(error).slice(0, 200),
}),
);
const freshToken = await createInstallationToken(env, installationId);
const freshToken = await createInstallationToken(env, installationId, { forceRefresh: refreshForPermission });
return await operation(freshToken);
}
}
Expand All @@ -215,26 +224,39 @@ async function mintInstallationToken(
env: Env,
installationId: number,
cached: { token: string; expiresAtMs: number } | null,
forceRefresh = false,
): Promise<string> {
// Self-host broker mode: a brokered self-host holds no App private key, so source the installation token from
// the central Orb (enrollment secret → short-lived token) instead of minting locally. Cloud sets no enrollment
// secret, so this branch is inert there → byte-identical. The token caches the same way (the install id is the
// self-host's single bound install). See src/orb/broker-client.
if (isOrbBrokerMode(env)) {
try {
const brokered = await fetchBrokeredInstallationToken(env);
const brokered = await fetchBrokeredInstallationToken(env, fetch, { forceRefresh });
await writeCachedToken(installationId, {
token: brokered.token,
expiresAtMs: brokered.expiresAtMs,
});
if (brokered.installationId === installationId && Object.keys(brokered.permissions).length > 0) {
await updateInstallationPermissions(env, installationId, brokered.permissions).catch((error) => {
console.warn(
JSON.stringify({
level: "warn",
event: "github_installation_permissions_update_failed",
installationId,
message: errorMessage(error).slice(0, 200),
}),
);
});
}
return brokered.token;
} catch (error) {
// Stale-token grace (#2): a brokered self-host holds no App key, so without this a single Orb mint failure
// fails the review (→ retry/DLQ) and an Orb blip during the re-mint window stalls the fleet. If the cached
// token is STILL within its real expiry, serve it — a valid token beats a stalled review (NO dangerous reuse:
// an actually-expired token is never served). Otherwise emit an alertable structured log and rethrow so the
// queue's retry/DLQ handles a genuine outage.
if (cached && cached.expiresAtMs > Date.now()) {
if (!forceRefresh && cached && cached.expiresAtMs > Date.now()) {
console.warn(
JSON.stringify({
level: "warn",
Expand Down
Loading
Loading