diff --git a/src/github/repo-doc-refresh-runner.ts b/src/github/repo-doc-refresh-runner.ts new file mode 100644 index 0000000000..251e1cd1f6 --- /dev/null +++ b/src/github/repo-doc-refresh-runner.ts @@ -0,0 +1,50 @@ +// Shared "refresh one repo's docs" runner (#3003, part of the repo-doc generation roadmap #2993) -- used by +// BOTH the scheduled sweep (src/queue/processors.ts) and the on-demand MCP trigger (src/mcp/server.ts), so +// there is exactly ONE code path deciding mode/eligibility/diffing (all of which already live inside +// openRepoDocPullRequest itself, per #3000/#3002/#3004/#3001) rather than two diverging ones. +// +// This module also owns the "last attempted at" marker the scheduled sweep uses to rate-limit re-checks +// (src/review/repo-doc-refresh-schedule.ts's isRepoDocRefreshDue), reusing the EXISTING generic signal-snapshot +// table (persistSignalSnapshot/listSignalSnapshots) rather than a new migration -- there is no DB column for +// this, matching #3002's own "manifest-only, no DB layer" precedent for this whole feature. The marker is +// recorded here (not in the sweep itself) so a MANUAL trigger also resets that clock, keeping the sweep from +// immediately re-checking a repo an operator just refreshed by hand. +import { getRepositorySettings, listSignalSnapshots, persistSignalSnapshot } from "../db/repositories"; +import { resolveRepoActionMode } from "./client"; +import { openRepoDocPullRequest, type RepoDocPullRequestResult } from "./repo-doc-pr"; +import { nowIso } from "../utils/json"; + +const REPO_DOC_REFRESH_ATTEMPT_SIGNAL_TYPE = "repo-doc-refresh-attempt"; + +/** When repo-doc generation was last ATTEMPTED for this repo (scheduled or manual), or `null` if never. Fed + * into isRepoDocRefreshDue by the scheduled sweep's fan-out to decide whether to even enqueue a per-repo job. */ +export async function getLastRepoDocRefreshAttemptedAt(env: Env, repoFullName: string): Promise { + const snapshots = await listSignalSnapshots(env, REPO_DOC_REFRESH_ATTEMPT_SIGNAL_TYPE, repoFullName); + return snapshots[0]?.generatedAt ?? null; +} + +async function recordRepoDocRefreshAttempt(env: Env, repoFullName: string): Promise { + await persistSignalSnapshot(env, { + id: crypto.randomUUID(), + signalType: REPO_DOC_REFRESH_ATTEMPT_SIGNAL_TYPE, + targetKey: repoFullName, + repoFullName, + payload: {}, + generatedAt: nowIso(), + }); +} + +/** + * Refresh one repo's AGENTS.md/CLAUDE.md (and skill file, when applicable) -- resolves the repo's action mode + * the same way other scheduled writers do (resolveRepoActionMode), calls openRepoDocPullRequest (the single + * source of truth for enable/scope/eligibility/diffing), and records that a refresh was ATTEMPTED regardless + * of outcome (opened, skipped, or an internal failure -- openRepoDocPullRequest never throws), so the + * scheduled sweep doesn't re-check this repo again until its own configured interval elapses. + */ +export async function performRepoDocRefresh(env: Env, repoFullName: string): Promise { + const settings = await getRepositorySettings(env, repoFullName); + const mode = await resolveRepoActionMode(env, settings); + const result = await openRepoDocPullRequest(env, repoFullName, mode); + await recordRepoDocRefreshAttempt(env, repoFullName); + return result; +} diff --git a/src/index.ts b/src/index.ts index 93c84ecb98..af9930720e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -189,6 +189,13 @@ async function enqueueScheduledJobs(env: Env, controller: ScheduledController): if (isHourly && hour === 3) { jobs.push({ type: "prune-retention", requestedBy: "schedule" }); } + // Repo-doc refresh sweep (#3003, part of #2993) -- once a day (09:00 UTC, distinct from prune-retention's + // 03:00 and the weekly report's Monday-12:00). The fan-out itself checks each opted-in repo's own + // repoDocGeneration.refreshIntervalDays (default weekly) before enqueuing a per-repo job, so this daily + // cadence is just how often eligibility is RE-CHECKED, not how often a repo is actually refreshed. + if (isHourly && hour === 9 && selfHostedReviews) { + jobs.push({ type: "repo-doc-refresh-sweep", requestedBy: "schedule" }); + } if (isFullSyncWindow) { jobs.push({ type: "generate-signal-snapshots", requestedBy: "schedule" }); jobs.push({ type: "build-burden-forecasts", requestedBy: "schedule" }); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 1da05b400d..149ef27761 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -49,6 +49,7 @@ import { decidePendingAgentAction } from "../services/agent-approval-queue"; import { buildNotificationFeed } from "../notifications/service"; import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot } from "../gittensor/api"; import { getRepositoryCollaboratorPermission } from "../github/app"; +import { performRepoDocRefresh } from "../github/repo-doc-refresh-runner"; import { sanitizePublicComment } from "../github/commands"; import { fetchPublicContributorProfile } from "../github/public"; import { listLatestRegistrySnapshots } from "../registry/sync"; @@ -438,6 +439,22 @@ const decidePendingActionOutputSchema = { action: pendingActionEntrySchema.optional(), }; +// #3003 (part of #2993) — on-demand repo-doc refresh, the manual counterpart to the scheduled sweep +// (src/queue/processors.ts's "repo-doc-refresh-sweep"). Both call the SAME performRepoDocRefresh runner, which +// itself calls openRepoDocPullRequest -- the one place enable/scope/eligibility/diffing is decided. +const refreshRepoDocsShape = { + owner: z.string().min(1), + repo: z.string().min(1), +}; + +const refreshRepoDocsOutputSchema = { + opened: z.boolean().optional(), + reused: z.boolean().optional(), + pullNumber: z.number().optional(), + url: z.string().optional(), + reason: z.string().optional(), +}; + // #784 (MCP slice) — the agent audit feed: executed actions + approval decisions for a repo. const auditFeedShape = { owner: z.string().min(1), @@ -1480,6 +1497,17 @@ export class GittensoryMcp { async (input) => this.toolResult(await this.decidePendingAction(input)), ); + server.registerTool( + "gittensory_refresh_repo_docs", + { + description: + "Force an immediate repo-doc refresh (AGENTS.md/CLAUDE.md, and a skill file when warranted) for one repo, without waiting for the scheduled interval. Only ever opens a pull request -- never a direct commit -- and only when repoDocGeneration is enabled for this repo and the generated content actually changed. Maintainer access required.", + inputSchema: refreshRepoDocsShape, + outputSchema: refreshRepoDocsOutputSchema, + }, + async (input) => this.toolResult(await this.refreshRepoDocs(input)), + ); + server.registerTool( "gittensory_get_agent_audit_feed", { @@ -2577,6 +2605,23 @@ export class GittensoryMcp { }; } + // #3003 — on-demand repo-doc refresh. This action only ever OPENS A PULL REQUEST (never merges/closes/commits + // directly), so -- unlike propose/decide's stage-then-accept pattern for genuinely destructive actions -- + // executing it synchronously in one call is appropriately safe. requireRepoManageAccess is checked FIRST, + // before performRepoDocRefresh touches anything. + private async refreshRepoDocs(input: z.infer>): Promise { + const fullName = `${input.owner}/${input.repo}`; + await this.requireRepoManageAccess(fullName); + const result = await performRepoDocRefresh(this.env, fullName); + if (!result.opened) { + return { summary: `No repo-doc pull request opened for ${fullName}: ${result.reason}`, data: { opened: false, reason: result.reason } }; + } + return { + summary: `${result.reused ? "Found the already-open" : "Opened a new"} repo-doc pull request for ${fullName}: ${result.url}`, + data: { opened: true, reused: result.reused, pullNumber: result.pullNumber, url: result.url }, + }; + } + // #784 — the agent audit feed: executed actions + approval decisions for a repo, newest first. // Maintainer-manage scoped; read-only and public-safe (action posture only — no trust/score metadata). private async getAgentAuditFeed(input: z.infer>): Promise { diff --git a/src/queue/processors.ts b/src/queue/processors.ts index f2c07b36b5..70d9c613a4 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -353,9 +353,12 @@ import { } from "../signals/focus-manifest"; import { loadRepoFocusManifest, + loadRepoFocusManifests, loadRepoReviewContext, } from "../signals/focus-manifest-loader"; import { resolveRepositorySettings } from "../settings/repository-settings"; +import { getLastRepoDocRefreshAttemptedAt, performRepoDocRefresh } from "../github/repo-doc-refresh-runner"; +import { isRepoDocRefreshDue } from "../review/repo-doc-refresh-schedule"; import type { LocalBranchAnalysisInput } from "../signals/local-branch"; import { hasPublicReviewAssessment, @@ -977,6 +980,13 @@ export async function processJob(env: Env, message: JobMessage): Promise { } await sweepRepoBacklogConvergence(env, message.repoFullName, message.requestedBy); return; + case "repo-doc-refresh-sweep": + if (!message.repoFullName && message.requestedBy !== "test") { + await fanOutRepoDocRefreshSweepJobs(env, message.requestedBy); + return; + } + if (message.repoFullName) await performRepoDocRefresh(env, message.repoFullName); + return; case "agent-regate-pr": // One bounded re-gate unit fanned out by the sweep (#audit-sweep-fanout): re-review + stamp a single PR. await regatePullRequest( @@ -1704,6 +1714,39 @@ async function fanOutBacklogConvergenceSweepJobs( }); } +// Repo-doc refresh sweep (#3003, part of #2993): enumerate every installed repo, bulk-load their +// .gittensory.yml manifests, and enqueue one per-repo job for each repo that (a) has +// repoDocGeneration.enabled: true and (b) is due per its own refreshIntervalDays (default weekly). No atomic +// fan-out dedup (unlike agent-regate-sweep) -- this runs once a day, not every tick, so a burst of overlapping +// fan-outs is not a realistic risk. Eligibility/scope/diffing itself lives entirely inside +// openRepoDocPullRequest (via performRepoDocRefresh) -- this fan-out is purely an enumeration + rate-limiting +// optimization so a stable repo isn't re-checked more often than its own configured interval. +async function fanOutRepoDocRefreshSweepJobs(env: Env, requestedBy: "schedule" | "api" | "test"): Promise { + const now = nowIso(); + const repoFullNames = (await listRepositories(env)).map((repo) => repo.fullName); + const manifests = await loadRepoFocusManifests(env, repoFullNames); + const due: string[] = []; + for (const repoFullName of repoFullNames) { + const manifest = manifests.get(repoFullName.toLowerCase()); + if (!manifest?.repoDocGeneration.enabled) continue; + const lastAttemptedAt = await getLastRepoDocRefreshAttemptedAt(env, repoFullName); + if (!isRepoDocRefreshDue(lastAttemptedAt, manifest.repoDocGeneration.refreshIntervalDays, now)) continue; + due.push(repoFullName); + } + await Promise.all( + due.map((repoFullName, index) => { + const message: JobMessage = { type: "repo-doc-refresh-sweep", requestedBy, repoFullName }; + const delaySeconds = Math.min(index * 10, 600); + return delaySeconds > 0 ? env.JOBS.send(message, { delaySeconds }) : env.JOBS.send(message); + }), + ); + await recordAuditEvent(env, { + eventType: "repo_doc.refresh.fanout", + outcome: "queued", + metadata: { repoCount: due.length, requestedBy }, + }); +} + // #selfhost-backlog-convergence: sweep one repo's open PRs for a stale/missing public review surface at the // current head (see selfhost/backlog-convergence.ts for why this is a distinct signal from the re-gate sweep's // own staleness check) and fan out one `agent-regate-pr` job per candidate, tagged with a `backlog-convergence:` diff --git a/src/review/repo-doc-refresh-schedule.ts b/src/review/repo-doc-refresh-schedule.ts new file mode 100644 index 0000000000..b6e829d375 --- /dev/null +++ b/src/review/repo-doc-refresh-schedule.ts @@ -0,0 +1,21 @@ +// Scheduled-refresh due-check (#3003, part of the repo-doc generation roadmap #2993). A tiny, pure predicate: +// has enough time passed since the last refresh ATTEMPT for this repo to warrant another one? This is purely a +// rate-limiting knob on the SCHEDULED sweep -- it never affects correctness, since openRepoDocPullRequest's own +// no-change short-circuit (#3004) already prevents a redundant PR regardless of how often it's invoked. Keeping +// this separate from the sweep's persistence/enumeration plumbing makes the "due" decision itself trivially +// unit-testable without any D1/queue setup. + +/** + * Whether a scheduled repo-doc refresh is due. `lastAttemptedAt` is `null` when this repo has never been + * attempted (or the marker was lost) -- always due in that case, so a newly-enabled repo isn't stuck waiting a + * full interval before its first PR. Otherwise due once `refreshIntervalDays` have elapsed since the last + * attempt, inclusive of the boundary (exactly `refreshIntervalDays` later counts as due). + */ +export function isRepoDocRefreshDue(lastAttemptedAt: string | null, refreshIntervalDays: number, now: string): boolean { + if (lastAttemptedAt === null) return true; + const lastAttemptedMs = Date.parse(lastAttemptedAt); + const nowMs = Date.parse(now); + if (!Number.isFinite(lastAttemptedMs) || !Number.isFinite(nowMs)) return true; + const intervalMs = refreshIntervalDays * 24 * 60 * 60 * 1000; + return nowMs - lastAttemptedMs >= intervalMs; +} diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 0d5ca12f20..5c4f29cb76 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -169,6 +169,11 @@ export type FocusManifestRepoDocGenerationConfig = { enabled: boolean; scope: FocusManifestRepoDocGenerationScope[]; allowOverwriteExisting: boolean; + /** How many days must elapse between scheduled refresh attempts for this repo (#3003). Default 7 (weekly). + * Purely a rate-limiting knob on the SCHEDULED sweep -- it never affects correctness, since + * openRepoDocPullRequest's own no-change short-circuit already prevents a redundant PR regardless of how + * often it's invoked; this just avoids re-checking a stable repo more often than the operator wants. */ + refreshIntervalDays: number; }; /** @@ -452,11 +457,14 @@ const EMPTY_CONTENT_LANE_CONFIG: FocusManifestContentLaneConfig = { validatorId: null, }; +const DEFAULT_REPO_DOC_REFRESH_INTERVAL_DAYS = 7; + const EMPTY_REPO_DOC_GENERATION_CONFIG: FocusManifestRepoDocGenerationConfig = { present: false, enabled: false, scope: ["agents"], allowOverwriteExisting: false, + refreshIntervalDays: DEFAULT_REPO_DOC_REFRESH_INTERVAL_DAYS, }; const EMPTY_MANIFEST: FocusManifest = { @@ -996,14 +1004,15 @@ function parseRepoDocGenerationConfig(value: JsonValue | undefined, warnings: st const enabled = normalizeOptionalBoolean(record.enabled, "repoDocGeneration.enabled", warnings) ?? false; const allowOverwriteExisting = normalizeOptionalBoolean(record.allowOverwriteExisting, "repoDocGeneration.allowOverwriteExisting", warnings) ?? false; const scope = parseRepoDocGenerationScope(record.scope, warnings); - return { present: true, enabled, scope, allowOverwriteExisting }; + const refreshIntervalDays = normalizeOptionalPositiveInteger(record.refreshIntervalDays, "repoDocGeneration.refreshIntervalDays", warnings) ?? DEFAULT_REPO_DOC_REFRESH_INTERVAL_DAYS; + return { present: true, enabled, scope, allowOverwriteExisting, refreshIntervalDays }; } /** Serialize a repoDocGeneration config back into the parse-compatible shape so a cached snapshot round-trips * through {@link parseRepoDocGenerationConfig} unchanged. Returns null when nothing is configured. */ export function repoDocGenerationConfigToJson(config: FocusManifestRepoDocGenerationConfig): JsonValue { if (!config.present) return null; - return { enabled: config.enabled, scope: config.scope, allowOverwriteExisting: config.allowOverwriteExisting }; + return { enabled: config.enabled, scope: config.scope, allowOverwriteExisting: config.allowOverwriteExisting, refreshIntervalDays: config.refreshIntervalDays }; } function normalizeOptionalEnum(value: JsonValue | undefined, field: string, allowed: readonly T[], warnings: string[]): T | null { diff --git a/src/types.ts b/src/types.ts index 966c1e2ff9..3ff21132c1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -225,6 +225,16 @@ export type JobMessage = requestedBy: "schedule" | "api" | "test"; repoFullName?: string; installationId?: number; + } + | { + // Scheduled repo-doc refresh (#3003, part of #2993). No `repoFullName` = fan-out: enumerate every repo + // with `.gittensory.yml repoDocGeneration.enabled: true` whose refresh interval has elapsed and enqueue + // one per-repo job each, mirroring "agent-regate-sweep"/"backlog-convergence-sweep". With `repoFullName` = + // refresh that one repo via openRepoDocPullRequest (the SAME function the on-demand MCP trigger calls) -- + // no separate eligibility/diffing logic lives in the queue processor itself. + type: "repo-doc-refresh-sweep"; + requestedBy: "schedule" | "api" | "test"; + repoFullName?: string; }; export type GitHubWebhookPayload = { diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 273240e445..ac5e27dca8 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -527,7 +527,7 @@ describe("compileFocusManifestPolicy", () => { review: { present: false, footerText: null, note: null, fields: {}, enrichmentAnalyzers: {}, profile: null, securityFocus: null, inlineComments: null, pathInstructions: [], instructions: null, excludePaths: [], preMergeChecks: [] }, features: { present: false, rag: null, reputation: null, unifiedComment: null, safety: null }, contentLane: { present: false, entryFileGlob: null, providerFileGlob: null, artifactGlob: null, collectionField: null, maxAppendedEntries: null, duplicateKeyFields: [], validatorId: null }, - repoDocGeneration: { present: false, enabled: false, scope: ["agents"], allowOverwriteExisting: false }, + repoDocGeneration: { present: false, enabled: false, scope: ["agents"], allowOverwriteExisting: false, refreshIntervalDays: 7 }, warnings: [], }); expect(policy.publicSafe.entryGuidance).toContain("Keep PRs focused."); @@ -1269,12 +1269,12 @@ describe("parseFocusManifest gate config", () => { describe("repoDocGeneration: (#3002, repo-doc generation config-as-code surface)", () => { it("defaults to fully disabled and absent when the key is omitted, and does not make the manifest present on its own", () => { const m = parseFocusManifest({}); - expect(m.repoDocGeneration).toEqual({ present: false, enabled: false, scope: ["agents"], allowOverwriteExisting: false }); + expect(m.repoDocGeneration).toEqual({ present: false, enabled: false, scope: ["agents"], allowOverwriteExisting: false, refreshIntervalDays: 7 }); expect(m.present).toBe(false); }); it("treats an explicit null the same as an omitted key", () => { - expect(parseFocusManifest({ repoDocGeneration: null }).repoDocGeneration).toEqual({ present: false, enabled: false, scope: ["agents"], allowOverwriteExisting: false }); + expect(parseFocusManifest({ repoDocGeneration: null }).repoDocGeneration).toEqual({ present: false, enabled: false, scope: ["agents"], allowOverwriteExisting: false, refreshIntervalDays: 7 }); }); it("warns and falls back to the default when the value is a non-mapping type (string or array)", () => { @@ -1286,9 +1286,9 @@ describe("parseFocusManifest gate config", () => { expect(asArray.warnings.some((w) => /"repoDocGeneration" must be a mapping/.test(w))).toBe(true); }); - it("parses enabled: true and defaults scope/allowOverwriteExisting, making the manifest present", () => { + it("parses enabled: true and defaults scope/allowOverwriteExisting/refreshIntervalDays, making the manifest present", () => { const m = parseFocusManifest({ repoDocGeneration: { enabled: true } }); - expect(m.repoDocGeneration).toEqual({ present: true, enabled: true, scope: ["agents"], allowOverwriteExisting: false }); + expect(m.repoDocGeneration).toEqual({ present: true, enabled: true, scope: ["agents"], allowOverwriteExisting: false, refreshIntervalDays: 7 }); expect(m.present).toBe(true); }); @@ -1300,7 +1300,24 @@ describe("parseFocusManifest gate config", () => { it("parses allowOverwriteExisting independently of enabled", () => { const m = parseFocusManifest({ repoDocGeneration: { enabled: false, allowOverwriteExisting: true } }); - expect(m.repoDocGeneration).toEqual({ present: true, enabled: false, scope: ["agents"], allowOverwriteExisting: true }); + expect(m.repoDocGeneration).toEqual({ present: true, enabled: false, scope: ["agents"], allowOverwriteExisting: true, refreshIntervalDays: 7 }); + }); + + it("parses a valid refreshIntervalDays and defaults to 7 (weekly) when omitted", () => { + const m = parseFocusManifest({ repoDocGeneration: { enabled: true, refreshIntervalDays: 3 } }); + expect(m.repoDocGeneration.refreshIntervalDays).toBe(3); + const defaulted = parseFocusManifest({ repoDocGeneration: { enabled: true } }); + expect(defaulted.repoDocGeneration.refreshIntervalDays).toBe(7); + }); + + it("warns and defaults refreshIntervalDays to 7 when the value is not a positive whole number", () => { + const zero = parseFocusManifest({ repoDocGeneration: { enabled: true, refreshIntervalDays: 0 } }); + expect(zero.repoDocGeneration.refreshIntervalDays).toBe(7); + expect(zero.warnings.some((w) => /repoDocGeneration\.refreshIntervalDays/.test(w))).toBe(true); + const fractional = parseFocusManifest({ repoDocGeneration: { enabled: true, refreshIntervalDays: 2.5 } }); + expect(fractional.repoDocGeneration.refreshIntervalDays).toBe(7); + const negative = parseFocusManifest({ repoDocGeneration: { enabled: true, refreshIntervalDays: -1 } }); + expect(negative.repoDocGeneration.refreshIntervalDays).toBe(7); }); it("accepts an explicit multi-entry scope list", () => { diff --git a/test/unit/index.test.ts b/test/unit/index.test.ts index 9e7e7deaf6..74d670c777 100644 --- a/test/unit/index.test.ts +++ b/test/unit/index.test.ts @@ -678,6 +678,40 @@ describe("worker entrypoint", () => { expect(sent.some((m) => m.type === "rag-index-repo")).toBe(false); }); + it("enqueues the repo-doc refresh sweep once a day at 09:00 UTC on a self-hosted runtime (#3003)", async () => { + const sent: Array = []; + const env = createTestEnv({ + JOBS: { + async send(message: import("../../src/types").JobMessage) { + sent.push(message); + }, + } as unknown as Queue, + }); + const waitUntil: Promise[] = []; + + await worker.scheduled(controllerFor("2026-06-01T09:00:00.000Z"), env, executionContext(waitUntil)); + await Promise.all(waitUntil); + + expect(sent).toEqual(expect.arrayContaining([{ type: "repo-doc-refresh-sweep", requestedBy: "schedule" }])); + }); + + it("does NOT enqueue the repo-doc refresh sweep outside the 09:00 UTC window", async () => { + const sent: Array = []; + const env = createTestEnv({ + JOBS: { + async send(message: import("../../src/types").JobMessage) { + sent.push(message); + }, + } as unknown as Queue, + }); + const waitUntil: Promise[] = []; + + await worker.scheduled(controllerFor("2026-06-01T10:00:00.000Z"), env, executionContext(waitUntil)); // hourly but not 09:00 + await Promise.all(waitUntil); + + expect(sent.some((m) => m.type === "repo-doc-refresh-sweep")).toBe(false); + }); + it("enqueues weekly value report generation during the Monday report window", async () => { const sent: Array = []; const env = createTestEnv({ diff --git a/test/unit/mcp-refresh-repo-docs.test.ts b/test/unit/mcp-refresh-repo-docs.test.ts new file mode 100644 index 0000000000..676203bef5 --- /dev/null +++ b/test/unit/mcp-refresh-repo-docs.test.ts @@ -0,0 +1,112 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { generateKeyPairSync } from "node:crypto"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { GittensoryMcp } from "../../src/mcp/server"; +import { upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader"; +import type { AuthIdentity } from "../../src/auth/security"; +import { createTestEnv } from "../helpers/d1"; + +const REPO = "owner/widgets"; +const [PROJECT, CHUNK_REPO] = ["owner", "widgets"]; +const TOKEN_URL = /\/access_tokens$/; + +function generateRsaPrivateKeyPem(): string { + return generateKeyPairSync("rsa", { modulusLength: 2048, privateKeyEncoding: { type: "pkcs1", format: "pem" }, publicKeyEncoding: { type: "pkcs1", format: "pem" } }).privateKey; +} + +function envWithKey(overrides: Record = {}) { + return createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), ...overrides }); +} + +async function seedChunk(env: ReturnType, path: string, text: string): Promise { + await env.DB.prepare("INSERT INTO repo_chunks (id, project, repo, path, chunk_index, kind, text) VALUES (?,?,?,?,?,?,?)").bind(`${path}::0`, PROJECT, CHUNK_REPO, path, 0, "code", text).run(); +} + +async function connect(env: Env, identity?: AuthIdentity) { + const server = (identity ? new GittensoryMcp(env, identity) : new GittensoryMcp(env)).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "gittensory-refresh-repo-docs-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +describe("MCP gittensory_refresh_repo_docs (#3003)", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("opens a repo-doc pull request for an enabled, eligible repo", async () => { + const env = envWithKey(); + await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: REPO, private: false, owner: { login: "owner" }, default_branch: "main" }, 555); + await upsertRepoFocusManifest(env, REPO, { repoDocGeneration: { enabled: true } }); + await seedChunk(env, "src/widget.ts", "export function widget() {}"); + await seedChunk(env, "package.json", JSON.stringify({ scripts: { build: "tsc" } })); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (TOKEN_URL.test(url)) return Response.json({ token: "t" }); + if (url.includes("/pulls?") && method === "GET") return Response.json([]); + if (url.includes("/contents/") && method === "GET") return new Response("not found", { status: 404 }); + if (url.endsWith("/branches/main")) return Response.json({ commit: { sha: "base-commit-sha", commit: { tree: { sha: "base-tree-sha" } } } }); + if (url.endsWith("/git/trees") && method === "POST") return Response.json({ sha: "tree-sha" }); + if (url.endsWith("/git/commits") && method === "POST") return Response.json({ sha: "commit-sha" }); + if (url.endsWith("/git/refs") && method === "POST") return Response.json({}); + if (url.endsWith("/repos/owner/widgets/pulls") && method === "POST") return Response.json({ number: 101, html_url: "https://github.com/owner/widgets/pull/101" }); + return new Response("unexpected", { status: 500 }); + }); + + const client = await connect(env); + const result = await client.callTool({ name: "gittensory_refresh_repo_docs", arguments: { owner: "owner", repo: "widgets" } }); + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toEqual({ opened: true, reused: false, pullNumber: 101, url: "https://github.com/owner/widgets/pull/101" }); + }); + + it("reports the already-open PR when one exists on the repo-doc branch", async () => { + const env = envWithKey(); + await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: REPO, private: false, owner: { login: "owner" }, default_branch: "main" }, 555); + await upsertRepoFocusManifest(env, REPO, { repoDocGeneration: { enabled: true } }); + await seedChunk(env, "src/widget.ts", "export function widget() {}"); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (TOKEN_URL.test(url)) return Response.json({ token: "t" }); + if (url.includes("/pulls?") && method === "GET") return Response.json([{ number: 55, html_url: "https://github.com/owner/widgets/pull/55" }]); + return new Response("unexpected", { status: 500 }); + }); + + const client = await connect(env); + const result = await client.callTool({ name: "gittensory_refresh_repo_docs", arguments: { owner: "owner", repo: "widgets" } }); + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toEqual({ opened: true, reused: true, pullNumber: 55, url: "https://github.com/owner/widgets/pull/55" }); + }); + + it("succeeds the tool call but reports opened: false when repo-doc generation is not enabled", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: REPO, private: false, owner: { login: "owner" } }, 555); + const client = await connect(env); + const result = await client.callTool({ name: "gittensory_refresh_repo_docs", arguments: { owner: "owner", repo: "widgets" } }); + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toEqual({ opened: false, reason: "repo-doc generation is not enabled for this repository (.gittensory.yml repoDocGeneration.enabled)" }); + }); + + it("denies a static MCP-token caller when the repo is not in MCP_ACTUATION_REPO_ALLOWLIST", async () => { + const env = createTestEnv({ MCP_ACTUATION_REPO_ALLOWLIST: "" }); + await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: REPO, private: false, owner: { login: "owner" } }, 555); + const client = await connect(env); // default identity: { kind: "static", actor: "mcp" } + const result = await client.callTool({ name: "gittensory_refresh_repo_docs", arguments: { owner: "owner", repo: "widgets" } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).toMatch(/MCP_ACTUATION_REPO_ALLOWLIST/); + }); + + it("leaves the api static identity unconditionally trusted (unaffected by the mcp allowlist)", async () => { + const env = createTestEnv({ MCP_ACTUATION_REPO_ALLOWLIST: "" }); + await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: REPO, private: false, owner: { login: "owner" } }, 555); + const client = await connect(env, { kind: "static", actor: "api" } as AuthIdentity); + const result = await client.callTool({ name: "gittensory_refresh_repo_docs", arguments: { owner: "owner", repo: "widgets" } }); + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toMatchObject({ opened: false }); + }); +}); diff --git a/test/unit/repo-doc-refresh-runner.test.ts b/test/unit/repo-doc-refresh-runner.test.ts new file mode 100644 index 0000000000..1e32a48e57 --- /dev/null +++ b/test/unit/repo-doc-refresh-runner.test.ts @@ -0,0 +1,93 @@ +import { generateKeyPairSync } from "node:crypto"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getLastRepoDocRefreshAttemptedAt, performRepoDocRefresh } from "../../src/github/repo-doc-refresh-runner"; +import { upsertRepositoryFromGitHub, upsertRepositorySettings } from "../../src/db/repositories"; +import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader"; +import { createTestEnv } from "../helpers/d1"; + +const REPO = "owner/widgets"; +const [PROJECT, CHUNK_REPO] = ["owner", "widgets"]; + +function generateRsaPrivateKeyPem(): string { + return generateKeyPairSync("rsa", { modulusLength: 2048, privateKeyEncoding: { type: "pkcs1", format: "pem" }, publicKeyEncoding: { type: "pkcs1", format: "pem" } }).privateKey; +} + +function envWithKey() { + return createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); +} + +async function seedChunk(env: ReturnType, path: string, text: string): Promise { + await env.DB.prepare("INSERT INTO repo_chunks (id, project, repo, path, chunk_index, kind, text) VALUES (?,?,?,?,?,?,?)").bind(`${path}::0`, PROJECT, CHUNK_REPO, path, 0, "code", text).run(); +} + +async function seedInstalledEnabledRepo(env: ReturnType): Promise { + await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: REPO, private: false, owner: { login: "owner" }, default_branch: "main" }, 555); + await upsertRepoFocusManifest(env, REPO, { repoDocGeneration: { enabled: true } }); + await seedChunk(env, "src/widget.ts", "export function widget() {}"); + await seedChunk(env, "package.json", JSON.stringify({ scripts: { build: "tsc" } })); +} + +const TOKEN_URL = /\/access_tokens$/; + +describe("getLastRepoDocRefreshAttemptedAt (#3003)", () => { + it("returns null when a repo has never been attempted", async () => { + const env = createTestEnv(); + expect(await getLastRepoDocRefreshAttemptedAt(env, REPO)).toBeNull(); + }); +}); + +describe("performRepoDocRefresh (#3003)", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("resolves the repo's action mode, calls openRepoDocPullRequest, and records the attempt regardless of outcome", async () => { + const env = envWithKey(); + await seedInstalledEnabledRepo(env); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (TOKEN_URL.test(url)) return Response.json({ token: "t" }); + const method = init?.method ?? "GET"; + if (url.includes("/pulls?") && method === "GET") return Response.json([]); + if (url.includes("/contents/") && method === "GET") return new Response("not found", { status: 404 }); + if (url.endsWith("/branches/main")) return Response.json({ commit: { sha: "base-commit-sha", commit: { tree: { sha: "base-tree-sha" } } } }); + if (url.endsWith("/git/trees") && method === "POST") return Response.json({ sha: "tree-sha" }); + if (url.endsWith("/git/commits") && method === "POST") return Response.json({ sha: "commit-sha" }); + if (url.endsWith("/git/refs") && method === "POST") return Response.json({}); + if (url.endsWith("/repos/owner/widgets/pulls") && method === "POST") return Response.json({ number: 91, html_url: "https://github.com/owner/widgets/pull/91" }); + return new Response("unexpected", { status: 500 }); + }); + + expect(await getLastRepoDocRefreshAttemptedAt(env, REPO)).toBeNull(); + const result = await performRepoDocRefresh(env, REPO); + expect(result).toEqual({ opened: true, reused: false, pullNumber: 91, url: "https://github.com/owner/widgets/pull/91", claudeMode: "symlink" }); + + const attemptedAt = await getLastRepoDocRefreshAttemptedAt(env, REPO); + expect(attemptedAt).not.toBeNull(); + expect(Number.isFinite(Date.parse(attemptedAt!))).toBe(true); + }); + + it("records an attempt even when the repo declines (e.g. generation disabled)", async () => { + const env = envWithKey(); + await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: REPO, private: false, owner: { login: "owner" } }, 555); + // repoDocGeneration is NOT enabled for this repo. + const result = await performRepoDocRefresh(env, REPO); + expect(result).toEqual({ opened: false, reason: "repo-doc generation is not enabled for this repository (.gittensory.yml repoDocGeneration.enabled)" }); + expect(await getLastRepoDocRefreshAttemptedAt(env, REPO)).not.toBeNull(); + }); + + it("resolves agent-paused mode from repository settings before delegating (dry-run instances never write)", async () => { + const env = envWithKey(); + await seedInstalledEnabledRepo(env); + await upsertRepositorySettings(env, { repoFullName: REPO, agentPaused: true }); + let tokenMinted = false; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (TOKEN_URL.test(url)) tokenMinted = true; + return new Response("unexpected", { status: 500 }); + }); + const result = await performRepoDocRefresh(env, REPO); + expect(result).toEqual({ opened: false, reason: 'repo-doc pull request not opened: action mode is "paused"' }); + expect(tokenMinted).toBe(false); + }); +}); diff --git a/test/unit/repo-doc-refresh-schedule.test.ts b/test/unit/repo-doc-refresh-schedule.test.ts new file mode 100644 index 0000000000..d3aa2e31c6 --- /dev/null +++ b/test/unit/repo-doc-refresh-schedule.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { isRepoDocRefreshDue } from "../../src/review/repo-doc-refresh-schedule"; + +const NOW = "2026-07-11T00:00:00.000Z"; + +describe("isRepoDocRefreshDue (#3003)", () => { + it("is always due when never attempted before (null marker)", () => { + expect(isRepoDocRefreshDue(null, 7, NOW)).toBe(true); + }); + + it("is NOT due when less than the interval has elapsed", () => { + const lastAttemptedAt = "2026-07-05T00:00:00.000Z"; // 6 days before NOW + expect(isRepoDocRefreshDue(lastAttemptedAt, 7, NOW)).toBe(false); + }); + + it("is due exactly at the interval boundary (inclusive)", () => { + const lastAttemptedAt = "2026-07-04T00:00:00.000Z"; // exactly 7 days before NOW + expect(isRepoDocRefreshDue(lastAttemptedAt, 7, NOW)).toBe(true); + }); + + it("is due when more than the interval has elapsed", () => { + const lastAttemptedAt = "2026-06-01T00:00:00.000Z"; + expect(isRepoDocRefreshDue(lastAttemptedAt, 7, NOW)).toBe(true); + }); + + it("respects a custom (non-default) interval", () => { + const lastAttemptedAt = "2026-07-10T00:00:00.000Z"; // 1 day before NOW + expect(isRepoDocRefreshDue(lastAttemptedAt, 1, NOW)).toBe(true); + expect(isRepoDocRefreshDue(lastAttemptedAt, 2, NOW)).toBe(false); + }); + + it("fails open (due) when lastAttemptedAt is not a parseable date", () => { + expect(isRepoDocRefreshDue("not-a-date", 7, NOW)).toBe(true); + }); + + it("fails open (due) when now is not a parseable date", () => { + expect(isRepoDocRefreshDue("2026-07-01T00:00:00.000Z", 7, "not-a-date")).toBe(true); + }); +}); diff --git a/test/unit/repo-doc-refresh-sweep.test.ts b/test/unit/repo-doc-refresh-sweep.test.ts new file mode 100644 index 0000000000..15d79bb0ed --- /dev/null +++ b/test/unit/repo-doc-refresh-sweep.test.ts @@ -0,0 +1,90 @@ +import { generateKeyPairSync } from "node:crypto"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { processJob } from "../../src/queue/processors"; +import { upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader"; +import { getLastRepoDocRefreshAttemptedAt } from "../../src/github/repo-doc-refresh-runner"; +import { createTestEnv } from "../helpers/d1"; +import type { JobMessage } from "../../src/types"; + +function generateRsaPrivateKeyPem(): string { + return generateKeyPairSync("rsa", { modulusLength: 2048, privateKeyEncoding: { type: "pkcs1", format: "pem" }, publicKeyEncoding: { type: "pkcs1", format: "pem" } }).privateKey; +} + +const TOKEN_URL = /\/access_tokens$/; + +describe("repo-doc-refresh-sweep fan-out (#3003)", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("enqueues one job per enabled+due repo, skipping a disabled repo entirely", async () => { + const sent: JobMessage[] = []; + const env = createTestEnv({ JOBS: { async send(message: JobMessage) { sent.push(message); } } as unknown as Queue }); + await upsertRepositoryFromGitHub(env, { name: "enabled-repo", full_name: "owner/enabled-repo", private: false, owner: { login: "owner" } }); + await upsertRepoFocusManifest(env, "owner/enabled-repo", { repoDocGeneration: { enabled: true } }); + await upsertRepositoryFromGitHub(env, { name: "disabled-repo", full_name: "owner/disabled-repo", private: false, owner: { login: "owner" } }); + // owner/disabled-repo has no repoDocGeneration config at all -- defaults to disabled. + + await processJob(env, { type: "repo-doc-refresh-sweep", requestedBy: "schedule" }); + + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ type: "repo-doc-refresh-sweep", repoFullName: "owner/enabled-repo" }); + const fanout = await env.DB.prepare("select outcome, metadata_json from audit_events where event_type = ?").bind("repo_doc.refresh.fanout").first<{ outcome: string; metadata_json: string }>(); + expect(fanout?.outcome).toBe("queued"); + expect(JSON.parse(fanout?.metadata_json ?? "{}")).toMatchObject({ repoCount: 1, requestedBy: "schedule" }); + }); + + it("skips an enabled repo that was already attempted within its own refresh interval", async () => { + const sent: JobMessage[] = []; + const env = createTestEnv({ JOBS: { async send(message: JobMessage) { sent.push(message); } } as unknown as Queue }); + await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: "owner/widgets", private: false, owner: { login: "owner" } }, 555); + await upsertRepoFocusManifest(env, "owner/widgets", { repoDocGeneration: { enabled: true, refreshIntervalDays: 7 } }); + // A prior attempt "just now" -- well within the 7-day interval, so this repo is not due yet. + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => (TOKEN_URL.test(input.toString()) ? Response.json({ token: "t" }) : new Response("unexpected", { status: 500 }))); + await processJob(env, { type: "repo-doc-refresh-sweep", requestedBy: "schedule", repoFullName: "owner/widgets" }); + vi.unstubAllGlobals(); + sent.length = 0; + + await processJob(env, { type: "repo-doc-refresh-sweep", requestedBy: "schedule" }); + + expect(sent).toHaveLength(0); + }); + + it("staggers a second due repo's enqueue delay", async () => { + const sent: Array<{ message: JobMessage; delaySeconds?: number }> = []; + const env = createTestEnv({ + JOBS: { async send(m: JobMessage, options?: { delaySeconds?: number }) { sent.push({ message: m, ...(options?.delaySeconds === undefined ? {} : { delaySeconds: options.delaySeconds }) }); } } as unknown as Queue, + }); + await upsertRepositoryFromGitHub(env, { name: "repo-a", full_name: "owner/repo-a", private: false, owner: { login: "owner" } }); + await upsertRepoFocusManifest(env, "owner/repo-a", { repoDocGeneration: { enabled: true } }); + await upsertRepositoryFromGitHub(env, { name: "repo-b", full_name: "owner/repo-b", private: false, owner: { login: "owner" } }); + await upsertRepoFocusManifest(env, "owner/repo-b", { repoDocGeneration: { enabled: true } }); + + await processJob(env, { type: "repo-doc-refresh-sweep", requestedBy: "schedule" }); + + expect(sent).toHaveLength(2); + expect(sent.some((s) => (s.delaySeconds ?? 0) > 0)).toBe(true); + }); + + it("no-ops safely on a missing repoFullName (test mode) without fanning out", async () => { + const sent: JobMessage[] = []; + const env = createTestEnv({ JOBS: { async send(message: JobMessage) { sent.push(message); } } as unknown as Queue }); + await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: "owner/widgets", private: false, owner: { login: "owner" } }); + await upsertRepoFocusManifest(env, "owner/widgets", { repoDocGeneration: { enabled: true } }); + + await processJob(env, { type: "repo-doc-refresh-sweep", requestedBy: "test" }); + + expect(sent).toHaveLength(0); + }); + + it("dispatches a per-repo message to performRepoDocRefresh, recording an attempt marker", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); + await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: "owner/widgets", private: false, owner: { login: "owner" } }, 555); + // repoDocGeneration left disabled (default) -- performRepoDocRefresh should decline cleanly, not throw, + // and still record that a refresh was attempted. + await processJob(env, { type: "repo-doc-refresh-sweep", requestedBy: "schedule", repoFullName: "owner/widgets" }); + + expect(await getLastRepoDocRefreshAttemptedAt(env, "owner/widgets")).not.toBeNull(); + }); +});