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
50 changes: 50 additions & 0 deletions src/github/repo-doc-refresh-runner.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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<void> {
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<RepoDocPullRequestResult> {
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;
}
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
45 changes: 45 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -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<z.ZodObject<typeof refreshRepoDocsShape>>): Promise<ToolPayload> {
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<z.ZodObject<typeof auditFeedShape>>): Promise<ToolPayload> {
Expand Down
43 changes: 43 additions & 0 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -977,6 +980,13 @@ export async function processJob(env: Env, message: JobMessage): Promise<void> {
}
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(
Expand Down Expand Up @@ -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<void> {
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:`
Expand Down
21 changes: 21 additions & 0 deletions src/review/repo-doc-refresh-schedule.ts
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 11 additions & 2 deletions src/signals/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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<T extends string>(value: JsonValue | undefined, field: string, allowed: readonly T[], warnings: string[]): T | null {
Expand Down
10 changes: 10 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading
Loading