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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ REDIS_URL=redis://redis:6379 # REQUIRED for the self-host review
# GITHUB_CACHE_TTL_SECONDS=20 # Short default Redis TTL for safe GitHub GET response caching. Set 0 to disable.
# GITHUB_BRANCH_PROTECTION_CACHE_TTL_SECONDS=1200 # TTL for required-status branch protection reads.
# GITHUB_METADATA_CACHE_TTL_SECONDS=600 # TTL for stable repo/user/installation metadata reads.
# GITHUB_COMMIT_CACHE_TTL_SECONDS=900 # TTL for bare /commits/{ref} resolves; dedups the two hourly upstream ref→SHA reads.
# QDRANT_URL= # set to http://qdrant:6333 to use Qdrant as the RAG vector store
# # (--profile qdrant). Overrides the built-in sqlite-vec / pgvector.
# DISCORD_WEBHOOK_URL= # one Discord channel for per-action notifications (merged/closed/
Expand Down
34 changes: 33 additions & 1 deletion src/github/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ const GITHUB_REST_RATE_LIMIT_OBSERVATION_METRIC = "gittensory_github_rest_rate_l
const GITHUB_REST_RATE_LIMIT_RESPONSE_METRIC = "gittensory_github_rest_rate_limit_responses_total";
const DEFAULT_BRANCH_PROTECTION_TTL_SECONDS = 20 * 60;
const DEFAULT_METADATA_TTL_SECONDS = 10 * 60;
// A bare `/commits/{ref}` read resolves a ref to its HEAD commit — mutable (a branch moves), so cache it only
// briefly. Long enough to dedup the two upstream ref→SHA resolves that fire in the SAME hourly window (scoring +
// drift), short enough that the pinned SHA is never meaningfully stale.
const DEFAULT_COMMIT_TTL_SECONDS = 15 * 60;
export const GITHUB_RESPONSE_CACHE_REPLAY_HEADER = "x-gittensory-cache";

