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
7 changes: 7 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2836,6 +2836,13 @@
"nullable": true
}
},
"queueTrends": {
"type": "object",
"nullable": true,
"additionalProperties": {
"nullable": true
}
},
"collisions": {
"type": "object",
"additionalProperties": {
Expand Down
5 changes: 5 additions & 0 deletions migrations/0021_queue_trend_snapshots.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS repo_queue_trend_snapshots (
repo_full_name TEXT PRIMARY KEY,
payload_json TEXT NOT NULL DEFAULT '{}',
generated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
8 changes: 7 additions & 1 deletion src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
getLatestScoringModelSnapshot,
getPullRequest,
getRepository,
getRepoQueueTrendSnapshot,
getRepositorySettings,
recordAuditEvent,
getContributorEvidence,
Expand Down Expand Up @@ -148,6 +149,7 @@ import {
} from "../services/weekly-value-report";
import { loadOrComputeIssueQualityResponse } from "../services/issue-quality";
import { loadOrComputeBurdenForecastResponse } from "../services/burden-forecast";
import { buildUnavailableQueueTrendReport } from "../services/queue-trends";
import { loadOrComputeRepoOutcomePatternsResponse } from "../services/repo-outcome-patterns";
import {
buildBountyAdvisory,
Expand Down Expand Up @@ -2708,7 +2710,7 @@ function buildDigestItems(args: {

async function buildRepoIntelligenceResponse(env: Env, fullName: string) {
let burdenForecastError: unknown;
const [repo, snapshots, dataQuality, burdenForecast] = await Promise.all([
const [repo, snapshots, dataQuality, burdenForecast, queueTrends] = await Promise.all([
getRepository(env, fullName),
Promise.all(
["queue-health", "config-quality", "label-audit", "maintainer-lane", "maintainer-cut-readiness", "contributor-intake-health"].map(async (signalType) => [
Expand All @@ -2721,6 +2723,7 @@ async function buildRepoIntelligenceResponse(env: Env, fullName: string) {
burdenForecastError = error;
return null;
}),
getRepoQueueTrendSnapshot(env, fullName),
]);
const intelligenceDataQuality = burdenForecastError
? withDataQualityWarning(dataQuality, `Burden forecast unavailable for ${fullName}: ${errorMessage(burdenForecastError)}`)
Expand All @@ -2737,6 +2740,7 @@ async function buildRepoIntelligenceResponse(env: Env, fullName: string) {
},
}
: {};
const queueTrendReport = queueTrends?.payload ?? (buildUnavailableQueueTrendReport(fullName) as unknown as Record<string, never>);
if (snapshotMap["queue-health"] && snapshotMap["config-quality"] && snapshotMap["label-audit"]) {
return {
status: "ready",
Expand All @@ -2746,6 +2750,7 @@ async function buildRepoIntelligenceResponse(env: Env, fullName: string) {
repo,
lane: buildLaneAdvice(repo, fullName),
queueHealth: snapshotMap["queue-health"],
queueTrends: queueTrendReport,
configQuality: snapshotMap["config-quality"],
labelAudit: snapshotMap["label-audit"],
maintainerLane: snapshotMap["maintainer-lane"],
Expand Down Expand Up @@ -2777,6 +2782,7 @@ async function buildRepoIntelligenceResponse(env: Env, fullName: string) {
repo,
lane: buildLaneAdvice(repo, fullName),
queueHealth,
queueTrends: queueTrendReport,
collisions,
configQuality,
labelAudit,
Expand Down
45 changes: 45 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
recentMergedPullRequests,
repositories,
repoGithubTotalsSnapshots,
repoQueueTrendSnapshots,
registryDriftEvents,
repoLabels,
repoSnapshots,
Expand Down Expand Up @@ -117,6 +118,7 @@ import type {
RegistryDriftEventRecord,
RepoLabelRecord,
RepoGithubTotalsSnapshotRecord,
RepoQueueTrendSnapshotRecord,
RepoSnapshotRecord,
RepoSyncSegmentRecord,
RepoSyncStateRecord,
Expand Down Expand Up @@ -633,6 +635,24 @@ export async function getLatestRepoGithubTotalsSnapshot(env: Env, fullName: stri
return row ? toRepoGithubTotalsSnapshotRecord(row) : null;
}

export async function listRepoGithubTotalsSnapshotHistory(
env: Env,
fullName: string,
options: { sinceIso?: string | undefined; limit?: number | undefined } = {},
): Promise<RepoGithubTotalsSnapshotRecord[]> {
const db = getDb(env.DB);
const limit = Math.max(2, Math.min(options.limit ?? 120, 240));
const conditions = [eq(repoGithubTotalsSnapshots.repoFullName, fullName)];
if (options.sinceIso) conditions.push(gte(repoGithubTotalsSnapshots.fetchedAt, options.sinceIso));
const rows = await db
.select()
.from(repoGithubTotalsSnapshots)
.where(and(...conditions))
.orderBy(desc(repoGithubTotalsSnapshots.fetchedAt))
.limit(limit);
return rows.map(toRepoGithubTotalsSnapshotRecord).reverse();
}

export async function listLatestRepoGithubTotalsSnapshots(env: Env): Promise<RepoGithubTotalsSnapshotRecord[]> {
const db = getDb(env.DB);
const latestRows = await db
Expand All @@ -654,6 +674,23 @@ export async function listLatestRepoGithubTotalsSnapshots(env: Env): Promise<Rep
return rows.map(toRepoGithubTotalsSnapshotRecord).sort((left, right) => left.repoFullName.localeCompare(right.repoFullName));
}

export async function upsertRepoQueueTrendSnapshot(env: Env, snapshot: RepoQueueTrendSnapshotRecord): Promise<void> {
const db = getDb(env.DB);
await db
.insert(repoQueueTrendSnapshots)
.values({ repoFullName: snapshot.repoFullName, payloadJson: jsonString(snapshot.payload), generatedAt: snapshot.generatedAt })
.onConflictDoUpdate({
target: repoQueueTrendSnapshots.repoFullName,
set: { payloadJson: jsonString(snapshot.payload), generatedAt: snapshot.generatedAt },
});
}

export async function getRepoQueueTrendSnapshot(env: Env, repoFullName: string): Promise<RepoQueueTrendSnapshotRecord | null> {
const db = getDb(env.DB);
const [row] = await db.select().from(repoQueueTrendSnapshots).where(eq(repoQueueTrendSnapshots.repoFullName, repoFullName)).limit(1);
return row ? toRepoQueueTrendSnapshotRecord(row) : null;
}

export async function upsertPullRequestDetailSyncState(env: Env, state: PullRequestDetailSyncStateRecord): Promise<void> {
const db = getDb(env.DB);
await db
Expand Down Expand Up @@ -2855,6 +2892,14 @@ function toRepoGithubTotalsSnapshotRecord(row: typeof repoGithubTotalsSnapshots.
};
}

function toRepoQueueTrendSnapshotRecord(row: typeof repoQueueTrendSnapshots.$inferSelect): RepoQueueTrendSnapshotRecord {
return {
repoFullName: row.repoFullName,
payload: parseJson<Record<string, JsonValue>>(row.payloadJson, {}),
generatedAt: row.generatedAt,
};
}

function toPullRequestDetailSyncStateRecord(row: typeof pullRequestDetailSyncState.$inferSelect): PullRequestDetailSyncStateRecord {
return {
repoFullName: row.repoFullName,
Expand Down
6 changes: 6 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,12 @@ export const burdenForecasts = sqliteTable("burden_forecasts", {
generatedAt: text("generated_at").notNull().default("CURRENT_TIMESTAMP"),
});

export const repoQueueTrendSnapshots = sqliteTable("repo_queue_trend_snapshots", {
repoFullName: text("repo_full_name").primaryKey(),
payloadJson: text("payload_json").notNull().default("{}"),
generatedAt: text("generated_at").notNull().default("CURRENT_TIMESTAMP"),
});

export const registryDriftEvents = sqliteTable("registry_drift_events", {
id: text("id").primaryKey(),
repoFullName: text("repo_full_name").notNull(),
Expand Down
7 changes: 6 additions & 1 deletion src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
getLatestRepoGithubTotalsSnapshot,
getIssue,
getRepository,
getRepoQueueTrendSnapshot,
listCheckSummaries,
listContributorRepoStats,
listContributorIssues,
Expand Down Expand Up @@ -46,6 +47,7 @@ import { loadOrComputeIssueQualityResponse } from "../services/issue-quality";
import { loadOrComputeBurdenForecastResponse } from "../services/burden-forecast";
import { buildMcpClientTelemetry } from "../services/client-telemetry";
import { loadOrComputeRepoOutcomePatternsResponse } from "../services/repo-outcome-patterns";
import { buildUnavailableQueueTrendReport } from "../services/queue-trends";
import {
applyMcpPlanningChoices,
buildMcpPlanningElicitationAudit,
Expand Down Expand Up @@ -275,6 +277,7 @@ const repoContextOutputSchema = {
repo: z.unknown().optional(),
lane: z.unknown().optional(),
queueHealth: z.unknown().optional(),
queueTrends: z.unknown().optional(),
collisions: z.unknown().optional(),
configQuality: z.unknown().optional(),
dataQuality: z.unknown().optional(),
Expand Down Expand Up @@ -805,12 +808,13 @@ export class GittensoryMcp {

private async getRepoContext(input: { owner: string; repo: string }): Promise<ToolPayload> {
const fullName = `${input.owner}/${input.repo}`;
const [repo, issues, pullRequests, recentMergedPullRequests, queueCounts] = await Promise.all([
const [repo, issues, pullRequests, recentMergedPullRequests, queueCounts, queueTrends] = await Promise.all([
getRepository(this.env, fullName),
listIssueSignalSample(this.env, fullName),
listOpenPullRequests(this.env, fullName),
listRecentMergedPullRequests(this.env, fullName),
this.loadOpenQueueCounts(fullName),
getRepoQueueTrendSnapshot(this.env, fullName),
]);
const collisions = buildCollisionReport(fullName, issues, pullRequests, recentMergedPullRequests);
return {
Expand All @@ -820,6 +824,7 @@ export class GittensoryMcp {
repo,
lane: buildLaneAdvice(repo, fullName),
queueHealth: buildQueueHealth(repo, issues, pullRequests, collisions, queueCounts),
queueTrends: queueTrends?.payload ?? buildUnavailableQueueTrendReport(fullName),
collisions,
configQuality: buildConfigQuality(repo, issues, pullRequests, fullName),
dataQuality: await this.loadRepoDataQuality(fullName),
Expand Down
1 change: 1 addition & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1748,6 +1748,7 @@ export const RepoIntelligenceSchema = z
repo: RepositorySchema.nullable(),
lane: LaneAdviceSchema,
queueHealth: z.record(z.string(), z.unknown()).nullable().optional(),
queueTrends: z.record(z.string(), z.unknown()).nullable().optional(),
collisions: z.record(z.string(), z.unknown()).optional(),
configQuality: z.record(z.string(), z.unknown()).nullable().optional(),
labelAudit: z.record(z.string(), z.unknown()).nullable().optional(),
Expand Down
20 changes: 19 additions & 1 deletion src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
listIssues,
listIssueSignalSample,
listLatestSignalSnapshotsByTarget,
listSignalSnapshots,
listRepoGithubTotalsSnapshotHistory,
listOtherOpenPullRequests,
listOpenPullRequests,
listPullRequests,
Expand All @@ -35,6 +37,7 @@ import {
persistSignalSnapshot,
recordWebhookEvent,
replaceCollisionEdges,
upsertRepoQueueTrendSnapshot,
upsertAgentCommandAnswer,
upsertOfficialMinerDetection,
rollupProductUsageDaily,
Expand Down Expand Up @@ -84,6 +87,7 @@ import { commandAuthorizationAllowedRoles, commandAuthorizationNeedsMinerDetecti
import { loadIssueQualityReportMap } from "../services/issue-quality";
import { generateWeeklyValueReport } from "../services/weekly-value-report";
import { REPO_OUTCOME_PATTERNS_SIGNAL, computeRepoOutcomePatterns } from "../services/repo-outcome-patterns";
import { buildQueueTrendReport, QUEUE_TREND_HISTORY_DAYS } from "../services/queue-trends";
import {
buildUpstreamRulesetSnapshot,
detectAndPersistUpstreamDrift,
Expand Down Expand Up @@ -463,13 +467,16 @@ async function buildBurdenForecasts(env: Env, repoFullName?: string): Promise<vo
export async function generateSignalSnapshots(env: Env, repoFullName?: string): Promise<void> {
const repositories = (await listRepositories(env)).filter((repo) => repo.isRegistered && (!repoFullName || repo.fullName === repoFullName));
for (const repo of repositories) {
const [issues, pullRequests, recentMergedPullRequests, labels, queueCounts, bounties] = await Promise.all([
const trendSince = new Date(Date.now() - QUEUE_TREND_HISTORY_DAYS * 24 * 60 * 60 * 1000).toISOString();
const [issues, pullRequests, recentMergedPullRequests, labels, queueCounts, bounties, totalsHistory, queueHealthHistory] = await Promise.all([
listIssueSignalSample(env, repo.fullName),
listOpenPullRequests(env, repo.fullName),
listRecentMergedPullRequests(env, repo.fullName),
listRepoLabels(env, repo.fullName),
loadOpenQueueCounts(env, repo.fullName),
listBountiesByRepo(env, repo.fullName),
listRepoGithubTotalsSnapshotHistory(env, repo.fullName, { sinceIso: trendSince, limit: 120 }),
listSignalSnapshots(env, "queue-health", repo.fullName),
]);
const collisions = buildCollisionReport(repo.fullName, issues, pullRequests, recentMergedPullRequests);
const queueHealth = buildQueueHealth(repo, issues, pullRequests, collisions, queueCounts);
Expand All @@ -489,6 +496,17 @@ export async function generateSignalSnapshots(env: Env, repoFullName?: string):
payload: queueHealth as unknown as Record<string, never>,
generatedAt,
});
await upsertRepoQueueTrendSnapshot(env, {
repoFullName: repo.fullName,
payload: buildQueueTrendReport({
repoFullName: repo.fullName,
totalsSnapshots: totalsHistory,
queueHealthSnapshots: queueHealthHistory,
currentQueueHealth: queueHealth,
generatedAt,
}) as unknown as Record<string, never>,
generatedAt,
});
await persistSignalSnapshot(env, {
id: crypto.randomUUID(),
signalType: "config-quality",
Expand Down
Loading