/** A shared cache for safe GitHub GET responses (e.g. Redis on the self-host). Stores only status/body/
Expand All @@ -45,13 +49,18 @@ export function setGitHubResponseCache(cache: GitHubResponseCache | null): void
responseCache = cache;
}

export type GitHubCacheClass = "branch_protection" | "metadata";
export type GitHubCacheClass = "branch_protection" | "metadata" | "commit";
type EnvLookup = Record<string, string | undefined>;
export type GitHubTimeoutFetchInit = RequestInit & {
/** Opt in to using this response's REST bucket headers for self-host queue admission control. */
githubRateLimitAdmission?: boolean;
/** Stable actor key for admission control. Installation-token reads should use the installation id. */
githubRateLimitAdmissionKey?: string;
/** Consulted ONLY when this GET is about to make a NETWORK read (a cache hit is always served first, for free).
* Return true to skip the network read — timeoutFetch then resolves to a synthetic 503 so a best-effort caller
* can fall back without spending a REST request. Lets a budget-gate suppress fresh reads while still serving
* free cache hits under pressure. */
githubSkipNetworkWhen?: () => boolean | Promise<boolean>;
};
export type GitHubRateLimitAdmissionKey = string;
export type LocalGitHubRestRateLimitObservation = {
Expand Down Expand Up @@ -99,6 +108,9 @@ function githubCacheClassForUrl(url: string): GitHubCacheClass | null {
if (!url.startsWith(`${GITHUB_API_PREFIX}/`)) return null;
const path = githubApiPath(url);
if (/^\/repos\/[^/]+\/[^/]+\/branches\/[^/]+\/protection\/required_status_checks(?:$|[?#])/.test(path)) return "branch_protection";
// A BARE `/repos/{o}/{r}/commits/{ref}` read (no `/check-runs`, `/status`, `/pulls`, … suffix) resolves a ref to
// its HEAD commit. Only the two upstream ref→SHA resolves use this shape; caching it briefly dedups them.
if (/^\/repos\/[^/]+\/[^/]+\/commits\/[^/?#]+(?:$|[?#])/.test(path)) return "commit";
if (
(/^\/users\/[^/?#]+(?:$|[?#])/.test(path) ||
/^\/repos\/[^/?#]+\/[^/?#]+(?:$|[?#])/.test(path) ||
Expand All @@ -122,6 +134,9 @@ export function githubResponseCacheTtlSeconds(cls: GitHubCacheClass, env: EnvLoo
if (cls === "branch_protection") {
return positiveEnvSeconds(env, "GITHUB_BRANCH_PROTECTION_CACHE_TTL_SECONDS", DEFAULT_BRANCH_PROTECTION_TTL_SECONDS);
}
if (cls === "commit") {
return positiveEnvSeconds(env, "GITHUB_COMMIT_CACHE_TTL_SECONDS", DEFAULT_COMMIT_TTL_SECONDS);
}
return positiveEnvSeconds(env, "GITHUB_METADATA_CACHE_TTL_SECONDS", DEFAULT_METADATA_TTL_SECONDS);
}

Expand Down Expand Up @@ -444,6 +459,17 @@ function waitForVolatileReplay(shared: Promise<CachedGitHubResponse | null>, sig

// A 12s hard cap on every GitHub request. Centralised here so the app token/installation raw fetches plus comment /
// label / check-run / pr-action Octokit helpers all inherit the cache boundary, retry, and timeout behavior.
/** A caller's githubSkipNetworkWhen opts a best-effort read out of the NETWORK (never out of the cache). */
async function shouldSkipGitHubNetworkRead(init?: GitHubTimeoutFetchInit): Promise<boolean> {
return init?.githubSkipNetworkWhen ? Boolean(await init.githubSkipNetworkWhen()) : false;
}

// A synthetic non-OK returned when githubSkipNetworkWhen suppresses a network read (e.g. a budget-gated best-effort
// resolve). The caller detects !response.ok and falls back without spending a REST request.
function githubNetworkSkippedResponse(): Response {
return new Response(null, { status: 503, headers: { [GITHUB_RESPONSE_CACHE_REPLAY_HEADER]: "network-skip" } });
}

export async function timeoutFetch(input: RequestInfo | URL, init?: GitHubTimeoutFetchInit): Promise<Response> {
const method = requestMethod(input, init);
const url = requestUrl(input);
Expand All @@ -455,6 +481,8 @@ export async function timeoutFetch(input: RequestInfo | URL, init?: GitHubTimeou
}
const useCache = responseCache !== null && cls !== null;
if (!useCache) {
// No cache to hit → this IS a network read, so honor a caller's budget-gate before spending the request.
if (await shouldSkipGitHubNetworkRead(init)) return githubNetworkSkippedResponse();
recordGitHubCacheMetric("bypassed", cacheBypassClass(method, url, headers));
return fetchWithGitHubRetry(input, init);
}
Expand All @@ -479,6 +507,10 @@ export async function timeoutFetch(input: RequestInfo | URL, init?: GitHubTimeou
if (replay) return responseFromCached(replay, "coalesced");
}

// Cache MISS with no in-flight fetch to coalesce onto → a fresh network read. Honor a caller's budget-gate here
// (AFTER the cache-hit + coalesce checks, so a free cached/in-flight result is never suppressed by budget pressure).
if (await shouldSkipGitHubNetworkRead(init)) return githubNetworkSkippedResponse();

const request = fetchAndMaybeCacheGitHubGet(input, init, url, cacheKey, cls).then(
(result) => ({ ok: true as const, result }),
(error: unknown) => ({ ok: false as const, error }),
Expand Down
16 changes: 2 additions & 14 deletions src/scoring/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
} from "../db/repositories";
import { timeoutFetch } from "../github/client";
import { getLatestRegistrySnapshot } from "../registry/sync";
import { resolveUpstreamCommitSha } from "../upstream/commit";
import { syncUnmodeledScoringConstantDrift } from "../upstream/unmodeled-scoring-drift";
import type { JsonValue, ScoringModelSnapshotRecord } from "../types";
import { errorMessage, nowIso } from "../utils/json";
Expand Down Expand Up @@ -77,19 +78,6 @@ function upstreamRawUrl(config: { repo: string; ref: string }, path: string): st
return `https://raw.githubusercontent.com/${config.repo}/${encodeURIComponent(config.ref)}/${path}`;
}

// Fetch the HEAD commit SHA of the upstream ref for audit trail. Fail-open: a network hiccup or a
// missing administration token must never block the constants refresh itself.
async function fetchUpstreamRefSha(upstream: { repo: string; ref: string }, token: string | undefined): Promise<string | null> {
try {
const response = await timeoutFetch(`https://api.github.com/repos/${upstream.repo}/commits/${encodeURIComponent(upstream.ref)}`, { headers: githubHeaders(token, "application/vnd.github+json") });
if (!response.ok) return null;
const data = (await response.json()) as { sha?: string };
return typeof data.sha === "string" && data.sha.length > 0 ? data.sha : null;
} catch {
return null;
}
}

// Single source of truth (#812): every recognized upstream constant name is a key of
// DEFAULT_SCORING_CONSTANTS, so the known-only parser, the unmodeled-drift detector, and the preview-side
// fallbacks all derive from one place. The density-era constants are included because the density model is
Expand All @@ -109,7 +97,7 @@ export async function refreshScoringModelSnapshot(env: Env): Promise<ScoringMode
// change what every repo scores against: resolve ref → SHA first, then fetch the constants AT that SHA (an
// atomic SHA↔constants binding, recorded in the payload). Best-effort — if the SHA can't be resolved (a
// transient API error) fall back to the mutable ref so a refresh is never blocked purely on the SHA lookup.
const upstreamSourceSha = await fetchUpstreamRefSha(upstream, env.GITHUB_PUBLIC_TOKEN);
const upstreamSourceSha = await resolveUpstreamCommitSha(env, upstream);
const fetchRef = upstreamSourceSha ?? upstream.ref;
// Surface the unpinned fall-back: when the SHA can't be resolved we fetch from the MUTABLE ref, so a later
// upstream force-push could change what every repo scores against with no other signal. (#audit-3.6/drift)
Expand Down
49 changes: 49 additions & 0 deletions src/upstream/commit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { timeoutFetch } from "../github/client";
import { shouldWaitForGitHubRateLimit } from "../github/rate-limit";

function upstreamCommitHeaders(token: string | undefined): Record<string, string> {
return {
accept: "application/vnd.github+json",
"user-agent": "gittensory/0.1",
"x-github-api-version": "2022-11-28",
...(token ? { authorization: `Bearer ${token}` } : {}),
};
}

/**
* Resolve an upstream ref (branch/tag) to its immutable HEAD commit SHA — the pin recorded by BOTH the
* scoring-model refresh (`refreshScoringModelSnapshot`) and the upstream-drift refresh. It was fetched twice per
* hour (one `GET /repos/{repo}/commits/{ref}` in each of those hourly jobs). This is the single shared resolver so:
*
* - **Dedup:** both jobs issue the identical bare `/commits/{ref}` read through `timeoutFetch`, which the
* self-host GitHub response cache serves from its short-TTL `commit` class — so within a window the ref
* resolves ONCE (a cache hit for the second job) instead of twice.
* - **Budget-gate:** it yields (returns null) when the shared REST budget is at/below the low-water floor, so
* this best-effort audit resolve never spends a scarce request during a rate-limit crunch (the same hourly
* window where the heavy maintenance fan-out runs).
*
* Fail-open: a rate-limit yield, a network/parse error, a non-OK status, or a missing SHA all return null — every
* caller already treats null as "fall back to the mutable ref", so a resolve failure never blocks the refresh.
*/
export async function resolveUpstreamCommitSha(
env: Env,
config: { repo: string; ref: string },
): Promise<string | null> {
try {
const response = await timeoutFetch(
`https://api.github.com/repos/${config.repo}/commits/${encodeURIComponent(config.ref)}`,
{
headers: upstreamCommitHeaders(env.GITHUB_PUBLIC_TOKEN),
// Budget-gate the NETWORK read only: a cached resolve is still served for free even under pressure; a fresh
// read is skipped (→ synthetic non-OK → null → caller falls back to the mutable ref) when the REST budget is
// at/below the low-water floor. This callback runs ONLY on a cache miss, so it never suppresses a cache hit.
githubSkipNetworkWhen: () => shouldWaitForGitHubRateLimit(env).then(Boolean),
},
);
if (!response.ok) return null;
const data = (await response.json()) as { sha?: string };
return typeof data.sha === "string" && data.sha.length > 0 ? data.sha : null;
} catch {
return null;
}
}
15 changes: 2 additions & 13 deletions src/upstream/ruleset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
upsertUpstreamDriftReport,
} from "../db/repositories";
import { timeoutFetch } from "../github/client";
import { resolveUpstreamCommitSha } from "./commit";
import { isGlobalAgentPause } from "../settings/agent-execution";
import { normalizeRegistryPayload } from "../registry/normalize";
import { DEFAULT_GITTENSOR_UPSTREAM_REF, DEFAULT_GITTENSOR_UPSTREAM_REPO, detectActiveModel, findUnmodeledConstantKeys, parsePythonNumberConstants } from "../scoring/model";
Expand Down Expand Up @@ -104,7 +105,7 @@ export type UpstreamStatus = {
export async function refreshUpstreamSourceSnapshots(env: Env): Promise<UpstreamSourceSnapshotRecord[]> {
const config = upstreamConfig(env);
const fetchedAt = nowIso();
const [previousByKey, commitSha] = await Promise.all([latestSourcesByKey(env), fetchUpstreamCommitSha(env, config)]);
const [previousByKey, commitSha] = await Promise.all([latestSourcesByKey(env), resolveUpstreamCommitSha(env, config)]);
const snapshots = await Promise.all(
TRACKED_SOURCES.map((source) => fetchTrackedSource(env, config, source, fetchedAt, commitSha, previousByKey.get(source.key))),
);
Expand Down Expand Up @@ -412,18 +413,6 @@ async function latestSourcesByKey(env: Env): Promise<Map<string, UpstreamSourceS
return new Map((await listLatestUpstreamSourceSnapshotsByKey(env)).map((source) => [source.sourceKey, source]));
}

async function fetchUpstreamCommitSha(env: Env, config: { repo: string; ref: string }): Promise<string | null> {
const url = `https://api.github.com/repos/${config.repo}/commits/${encodeURIComponent(config.ref)}`;
try {
const response = await timeoutFetch(url, { headers: githubHeaders(env.GITHUB_PUBLIC_TOKEN, "application/vnd.github+json") });
if (!response.ok) return null;
const payload = (await response.json()) as { sha?: string };
return payload.sha ?? null;
} catch {
return null;
}
}

async function fetchTrackedSource(
env: Env,
config: { repo: string; ref: string },
Expand Down
12 changes: 12 additions & 0 deletions test/unit/github-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,15 +585,27 @@ describe("timeoutFetch", () => {
it("resolves per-class cache TTL env overrides with safe fallbacks", () => {
expect(githubResponseCacheTtlSeconds("branch_protection", {})).toBe(20 * 60);
expect(githubResponseCacheTtlSeconds("metadata", {})).toBe(10 * 60);
expect(githubResponseCacheTtlSeconds("commit", {})).toBe(15 * 60);
expect(githubResponseCacheTtlSeconds("branch_protection", { GITHUB_BRANCH_PROTECTION_CACHE_TTL_SECONDS: "3600" })).toBe(3600);
expect(githubResponseCacheTtlSeconds("metadata", { GITHUB_METADATA_CACHE_TTL_SECONDS: "90.8" })).toBe(90);
expect(githubResponseCacheTtlSeconds("commit", { GITHUB_COMMIT_CACHE_TTL_SECONDS: "300" })).toBe(300);
expect(githubResponseCacheTtlSeconds("commit", { GITHUB_COMMIT_CACHE_TTL_SECONDS: "0" })).toBe(15 * 60);
expect(githubResponseCacheTtlSeconds("branch_protection", { GITHUB_BRANCH_PROTECTION_CACHE_TTL_SECONDS: "" })).toBe(20 * 60);
expect(githubResponseCacheTtlSeconds("metadata", { GITHUB_METADATA_CACHE_TTL_SECONDS: "0" })).toBe(10 * 60);
expect(githubResponseCacheTtlSeconds("metadata", { GITHUB_METADATA_CACHE_TTL_SECONDS: "0.5" })).toBe(10 * 60);
expect(githubResponseCacheTtlSeconds("metadata", { GITHUB_METADATA_CACHE_TTL_SECONDS: "not-a-number" })).toBe(10 * 60);
expect(githubResponseCacheTtlSeconds("metadata", { GITHUB_METADATA_CACHE_TTL_SECONDS: "Infinity" })).toBe(10 * 60);
});

it("caches a BARE /commits/{ref} resolve (the upstream ref→SHA read) but NOT its suffixed CI subresources", () => {
expect(isCacheableGithubUrl("https://api.github.com/repos/entrius/gittensor/commits/main")).toBe(true);
expect(isCacheableGithubUrl("https://api.github.com/repos/o/r/commits/abc123?foo=1")).toBe(true);
// The mutable CI/pulls subresources under /commits/{sha} must still always hit the live API.
expect(isCacheableGithubUrl("https://api.github.com/repos/o/r/commits/abc/status")).toBe(false);
expect(isCacheableGithubUrl("https://api.github.com/repos/o/r/commits/abc/check-runs?per_page=100")).toBe(false);
expect(isCacheableGithubUrl("https://api.github.com/repos/o/r/commits/abc/pulls")).toBe(false);
});

it("uses configured TTL overrides for stable GitHub metadata and branch-protection reads", async () => {
vi.stubEnv("GITHUB_BRANCH_PROTECTION_CACHE_TTL_SECONDS", "3600");
vi.stubEnv("GITHUB_METADATA_CACHE_TTL_SECONDS", "900");
Expand Down
Loading
Loading