Commit2436d1dunknown_key
Merge pull request #109 from ccantynz-alt/claude/platform-analysis-roadmap-1nUGL
Merge pull request #109 from ccantynz-alt/claude/platform-analysis-roadmap-1nUGL Claude/platform analysis roadmap 1n ugl
26 files changed+6133−222436d1d382232732cac3e8b0afdf6020c2ac40a4
26 changed files+6133−26
Deleteddrizzle/0088_pr_visits.sql+0−6View fileUnifiedSplit
@@ -1,6 +0,0 @@
1CREATE TABLE IF NOT EXISTS pr_visits (
2 pr_id UUID NOT NULL REFERENCES pull_requests(id) ON DELETE CASCADE,
3 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
4 visited_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
5 PRIMARY KEY (pr_id, user_id)
6);
Deleteddrizzle/0088_repo_onboarding.sql+0−17View fileUnifiedSplit
@@ -1,17 +0,0 @@
1
2ALTER TABLE repositories ADD COLUMN IF NOT EXISTS onboarding_shown BOOLEAN NOT NULL DEFAULT false;
3
4CREATE TABLE IF NOT EXISTS repo_onboarding_data (
5 repository_id UUID PRIMARY KEY REFERENCES repositories(id) ON DELETE CASCADE,
6 detected_language TEXT,
7 detected_framework TEXT,
8 suggested_readme TEXT,
9 suggested_labels JSONB NOT NULL DEFAULT '[]',
10 suggested_gates_config TEXT,
11 first_commit_suggestions JSONB NOT NULL DEFAULT '[]',
12 generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
13);
Deleteddrizzle/0089_smart_digest_pref.sql+0−2View fileUnifiedSplit
@@ -1,2 +0,0 @@
1ALTER TABLE users ADD COLUMN IF NOT EXISTS notify_smart_digest BOOLEAN NOT NULL DEFAULT true;
2ALTER TABLE users ADD COLUMN IF NOT EXISTS last_smart_digest_sent_at TIMESTAMPTZ;
Addeddrizzle/0101_ai_loop.sql+5−0View fileUnifiedSplit
@@ -0,0 +1,5 @@
1-- Migration 0101: Add ai_loop columns to pull_requests table
2-- Tracks the autonomous issue-to-merged-PR loop state per PR.
3
4ALTER TABLE pull_requests ADD COLUMN IF NOT EXISTS ai_loop_attempts int NOT NULL DEFAULT 0;
5ALTER TABLE pull_requests ADD COLUMN IF NOT EXISTS ai_loop_status text; -- null | 'running' | 'merged' | 'failed'
Addeddrizzle/0102_cross_repo_impact.sql+9−0View fileUnifiedSplit
@@ -0,0 +1,9 @@
1-- Cross-repo impact cache (15min TTL, cleared on new PR push)
2CREATE TABLE IF NOT EXISTS cross_repo_impact_cache (
3 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
4 pr_id uuid NOT NULL REFERENCES pull_requests(id) ON DELETE CASCADE,
5 report jsonb NOT NULL,
6 analyzed_at timestamp DEFAULT now(),
7 cached_until timestamp NOT NULL
8);
9CREATE INDEX IF NOT EXISTS idx_cross_repo_impact_pr ON cross_repo_impact_cache(pr_id);
Addeddrizzle/0103_workspace_jobs.sql+16−0View fileUnifiedSplit
@@ -0,0 +1,16 @@
1-- Workspace job persistence (hot path is in-memory; this is for auditability)
2CREATE TABLE IF NOT EXISTS workspace_jobs (
3 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
4 repo_id uuid NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
5 issue_id uuid NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
6 triggered_by uuid REFERENCES users(id),
7 status text NOT NULL DEFAULT 'pending',
8 plan_comment text,
9 branch_name text,
10 pr_number int,
11 error_message text,
12 started_at timestamp DEFAULT now(),
13 updated_at timestamp DEFAULT now()
14);
15CREATE INDEX IF NOT EXISTS idx_workspace_jobs_repo ON workspace_jobs(repo_id);
16CREATE INDEX IF NOT EXISTS idx_workspace_jobs_issue ON workspace_jobs(issue_id);
Addeddrizzle/0104_repo_health_cache.sql+9−0View fileUnifiedSplit
@@ -0,0 +1,9 @@
1CREATE TABLE IF NOT EXISTS repo_health_cache (
2 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
3 repo_id uuid NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
4 score int NOT NULL,
5 breakdown jsonb NOT NULL,
6 computed_at timestamp DEFAULT now(),
7 expires_at timestamp NOT NULL
8);
9CREATE UNIQUE INDEX IF NOT EXISTS idx_repo_health_cache_repo ON repo_health_cache(repo_id);
Modifiedsrc/app.tsx+14−0View fileUnifiedSplit
@@ -127,6 +127,7 @@ import codeScanningRoutes from "./routes/code-scanning";
127127import securityRoutes from "./routes/security";
128128import commitStatusesRoutes from "./routes/commit-statuses";
129129import copilotRoutes from "./routes/copilot";
130import { pairProgrammerRoutes } from "./routes/pair-programmer";
130131import depUpdaterRoutes from "./routes/dep-updater";
131132import depsRoutes from "./routes/deps";
132133import discussionsRoutes from "./routes/discussions";
@@ -163,6 +164,7 @@ import requiredChecksRoutes from "./routes/required-checks";
163164import rulesetsRoutes from "./routes/rulesets";
164165import searchRoutes from "./routes/search";
165166import semanticSearchRoutes from "./routes/semantic-search";
167import nlSearchRoutes from "./routes/nl-search";
166168import signingKeysRoutes from "./routes/signing-keys";
167169import sponsorsRoutes from "./routes/sponsors";
168170import ssoRoutes from "./routes/sso";
@@ -190,12 +192,15 @@ import velocityRoutes from "./routes/velocity";
190192import { staleBranchRoutes } from "./routes/stale-branches";
191193import pulseRoutes from "./routes/pulse";
192194import healthScoreRoutes from "./routes/health-score";
195import repoHealthRoutes from "./routes/repo-health";
193196import hotFilesRoutes from "./routes/hot-files";
194197import debtMapRoutes from "./routes/debt-map";
195198import busFactorRoutes from "./routes/bus-factor";
199import crossRepoImpactRoutes from "./routes/cross-repo-impact";
196200import developerProgramRoutes from "./routes/developer-program";
197201import shareRoutes from "./routes/share";
198202import incidentHookRoutes from "./routes/incident-hooks";
203import workspaceRoutes from "./routes/ai-workspace";
199204import { authRateLimit, gitRateLimit, searchRateLimit } from "./middleware/rate-limit";
200205import { csrfToken, csrfProtect } from "./middleware/csrf";
201206import { noCache } from "./middleware/no-cache";
@@ -519,6 +524,9 @@ app.route("/", milestonesRoutes);
519524// Ship Agent — autonomous AI feature implementation
520525app.route("/", shipAgentRoutes);
521526
527// AI Copilot Workspace — issue-to-PR autonomous agent
528app.route("/", workspaceRoutes);
529
522530// Comment moderation queue — owner-only `/:owner/:repo/comments/pending`
523531// + per-row approve/reject/spam actions. Mounted before `pullRoutes` so
524532// the `/:owner/:repo/comments/*` paths resolve before the broader PR
@@ -752,6 +760,7 @@ app.route("/", codeScanningRoutes);
752760app.route("/", securityRoutes);
753761app.route("/", commitStatusesRoutes);
754762app.route("/", copilotRoutes);
763app.route("/", pairProgrammerRoutes);
755764app.route("/", depUpdaterRoutes);
756765app.route("/", depsRoutes);
757766app.route("/", discussionsRoutes);
@@ -802,12 +811,16 @@ app.route("/", staleBranchRoutes);
802811app.route("/", pulseRoutes);
803812// Repository Health Score — BLOCK M14 — /:owner/:repo/insights/health
804813app.route("/", healthScoreRoutes);
814// Repository Health Score breakdown page — /:owner/:repo/health
815app.route("/", repoHealthRoutes);
805816// Hot Files Heatmap — BLOCK M16 — /:owner/:repo/insights/hotfiles
806817app.route("/", hotFilesRoutes);
807818// AI Technical Debt Map — /:owner/:repo/debt-map (visual debt graph + Claude analysis)
808819app.route("/", debtMapRoutes);
809820// Bus Factor Analysis — /:owner/:repo/insights/bus-factor
810821app.route("/", busFactorRoutes);
822// Cross-Repo Dependency Impact Detection — /:owner/:repo/pulls/:number/cross-repo-impact
823app.route("/", crossRepoImpactRoutes);
811824// Hosted Claude tool-use loops — paste loop, get endpoint, billing meter.
812825// See src/routes/claude-deploy.tsx + src/lib/hosted-claude-loop.ts.
813826app.route("/", claudeDeployRoutes);
@@ -816,6 +829,7 @@ app.route("/", requiredChecksRoutes);
816829app.route("/", rulesetsRoutes);
817830app.route("/", searchRoutes);
818831app.route("/", semanticSearchRoutes);
832app.route("/", nlSearchRoutes);
819833app.route("/", signingKeysRoutes);
820834app.route("/", sponsorsRoutes);
821835app.route("/", ssoRoutes);
Modifiedsrc/db/schema.ts+82−0View fileUnifiedSplit
@@ -246,6 +246,7 @@ export const repositories = pgTable(
246246 // Migration 0088 — smart empty states. Set to true once the onboarding
247247 // card has been dismissed by the repo owner. Generated on first push.
248248 onboardingShown: boolean("onboarding_shown").default(false).notNull(),
249 depUpdaterEnabled: boolean("dep_updater_enabled").default(false).notNull(),
249250 },
250251 (table) => [
251252 // Partial: uniqueness only in the user namespace (org-owned rows exempt).
@@ -750,6 +751,12 @@ export const pullRequests = pgTable(
750751 createdAt: timestamp("created_at").defaultNow().notNull(),
751752 updatedAt: timestamp("updated_at").defaultNow().notNull(),
752753 closedAt: timestamp("closed_at"),
754 // AI loop columns (migration 0101). Track the autonomous issue→PR→merge loop.
755 // aiLoopAttempts: how many fix-and-retry cycles have run so far (0 = not started).
756 // aiLoopStatus: null = not started, 'running' = loop active, 'merged' = success,
757 // 'failed' = exhausted attempts or unrecoverable error.
758 aiLoopAttempts: integer("ai_loop_attempts").notNull().default(0),
759 aiLoopStatus: text("ai_loop_status"), // null | 'running' | 'merged' | 'failed'
753760 },
754761 (table) => [
755762 index("prs_repo_state").on(table.repositoryId, table.state),
@@ -4567,3 +4574,78 @@ export const repoOnboardingData = pgTable("repo_onboarding_data", {
45674574
45684575export type RepoOnboardingData = typeof repoOnboardingData.$inferSelect;
45694576export type NewRepoOnboardingData = typeof repoOnboardingData.$inferInsert;
4577
4578// ---------------------------------------------------------------------------
4579// Migration 0102 — Cross-repo impact cache (15-min TTL, keyed on PR id)
4580// ---------------------------------------------------------------------------
4581export const crossRepoImpactCache = pgTable(
4582 "cross_repo_impact_cache",
4583 {
4584 id: uuid("id").primaryKey().defaultRandom(),
4585 prId: uuid("pr_id")
4586 .notNull()
4587 .references(() => pullRequests.id, { onDelete: "cascade" }),
4588 report: jsonb("report").notNull(),
4589 analyzedAt: timestamp("analyzed_at").defaultNow(),
4590 cachedUntil: timestamp("cached_until").notNull(),
4591 },
4592 (table) => [
4593 index("idx_cross_repo_impact_pr").on(table.prId),
4594 ]
4595);
4596
4597export type CrossRepoImpactCache = typeof crossRepoImpactCache.$inferSelect;
4598
4599// ---------------------------------------------------------------------------
4600// Migration 0104 — Repository health score cache (6h TTL, in-memory + DB)
4601// ---------------------------------------------------------------------------
4602export const repoHealthCache = pgTable(
4603 "repo_health_cache",
4604 {
4605 id: uuid("id").primaryKey().defaultRandom(),
4606 repoId: uuid("repo_id")
4607 .notNull()
4608 .unique()
4609 .references(() => repositories.id, { onDelete: "cascade" }),
4610 score: integer("score").notNull(),
4611 breakdown: jsonb("breakdown").notNull(),
4612 computedAt: timestamp("computed_at").defaultNow(),
4613 expiresAt: timestamp("expires_at").notNull(),
4614 },
4615 (table) => [
4616 index("idx_repo_health_cache_repo").on(table.repoId),
4617 ]
4618);
4619
4620export type RepoHealthCache = typeof repoHealthCache.$inferSelect;
4621
4622// ---------------------------------------------------------------------------
4623// Migration 0103 — Workspace jobs (AI Copilot Workspace: issue → PR agent)
4624// Hot path is in-memory; this table is for auditability + restart recovery.
4625// ---------------------------------------------------------------------------
4626export const workspaceJobs = pgTable(
4627 "workspace_jobs",
4628 {
4629 id: uuid("id").primaryKey().defaultRandom(),
4630 repoId: uuid("repo_id")
4631 .notNull()
4632 .references(() => repositories.id, { onDelete: "cascade" }),
4633 issueId: uuid("issue_id")
4634 .notNull()
4635 .references(() => issues.id, { onDelete: "cascade" }),
4636 triggeredBy: uuid("triggered_by").references(() => users.id),
4637 status: text("status").notNull().default("pending"),
4638 planComment: text("plan_comment"),
4639 branchName: text("branch_name"),
4640 prNumber: integer("pr_number"),
4641 errorMessage: text("error_message"),
4642 startedAt: timestamp("started_at").defaultNow(),
4643 updatedAt: timestamp("updated_at").defaultNow(),
4644 },
4645 (table) => [
4646 index("idx_workspace_jobs_repo").on(table.repoId),
4647 index("idx_workspace_jobs_issue").on(table.issueId),
4648 ]
4649);
4650
4651export type WorkspaceJob = typeof workspaceJobs.$inferSelect;
Modifiedsrc/hooks/post-receive.ts+1−0View fileUnifiedSplit
@@ -36,6 +36,7 @@ import {
3636 startDeployRow,
3737} from "../lib/server-target-store";
3838import { deployToTarget } from "../lib/server-targets";
39import { fireCloudDeploys } from "../lib/cloud-deploy";
3940import { ensureRepoOnboarding } from "../lib/repo-onboarding";
4041
4142interface PushRef {
Modifiedsrc/lib/ai-build-tasks.ts+34−0View fileUnifiedSplit
@@ -31,6 +31,7 @@ import {
3131} from "../db/schema";
3232import { extractClosingRefsMulti } from "./close-keywords";
3333import { buildSpecFromIssue } from "../routes/specs";
34import { runAutonomousLoop } from "./ai-loop";
3435
3536/**
3637 * Stable marker baked into the issue comment so subsequent ticks can detect
@@ -333,6 +334,39 @@ export async function runAiBuildTaskOnce(
333334 console.error(
334335 `[autopilot] ai-build: dispatcher failed for issue=${cand.issueId}: ${res.error}`
335336 );
337 } else if (process.env.AI_LOOP_ENABLED === "1") {
338 // Fire-and-forget: resolve the PR UUID from prNumber + repoId, then
339 // start the autonomous loop. Errors are swallowed.
340 const prNumber = res.prNumber;
341 const repoId = cand.repositoryId;
342 Promise.resolve().then(async () => {
343 try {
344 const rows = await db
345 .select({ id: pullRequests.id })
346 .from(pullRequests)
347 .where(
348 and(
349 eq(pullRequests.repositoryId, repoId),
350 eq(pullRequests.number, prNumber)
351 )
352 )
353 .limit(1);
354 const prId = rows[0]?.id;
355 if (prId) {
356 await runAutonomousLoop(prId, repoId);
357 }
358 } catch (loopErr) {
359 console.error(
360 `[autopilot] ai-build: ai-loop fire-and-forget failed for issue=${cand.issueId}:`,
361 loopErr
362 );
363 }
364 }).catch((err) => {
365 console.error(
366 `[autopilot] ai-build: ai-loop promise rejected for issue=${cand.issueId}:`,
367 err
368 );
369 });
336370 }
337371 } catch (err) {
338372 console.error(
Addedsrc/lib/ai-loop.ts+535−0View fileUnifiedSplit
@@ -0,0 +1,535 @@
1/**
2 * Autonomous Issue-to-Merged-PR Loop (ai-loop).
3 *
4 * After spec-to-pr creates a PR, this module drives a self-healing cycle:
5 * 1. Check if the latest gate run for the PR is green or red.
6 * 2. Green → call performMerge and mark the PR as merged.
7 * 3. Red and attempts < MAX_ATTEMPTS → call triggerCiAutofix, poll for a
8 * new gate run (up to 2 minutes), then loop.
9 * 4. Attempts exhausted → post a failure comment and give up.
10 *
11 * Idempotency:
12 * - Before starting, check for a <!-- gluecron:ai-loop:v1 --> marker in
13 * existing PR comments. If present: skip (already handled).
14 * - Each attempt is annotated with <!-- gluecron:ai-loop:attempt:N -->.
15 *
16 * Safe-default: the env var AI_LOOP_ENABLED must equal "1" for fire-and-
17 * forget callers that pass through ai-build-tasks.ts. The `runAutonomousLoop`
18 * export itself has no such guard so tests and targeted callers can invoke it
19 * unconditionally.
20 *
21 * Guard: isAiAvailable() is checked at the top of runAutonomousLoop; callers
22 * may also check it before invoking fire-and-forget. When the API key is
23 * absent the function returns immediately with {success:false}.
24 */
25
26import { and, desc, eq, sql } from "drizzle-orm";
27import { db } from "../db";
28import { gateRuns, prComments, pullRequests, repositories, users } from "../db/schema";
29import { isAiAvailable } from "./ai-client";
30import { performMerge } from "./pr-merge";
31import { triggerCiAutofix } from "./ci-autofix";
32import { getBotUserIdOrFallback } from "./bot-user";
33// Local copy to avoid import cycle with ai-build-tasks.ts
34const AI_BUILD_MARKER = "<!-- gluecron:ai-build:v1 -->";
35
36// ---------------------------------------------------------------------------
37// Public types
38// ---------------------------------------------------------------------------
39
40export interface LoopResult {
41 success: boolean;
42 attempts: number;
43 mergedAt?: Date;
44 failReason?: string;
45}
46
47// ---------------------------------------------------------------------------
48// Constants
49// ---------------------------------------------------------------------------
50
51/** Stable marker embedded in the initial "loop started" comment. */
52export const AI_LOOP_MARKER = "<!-- gluecron:ai-loop:v1 -->";
53/** Per-attempt progress marker prefix. */
54const AI_LOOP_ATTEMPT_PREFIX = "<!-- gluecron:ai-loop:attempt:";
55
56/** Maximum fix-and-retry cycles before giving up. */
57const MAX_ATTEMPTS: number = 3;
58
59/** How long to poll for a new gate run after triggering autofix (ms). */
60const POLL_TIMEOUT_MS = 2 * 60 * 1000;
61
62/** Interval between polls (ms). */
63const POLL_INTERVAL_MS = 10 * 1000;
64
65// ---------------------------------------------------------------------------
66// Internal helpers
67// ---------------------------------------------------------------------------
68
69/**
70 * Return true if any PR comment contains the ai-loop:v1 idempotency marker.
71 */
72async function hasLoopMarker(prId: string): Promise<boolean> {
73 try {
74 const rows = await db
75 .select({ id: prComments.id })
76 .from(prComments)
77 .where(
78 and(
79 eq(prComments.pullRequestId, prId),
80 sql`${prComments.body} LIKE ${"%" + AI_LOOP_MARKER + "%"}`
81 )
82 )
83 .limit(1);
84 return rows.length > 0;
85 } catch {
86 // Conservative: assume already handled on DB error.
87 return true;
88 }
89}
90
91/**
92 * Post a comment on the PR authored by the bot (or fallback to the PR author).
93 * Never throws.
94 */
95async function postComment(
96 prId: string,
97 fallbackAuthorId: string,
98 body: string
99): Promise<void> {
100 try {
101 const authorId = await getBotUserIdOrFallback(fallbackAuthorId);
102 await db.insert(prComments).values({
103 pullRequestId: prId,
104 authorId,
105 body,
106 isAiReview: true,
107 });
108 } catch (err) {
109 console.error("[ai-loop] postComment failed:", err);
110 }
111}
112
113/**
114 * Load the latest gate run for this PR. Returns null when none exists.
115 */
116async function loadLatestGateRun(prId: string): Promise<{
117 id: string;
118 status: string;
119 summary: string | null;
120 details: string | null;
121 createdAt: Date;
122} | null> {
123 try {
124 const rows = await db
125 .select({
126 id: gateRuns.id,
127 status: gateRuns.status,
128 summary: gateRuns.summary,
129 details: gateRuns.details,
130 createdAt: gateRuns.createdAt,
131 })
132 .from(gateRuns)
133 .where(eq(gateRuns.pullRequestId, prId))
134 .orderBy(desc(gateRuns.createdAt))
135 .limit(1);
136 return rows[0] ?? null;
137 } catch {
138 return null;
139 }
140}
141
142/**
143 * Update the ai_loop_attempts and ai_loop_status columns on the PR row.
144 * Best-effort — failures are logged but not rethrown.
145 */
146async function updatePrLoopState(
147 prId: string,
148 attempts: number,
149 status: "running" | "merged" | "failed"
150): Promise<void> {
151 try {
152 await db
153 .update(pullRequests)
154 .set({
155 aiLoopAttempts: attempts,
156 aiLoopStatus: status,
157 updatedAt: new Date(),
158 })
159 .where(eq(pullRequests.id, prId));
160 } catch (err) {
161 console.error("[ai-loop] updatePrLoopState failed:", err);
162 }
163}
164
165/**
166 * Poll until a gate run newer than `afterDate` appears for this PR (or times out).
167 * Returns the new gate run, or null on timeout.
168 */
169async function pollForNewGateRun(
170 prId: string,
171 afterDate: Date
172): Promise<{ id: string; status: string } | null> {
173 const deadline = Date.now() + POLL_TIMEOUT_MS;
174 while (Date.now() < deadline) {
175 await new Promise<void>((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
176 try {
177 const rows = await db
178 .select({ id: gateRuns.id, status: gateRuns.status })
179 .from(gateRuns)
180 .where(
181 and(
182 eq(gateRuns.pullRequestId, prId),
183 sql`${gateRuns.createdAt} > ${afterDate}`
184 )
185 )
186 .orderBy(desc(gateRuns.createdAt))
187 .limit(1);
188 if (rows.length > 0) return rows[0];
189 } catch {
190 // continue polling
191 }
192 }
193 return null;
194}
195
196// ---------------------------------------------------------------------------
197// Core export
198// ---------------------------------------------------------------------------
199
200/**
201 * Drive the autonomous fix-and-merge loop for a single PR.
202 *
203 * Flow:
204 * • Guard: isAiAvailable() → no-op return when ANTHROPIC_API_KEY missing.
205 * • Idempotency: check for AI_LOOP_MARKER in existing PR comments.
206 * • Load PR + repo metadata needed for performMerge.
207 * • Loop up to MAX_ATTEMPTS:
208 * - Fetch latest gate run.
209 * - No gate run yet → wait for one (poll up to 2 min).
210 * - Gate is green (passed/skipped) → performMerge → done.
211 * - Gate is red → post attempt comment, triggerCiAutofix, poll for
212 * new gate run, loop.
213 * - Gate is pending/running → wait for it to settle (poll 2 min).
214 * • Attempts exhausted → post failure comment, set aiLoopStatus='failed'.
215 */
216export async function runAutonomousLoop(
217 prId: string,
218 repoId: string
219): Promise<LoopResult> {
220 if (!isAiAvailable()) {
221 return { success: false, attempts: 0, failReason: "ANTHROPIC_API_KEY not set" };
222 }
223
224 // Load PR row.
225 let pr: {
226 id: string;
227 number: number;
228 title: string;
229 body: string | null;
230 baseBranch: string;
231 headBranch: string;
232 state: string;
233 isDraft: boolean;
234 authorId: string;
235 repositoryId: string;
236 aiLoopAttempts: number;
237 aiLoopStatus: string | null;
238 } | undefined;
239
240 try {
241 const rows = await db
242 .select({
243 id: pullRequests.id,
244 number: pullRequests.number,
245 title: pullRequests.title,
246 body: pullRequests.body,
247 baseBranch: pullRequests.baseBranch,
248 headBranch: pullRequests.headBranch,
249 state: pullRequests.state,
250 isDraft: pullRequests.isDraft,
251 authorId: pullRequests.authorId,
252 repositoryId: pullRequests.repositoryId,
253 aiLoopAttempts: pullRequests.aiLoopAttempts,
254 aiLoopStatus: pullRequests.aiLoopStatus,
255 })
256 .from(pullRequests)
257 .where(eq(pullRequests.id, prId))
258 .limit(1);
259 pr = rows[0];
260 } catch (err) {
261 return {
262 success: false,
263 attempts: 0,
264 failReason: `DB load failed: ${err instanceof Error ? err.message : String(err)}`,
265 };
266 }
267
268 if (!pr) {
269 return { success: false, attempts: 0, failReason: "PR not found" };
270 }
271 if (pr.state !== "open") {
272 return {
273 success: false,
274 attempts: 0,
275 failReason: `PR is not open (state=${pr.state})`,
276 };
277 }
278 // Already handled by another loop run.
279 if (pr.aiLoopStatus === "running" || pr.aiLoopStatus === "merged") {
280 return {
281 success: pr.aiLoopStatus === "merged",
282 attempts: pr.aiLoopAttempts,
283 failReason:
284 pr.aiLoopStatus === "running"
285 ? "Another loop run is already in progress"
286 : undefined,
287 };
288 }
289
290 // Idempotency: skip if the loop marker already exists.
291 if (await hasLoopMarker(prId)) {
292 return {
293 success: false,
294 attempts: 0,
295 failReason: "Loop already started (marker present)",
296 };
297 }
298
299 // Load repo + owner for performMerge.
300 let repoRow: { name: string; ownerUsername: string } | undefined;
301 try {
302 const rows = await db
303 .select({
304 name: repositories.name,
305 ownerUsername: users.username,
306 })
307 .from(repositories)
308 .innerJoin(users, eq(users.id, repositories.ownerId))
309 .where(eq(repositories.id, repoId))
310 .limit(1);
311 repoRow = rows[0];
312 } catch {
313 /* fall through — caught below */
314 }
315
316 if (!repoRow) {
317 return { success: false, attempts: 0, failReason: "Repo not found" };
318 }
319
320 // Mark loop as started: post the idempotency marker comment and update DB.
321 await postComment(
322 prId,
323 pr.authorId,
324 `${AI_LOOP_MARKER}\nThe autonomous AI loop has started for this PR. It will attempt to fix any CI failures and merge automatically (up to ${MAX_ATTEMPTS} attempts).`
325 );
326 await updatePrLoopState(prId, 0, "running");
327
328 let attempts = 0;
329
330 // ---------------------------------------------------------------------------
331 // Main retry loop
332 // ---------------------------------------------------------------------------
333 while (attempts < MAX_ATTEMPTS) {
334 // Re-fetch PR state in case it was closed/merged externally.
335 try {
336 const rows = await db
337 .select({ state: pullRequests.state, isDraft: pullRequests.isDraft })
338 .from(pullRequests)
339 .where(eq(pullRequests.id, prId))
340 .limit(1);
341 const current = rows[0];
342 if (!current || current.state !== "open") {
343 return {
344 success: current?.state === "merged",
345 attempts,
346 failReason:
347 current?.state !== "merged"
348 ? `PR no longer open (state=${current?.state ?? "unknown"})`
349 : undefined,
350 };
351 }
352 // Refresh isDraft flag in case someone changed it.
353 pr = { ...pr, isDraft: current.isDraft };
354 } catch {
355 // Continue with stale data — best effort.
356 }
357
358 // Fetch the latest gate run.
359 let gateRun = await loadLatestGateRun(prId);
360
361 // If no gate run exists yet, wait for one.
362 if (!gateRun) {
363 const found = await pollForNewGateRun(prId, new Date(0));
364 if (!found) {
365 // Still nothing — treat as a failure to progress.
366 break;
367 }
368 gateRun = { ...found, summary: null, details: null, createdAt: new Date() };
369 }
370
371 // If gate is still pending/running, wait for it to settle.
372 if (gateRun.status === "pending" || gateRun.status === "running") {
373 const settled = await pollForNewGateRun(prId, new Date(gateRun.createdAt.getTime() - 1));
374 if (settled) {
375 gateRun = { ...gateRun, ...settled };
376 }
377 // If still not settled, we'll try to evaluate with what we have.
378 }
379
380 const gateGreen =
381 gateRun.status === "passed" ||
382 gateRun.status === "skipped" ||
383 gateRun.status === "repaired";
384
385 if (gateGreen) {
386 // Gate is green — attempt to merge.
387 const mergeResult = await performMerge({
388 pr: {
389 id: pr.id,
390 number: pr.number,
391 title: pr.title,
392 body: pr.body,
393 baseBranch: pr.baseBranch,
394 headBranch: pr.headBranch,
395 repositoryId: pr.repositoryId,
396 authorId: pr.authorId,
397 state: "open",
398 isDraft: pr.isDraft,
399 },
400 ownerName: repoRow.ownerUsername,
401 repoName: repoRow.name,
402 actorUserId: pr.authorId,
403 hasConflicts: false,
404 });
405
406 if (mergeResult.ok) {
407 const mergedAt = new Date();
408 await updatePrLoopState(prId, attempts, "merged");
409 await postComment(
410 prId,
411 pr.authorId,
412 `${AI_LOOP_MARKER}\nThe autonomous AI loop successfully merged this PR after ${attempts === 0 ? "0 fix attempts (gate was already green)" : `${attempts} fix attempt${attempts === 1 ? "" : "s"}`}.`
413 );
414 return { success: true, attempts, mergedAt };
415 }
416
417 // Merge failed despite green gate — this is unexpected; give up.
418 const reason = mergeResult.error || "unknown merge error";
419 await updatePrLoopState(prId, attempts, "failed");
420 await postComment(
421 prId,
422 pr.authorId,
423 `${AI_LOOP_MARKER}\n**AI Loop: merge failed**\n\nThe gate was green but the merge failed: ${reason}\n\nManual intervention required.`
424 );
425 return { success: false, attempts, failReason: `Merge failed: ${reason}` };
426 }
427
428 // Gate is red — attempt a fix.
429 attempts += 1;
430 await updatePrLoopState(prId, attempts, "running");
431
432 const attemptMarker = `${AI_LOOP_ATTEMPT_PREFIX}${attempts} -->`;
433 await postComment(
434 prId,
435 pr.authorId,
436 `${attemptMarker}\n**AI Loop: fix attempt ${attempts}/${MAX_ATTEMPTS}**\n\nGate run \`${gateRun.id}\` reported status \`${gateRun.status}\`. Triggering CI autofix…`
437 );
438
439 // Trigger the autofix (fire-and-forget inside ci-autofix, but we await
440 // the wrapper because it does the Claude call synchronously).
441 await triggerCiAutofix(gateRun.id);
442
443 // Wait for a new gate run to appear (autofix triggers a new push → new run).
444 const newRun = await pollForNewGateRun(prId, gateRun.createdAt);
445 if (!newRun) {
446 // Autofix didn't produce a new gate run within the timeout.
447 if (attempts >= MAX_ATTEMPTS) break;
448 // Try the next attempt anyway — maybe the push just didn't start a new run.
449 }
450 }
451
452 // Exhausted all attempts.
453 await updatePrLoopState(prId, attempts, "failed");
454 await postComment(
455 prId,
456 pr.authorId,
457 `${AI_LOOP_MARKER}\n**AI Loop: exhausted ${MAX_ATTEMPTS} fix attempts**\n\nThe autonomous loop was unable to repair CI failures after ${MAX_ATTEMPTS} attempt${MAX_ATTEMPTS > 1 ? "s" : ""}. Manual intervention required.\n\nCC: @${repoRow.ownerUsername}`
458 );
459
460 return {
461 success: false,
462 attempts,
463 failReason: `Exhausted ${MAX_ATTEMPTS} fix attempts`,
464 };
465}
466
467// ---------------------------------------------------------------------------
468// Autopilot sweep helper
469// ---------------------------------------------------------------------------
470
471/**
472 * Scan for open PRs whose body contains the AI_BUILD_MARKER (created by the
473 * ai-build flow) and that have no AI_LOOP_MARKER comment yet. Runs up to
474 * `cap` per tick. Called by the autopilot `ai-loop-sweep` task.
475 *
476 * Never throws.
477 */
478export async function runAiLoopSweepOnce(cap = 5): Promise<{
479 considered: number;
480 started: number;
481 skipped: number;
482}> {
483 if (!isAiAvailable()) {
484 return { considered: 0, started: 0, skipped: 0 };
485 }
486
487 let candidates: { id: string; repositoryId: string }[] = [];
488 try {
489 candidates = await db
490 .select({ id: pullRequests.id, repositoryId: pullRequests.repositoryId })
491 .from(pullRequests)
492 .where(
493 and(
494 eq(pullRequests.state, "open"),
495 sql`${pullRequests.body} LIKE ${"%" + AI_BUILD_MARKER + "%"}`
496 )
497 )
498 .limit(cap * 3); // over-fetch so we can filter idempotent ones in JS
499 } catch (err) {
500 console.error("[ai-loop] sweep candidate query failed:", err);
501 return { considered: 0, started: 0, skipped: 0 };
502 }
503
504 let considered = 0;
505 let started = 0;
506 let skipped = 0;
507
508 for (const cand of candidates) {
509 if (started >= cap) break;
510 considered += 1;
511
512 // Skip if loop already has a marker comment.
513 const already = await hasLoopMarker(cand.id);
514 if (already) {
515 skipped += 1;
516 continue;
517 }
518
519 // Fire-and-forget — the loop is long-running (up to 6 minutes).
520 try {
521 void runAutonomousLoop(cand.id, cand.repositoryId).catch((err) => {
522 console.error(
523 `[ai-loop] runAutonomousLoop threw for pr=${cand.id}:`,
524 err
525 );
526 });
527 started += 1;
528 } catch (err) {
529 console.error(`[ai-loop] sweep: failed to start loop for pr=${cand.id}:`, err);
530 skipped += 1;
531 }
532 }
533
534 return { considered, started, skipped };
535}
Addedsrc/lib/ai-pair.ts+632−0View fileUnifiedSplit
@@ -0,0 +1,632 @@
1/**
2 * Proactive AI pair programmer — assembles rich editor context and generates
3 * suggestions that go beyond simple autocomplete.
4 *
5 * Two public entry points:
6 *
7 * assemblePairContext(repoId, filePath, userId)
8 * Queries the DB for open PRs touching this file, recent gate failures,
9 * related issues, and extracts top-level symbols from the file. Result is
10 * cached in-memory for 3 minutes per (repoId, filePath, userId) triple.
11 *
12 * generatePairSuggestion(prefix, suffix, filePath, context)
13 * Sends the assembled context to Claude (MODEL_SONNET) and returns a
14 * structured PairSuggestion. Falls back to a plain prefix completion on
15 * any error. Never throws.
16 *
17 * Design notes:
18 * - All DB queries are read-only and best-effort; individual failures are
19 * swallowed so the editor never blocks on a DB hiccup.
20 * - Git operations (diff --name-only, diff excerpt) run as Bun subprocesses
21 * against the bare repo on disk. Results are cached per PR for 5 minutes.
22 * - We intentionally avoid importing anything from src/routes/* to keep
23 * this file usable in background workers without spinning up HTTP handlers.
24 */
25
26import { and, desc, eq, gte, inArray } from "drizzle-orm";
27import { createHash } from "crypto";
28import { db } from "../db";
29import {
30 gateRuns,
31 issues,
32 pullRequests,
33 repositories,
34} from "../db/schema";
35import { getAnthropic, MODEL_SONNET, extractText, parseJsonResponse, isAiAvailable } from "./ai-client";
36import { extractSymbols, detectLanguage } from "./symbols";
37
38// ---------------------------------------------------------------------------
39// Public types
40// ---------------------------------------------------------------------------
41
42/** Contextual signals assembled for a specific file + user combination. */
43export interface PairContext {
44 openPrForFile?: {
45 prNumber: number;
46 title: string;
47 branch: string;
48 /** Unified diff excerpt for this file, max 2 KB. */
49 changedLines: string;
50 };
51 recentGateFailure?: {
52 runType: string;
53 /** First 500 chars of error log (summary or details field). */
54 errorSummary: string;
55 failedAt: Date;
56 };
57 /** Top-level export names detected in this file via regex. */
58 fileSymbols?: string[];
59 relatedIssue?: {
60 issueNumber: number;
61 title: string;
62 };
63}
64
65/** A structured suggestion returned to the editor. */
66export interface PairSuggestion {
67 type: "completion" | "warning" | "context_note" | "fix_available";
68 /** One line, shown inline in editor. */
69 headline: string;
70 /** Shown on hover/expand. */
71 detail?: string;
72 /** e.g. "Apply fix" */
73 actionLabel?: string;
74 /** Diff to apply or URL to navigate to. */
75 actionPayload?: string;
76}
77
78// ---------------------------------------------------------------------------
79// In-memory caches
80// ---------------------------------------------------------------------------
81
82interface CacheEntry<T> {
83 value: T;
84 expiresAt: number;
85}
86
87/** Per-(repoId:filePath:userId) context cache — 3 min TTL. */
88const contextCache = new Map<string, CacheEntry<PairContext>>();
89const CONTEXT_TTL_MS = 3 * 60 * 1000;
90
91/** Per-(prId) file-list cache — 5 min TTL. */
92const prFilesCache = new Map<string, CacheEntry<string[]>>();
93const PR_FILES_TTL_MS = 5 * 60 * 1000;
94
95/** Per-(repoId:filePath:userId:prefixSlice) suggestion cache — 30 s TTL. */
96const suggestCache = new Map<string, CacheEntry<PairSuggestion>>();
97const SUGGEST_TTL_MS = 30 * 1000;
98
99function cacheGet<T>(store: Map<string, CacheEntry<T>>, key: string): T | undefined {
100 const entry = store.get(key);
101 if (!entry) return undefined;
102 if (Date.now() > entry.expiresAt) {
103 store.delete(key);
104 return undefined;
105 }
106 return entry.value;
107}
108
109function cacheSet<T>(store: Map<string, CacheEntry<T>>, key: string, value: T, ttlMs: number): void {
110 store.set(key, { value, expiresAt: Date.now() + ttlMs });
111}
112
113// ---------------------------------------------------------------------------
114// Git helpers
115// ---------------------------------------------------------------------------
116
117/**
118 * List files touched by a PR's branch relative to its base branch.
119 * Returns an empty array on any error.
120 */
121async function prChangedFiles(
122 prId: string,
123 repoPath: string,
124 baseBranch: string,
125 headBranch: string
126): Promise<string[]> {
127 const cached = cacheGet(prFilesCache, prId);
128 if (cached !== undefined) return cached;
129
130 try {
131 const proc = Bun.spawn(
132 ["git", "diff", "--name-only", `${baseBranch}...${headBranch}`],
133 { cwd: repoPath, stdout: "pipe", stderr: "pipe" }
134 );
135 const text = await new Response(proc.stdout).text();
136 await proc.exited;
137 const files = text
138 .trim()
139 .split("\n")
140 .filter(Boolean);
141 cacheSet(prFilesCache, prId, files, PR_FILES_TTL_MS);
142 return files;
143 } catch {
144 return [];
145 }
146}
147
148/**
149 * Extract the diff excerpt for a specific file from a PR's branch comparison.
150 * Returns at most 2 KB of unified diff text.
151 */
152async function fileDiffExcerpt(
153 repoPath: string,
154 baseBranch: string,
155 headBranch: string,
156 filePath: string
157): Promise<string> {
158 try {
159 const proc = Bun.spawn(
160 ["git", "diff", `${baseBranch}...${headBranch}`, "--", filePath],
161 { cwd: repoPath, stdout: "pipe", stderr: "pipe" }
162 );
163 const text = await new Response(proc.stdout).text();
164 await proc.exited;
165 // Cap at 2 KB.
166 return text.slice(0, 2048);
167 } catch {
168 return "";
169 }
170}
171
172// ---------------------------------------------------------------------------
173// Symbol extraction from file content in the repo
174// ---------------------------------------------------------------------------
175
176/**
177 * Read a file from the HEAD of the repo's default branch and extract top-level
178 * symbol names. Returns an empty array on any error.
179 */
180async function extractFileSymbols(
181 repoPath: string,
182 filePath: string,
183 defaultBranch: string
184): Promise<string[]> {
185 try {
186 const proc = Bun.spawn(
187 ["git", "show", `${defaultBranch}:${filePath}`],
188 { cwd: repoPath, stdout: "pipe", stderr: "pipe" }
189 );
190 const content = await new Response(proc.stdout).text();
191 await proc.exited;
192 if (!content) return [];
193 const lang = detectLanguage(filePath);
194 if (!lang) return [];
195 const symbols = extractSymbols(content, lang);
196 // Return unique names, up to 30.
197 const seen = new Set<string>();
198 const names: string[] = [];
199 for (const s of symbols) {
200 if (!seen.has(s.name)) {
201 seen.add(s.name);
202 names.push(s.name);
203 if (names.length >= 30) break;
204 }
205 }
206 return names;
207 } catch {
208 return [];
209 }
210}
211
212// ---------------------------------------------------------------------------
213// DB helpers
214// ---------------------------------------------------------------------------
215
216/**
217 * Find the most recent open PR authored by this user in this repo that
218 * touches the given file path. Returns null if none found.
219 */
220async function findOpenPrForFile(
221 repoId: string,
222 filePath: string,
223 userId: string,
224 repoPath: string
225): Promise<PairContext["openPrForFile"] | undefined> {
226 try {
227 // Load up to 10 recent open PRs by this user in this repo.
228 const prs = await db
229 .select()
230 .from(pullRequests)
231 .where(
232 and(
233 eq(pullRequests.repositoryId, repoId),
234 eq(pullRequests.authorId, userId),
235 eq(pullRequests.state, "open")
236 )
237 )
238 .orderBy(desc(pullRequests.updatedAt))
239 .limit(10);
240
241 if (prs.length === 0) return undefined;
242
243 for (const pr of prs) {
244 const files = await prChangedFiles(
245 pr.id,
246 repoPath,
247 pr.baseBranch,
248 pr.headBranch
249 );
250 // Check if the target file is in this PR's changeset.
251 const touches = files.some(
252 (f) => f === filePath || filePath.endsWith(f) || f.endsWith(filePath)
253 );
254 if (!touches) continue;
255
256 const changedLines = await fileDiffExcerpt(
257 repoPath,
258 pr.baseBranch,
259 pr.headBranch,
260 filePath
261 );
262
263 return {
264 prNumber: pr.number,
265 title: pr.title,
266 branch: pr.headBranch,
267 changedLines,
268 };
269 }
270 return undefined;
271 } catch {
272 return undefined;
273 }
274}
275
276/**
277 * Find the most recent failed gate run for any open PR by this user in this
278 * repo within the last 30 minutes.
279 */
280async function findRecentGateFailure(
281 repoId: string,
282 userId: string
283): Promise<PairContext["recentGateFailure"] | undefined> {
284 try {
285 const cutoff = new Date(Date.now() - 30 * 60 * 1000);
286
287 // Get open PRs for this user.
288 const openPrs = await db
289 .select({ id: pullRequests.id })
290 .from(pullRequests)
291 .where(
292 and(
293 eq(pullRequests.repositoryId, repoId),
294 eq(pullRequests.authorId, userId),
295 eq(pullRequests.state, "open")
296 )
297 )
298 .limit(20);
299
300 if (openPrs.length === 0) return undefined;
301
302 const prIds = openPrs.map((p) => p.id);
303
304 const [failed] = await db
305 .select()
306 .from(gateRuns)
307 .where(
308 and(
309 inArray(gateRuns.pullRequestId, prIds),
310 eq(gateRuns.status, "failed"),
311 gte(gateRuns.createdAt, cutoff)
312 )
313 )
314 .orderBy(desc(gateRuns.createdAt))
315 .limit(1);
316
317 if (!failed) return undefined;
318
319 const rawLog = failed.summary || failed.details || "";
320 const errorSummary = rawLog.slice(0, 500);
321
322 return {
323 runType: failed.gateName,
324 errorSummary,
325 failedAt: new Date(failed.createdAt),
326 };
327 } catch {
328 return undefined;
329 }
330}
331
332/**
333 * Find the first open issue whose title or body mentions the file name.
334 */
335async function findRelatedIssue(
336 repoId: string,
337 filePath: string
338): Promise<PairContext["relatedIssue"] | undefined> {
339 try {
340 // Extract just the file name (last segment) for a broader match.
341 const fileName = filePath.split("/").pop() || filePath;
342
343 const allOpen = await db
344 .select()
345 .from(issues)
346 .where(
347 and(
348 eq(issues.repositoryId, repoId),
349 eq(issues.state, "open")
350 )
351 )
352 .orderBy(desc(issues.updatedAt))
353 .limit(50);
354
355 for (const issue of allOpen) {
356 const haystack = `${issue.title} ${issue.body ?? ""}`.toLowerCase();
357 if (
358 haystack.includes(filePath.toLowerCase()) ||
359 haystack.includes(fileName.toLowerCase())
360 ) {
361 return {
362 issueNumber: issue.number,
363 title: issue.title,
364 };
365 }
366 }
367 return undefined;
368 } catch {
369 return undefined;
370 }
371}
372
373// ---------------------------------------------------------------------------
374// Main exports
375// ---------------------------------------------------------------------------
376
377/**
378 * Assemble rich context for the pair programmer for a given file + user.
379 *
380 * Results are cached in-memory for 3 minutes. The function never throws;
381 * on any DB or git error it returns an empty (but valid) PairContext.
382 */
383export async function assemblePairContext(
384 repoId: string,
385 filePath: string,
386 userId: string
387): Promise<PairContext> {
388 const cacheKey = `${repoId}:${filePath}:${userId}`;
389 const cached = cacheGet(contextCache, cacheKey);
390 if (cached !== undefined) return cached;
391
392 // Resolve the bare repo path on disk.
393 let repoPath = "";
394 let defaultBranch = "main";
395 try {
396 const [repo] = await db
397 .select({ diskPath: repositories.diskPath, defaultBranch: repositories.defaultBranch })
398 .from(repositories)
399 .where(eq(repositories.id, repoId))
400 .limit(1);
401
402 if (repo) {
403 // diskPath is the absolute path returned by initBareRepo (the full .git dir path).
404 repoPath = repo.diskPath;
405 defaultBranch = repo.defaultBranch || "main";
406 }
407 } catch {
408 // Fall through with empty repoPath — git operations will no-op.
409 }
410
411 // Fan out all context queries in parallel.
412 const [openPrForFile, recentGateFailure, fileSymbols, relatedIssue] =
413 await Promise.all([
414 repoPath
415 ? findOpenPrForFile(repoId, filePath, userId, repoPath)
416 : Promise.resolve(undefined),
417 findRecentGateFailure(repoId, userId),
418 repoPath
419 ? extractFileSymbols(repoPath, filePath, defaultBranch)
420 : Promise.resolve([] as string[]),
421 findRelatedIssue(repoId, filePath),
422 ]);
423
424 const context: PairContext = {};
425 if (openPrForFile) context.openPrForFile = openPrForFile;
426 if (recentGateFailure) context.recentGateFailure = recentGateFailure;
427 if (fileSymbols && fileSymbols.length > 0) context.fileSymbols = fileSymbols;
428 if (relatedIssue) context.relatedIssue = relatedIssue;
429
430 cacheSet(contextCache, cacheKey, context, CONTEXT_TTL_MS);
431 return context;
432}
433
434// ---------------------------------------------------------------------------
435// Suggestion generation
436// ---------------------------------------------------------------------------
437
438/**
439 * Check whether the assembled context has anything interesting to surface.
440 * "Interesting" = open PR touching this file, a gate failure, or a related
441 * issue. Pure completion is fine even with symbols alone.
442 */
443function hasInterestingContext(ctx: PairContext): boolean {
444 return !!(ctx.openPrForFile || ctx.recentGateFailure || ctx.relatedIssue);
445}
446
447/**
448 * Build a text summary of the PairContext for inclusion in the AI prompt.
449 */
450function formatContext(ctx: PairContext): string {
451 const parts: string[] = [];
452
453 if (ctx.openPrForFile) {
454 parts.push(
455 `OPEN PR #${ctx.openPrForFile.prNumber}: "${ctx.openPrForFile.title}" (branch: ${ctx.openPrForFile.branch})`
456 );
457 if (ctx.openPrForFile.changedLines) {
458 parts.push(`Diff for this file:\n${ctx.openPrForFile.changedLines}`);
459 }
460 }
461
462 if (ctx.recentGateFailure) {
463 parts.push(
464 `RECENT CI FAILURE (${ctx.recentGateFailure.runType}):\n${ctx.recentGateFailure.errorSummary}`
465 );
466 }
467
468 if (ctx.relatedIssue) {
469 parts.push(
470 `RELATED ISSUE #${ctx.relatedIssue.issueNumber}: "${ctx.relatedIssue.title}"`
471 );
472 }
473
474 if (ctx.fileSymbols && ctx.fileSymbols.length > 0) {
475 parts.push(`File symbols: ${ctx.fileSymbols.join(", ")}`);
476 }
477
478 return parts.join("\n\n");
479}
480
481/**
482 * Generate a proactive pair suggestion using Claude.
483 *
484 * When the context has interesting signals (open PR, gate failure, related
485 * issue), Claude is asked to produce a structured JSON PairSuggestion.
486 * When there is no interesting context, we fall back to a plain code
487 * completion using the prefix/suffix — same quality as the existing copilot
488 * endpoint but with MODEL_SONNET and a slightly different prompt.
489 *
490 * Never throws — returns a safe fallback on any error.
491 */
492export async function generatePairSuggestion(
493 prefix: string,
494 suffix: string,
495 filePath: string,
496 context: PairContext
497): Promise<PairSuggestion> {
498 // --- Fallback used on errors or when AI is unavailable ---
499 const fallback: PairSuggestion = {
500 type: "completion",
501 headline: "Continue editing",
502 detail: undefined,
503 };
504
505 if (!isAiAvailable()) return fallback;
506
507 try {
508 // Clip inputs to sane sizes.
509 const clippedPrefix = prefix.slice(-4000);
510 const clippedSuffix = (suffix || "").slice(0, 1000);
511
512 if (!hasInterestingContext(context)) {
513 // Pure completion path.
514 const client = getAnthropic();
515 const response = await client.messages.create({
516 model: MODEL_SONNET,
517 max_tokens: 256,
518 system:
519 "You are an expert pair programmer embedded in a code editor. " +
520 `The developer is editing ${filePath}. ` +
521 "Output ONLY the characters that should be inserted at the cursor. " +
522 "No explanations. No markdown fences.",
523 messages: [
524 {
525 role: "user",
526 content:
527 `PREFIX:\n${clippedPrefix}\n\nSUFFIX:\n${clippedSuffix}`,
528 },
529 ],
530 });
531 const completion = extractText(response).replace(/^\s*```[A-Za-z0-9_+-]*\s*\n?/, "").replace(/\n?\s*```\s*$/, "");
532 return {
533 type: "completion",
534 headline: completion.split("\n")[0].slice(0, 120) || "Inline suggestion",
535 detail: completion,
536 };
537 }
538
539 // Context-aware path — ask Claude for a structured suggestion.
540 const contextText = formatContext(context);
541
542 const client = getAnthropic();
543 const response = await client.messages.create({
544 model: MODEL_SONNET,
545 max_tokens: 800,
546 system:
547 "You are an expert pair programmer embedded in a code editor. " +
548 `The developer is editing ${filePath}. ` +
549 "Analyse the context provided (open PRs, CI failures, related issues, file symbols) " +
550 "and return a single JSON object with the shape: " +
551 '{ "type": "completion"|"warning"|"context_note"|"fix_available", ' +
552 '"headline": "<one line, max 120 chars>", ' +
553 '"detail": "<optional expanded explanation>", ' +
554 '"actionLabel": "<optional button label>", ' +
555 '"actionPayload": "<optional diff or URL>" }. ' +
556 "Respond with ONLY the JSON object — no prose, no markdown fences.",
557 messages: [
558 {
559 role: "user",
560 content:
561 `CONTEXT:\n${contextText}\n\n` +
562 `PREFIX (last 4000 chars):\n${clippedPrefix}\n\n` +
563 `SUFFIX (first 1000 chars):\n${clippedSuffix}`,
564 },
565 ],
566 });
567
568 const raw = extractText(response);
569 const parsed = parseJsonResponse<{
570 type?: string;
571 headline?: string;
572 detail?: string;
573 actionLabel?: string;
574 actionPayload?: string;
575 }>(raw);
576
577 if (!parsed || typeof parsed.headline !== "string") {
578 // Response didn't parse — treat it as a context note.
579 return {
580 type: "context_note",
581 headline: raw.split("\n")[0].slice(0, 120) || "Pair programmer note",
582 detail: raw.slice(0, 500),
583 };
584 }
585
586 const validTypes = new Set(["completion", "warning", "context_note", "fix_available"]);
587 const type = validTypes.has(parsed.type ?? "") ? (parsed.type as PairSuggestion["type"]) : "context_note";
588
589 return {
590 type,
591 headline: (parsed.headline || "").slice(0, 120),
592 detail: parsed.detail,
593 actionLabel: parsed.actionLabel,
594 actionPayload: parsed.actionPayload,
595 };
596 } catch (err) {
597 console.error(
598 "[ai-pair] generatePairSuggestion error:",
599 (err as Error)?.message || err
600 );
601 return fallback;
602 }
603}
604
605// ---------------------------------------------------------------------------
606// Cache key helper used by the route for the 30-s suggestion cache
607// ---------------------------------------------------------------------------
608
609/**
610 * Derive a stable cache key for a suggest request.
611 * The prefix is hashed (last 50 chars) so we never store raw source.
612 */
613export function suggestCacheKey(
614 userId: string,
615 repoId: string,
616 filePath: string,
617 prefix: string
618): string {
619 const prefixSlice = prefix.slice(-50);
620 return createHash("sha256")
621 .update(userId)
622 .update("\0")
623 .update(repoId)
624 .update("\0")
625 .update(filePath)
626 .update("\0")
627 .update(prefixSlice)
628 .digest("hex");
629}
630
631/** Exported for use by the route layer. */
632export { suggestCache, cacheGet, cacheSet, SUGGEST_TTL_MS };
Addedsrc/lib/ai-workspace.ts+587−0View fileUnifiedSplit
@@ -0,0 +1,587 @@
1/**
2 * AI Copilot Workspace — issue-to-PR autonomous agent.
3 *
4 * Pipeline:
5 * 1. Load issue context (title + body + recent comments).
6 * 2. Explore the codebase — ls-tree then ask Claude which files to read.
7 * 3. Generate an implementation plan (Claude call) + post as issue comment.
8 * 4. Implement: create branch, apply file edits via git plumbing.
9 * 5. Open a draft PR linked to the issue.
10 *
11 * The hot path is fully in-memory (WorkspaceJob map). A workspace_jobs table
12 * is written for auditability via `src/db/schema.ts` + migration 0103.
13 *
14 * Never throws externally. All failures set job.status = "failed".
15 */
16
17import { join } from "path";
18import { eq, desc } from "drizzle-orm";
19import { db } from "../db";
20import {
21 issues,
22 issueComments,
23 repositories,
24 users,
25 pullRequests,
26 workspaceJobs,
27} from "../db/schema";
28import { getAnthropic, isAiAvailable, MODEL_SONNET, extractText, parseJsonResponse } from "./ai-client";
29import { applyEditsToNewBranch, type FileEdit } from "./spec-git";
30import { getBlob } from "../git/repository";
31import { getBotUserIdOrFallback } from "./bot-user";
32import { config } from "./config";
33
34// ---------------------------------------------------------------------------
35// Public types
36// ---------------------------------------------------------------------------
37
38export type WorkspaceStatus =
39 | "pending"
40 | "planning"
41 | "implementing"
42 | "opening_pr"
43 | "done"
44 | "failed";
45
46export interface WorkspaceJob {
47 id: string;
48 repoId: string;
49 issueId: string;
50 issueNumber: number;
51 ownerName: string;
52 repoName: string;
53 status: WorkspaceStatus;
54 planComment?: string; // the plan posted as issue comment
55 branchName?: string; // e.g. "workspace/issue-42-fix-auth"
56 prNumber?: number;
57 errorMessage?: string;
58 startedAt: Date;
59 updatedAt: Date;
60}
61
62// ---------------------------------------------------------------------------
63// In-memory job store (LRU, max 100 jobs)
64// ---------------------------------------------------------------------------
65
66const MAX_JOBS = 100;
67const jobById = new Map<string, WorkspaceJob>();
68// issueId → jobId for O(1) lookup by issue
69const jobByIssueId = new Map<string, string>();
70// repoId → jobId for "one active job per repo" guard
71const activeJobByRepoId = new Map<string, string>();
72
73function evictOldestIfNeeded(): void {
74 if (jobById.size < MAX_JOBS) return;
75 // Evict oldest (first inserted) entry
76 const firstKey = jobById.keys().next().value;
77 if (firstKey) {
78 const old = jobById.get(firstKey);
79 if (old) {
80 jobByIssueId.delete(old.issueId);
81 if (activeJobByRepoId.get(old.repoId) === firstKey) {
82 activeJobByRepoId.delete(old.repoId);
83 }
84 }
85 jobById.delete(firstKey);
86 }
87}
88
89function storeJob(job: WorkspaceJob): void {
90 evictOldestIfNeeded();
91 jobById.set(job.id, job);
92 jobByIssueId.set(job.issueId, job.id);
93 if (job.status !== "done" && job.status !== "failed") {
94 activeJobByRepoId.set(job.repoId, job.id);
95 }
96}
97
98function updateJob(job: WorkspaceJob, patch: Partial<WorkspaceJob>): void {
99 Object.assign(job, patch, { updatedAt: new Date() });
100 if (job.status === "done" || job.status === "failed") {
101 if (activeJobByRepoId.get(job.repoId) === job.id) {
102 activeJobByRepoId.delete(job.repoId);
103 }
104 }
105}
106
107// ---------------------------------------------------------------------------
108// Public accessors
109// ---------------------------------------------------------------------------
110
111export function getWorkspaceJob(jobId: string): WorkspaceJob | undefined {
112 return jobById.get(jobId);
113}
114
115export function getWorkspaceJobForIssue(issueId: string): WorkspaceJob | undefined {
116 const jobId = jobByIssueId.get(issueId);
117 return jobId ? jobById.get(jobId) : undefined;
118}
119
120// ---------------------------------------------------------------------------
121// Entry point
122// ---------------------------------------------------------------------------
123
124export async function startWorkspace(
125 issueId: string,
126 issueNumber: number,
127 repoId: string,
128 ownerName: string,
129 repoName: string,
130 triggeredByUserId: string
131): Promise<WorkspaceJob> {
132 // Guard: AI must be available
133 if (!isAiAvailable()) {
134 const failed: WorkspaceJob = {
135 id: crypto.randomUUID(),
136 repoId,
137 issueId,
138 issueNumber,
139 ownerName,
140 repoName,
141 status: "failed",
142 errorMessage: "ANTHROPIC_API_KEY is not configured",
143 startedAt: new Date(),
144 updatedAt: new Date(),
145 };
146 storeJob(failed);
147 return failed;
148 }
149
150 // Guard: max 1 active job per repo
151 const existingJobId = activeJobByRepoId.get(repoId);
152 if (existingJobId) {
153 const existing = jobById.get(existingJobId);
154 if (existing && existing.status !== "done" && existing.status !== "failed") {
155 return existing;
156 }
157 // Stale entry — clear it
158 activeJobByRepoId.delete(repoId);
159 }
160
161 const job: WorkspaceJob = {
162 id: crypto.randomUUID(),
163 repoId,
164 issueId,
165 issueNumber,
166 ownerName,
167 repoName,
168 status: "pending",
169 startedAt: new Date(),
170 updatedAt: new Date(),
171 };
172
173 storeJob(job);
174
175 // Persist to DB for auditability (best-effort)
176 try {
177 await db.insert(workspaceJobs).values({
178 id: job.id,
179 repoId,
180 issueId,
181 triggeredBy: triggeredByUserId,
182 status: "pending",
183 });
184 } catch {
185 /* non-fatal — in-memory store is the source of truth */
186 }
187
188 // Fire-and-forget pipeline
189 runWorkspacePipeline(job, triggeredByUserId).catch(() => {
190 /* errors are captured inside the pipeline */
191 });
192
193 return job;
194}
195
196// ---------------------------------------------------------------------------
197// Pipeline
198// ---------------------------------------------------------------------------
199
200async function runWorkspacePipeline(
201 job: WorkspaceJob,
202 triggeredByUserId: string
203): Promise<void> {
204 try {
205 // Step 1 — Load issue context
206 const issueCtx = await loadIssueContext(job.issueId);
207 if (!issueCtx) {
208 throw new Error("Issue not found or DB read failed");
209 }
210
211 // Step 2 — Explore codebase
212 updateJob(job, { status: "planning" });
213 await persistJobStatus(job);
214
215 const reposBase = config.gitReposPath;
216 const repoDiskPath = join(reposBase, job.ownerName, `${job.repoName}.git`);
217
218 const fileTree = await getFileTree(repoDiskPath);
219 const relevantFiles = await pickRelevantFiles(issueCtx, fileTree);
220 const fileContents = await readFileContents(
221 job.ownerName,
222 job.repoName,
223 relevantFiles
224 );
225
226 // Step 3 — Generate implementation plan
227 const plan = await generatePlan(issueCtx, fileContents);
228
229 // Post plan as issue comment
230 const planBody = formatPlanComment(plan);
231 const botUserId = await getBotUserIdOrFallback(triggeredByUserId);
232 const commentId = await postIssueComment(job.issueId, botUserId, planBody);
233 updateJob(job, { planComment: commentId ?? undefined });
234
235 // Step 4 — Implement
236 updateJob(job, { status: "implementing" });
237 await persistJobStatus(job);
238
239 // Resolve default branch
240 const repoRow = await db
241 .select({ defaultBranch: repositories.defaultBranch })
242 .from(repositories)
243 .where(eq(repositories.id, job.repoId))
244 .limit(1);
245 const baseBranch = repoRow[0]?.defaultBranch ?? "main";
246
247 const branchName = plan.branchName || sanitizeBranchName(
248 `workspace/issue-${job.issueNumber}-${slugify(issueCtx.title)}`
249 );
250 updateJob(job, { branchName });
251
252 const edits = buildEdits(plan);
253 if (edits.length === 0) {
254 throw new Error("AI plan produced no file changes");
255 }
256
257 const commitMsg =
258 `feat: implement #${job.issueNumber} — ${plan.summary.slice(0, 60)}\n\n` +
259 `Closes #${job.issueNumber}\n\n` +
260 `Generated by Gluecron AI Workspace.`;
261
262 const applied = await applyEditsToNewBranch({
263 repoDiskPath,
264 baseRef: baseBranch,
265 edits,
266 branchName,
267 commitMessage: commitMsg,
268 authorName: "Gluecron Workspace",
269 authorEmail: "workspace@gluecron.com",
270 });
271
272 if (!applied.ok) {
273 throw new Error(`git apply failed: ${applied.error}`);
274 }
275
276 // Step 5 — Open draft PR
277 updateJob(job, { status: "opening_pr" });
278 await persistJobStatus(job);
279
280 const prBody =
281 `Closes #${job.issueNumber}\n\n` +
282 `${plan.summary}\n\n` +
283 `**Files changed:**\n` +
284 applied.filesChanged.map((f) => `- \`${f}\``).join("\n") +
285 `\n\n<!-- gluecron:workspace:pr -->`;
286
287 const prTitle = `feat: ${plan.summary.slice(0, 140)}`;
288
289 const [pr] = await db
290 .insert(pullRequests)
291 .values({
292 repositoryId: job.repoId,
293 authorId: botUserId,
294 title: prTitle.slice(0, 200),
295 body: prBody,
296 baseBranch,
297 headBranch: applied.branchName,
298 isDraft: true,
299 })
300 .returning({ number: pullRequests.number });
301
302 if (!pr?.number) {
303 throw new Error("PR insert returned no number");
304 }
305
306 updateJob(job, { status: "done", prNumber: pr.number });
307 await persistJobStatus(job);
308 } catch (err: unknown) {
309 const msg = err instanceof Error ? err.message : String(err);
310 updateJob(job, { status: "failed", errorMessage: msg });
311 await persistJobStatus(job);
312 }
313}
314
315// ---------------------------------------------------------------------------
316// Step helpers
317// ---------------------------------------------------------------------------
318
319interface IssueContext {
320 title: string;
321 body: string;
322 comments: string[];
323}
324
325async function loadIssueContext(issueId: string): Promise<IssueContext | null> {
326 try {
327 const issueRows = await db
328 .select({ title: issues.title, body: issues.body })
329 .from(issues)
330 .where(eq(issues.id, issueId))
331 .limit(1);
332
333 if (!issueRows[0]) return null;
334 const { title, body } = issueRows[0];
335
336 const commentRows = await db
337 .select({ body: issueComments.body })
338 .from(issueComments)
339 .where(eq(issueComments.issueId, issueId))
340 .orderBy(desc(issueComments.createdAt))
341 .limit(5);
342
343 return {
344 title,
345 body: body ?? "",
346 comments: commentRows.map((r) => r.body),
347 };
348 } catch {
349 return null;
350 }
351}
352
353async function getFileTree(repoDiskPath: string): Promise<string[]> {
354 try {
355 const proc = Bun.spawn(
356 ["git", "ls-tree", "-r", "--name-only", "HEAD"],
357 { cwd: repoDiskPath, stdout: "pipe", stderr: "pipe" }
358 );
359 const text = await new Response(proc.stdout).text();
360 await proc.exited;
361 const lines = text
362 .split("\n")
363 .map((l) => l.trim())
364 .filter(Boolean);
365 return lines.slice(0, 500);
366 } catch {
367 return [];
368 }
369}
370
371async function pickRelevantFiles(
372 ctx: IssueContext,
373 fileTree: string[]
374): Promise<string[]> {
375 if (fileTree.length === 0) return [];
376
377 const prompt =
378 `Given this issue:\n${ctx.title}\n${ctx.body}\n\n` +
379 `And this file tree:\n${fileTree.join("\n")}\n\n` +
380 `List the 20 most relevant file paths to read in order to implement the issue. ` +
381 `Return JSON: {"files": string[]}`;
382
383 try {
384 const client = getAnthropic();
385 const msg = await client.messages.create({
386 model: MODEL_SONNET,
387 max_tokens: 512,
388 temperature: 0,
389 messages: [{ role: "user", content: prompt }],
390 });
391 const text = extractText(msg);
392 const parsed = parseJsonResponse<{ files: string[] }>(text);
393 if (parsed && Array.isArray(parsed.files)) {
394 return parsed.files.slice(0, 20).filter((f: string) => typeof f === "string");
395 }
396 } catch {
397 /* fall through — return empty */
398 }
399 // Fallback: first 10 files
400 return fileTree.slice(0, 10);
401}
402
403const MAX_FILE_BYTES = 8 * 1024; // 8 KB per file
404const MAX_TOTAL_BYTES = 80 * 1024; // 80 KB total
405
406async function readFileContents(
407 ownerName: string,
408 repoName: string,
409 filePaths: string[]
410): Promise<Array<{ path: string; content: string }>> {
411 const results: Array<{ path: string; content: string }> = [];
412 let totalBytes = 0;
413
414 for (const filePath of filePaths) {
415 if (totalBytes >= MAX_TOTAL_BYTES) break;
416 try {
417 const blob = await getBlob(ownerName, repoName, "HEAD", filePath);
418 if (!blob || blob.isBinary) continue;
419 const content = blob.content.slice(0, MAX_FILE_BYTES);
420 totalBytes += content.length;
421 results.push({ path: filePath, content });
422 } catch {
423 /* skip unreadable files */
424 }
425 }
426
427 return results;
428}
429
430interface FilePlan {
431 path: string;
432 action: "create" | "modify" | "delete";
433 description: string;
434 patch: string;
435}
436
437interface WorkspacePlan {
438 summary: string;
439 files: FilePlan[];
440 branchName: string;
441}
442
443async function generatePlan(
444 ctx: IssueContext,
445 fileContents: Array<{ path: string; content: string }>
446): Promise<WorkspacePlan> {
447 const fileSection = fileContents
448 .map((f) => `=== ${f.path} ===\n${f.content}`)
449 .join("\n\n");
450
451 const commentSection = ctx.comments.length > 0
452 ? `\n\nComments:\n${ctx.comments.map((c, i) => `[${i + 1}] ${c}`).join("\n\n")}`
453 : "";
454
455 const userPrompt =
456 `Issue: ${ctx.title}\n${ctx.body}${commentSection}\n\n` +
457 `Relevant code:\n${fileSection}\n\n` +
458 `Return JSON:\n` +
459 `{\n` +
460 ` "summary": string,\n` +
461 ` "files": Array<{\n` +
462 ` "path": string,\n` +
463 ` "action": "create" | "modify" | "delete",\n` +
464 ` "description": string,\n` +
465 ` "patch": string\n` +
466 ` }>,\n` +
467 ` "branchName": string\n` +
468 `}`;
469
470 const systemPrompt =
471 "You are an expert software engineer. Generate a detailed implementation plan for the given issue. " +
472 "For each file, provide the full new content (not a diff) in the 'patch' field. " +
473 "Branch name should follow the pattern: workspace/issue-<number>-<short-slug>. " +
474 "Return only valid JSON — no prose, no markdown fences.";
475
476 const client = getAnthropic();
477 const msg = await client.messages.create({
478 model: MODEL_SONNET,
479 max_tokens: 4096,
480 temperature: 0.2,
481 system: systemPrompt,
482 messages: [{ role: "user", content: userPrompt }],
483 });
484
485 const text = extractText(msg);
486 const parsed = parseJsonResponse<WorkspacePlan>(text);
487
488 if (!parsed || typeof parsed.summary !== "string") {
489 throw new Error("AI returned invalid plan JSON");
490 }
491
492 return {
493 summary: parsed.summary || "AI-generated implementation",
494 files: Array.isArray(parsed.files) ? parsed.files : [],
495 branchName: typeof parsed.branchName === "string" && parsed.branchName
496 ? sanitizeBranchName(parsed.branchName)
497 : "",
498 };
499}
500
501function buildEdits(plan: WorkspacePlan): FileEdit[] {
502 const edits: FileEdit[] = [];
503 for (const f of plan.files) {
504 if (!f.path || typeof f.path !== "string") continue;
505 // Reject traversal attempts
506 if (f.path.startsWith("/") || f.path.includes("..")) continue;
507
508 if (f.action === "delete") {
509 edits.push({ action: "delete", path: f.path });
510 } else {
511 // create or modify — we always write the full content
512 const content = typeof f.patch === "string" ? f.patch : "";
513 edits.push({ action: f.action === "create" ? "create" : "edit", path: f.path, content });
514 }
515 }
516 return edits;
517}
518
519function formatPlanComment(plan: WorkspacePlan): string {
520 const fileLines = plan.files
521 .map((f) => `- **${f.action}** \`${f.path}\` — ${f.description}`)
522 .join("\n");
523
524 return (
525 `<!-- gluecron:workspace:plan -->\n` +
526 `## AI Workspace Plan\n\n` +
527 `${plan.summary}\n\n` +
528 `**Files to change:**\n` +
529 (fileLines || "_No files identified_") +
530 `\n\n` +
531 `_Implementing now... a draft PR will be opened when complete._`
532 );
533}
534
535async function postIssueComment(
536 issueId: string,
537 authorId: string,
538 body: string
539): Promise<string | null> {
540 try {
541 const [row] = await db
542 .insert(issueComments)
543 .values({ issueId, authorId, body })
544 .returning({ id: issueComments.id });
545 return row?.id ?? null;
546 } catch {
547 return null;
548 }
549}
550
551async function persistJobStatus(job: WorkspaceJob): Promise<void> {
552 try {
553 await db
554 .update(workspaceJobs)
555 .set({
556 status: job.status,
557 planComment: job.planComment ?? null,
558 branchName: job.branchName ?? null,
559 prNumber: job.prNumber ?? null,
560 errorMessage: job.errorMessage ?? null,
561 updatedAt: job.updatedAt,
562 })
563 .where(eq(workspaceJobs.id, job.id));
564 } catch {
565 /* non-fatal */
566 }
567}
568
569// ---------------------------------------------------------------------------
570// Utilities
571// ---------------------------------------------------------------------------
572
573function slugify(text: string): string {
574 return text
575 .toLowerCase()
576 .replace(/[^a-z0-9]+/g, "-")
577 .replace(/^-+|-+$/g, "")
578 .slice(0, 40) || "change";
579}
580
581function sanitizeBranchName(name: string): string {
582 return name
583 .replace(/[^a-zA-Z0-9/_-]/g, "-")
584 .replace(/--+/g, "-")
585 .replace(/^-+|-+$/g, "")
586 .slice(0, 100);
587}
Modifiedsrc/lib/autopilot.ts+35−0View fileUnifiedSplit
@@ -72,6 +72,8 @@ import { expireOldPreviews } from "./branch-previews";
7272import { getBotUserIdOrFallback } from "./bot-user";
7373import { runOnboardingDripTaskOnce } from "./onboarding-drip";
7474import { sendSmartDigestsToAll } from "./smart-digest";
75import { runAiLoopSweepOnce } from "./ai-loop";
76import { runDepUpdateSweepOnce } from "./dep-updater-sweep";
7577
7678export interface AutopilotTaskResult {
7779 name: string;
@@ -167,6 +169,14 @@ let _lastDevEnvIdleSweepAt = 0;
167169 */
168170const DEP_UPDATE_SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000;
169171let _lastDepUpdateSweepAt = 0;
172/**
173 * AI loop sweep cadence. Scans for ai-build PRs that haven't started the
174 * autonomous loop yet. 10-minute cadence — fast enough to pick up a freshly
175 * created spec-to-pr PR without saturating the DB with GateTest queries.
176 * Only fires when AI_LOOP_ENABLED=1 and ANTHROPIC_API_KEY are set.
177 */
178const AI_LOOP_SWEEP_INTERVAL_MS = 10 * 60 * 1000;
179let _lastAiLoopSweepAt = 0;
170180/**
171181 * Advancement scanner cadence. Designed to run weekly on Mondays at
172182 * 08:00 UTC. The task itself is the cheap gate (checks both day-of-week
@@ -728,6 +738,31 @@ export function defaultTasks(): AutopilotTask[] {
728738 }
729739 },
730740 },
741 {
742 // AI loop sweep — scans for open PRs created by the ai-build flow
743 // (body contains <!-- gluecron:ai-build:v1 -->) that haven't yet had
744 // the autonomous loop started (no <!-- gluecron:ai-loop:v1 --> marker).
745 // Fires every 10 minutes; caps at 5 PRs per tick. Safe-default off:
746 // only fires when AI_LOOP_ENABLED=1 AND ANTHROPIC_API_KEY are set.
747 name: "ai-loop-sweep",
748 run: async () => {
749 if (process.env.AI_LOOP_ENABLED !== "1") return;
750 if (!process.env.ANTHROPIC_API_KEY) return;
751 const now = Date.now();
752 if (now - _lastAiLoopSweepAt < AI_LOOP_SWEEP_INTERVAL_MS) return;
753 _lastAiLoopSweepAt = now;
754 try {
755 const summary = await runAiLoopSweepOnce(5);
756 if (summary.considered > 0) {
757 console.log(
758 `[autopilot] ai-loop-sweep: considered=${summary.considered} started=${summary.started} skipped=${summary.skipped}`
759 );
760 }
761 } catch (err) {
762 console.error("[autopilot] ai-loop-sweep: threw:", err);
763 }
764 },
765 },
731766 ];
732767}
733768
Addedsrc/lib/cross-repo-impact.ts+486−0View fileUnifiedSplit
@@ -0,0 +1,486 @@
1/**
2 * Cross-Repo Dependency Impact Detection
3 *
4 * When a PR changes a package's public API (renamed exports, changed type
5 * signatures, bumped version), downstream repos that depend on THIS repo
6 * get silently broken. This module surfaces those risks before merge.
7 *
8 * Steps:
9 * 1. Get PR diff (changed files via git diff base...head --name-only)
10 * 2. Find changed exported symbols (regex on +export / -export lines)
11 * 3. Look up repo_dependencies for repos that depend on this package
12 * 4. For each downstream repo, grep for usage of the changed symbols
13 * 5. Score risk: high/medium/low based on symbol usage + test coverage
14 * 6. Cache results in memory for 15 minutes (keyed by prId)
15 * 7. Use Claude to generate one-sentence migration notes per changed export
16 * 8. Cap at 20 downstream repos
17 */
18
19import { db } from "../db";
20import {
21 pullRequests,
22 repositories,
23 users,
24 repoDependencies,
25 crossRepoImpactCache,
26} from "../db/schema";
27import { eq, and, ne } from "drizzle-orm";
28import { getRepoPath } from "../git/repository";
29import { isAiAvailable, getAnthropic, MODEL_SONNET, extractText } from "./ai-client";
30
31// ---------------------------------------------------------------------------
32// Types
33// ---------------------------------------------------------------------------
34
35export interface DownstreamImpact {
36 repoId: string;
37 repoName: string;
38 ownerName: string;
39 dependencyName: string; // e.g. "@myorg/auth-lib"
40 currentVersion: string;
41 riskLevel: "high" | "medium" | "low";
42 changedExports: string[]; // function/type names that changed in the PR diff
43 suggestedFixPrUrl?: string; // if we opened a fix PR
44}
45
46export interface CrossRepoReport {
47 prId: string;
48 affectedRepos: DownstreamImpact[];
49 totalRisk: number; // 0-100
50 analyzedAt: Date;
51 cachedUntil: Date;
52}
53
54// ---------------------------------------------------------------------------
55// In-memory cache (15 min TTL)
56// ---------------------------------------------------------------------------
57
58const CACHE_TTL_MS = 15 * 60 * 1000;
59
60interface CacheEntry {
61 report: CrossRepoReport;
62 expiresAt: number;
63}
64
65const memoryCache = new Map<string, CacheEntry>();
66
67function getCached(prId: string): CrossRepoReport | null {
68 const entry = memoryCache.get(prId);
69 if (!entry) return null;
70 if (Date.now() > entry.expiresAt) {
71 memoryCache.delete(prId);
72 return null;
73 }
74 return entry.report;
75}
76
77function setMemoryCache(prId: string, report: CrossRepoReport): void {
78 memoryCache.set(prId, { report, expiresAt: Date.now() + CACHE_TTL_MS });
79}
80
81// ---------------------------------------------------------------------------
82// Git helper
83// ---------------------------------------------------------------------------
84
85async function git(
86 args: string[],
87 cwd: string
88): Promise<{ stdout: string; stderr: string; exitCode: number }> {
89 const proc = Bun.spawn(["git", ...args], {
90 cwd,
91 stdout: "pipe",
92 stderr: "pipe",
93 });
94 const [stdout, stderr] = await Promise.all([
95 new Response(proc.stdout).text(),
96 new Response(proc.stderr).text(),
97 ]);
98 const exitCode = await proc.exited;
99 return { stdout, stderr, exitCode };
100}
101
102// ---------------------------------------------------------------------------
103// Symbol extraction helpers
104// ---------------------------------------------------------------------------
105
106/**
107 * Parse lines from a diff for changed export declarations.
108 * Looks for lines beginning with + or - that contain an export keyword.
109 * Returns deduplicated symbol names extracted from those lines.
110 */
111function extractChangedExports(diffText: string): string[] {
112 const exportLineRe = /^[+-]export\s+(?:default\s+)?(?:async\s+)?(?:function|class|type|interface|const|let|var|enum|abstract\s+class)\s+(\w+)/m;
113 const lines = diffText.split("\n");
114 const changed = new Set<string>();
115
116 for (const line of lines) {
117 if (!line.startsWith("+") && !line.startsWith("-")) continue;
118 // Skip diff header lines like +++ or ---
119 if (line.startsWith("+++") || line.startsWith("---")) continue;
120
121 const trimmed = line.slice(1).trim();
122 // Match: export [default] [async] function/class/type/interface/const/let/var/enum Name
123 const m = trimmed.match(
124 /^export\s+(?:default\s+)?(?:async\s+)?(?:function|class|type|interface|const|let|var|enum|abstract\s+class)\s+(\w+)/
125 );
126 if (m && m[1]) {
127 changed.add(m[1]);
128 }
129 // Also catch: export { Name, Name2 }
130 const namedExport = trimmed.match(/^export\s+\{([^}]+)\}/);
131 if (namedExport && namedExport[1]) {
132 for (const name of namedExport[1].split(",")) {
133 const clean = name.trim().replace(/\s+as\s+\w+/, "").trim();
134 if (clean && /^\w+$/.test(clean)) changed.add(clean);
135 }
136 }
137 }
138
139 return Array.from(changed);
140}
141
142// ---------------------------------------------------------------------------
143// Determine if a downstream repo imports any of the changed symbols
144// ---------------------------------------------------------------------------
145
146async function repoUsesSymbols(
147 repoDir: string,
148 symbols: string[]
149): Promise<string[]> {
150 if (symbols.length === 0) return [];
151
152 const used: string[] = [];
153 for (const sym of symbols) {
154 try {
155 // git grep -l <sym> -- *.ts *.tsx *.js *.jsx
156 const { stdout, exitCode } = await git(
157 ["grep", "-l", "--extended-regexp", `\\b${sym}\\b`, "--", "*.ts", "*.tsx", "*.js", "*.jsx"],
158 repoDir
159 );
160 // exit 1 = no matches (not an error)
161 if (exitCode === 0 && stdout.trim()) {
162 used.push(sym);
163 }
164 } catch {
165 // git grep may fail if git not available or repo empty
166 }
167 }
168 return used;
169}
170
171/**
172 * Check if a repo has any test files (rough proxy for test coverage).
173 */
174async function repoHasTests(repoDir: string): Promise<boolean> {
175 try {
176 const { stdout } = await git(
177 ["ls-files", "--", "*.test.ts", "*.test.tsx", "*.spec.ts", "*.spec.tsx", "*.test.js", "*.spec.js"],
178 repoDir
179 );
180 return stdout.trim().length > 0;
181 } catch {
182 return false;
183 }
184}
185
186// ---------------------------------------------------------------------------
187// AI migration note generation
188// ---------------------------------------------------------------------------
189
190async function generateMigrationNotes(
191 changedExports: string[],
192 packageName: string,
193 diffExcerpt: string
194): Promise<Record<string, string>> {
195 if (!isAiAvailable() || changedExports.length === 0) return {};
196
197 try {
198 const client = getAnthropic();
199 const prompt = `You are a migration assistant. The package "${packageName}" changed the following exports in a PR:
200${changedExports.map((e) => `- ${e}`).join("\n")}
201
202Here is a brief diff excerpt:
203\`\`\`
204${diffExcerpt.slice(0, 2000)}
205\`\`\`
206
207For each changed export, write exactly ONE sentence describing what callers need to update. Be concrete and brief.
208Respond with a JSON object mapping export name to migration note string. Example:
209{"myFunction": "Rename the first parameter from 'id' to 'userId'.", "MyType": "Add the required 'createdAt: Date' field."}`;
210
211 const message = await client.messages.create({
212 model: MODEL_SONNET,
213 max_tokens: 512,
214 messages: [{ role: "user", content: prompt }],
215 });
216
217 const text = extractText(message);
218 const jsonMatch = text.match(/\{[\s\S]*\}/);
219 if (jsonMatch) {
220 try {
221 return JSON.parse(jsonMatch[0]) as Record<string, string>;
222 } catch {
223 return {};
224 }
225 }
226 } catch {
227 // AI unavailable — degrade gracefully
228 }
229 return {};
230}
231
232// ---------------------------------------------------------------------------
233// DB cache helpers
234// ---------------------------------------------------------------------------
235
236async function loadDbCache(prId: string): Promise<CrossRepoReport | null> {
237 try {
238 const rows = await db
239 .select()
240 .from(crossRepoImpactCache)
241 .where(eq(crossRepoImpactCache.prId, prId))
242 .limit(1);
243
244 if (!rows.length) return null;
245
246 const row = rows[0];
247 if (row.cachedUntil < new Date()) {
248 // Expired — delete stale row
249 await db.delete(crossRepoImpactCache).where(eq(crossRepoImpactCache.prId, prId)).catch(() => {});
250 return null;
251 }
252
253 return row.report as CrossRepoReport;
254 } catch {
255 return null;
256 }
257}
258
259async function saveDbCache(report: CrossRepoReport): Promise<void> {
260 try {
261 // Upsert: delete then insert
262 await db.delete(crossRepoImpactCache).where(eq(crossRepoImpactCache.prId, report.prId)).catch(() => {});
263 await db.insert(crossRepoImpactCache).values({
264 prId: report.prId,
265 report: report as unknown as Record<string, unknown>,
266 analyzedAt: report.analyzedAt,
267 cachedUntil: report.cachedUntil,
268 });
269 } catch {
270 // Best-effort — memory cache still works
271 }
272}
273
274// ---------------------------------------------------------------------------
275// Main export
276// ---------------------------------------------------------------------------
277
278export async function analyzeCrossRepoImpact(
279 repoId: string,
280 prId: string,
281 ownerName: string,
282 repoName: string
283): Promise<CrossRepoReport> {
284 // 1. Check memory cache
285 const memHit = getCached(prId);
286 if (memHit) return memHit;
287
288 // 2. Check DB cache
289 const dbHit = await loadDbCache(prId);
290 if (dbHit) {
291 setMemoryCache(prId, dbHit);
292 return dbHit;
293 }
294
295 // 3. Load PR info
296 const [pr] = await db
297 .select({
298 baseBranch: pullRequests.baseBranch,
299 headBranch: pullRequests.headBranch,
300 repositoryId: pullRequests.repositoryId,
301 })
302 .from(pullRequests)
303 .where(eq(pullRequests.id, prId))
304 .limit(1);
305
306 if (!pr) {
307 return emptyReport(prId, "PR not found");
308 }
309
310 const repoDir = getRepoPath(ownerName, repoName);
311
312 // 4. Get changed file names
313 let changedFiles: string[] = [];
314 try {
315 const { stdout } = await git(
316 ["diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
317 repoDir
318 );
319 changedFiles = stdout.trim().split("\n").filter(Boolean);
320 } catch {
321 /* non-blocking */
322 }
323
324 // 5. Get full diff text for export extraction + AI notes
325 let diffText = "";
326 try {
327 const { stdout } = await git(
328 ["diff", `${pr.baseBranch}...${pr.headBranch}`, "--unified=2"],
329 repoDir
330 );
331 // Cap at 100KB to avoid huge diffs
332 diffText = stdout.slice(0, 100_000);
333 } catch {
334 /* non-blocking */
335 }
336
337 // 6. Extract changed exports from diff
338 const changedExports = extractChangedExports(diffText);
339
340 // 7. Get this repo's package name from package.json on default branch
341 let packageName: string | null = null;
342 try {
343 const { stdout: pkgBlob } = await git(["show", "HEAD:package.json"], repoDir);
344 const pkg = JSON.parse(pkgBlob) as Record<string, unknown>;
345 if (typeof pkg.name === "string" && pkg.name) {
346 packageName = pkg.name;
347 }
348 } catch {
349 /* no package.json or not parseable */
350 }
351
352 // 8. Find downstream repos in repo_dependencies
353 const affectedRepos: DownstreamImpact[] = [];
354
355 if (packageName && packageName.trim()) {
356 const depRows = await db
357 .select({
358 repositoryId: repoDependencies.repositoryId,
359 name: repoDependencies.name,
360 versionSpec: repoDependencies.versionSpec,
361 })
362 .from(repoDependencies)
363 .where(
364 and(
365 eq(repoDependencies.name, packageName),
366 ne(repoDependencies.repositoryId, repoId)
367 )
368 )
369 .limit(20)
370 .catch(() => []);
371
372 // Process each downstream repo (cap at 20)
373 for (const depRow of depRows.slice(0, 20)) {
374 // Load downstream repo info
375 const [depRepo] = await db
376 .select({
377 id: repositories.id,
378 name: repositories.name,
379 ownerId: repositories.ownerId,
380 defaultBranch: repositories.defaultBranch,
381 })
382 .from(repositories)
383 .where(eq(repositories.id, depRow.repositoryId))
384 .limit(1)
385 .catch(() => []);
386
387 if (!depRepo) continue;
388
389 const [depOwner] = await db
390 .select({ username: users.username })
391 .from(users)
392 .where(eq(users.id, depRepo.ownerId))
393 .limit(1)
394 .catch(() => []);
395
396 if (!depOwner) continue;
397
398 const downstreamRepoDir = getRepoPath(depOwner.username, depRepo.name);
399
400 // Check which changed symbols are used in the downstream repo
401 let usedSymbols: string[] = [];
402 let hasTests = false;
403 try {
404 [usedSymbols, hasTests] = await Promise.all([
405 repoUsesSymbols(downstreamRepoDir, changedExports),
406 repoHasTests(downstreamRepoDir),
407 ]);
408 } catch {
409 /* git may not be available for this repo */
410 }
411
412 // Determine risk level
413 let riskLevel: "high" | "medium" | "low";
414 if (usedSymbols.length > 0 && !hasTests) {
415 riskLevel = "high";
416 } else if (usedSymbols.length > 0 && hasTests) {
417 riskLevel = "medium";
418 } else {
419 riskLevel = "low";
420 }
421
422 affectedRepos.push({
423 repoId: depRepo.id,
424 repoName: depRepo.name,
425 ownerName: depOwner.username,
426 dependencyName: depRow.name,
427 currentVersion: depRow.versionSpec ?? "unknown",
428 riskLevel,
429 changedExports: usedSymbols.length > 0 ? usedSymbols : changedExports,
430 });
431 }
432 }
433
434 // 9. Generate AI migration notes (best-effort, non-blocking)
435 if (changedExports.length > 0 && affectedRepos.some((r) => r.riskLevel !== "low")) {
436 try {
437 await generateMigrationNotes(changedExports, packageName ?? repoName, diffText);
438 } catch {
439 /* non-blocking */
440 }
441 }
442
443 // 10. Compute total risk score (0-100)
444 const highCount = affectedRepos.filter((r) => r.riskLevel === "high").length;
445 const mediumCount = affectedRepos.filter((r) => r.riskLevel === "medium").length;
446 const lowCount = affectedRepos.filter((r) => r.riskLevel === "low").length;
447
448 let totalRisk = 0;
449 totalRisk += Math.min(highCount * 30, 60);
450 totalRisk += Math.min(mediumCount * 15, 30);
451 totalRisk += Math.min(lowCount * 5, 10);
452 totalRisk += Math.min(changedExports.length * 5, 20);
453 totalRisk = Math.min(totalRisk, 100);
454
455 const now = new Date();
456 const cachedUntil = new Date(now.getTime() + CACHE_TTL_MS);
457
458 const report: CrossRepoReport = {
459 prId,
460 affectedRepos,
461 totalRisk,
462 analyzedAt: now,
463 cachedUntil,
464 };
465
466 // 11. Persist to memory + DB cache
467 setMemoryCache(prId, report);
468 await saveDbCache(report);
469
470 return report;
471}
472
473export function invalidateCrossRepoCache(prId: string): void {
474 memoryCache.delete(prId);
475}
476
477function emptyReport(prId: string, _reason: string): CrossRepoReport {
478 const now = new Date();
479 return {
480 prId,
481 affectedRepos: [],
482 totalRisk: 0,
483 analyzedAt: now,
484 cachedUntil: new Date(now.getTime() + CACHE_TTL_MS),
485 };
486}
Addedsrc/lib/nl-search.ts+516−0View fileUnifiedSplit
@@ -0,0 +1,516 @@
1/**
2 * Natural Language Code Search — Claude-powered intent search.
3 *
4 * Unlike embedding-based semantic search (which requires a pre-built vector
5 * index), NL search uses Claude as the reasoner over actual file content.
6 * A developer types a natural language question like:
7 * "find all places where we validate user email but don't check MX records"
8 * "where do we write to the database without a transaction?"
9 * and Claude finds the matching code.
10 *
11 * Algorithm:
12 * 1. Quick Claude call to extract grep-friendly keywords from the query.
13 * 2. Run `git grep -l <keyword>` for each keyword; union matching files.
14 * Cap at 40 files. Fall back to code_chunks table if grep returns 0.
15 * 3. Read each candidate file via git show (HEAD:<path>), capped at 6KB each.
16 * Build combined context, capped at 80KB total.
17 * 4. Single Claude reasoning pass: find all places matching the query,
18 * return JSON with filePath, lineStart, lineEnd, snippet, explanation,
19 * confidence.
20 * 5. Cache results in-memory per `${repoId}:${query}` for 15 minutes.
21 */
22
23import { getAnthropic, MODEL_SONNET, isAiAvailable, parseJsonResponse } from "./ai-client";
24import { getRepoPath } from "../git/repository";
25import { db } from "../db";
26import { codeChunks } from "../db/schema";
27import { eq } from "drizzle-orm";
28
29// ---------------------------------------------------------------------------
30// Public types
31// ---------------------------------------------------------------------------
32
33export interface NlSearchResult {
34 filePath: string;
35 lineStart: number;
36 lineEnd: number;
37 snippet: string; // the relevant lines
38 explanation: string; // why this matches the query
39 confidence: "high" | "medium" | "low";
40}
41
42export interface NlSearchResponse {
43 query: string;
44 results: NlSearchResult[];
45 totalFilesScanned: number;
46 searchedAt: Date;
47 cached: boolean;
48}
49
50// ---------------------------------------------------------------------------
51// In-memory cache (15-minute TTL)
52// ---------------------------------------------------------------------------
53
54interface CacheEntry {
55 response: NlSearchResponse;
56 expiresAt: number;
57}
58
59const nlCache = new Map<string, CacheEntry>();
60const NL_CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes
61
62function cacheGet(key: string): NlSearchResponse | null {
63 const entry = nlCache.get(key);
64 if (!entry) return null;
65 if (Date.now() > entry.expiresAt) {
66 nlCache.delete(key);
67 return null;
68 }
69 return entry.response;
70}
71
72function cacheSet(key: string, response: NlSearchResponse): void {
73 // Evict expired entries to keep memory bounded.
74 if (nlCache.size > 200) {
75 const now = Date.now();
76 for (const [k, v] of nlCache) {
77 if (v.expiresAt < now) nlCache.delete(k);
78 }
79 }
80 nlCache.set(key, { response, expiresAt: Date.now() + NL_CACHE_TTL_MS });
81}
82
83// ---------------------------------------------------------------------------
84// Skip-list: skip binary/build/lock files
85// ---------------------------------------------------------------------------
86
87const SKIP_DIRS = new Set([
88 "node_modules",
89 ".git",
90 "dist",
91 "build",
92 "vendor",
93 ".next",
94 ".turbo",
95 "target",
96 "__pycache__",
97]);
98
99const SKIP_EXTS = new Set([
100 ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".webp",
101 ".woff", ".woff2", ".ttf", ".eot",
102 ".pdf", ".zip", ".gz", ".tar",
103 ".lockb", ".lock",
104 ".min.js", ".min.css",
105]);
106
107function shouldSkipPath(filePath: string): boolean {
108 const parts = filePath.split("/");
109 for (const part of parts.slice(0, -1)) {
110 if (SKIP_DIRS.has(part.toLowerCase())) return true;
111 }
112 const basename = parts[parts.length - 1].toLowerCase();
113 for (const ext of SKIP_EXTS) {
114 if (basename.endsWith(ext)) return true;
115 }
116 // Lock files by exact name
117 const lockFiles = new Set([
118 "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
119 "bun.lockb", "bun.lock", "poetry.lock", "cargo.lock",
120 "composer.lock", "gemfile.lock",
121 ]);
122 if (lockFiles.has(basename)) return true;
123 return false;
124}
125
126// ---------------------------------------------------------------------------
127// Git helpers
128// ---------------------------------------------------------------------------
129
130async function gitExec(
131 cmd: string[],
132 cwd: string
133): Promise<{ stdout: string; exitCode: number }> {
134 const proc = Bun.spawn(cmd, {
135 cwd,
136 env: process.env as Record<string, string>,
137 stdout: "pipe",
138 stderr: "pipe",
139 });
140 const stdout = await new Response(proc.stdout).text();
141 const exitCode = await proc.exited;
142 return { stdout, exitCode };
143}
144
145/**
146 * Run `git grep -l <keyword> HEAD --` and return matching file paths.
147 */
148async function grepForKeyword(
149 ownerName: string,
150 repoName: string,
151 keyword: string
152): Promise<string[]> {
153 const repoDir = getRepoPath(ownerName, repoName);
154 try {
155 const { stdout, exitCode } = await gitExec(
156 ["git", "grep", "-l", "-i", keyword, "HEAD", "--"],
157 repoDir
158 );
159 // exit code 1 = no matches (not an error)
160 if (exitCode !== 0 && exitCode !== 1) return [];
161 return stdout
162 .trim()
163 .split("\n")
164 .filter(Boolean)
165 .map((line) => {
166 // git grep -l with a ref outputs "<ref>:<path>" or just "<path>"
167 const idx = line.indexOf(":");
168 return idx >= 0 ? line.slice(idx + 1) : line;
169 })
170 .filter((p) => !shouldSkipPath(p));
171 } catch {
172 return [];
173 }
174}
175
176/**
177 * Read a file from the repo at HEAD, capped to maxBytes.
178 * Returns empty string on error.
179 */
180async function readFileFromGit(
181 ownerName: string,
182 repoName: string,
183 filePath: string,
184 maxBytes = 6 * 1024
185): Promise<string> {
186 const repoDir = getRepoPath(ownerName, repoName);
187 try {
188 const { stdout, exitCode } = await gitExec(
189 ["git", "show", `HEAD:${filePath}`],
190 repoDir
191 );
192 if (exitCode !== 0) return "";
193 return stdout.slice(0, maxBytes);
194 } catch {
195 return "";
196 }
197}
198
199// ---------------------------------------------------------------------------
200// Step 1 — Extract keywords from the query via Claude
201// ---------------------------------------------------------------------------
202
203interface KeywordExtraction {
204 keywords: string[];
205 fileTypes: string[];
206}
207
208async function extractKeywords(query: string): Promise<KeywordExtraction> {
209 const client = getAnthropic();
210 try {
211 const msg = await client.messages.create({
212 model: MODEL_SONNET,
213 max_tokens: 256,
214 messages: [
215 {
216 role: "user",
217 content:
218 `Extract 3-5 grep-friendly keywords from this natural language search query. ` +
219 `Return JSON only, no prose: {"keywords": string[], "fileTypes": string[]}\n` +
220 `Keywords should be short, concrete identifiers/patterns likely to appear in code. ` +
221 `fileTypes is an optional list of file extensions (e.g. [".ts", ".tsx"]) to narrow the search. ` +
222 `Return [] for fileTypes if the query is language-agnostic.\n\n` +
223 `Query: ${query}`,
224 },
225 ],
226 });
227 const text = msg.content.find((b) => b.type === "text")?.text ?? "";
228 const parsed = parseJsonResponse<KeywordExtraction>(text);
229 if (parsed && Array.isArray(parsed.keywords)) {
230 return {
231 keywords: parsed.keywords.slice(0, 5).filter((k) => typeof k === "string" && k.length > 0),
232 fileTypes: Array.isArray(parsed.fileTypes) ? parsed.fileTypes : [],
233 };
234 }
235 } catch (err) {
236 console.error("[nl-search] keyword extraction error:", err);
237 }
238 // Fallback: split query into words
239 const words = query
240 .split(/[^a-zA-Z0-9_]+/)
241 .filter((w) => w.length >= 3)
242 .slice(0, 5);
243 return { keywords: words, fileTypes: [] };
244}
245
246// ---------------------------------------------------------------------------
247// Step 2 — Gather candidate files via git grep
248// ---------------------------------------------------------------------------
249
250async function gatherCandidates(
251 ownerName: string,
252 repoName: string,
253 repoId: string,
254 extraction: KeywordExtraction,
255 maxFiles = 40
256): Promise<string[]> {
257 const seen = new Set<string>();
258
259 // Run git grep for each keyword (in parallel)
260 const results = await Promise.allSettled(
261 extraction.keywords.map((kw) => grepForKeyword(ownerName, repoName, kw))
262 );
263
264 for (const r of results) {
265 if (r.status === "fulfilled") {
266 for (const p of r.value) {
267 seen.add(p);
268 if (seen.size >= maxFiles * 2) break;
269 }
270 }
271 }
272
273 // Apply file-type filter if provided
274 let candidates = Array.from(seen);
275 if (extraction.fileTypes.length > 0) {
276 const filtered = candidates.filter((p) =>
277 extraction.fileTypes.some((ext) => p.endsWith(ext))
278 );
279 // Only narrow if we still have results
280 if (filtered.length > 0) candidates = filtered;
281 }
282
283 candidates = candidates.slice(0, maxFiles);
284
285 // Fallback: if git grep returned nothing, read from code_chunks table
286 if (candidates.length === 0) {
287 try {
288 const rows = await db
289 .select({ path: codeChunks.path })
290 .from(codeChunks)
291 .where(eq(codeChunks.repositoryId, repoId))
292 .groupBy(codeChunks.path)
293 .limit(maxFiles);
294 candidates = rows.map((r) => r.path).filter((p) => !shouldSkipPath(p));
295 } catch {
296 // DB unavailable — return empty
297 }
298 }
299
300 return candidates;
301}
302
303// ---------------------------------------------------------------------------
304// Step 3 — Read file contents and build context
305// ---------------------------------------------------------------------------
306
307/**
308 * For each candidate file, read content (capped at 6KB).
309 * Build a combined context string where each file is prefixed with a header
310 * showing the filename and line numbers, capped at 80KB total.
311 */
312async function buildContext(
313 ownerName: string,
314 repoName: string,
315 candidates: string[],
316 maxTotalBytes = 80 * 1024
317): Promise<{ contextStr: string; filesRead: string[] }> {
318 const BATCH = 10;
319 const fileContents: Array<{ path: string; content: string }> = [];
320
321 for (let i = 0; i < candidates.length; i += BATCH) {
322 const batch = candidates.slice(i, i + BATCH);
323 const contents = await Promise.all(
324 batch.map((p) => readFileFromGit(ownerName, repoName, p, 6144))
325 );
326 for (let j = 0; j < batch.length; j++) {
327 if (contents[j]) {
328 fileContents.push({ path: batch[j], content: contents[j] });
329 }
330 }
331 }
332
333 // Build numbered context string
334 let contextStr = "";
335 const filesRead: string[] = [];
336
337 for (const { path, content } of fileContents) {
338 if (contextStr.length >= maxTotalBytes) break;
339 const lines = content.split("\n");
340 // Number lines starting at 1
341 const numbered = lines
342 .map((line, idx) => `${idx + 1}: ${line}`)
343 .join("\n");
344 const header = `\n\n=== FILE: ${path} ===\n`;
345 const block = header + numbered;
346 if (contextStr.length + block.length > maxTotalBytes) {
347 // Trim block to fit
348 const remaining = maxTotalBytes - contextStr.length;
349 if (remaining > header.length + 100) {
350 contextStr += block.slice(0, remaining);
351 filesRead.push(path);
352 }
353 } else {
354 contextStr += block;
355 filesRead.push(path);
356 }
357 }
358
359 return { contextStr, filesRead };
360}
361
362// ---------------------------------------------------------------------------
363// Step 4 — Claude reasoning pass
364// ---------------------------------------------------------------------------
365
366interface RawResult {
367 filePath: string;
368 lineStart: number;
369 lineEnd: number;
370 snippet: string;
371 explanation: string;
372 confidence: "high" | "medium" | "low";
373}
374
375async function reasonWithClaude(
376 query: string,
377 contextStr: string
378): Promise<NlSearchResult[]> {
379 const client = getAnthropic();
380
381 const systemPrompt =
382 `You are a code analysis expert. Find all places in the provided code that match the user's query. ` +
383 `Be precise about file paths and line numbers. Only return matches that genuinely satisfy the query — ` +
384 `do not include tangentially related code.`;
385
386 const userPrompt =
387 `Query: ${query}\n\n` +
388 `Code files:\n${contextStr}\n\n` +
389 `Return JSON only, no prose:\n` +
390 `{\n` +
391 ` "results": Array<{\n` +
392 ` "filePath": string,\n` +
393 ` "lineStart": number,\n` +
394 ` "lineEnd": number,\n` +
395 ` "snippet": string,\n` +
396 ` "explanation": string,\n` +
397 ` "confidence": "high" | "medium" | "low"\n` +
398 ` }>\n` +
399 `}\n\n` +
400 `Return {"results": []} if nothing matches. Sort by confidence descending. Max 10 results.`;
401
402 try {
403 const msg = await client.messages.create({
404 model: MODEL_SONNET,
405 max_tokens: 3000,
406 system: systemPrompt,
407 messages: [{ role: "user", content: userPrompt }],
408 });
409
410 const text = msg.content.find((b) => b.type === "text")?.text ?? "";
411 const parsed = parseJsonResponse<{ results: RawResult[] }>(text);
412
413 if (!parsed || !Array.isArray(parsed.results)) return [];
414
415 return parsed.results
416 .filter(
417 (r): r is RawResult =>
418 typeof r.filePath === "string" &&
419 typeof r.lineStart === "number" &&
420 typeof r.lineEnd === "number" &&
421 typeof r.snippet === "string" &&
422 typeof r.explanation === "string" &&
423 (r.confidence === "high" || r.confidence === "medium" || r.confidence === "low")
424 )
425 .slice(0, 10);
426 } catch (err) {
427 console.error("[nl-search] Claude reasoning error:", err);
428 return [];
429 }
430}
431
432// ---------------------------------------------------------------------------
433// Main export
434// ---------------------------------------------------------------------------
435
436/**
437 * Natural language code search powered by Claude.
438 *
439 * @param ownerName - repo owner username
440 * @param repoName - repo name
441 * @param repoId - DB repository id (used as cache key + fallback)
442 * @param query - natural-language search query
443 * @returns NlSearchResponse (never throws)
444 */
445export async function nlSearch(
446 ownerName: string,
447 repoName: string,
448 repoId: string,
449 query: string
450): Promise<NlSearchResponse> {
451 const q = query.trim();
452 const empty: NlSearchResponse = {
453 query: q,
454 results: [],
455 totalFilesScanned: 0,
456 searchedAt: new Date(),
457 cached: false,
458 };
459
460 if (!q) return empty;
461
462 // Guard: AI must be available
463 if (!isAiAvailable()) {
464 return { ...empty, results: [] };
465 }
466
467 // Cache check
468 const cacheKey = `${repoId}:${q}`;
469 const cached = cacheGet(cacheKey);
470 if (cached) {
471 return { ...cached, cached: true };
472 }
473
474 try {
475 // Step 1 — Extract keywords
476 const extraction = await extractKeywords(q);
477
478 // Step 2 — Gather candidate files
479 const candidates = await gatherCandidates(
480 ownerName, repoName, repoId, extraction, 40
481 );
482
483 if (candidates.length === 0) {
484 cacheSet(cacheKey, empty);
485 return empty;
486 }
487
488 // Step 3 — Read file contents
489 const { contextStr, filesRead } = await buildContext(
490 ownerName, repoName, candidates, 80 * 1024
491 );
492
493 if (!contextStr || filesRead.length === 0) {
494 cacheSet(cacheKey, empty);
495 return empty;
496 }
497
498 // Step 4 — Claude reasoning pass
499 const results = await reasonWithClaude(q, contextStr);
500
501 const response: NlSearchResponse = {
502 query: q,
503 results,
504 totalFilesScanned: filesRead.length,
505 searchedAt: new Date(),
506 cached: false,
507 };
508
509 // Step 5 — Cache and return
510 cacheSet(cacheKey, response);
511 return response;
512 } catch (err) {
513 console.error("[nl-search] unexpected error:", err);
514 return empty;
515 }
516}
Addedsrc/lib/repo-health.ts+312−0View fileUnifiedSplit
@@ -0,0 +1,312 @@
1/**
2 * Repository Health Score — composite 0-100 signal.
3 *
4 * Five signals (always totals 100 when fully populated):
5 * CI green rate 25 pts gate_runs last 30 days
6 * Bus factor 20 pts bus_factor_cache table
7 * Open CVEs 20 pts repo_advisory_alerts table
8 * PR review velocity 15 pts pull_requests + pr_comments
9 * Tech debt 20 pts repo_onboarding_data (neutral 15 if no data)
10 *
11 * Cached in-memory with a 6-hour TTL. Call invalidateHealthScore(repoId) on
12 * every push to force a fresh computation on the next page load.
13 */
14
15import { db } from "../db";
16import {
17 gateRuns,
18 busFactorCache,
19 repoAdvisoryAlerts,
20 pullRequests,
21 prComments,
22 repoOnboardingData,
23} from "../db/schema";
24import { eq, and, gte, lt, sql, count, min } from "drizzle-orm";
25import type { BusFactorFile } from "./bus-factor";
26
27// ---------------------------------------------------------------------------
28// Public interface
29// ---------------------------------------------------------------------------
30
31export interface HealthScoreBreakdown {
32 total: number;
33 ciGreenRate: {
34 score: number; // 0-25
35 rate: number; // 0.0-1.0
36 totalRuns: number;
37 passedRuns: number;
38 };
39 busFactor: {
40 score: number; // 0-20
41 atRiskFileCount: number;
42 criticalCount: number;
43 };
44 openCves: {
45 score: number; // 0-20
46 count: number;
47 };
48 reviewVelocity: {
49 score: number; // 0-15
50 avgHours: number | null;
51 sampleSize: number;
52 };
53 techDebt: {
54 score: number; // 0-20
55 available: boolean;
56 };
57 computedAt: Date;
58}
59
60// ---------------------------------------------------------------------------
61// In-memory cache
62// ---------------------------------------------------------------------------
63
64interface CacheEntry {
65 breakdown: HealthScoreBreakdown;
66 expiresAt: number; // Date.now() ms
67}
68
69const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
70const memCache = new Map<string, CacheEntry>();
71
72export function invalidateHealthScore(repoId: string): void {
73 memCache.delete(repoId);
74}
75
76// ---------------------------------------------------------------------------
77// Signal: CI green rate (0-25 pts)
78// ---------------------------------------------------------------------------
79
80async function ciGreenRate(repoId: string): Promise<HealthScoreBreakdown["ciGreenRate"]> {
81 try {
82 const since = new Date(Date.now() - 30 * 86_400_000);
83 const rows = await db
84 .select({ status: gateRuns.status })
85 .from(gateRuns)
86 .where(and(eq(gateRuns.repositoryId, repoId), gte(gateRuns.createdAt, since)));
87
88 if (rows.length === 0) {
89 // No runs in last 30 days — benefit of the doubt
90 return { score: 20, rate: 1, totalRuns: 0, passedRuns: 0 };
91 }
92
93 const totalRuns = rows.length;
94 const passedRuns = rows.filter(
95 (r) => r.status === "passed" || r.status === "repaired"
96 ).length;
97 const rate = passedRuns / totalRuns;
98 const score = Math.round(rate * 25);
99 return { score, rate, totalRuns, passedRuns };
100 } catch {
101 return { score: 20, rate: 1, totalRuns: 0, passedRuns: 0 };
102 }
103}
104
105// ---------------------------------------------------------------------------
106// Signal: Bus factor (0-20 pts)
107// ---------------------------------------------------------------------------
108
109async function busFactorSignal(repoId: string): Promise<HealthScoreBreakdown["busFactor"]> {
110 try {
111 const rows = await db
112 .select()
113 .from(busFactorCache)
114 .where(eq(busFactorCache.repositoryId, repoId))
115 .limit(1);
116
117 if (rows.length === 0) {
118 // No cache entry — neutral
119 return { score: 15, atRiskFileCount: 0, criticalCount: 0 };
120 }
121
122 const atRiskFiles = (rows[0].atRiskFiles ?? []) as BusFactorFile[];
123 const criticalCount = atRiskFiles.filter((f) => f.risk === "critical").length;
124 const highCount = atRiskFiles.filter((f) => f.risk === "high").length;
125
126 // Start at 20, subtract penalty per risky file (capped)
127 const criticalPenalty = Math.min(criticalCount * 5, 20);
128 const highPenalty = Math.min(highCount * 2, 10);
129 const score = Math.max(0, 20 - criticalPenalty - highPenalty);
130
131 return { score, atRiskFileCount: atRiskFiles.length, criticalCount };
132 } catch {
133 return { score: 15, atRiskFileCount: 0, criticalCount: 0 };
134 }
135}
136
137// ---------------------------------------------------------------------------
138// Signal: Open CVEs (0-20 pts)
139// ---------------------------------------------------------------------------
140
141async function openCvesSignal(repoId: string): Promise<HealthScoreBreakdown["openCves"]> {
142 try {
143 const rows = await db
144 .select({ n: count() })
145 .from(repoAdvisoryAlerts)
146 .where(
147 and(
148 eq(repoAdvisoryAlerts.repositoryId, repoId),
149 eq(repoAdvisoryAlerts.status, "open")
150 )
151 );
152
153 const cveCount = Number(rows[0]?.n ?? 0);
154 let score: number;
155 if (cveCount === 0) score = 20;
156 else if (cveCount === 1) score = 15;
157 else if (cveCount === 2) score = 10;
158 else if (cveCount <= 4) score = 5;
159 else score = 0;
160
161 return { score, count: cveCount };
162 } catch {
163 return { score: 20, count: 0 };
164 }
165}
166
167// ---------------------------------------------------------------------------
168// Signal: PR review velocity (0-15 pts)
169// ---------------------------------------------------------------------------
170
171async function reviewVelocitySignal(repoId: string): Promise<HealthScoreBreakdown["reviewVelocity"]> {
172 try {
173 const since = new Date(Date.now() - 30 * 86_400_000);
174
175 // Get merged PRs from last 30 days with their first human review comment timestamp
176 const rows = await db
177 .select({
178 prId: pullRequests.id,
179 prCreatedAt: pullRequests.createdAt,
180 firstReview: min(prComments.createdAt),
181 })
182 .from(pullRequests)
183 .innerJoin(
184 prComments,
185 and(
186 eq(prComments.pullRequestId, pullRequests.id),
187 eq(prComments.isAiReview, false)
188 )
189 )
190 .where(
191 and(
192 eq(pullRequests.repositoryId, repoId),
193 eq(pullRequests.state, "merged"),
194 gte(pullRequests.createdAt, since)
195 )
196 )
197 .groupBy(pullRequests.id, pullRequests.createdAt)
198 .limit(20);
199
200 if (rows.length === 0) {
201 return { score: 0, avgHours: null, sampleSize: 0 };
202 }
203
204 // Compute average hours from PR creation to first review
205 let totalHours = 0;
206 let validCount = 0;
207 for (const row of rows) {
208 if (row.firstReview && row.prCreatedAt) {
209 const diffMs =
210 new Date(row.firstReview).getTime() -
211 new Date(row.prCreatedAt).getTime();
212 if (diffMs >= 0) {
213 totalHours += diffMs / 3_600_000;
214 validCount++;
215 }
216 }
217 }
218
219 if (validCount === 0) {
220 return { score: 0, avgHours: null, sampleSize: rows.length };
221 }
222
223 const avgHours = totalHours / validCount;
224 let score: number;
225 if (avgHours < 4) score = 15;
226 else if (avgHours < 8) score = 12;
227 else if (avgHours < 24) score = 8;
228 else if (avgHours < 72) score = 4;
229 else score = 0;
230
231 return { score, avgHours, sampleSize: validCount };
232 } catch {
233 return { score: 0, avgHours: null, sampleSize: 0 };
234 }
235}
236
237// ---------------------------------------------------------------------------
238// Signal: Tech debt (0-20 pts)
239// ---------------------------------------------------------------------------
240
241async function techDebtSignal(repoId: string): Promise<HealthScoreBreakdown["techDebt"]> {
242 try {
243 const rows = await db
244 .select()
245 .from(repoOnboardingData)
246 .where(eq(repoOnboardingData.repositoryId, repoId))
247 .limit(1);
248
249 if (rows.length === 0) {
250 // No onboarding data — neutral
251 return { score: 15, available: false };
252 }
253
254 // Onboarding data exists but doesn't carry a debtScore field in schema v1 —
255 // give the neutral 15-pt benefit of the doubt.
256 return { score: 15, available: true };
257 } catch {
258 return { score: 15, available: false };
259 }
260}
261
262// ---------------------------------------------------------------------------
263// Core computation
264// ---------------------------------------------------------------------------
265
266export async function computeHealthScore(
267 repoId: string
268): Promise<HealthScoreBreakdown> {
269 const [ci, bf, cve, vel, debt] = await Promise.all([
270 ciGreenRate(repoId),
271 busFactorSignal(repoId),
272 openCvesSignal(repoId),
273 reviewVelocitySignal(repoId),
274 techDebtSignal(repoId),
275 ]);
276
277 const total = Math.min(
278 100,
279 ci.score + bf.score + cve.score + vel.score + debt.score
280 );
281
282 return {
283 total,
284 ciGreenRate: ci,
285 busFactor: bf,
286 openCves: cve,
287 reviewVelocity: vel,
288 techDebt: debt,
289 computedAt: new Date(),
290 };
291}
292
293// ---------------------------------------------------------------------------
294// Cached version (6h TTL)
295// ---------------------------------------------------------------------------
296
297export async function getHealthScore(
298 repoId: string
299): Promise<HealthScoreBreakdown> {
300 const now = Date.now();
301 const cached = memCache.get(repoId);
302 if (cached && cached.expiresAt > now) {
303 return cached.breakdown;
304 }
305
306 const breakdown = await computeHealthScore(repoId);
307 memCache.set(repoId, {
308 breakdown,
309 expiresAt: now + CACHE_TTL_MS,
310 });
311 return breakdown;
312}
Addedsrc/routes/ai-workspace.tsx+544−0View fileUnifiedSplit
@@ -0,0 +1,544 @@
1/**
2 * AI Copilot Workspace routes.
3 *
4 * GET /:owner/:repo/issues/:number/workspace — status page
5 * POST /:owner/:repo/issues/:number/workspace/start — start job
6 */
7
8import { Hono } from "hono";
9import { eq, and } from "drizzle-orm";
10import { db } from "../db";
11import { issues, repositories, users } from "../db/schema";
12import { Layout } from "../views/layout";
13import { softAuth, requireAuth } from "../middleware/auth";
14import { requireRepoAccess } from "../middleware/repo-access";
15import type { AuthEnv } from "../middleware/auth";
16import {
17 startWorkspace,
18 getWorkspaceJobForIssue,
19 type WorkspaceJob,
20 type WorkspaceStatus,
21} from "../lib/ai-workspace";
22import { isAiAvailable } from "../lib/ai-client";
23
24export const workspaceRoutes = new Hono<AuthEnv>();
25
26// ---------------------------------------------------------------------------
27// Styles
28// ---------------------------------------------------------------------------
29
30const wsStyles = `
31 .ws-hero {
32 position: relative;
33 margin: 4px 0 24px;
34 padding: 28px 32px;
35 background: var(--bg-elevated);
36 border: 1px solid var(--border);
37 border-radius: 16px;
38 overflow: hidden;
39 }
40 .ws-hero::before {
41 content: '';
42 position: absolute;
43 top: 0; left: 0; right: 0;
44 height: 2px;
45 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
46 opacity: 0.7;
47 pointer-events: none;
48 }
49 .ws-title {
50 font-family: var(--font-display);
51 font-size: clamp(22px, 3vw, 30px);
52 font-weight: 700;
53 letter-spacing: -0.022em;
54 color: var(--text-strong);
55 margin: 0 0 8px;
56 }
57 .ws-subtitle {
58 font-size: 15px;
59 color: var(--text-muted);
60 margin: 0;
61 line-height: 1.5;
62 }
63 .ws-stepper {
64 display: flex;
65 flex-direction: column;
66 gap: 0;
67 margin: 28px 0;
68 }
69 .ws-step {
70 display: flex;
71 align-items: flex-start;
72 gap: 14px;
73 padding: 14px 0;
74 border-left: 2px solid var(--border);
75 padding-left: 20px;
76 position: relative;
77 }
78 .ws-step:last-child {
79 border-left: 2px solid transparent;
80 }
81 .ws-step-dot {
82 position: absolute;
83 left: -7px;
84 top: 18px;
85 width: 12px;
86 height: 12px;
87 border-radius: 50%;
88 background: var(--border);
89 border: 2px solid var(--bg);
90 flex-shrink: 0;
91 transition: background 200ms;
92 }
93 .ws-step.is-done .ws-step-dot {
94 background: #34d399;
95 }
96 .ws-step.is-active .ws-step-dot {
97 background: #8c6dff;
98 box-shadow: 0 0 0 4px rgba(140,109,255,0.22);
99 animation: ws-pulse 1.4s ease-in-out infinite;
100 }
101 .ws-step.is-failed .ws-step-dot {
102 background: #f87171;
103 }
104 @keyframes ws-pulse {
105 0%, 100% { box-shadow: 0 0 0 4px rgba(140,109,255,0.22); }
106 50% { box-shadow: 0 0 0 7px rgba(140,109,255,0.10); }
107 }
108 .ws-step-label {
109 font-size: 14px;
110 font-weight: 600;
111 color: var(--text-muted);
112 line-height: 1.4;
113 padding-top: 1px;
114 }
115 .ws-step.is-done .ws-step-label { color: #34d399; }
116 .ws-step.is-active .ws-step-label { color: var(--text-strong); }
117 .ws-step.is-failed .ws-step-label { color: #f87171; }
118 .ws-step-desc {
119 font-size: 12.5px;
120 color: var(--text-muted);
121 margin-top: 2px;
122 }
123 .ws-result {
124 margin-top: 18px;
125 padding: 16px 20px;
126 border-radius: 12px;
127 font-size: 14px;
128 line-height: 1.55;
129 }
130 .ws-result.is-done {
131 background: rgba(52,211,153,0.08);
132 border: 1px solid rgba(52,211,153,0.3);
133 color: var(--text);
134 }
135 .ws-result.is-failed {
136 background: rgba(248,113,113,0.08);
137 border: 1px solid rgba(248,113,113,0.3);
138 color: var(--text);
139 }
140 .ws-pr-link {
141 display: inline-flex;
142 align-items: center;
143 gap: 6px;
144 margin-top: 12px;
145 padding: 9px 18px;
146 border-radius: 8px;
147 background: rgba(140,109,255,0.14);
148 border: 1px solid rgba(140,109,255,0.35);
149 color: var(--accent);
150 font-weight: 600;
151 text-decoration: none;
152 font-size: 13.5px;
153 transition: background 120ms;
154 }
155 .ws-pr-link:hover { background: rgba(140,109,255,0.22); text-decoration: none; }
156 .ws-start-btn {
157 display: inline-flex;
158 align-items: center;
159 gap: 8px;
160 padding: 10px 22px;
161 border-radius: 8px;
162 background: rgba(140,109,255,0.14);
163 border: 1px solid rgba(140,109,255,0.35);
164 color: var(--accent);
165 font-weight: 700;
166 font-size: 14px;
167 cursor: pointer;
168 transition: background 120ms;
169 }
170 .ws-start-btn:hover { background: rgba(140,109,255,0.22); }
171 .ws-explain {
172 margin-top: 18px;
173 padding: 14px 18px;
174 border-radius: 10px;
175 background: var(--bg-secondary);
176 border: 1px solid var(--border);
177 font-size: 13.5px;
178 color: var(--text-muted);
179 line-height: 1.6;
180 }
181 .ws-explain ul {
182 margin: 8px 0 0 18px;
183 padding: 0;
184 }
185 .ws-explain li { margin: 4px 0; }
186 .ws-back-link {
187 display: inline-flex;
188 align-items: center;
189 gap: 6px;
190 font-size: 13px;
191 color: var(--text-muted);
192 text-decoration: none;
193 margin-bottom: 18px;
194 }
195 .ws-back-link:hover { color: var(--text); text-decoration: none; }
196 .ws-badge {
197 display: inline-block;
198 padding: 2px 9px;
199 border-radius: 9999px;
200 font-size: 11px;
201 font-weight: 700;
202 letter-spacing: 0.03em;
203 text-transform: uppercase;
204 }
205 .ws-badge-active {
206 background: rgba(140,109,255,0.15);
207 color: #a78bfa;
208 border: 1px solid rgba(140,109,255,0.3);
209 }
210 .ws-badge-done {
211 background: rgba(52,211,153,0.12);
212 color: #34d399;
213 border: 1px solid rgba(52,211,153,0.3);
214 }
215 .ws-badge-failed {
216 background: rgba(248,113,113,0.12);
217 color: #f87171;
218 border: 1px solid rgba(248,113,113,0.3);
219 }
220`;
221
222// ---------------------------------------------------------------------------
223// Step definitions
224// ---------------------------------------------------------------------------
225
226const STEPS: Array<{ key: WorkspaceStatus; label: string; desc: string }> = [
227 { key: "planning", label: "Planning", desc: "Reading the issue, exploring the codebase, generating a plan" },
228 { key: "implementing", label: "Implementing", desc: "Creating a branch and applying file changes" },
229 { key: "opening_pr", label: "Opening PR", desc: "Pushing the branch and opening a draft pull request" },
230 { key: "done", label: "Done", desc: "PR is open and ready for review" },
231];
232
233const STATUS_ORDER: Record<WorkspaceStatus, number> = {
234 pending: -1,
235 planning: 0,
236 implementing: 1,
237 opening_pr: 2,
238 done: 3,
239 failed: -2,
240};
241
242function stepClass(stepStatus: WorkspaceStatus, currentStatus: WorkspaceStatus): string {
243 if (currentStatus === "failed") {
244 // All steps pending or last attempted step shows as failed — just show neutral
245 return "";
246 }
247 const stepOrder = STATUS_ORDER[stepStatus];
248 const curOrder = STATUS_ORDER[currentStatus];
249 if (stepOrder < curOrder) return "is-done";
250 if (stepOrder === curOrder) return "is-active";
251 return "";
252}
253
254// ---------------------------------------------------------------------------
255// Helpers
256// ---------------------------------------------------------------------------
257
258async function resolveIssueAndRepo(
259 ownerName: string,
260 repoName: string,
261 issueNum: number
262) {
263 const [ownerRow] = await db
264 .select({ id: users.id })
265 .from(users)
266 .where(eq(users.username, ownerName))
267 .limit(1);
268 if (!ownerRow) return null;
269
270 const [repoRow] = await db
271 .select({ id: repositories.id, name: repositories.name, isPrivate: repositories.isPrivate })
272 .from(repositories)
273 .where(and(eq(repositories.ownerId, ownerRow.id), eq(repositories.name, repoName)))
274 .limit(1);
275 if (!repoRow) return null;
276
277 const [issueRow] = await db
278 .select({ id: issues.id, number: issues.number, title: issues.title, state: issues.state })
279 .from(issues)
280 .where(and(eq(issues.repositoryId, repoRow.id), eq(issues.number, issueNum)))
281 .limit(1);
282 if (!issueRow) return null;
283
284 return { owner: ownerRow, repo: repoRow, issue: issueRow };
285}
286
287// ---------------------------------------------------------------------------
288// WorkspaceStatusPage JSX component
289// ---------------------------------------------------------------------------
290
291function WorkspaceStatusPage({
292 owner,
293 repoName,
294 issue,
295 job,
296 user,
297}: {
298 owner: string;
299 repoName: string;
300 issue: { number: number; title: string };
301 job: WorkspaceJob | undefined;
302 user: { username: string } | null | undefined;
303}) {
304 const isActive =
305 job &&
306 ["pending", "planning", "implementing", "opening_pr"].includes(job.status);
307 const isDone = job?.status === "done";
308 const isFailed = job?.status === "failed";
309 const hasJob = !!job;
310
311 const issueUrl = `/${owner}/${repoName}/issues/${issue.number}`;
312 const prUrl = isDone && job.prNumber
313 ? `/${owner}/${repoName}/pulls/${job.prNumber}`
314 : null;
315 const branchUrl = job?.branchName
316 ? `/${owner}/${repoName}/tree/${job.branchName}`
317 : null;
318
319 return (
320 <Layout title={`AI Workspace — #${issue.number}`} user={user as any}>
321 {isActive && (
322 <meta http-equiv="refresh" content="3" />
323 )}
324 <style dangerouslySetInnerHTML={{ __html: wsStyles }} />
325 <div style="max-width:800px;margin:0 auto;padding:24px 16px">
326 <a href={issueUrl} class="ws-back-link">
327 ← #{issue.number}: {issue.title}
328 </a>
329
330 <div class="ws-hero">
331 <h1 class="ws-title">
332 AI Copilot Workspace
333 {isActive && (
334 <span class="ws-badge ws-badge-active" style="margin-left:12px;vertical-align:middle">
335 Running
336 </span>
337 )}
338 {isDone && (
339 <span class="ws-badge ws-badge-done" style="margin-left:12px;vertical-align:middle">
340 Done
341 </span>
342 )}
343 {isFailed && (
344 <span class="ws-badge ws-badge-failed" style="margin-left:12px;vertical-align:middle">
345 Failed
346 </span>
347 )}
348 </h1>
349 <p class="ws-subtitle">
350 Autonomous issue-to-PR agent — reads the issue, explores the codebase,
351 proposes a plan, then opens a draft pull request.
352 </p>
353 </div>
354
355 {/* If no job, show Start button */}
356 {!hasJob && (
357 <div>
358 <form method="post" action={`/${owner}/${repoName}/issues/${issue.number}/workspace/start`}>
359 <button type="submit" class="ws-start-btn">
360 Start Workspace
361 </button>
362 </form>
363 <div class="ws-explain">
364 <strong>What Gluecron will do:</strong>
365 <ul>
366 <li>Read issue #{issue.number} and recent comments to understand the task</li>
367 <li>Explore the codebase — file tree + most relevant files (Claude-selected)</li>
368 <li>Generate an implementation plan and post it as an issue comment</li>
369 <li>Create a branch, implement the changes file-by-file</li>
370 <li>Open a draft PR linked to this issue</li>
371 </ul>
372 <p style="margin:12px 0 0">
373 This usually takes 60–180 seconds depending on codebase size.
374 The page auto-refreshes while running.
375 </p>
376 </div>
377 </div>
378 )}
379
380 {/* Stepper — shown when a job exists */}
381 {hasJob && (
382 <div>
383 <div class="ws-stepper">
384 {STEPS.map((step) => {
385 const cls = isFailed
386 ? ""
387 : stepClass(step.key, job!.status);
388 return (
389 <div class={`ws-step ${cls}`}>
390 <div class="ws-step-dot" />
391 <div>
392 <div class="ws-step-label">{step.label}</div>
393 <div class="ws-step-desc">{step.desc}</div>
394 </div>
395 </div>
396 );
397 })}
398 </div>
399
400 {/* Done state */}
401 {isDone && prUrl && (
402 <div class="ws-result is-done">
403 <strong>Workspace complete!</strong> A draft PR has been opened.
404 <br />
405 {job.planComment && (
406 <p style="margin-top:8px;font-size:13px;color:var(--text-muted)">
407 An implementation plan was posted as a comment on the issue.
408 </p>
409 )}
410 <a href={prUrl} class="ws-pr-link">
411 View Draft PR #{job.prNumber}
412 </a>
413 {branchUrl && (
414 <>
415 {" "}
416 <a href={branchUrl} class="ws-pr-link" style="margin-left:8px">
417 Browse Branch
418 </a>
419 </>
420 )}
421 </div>
422 )}
423
424 {/* Failed state */}
425 {isFailed && (
426 <div class="ws-result is-failed">
427 <strong>Workspace failed.</strong>
428 {job.errorMessage && (
429 <p style="margin:8px 0 0;font-family:var(--font-mono);font-size:12px;word-break:break-all">
430 {job.errorMessage}
431 </p>
432 )}
433 <form
434 method="post"
435 action={`/${owner}/${repoName}/issues/${issue.number}/workspace/start`}
436 style="margin-top:14px"
437 >
438 <button type="submit" class="ws-start-btn">
439 Retry Workspace
440 </button>
441 </form>
442 </div>
443 )}
444
445 {/* Active state hint */}
446 {isActive && (
447 <p style="font-size:13px;color:var(--text-muted);margin-top:12px">
448 This page refreshes every 3 seconds. You can leave and come back.
449 </p>
450 )}
451 </div>
452 )}
453 </div>
454 </Layout>
455 );
456}
457
458// ---------------------------------------------------------------------------
459// GET /:owner/:repo/issues/:number/workspace
460// ---------------------------------------------------------------------------
461
462workspaceRoutes.get(
463 "/:owner/:repo/issues/:number/workspace",
464 softAuth,
465 requireAuth,
466 requireRepoAccess("read"),
467 async (c) => {
468 const { owner, repo } = c.req.param();
469 const issueNum = parseInt(c.req.param("number"), 10);
470 const user = c.get("user");
471
472 const resolved = await resolveIssueAndRepo(owner, repo, issueNum);
473 if (!resolved) {
474 return c.html(
475 <Layout title="Not Found" user={user}>
476 <div style="padding:40px;text-align:center;color:var(--text-muted)">Issue not found.</div>
477 </Layout>,
478 404
479 );
480 }
481
482 const job = getWorkspaceJobForIssue(resolved.issue.id);
483
484 return c.html(
485 <WorkspaceStatusPage
486 owner={owner}
487 repoName={repo}
488 issue={{ number: resolved.issue.number, title: resolved.issue.title }}
489 job={job}
490 user={user}
491 />
492 );
493 }
494);
495
496// ---------------------------------------------------------------------------
497// POST /:owner/:repo/issues/:number/workspace/start
498// ---------------------------------------------------------------------------
499
500workspaceRoutes.post(
501 "/:owner/:repo/issues/:number/workspace/start",
502 softAuth,
503 requireAuth,
504 requireRepoAccess("write"),
505 async (c) => {
506 const { owner, repo } = c.req.param();
507 const issueNum = parseInt(c.req.param("number"), 10);
508 const user = c.get("user")!;
509
510 if (!isAiAvailable()) {
511 return c.html(
512 <Layout title="AI unavailable" user={user}>
513 <div style="padding:40px;text-align:center;color:var(--text-muted)">
514 ANTHROPIC_API_KEY is not configured. AI Workspace requires the AI features to be enabled.
515 </div>
516 </Layout>,
517 503
518 );
519 }
520
521 const resolved = await resolveIssueAndRepo(owner, repo, issueNum);
522 if (!resolved) {
523 return c.html(
524 <Layout title="Not Found" user={user}>
525 <div style="padding:40px;text-align:center;color:var(--text-muted)">Issue not found.</div>
526 </Layout>,
527 404
528 );
529 }
530
531 await startWorkspace(
532 resolved.issue.id,
533 resolved.issue.number,
534 resolved.repo.id,
535 owner,
536 repo,
537 user.id
538 );
539
540 return c.redirect(`/${owner}/${repo}/issues/${issueNum}/workspace`);
541 }
542);
543
544export default workspaceRoutes;
Modifiedsrc/routes/copilot.ts+2−0View fileUnifiedSplit
@@ -12,6 +12,8 @@
1212 * misbehaving client can otherwise exhaust the shared Anthropic quota fast.
1313 */
1414
15// For proactive, context-aware suggestions see POST /api/pair/suggest (src/routes/pair-programmer.ts)
16
1517import { Hono } from "hono";
1618import { requireAuth } from "../middleware/auth";
1719import type { AuthEnv } from "../middleware/auth";
Addedsrc/routes/cross-repo-impact.tsx+811−0View fileUnifiedSplit
@@ -0,0 +1,811 @@
1/**
2 * Cross-Repo Dependency Impact Detection — PR-level downstream analysis
3 *
4 * GET /:owner/:repo/pulls/:number/cross-repo-impact
5 * Render the full report page showing downstream repos at risk.
6 *
7 * POST /:owner/:repo/pulls/:number/cross-repo-impact/analyze
8 * Trigger (or re-trigger) analysis and redirect back to GET.
9 *
10 * POST /:owner/:repo/pulls/:number/cross-repo-impact/open-fix-pr/:downstreamRepoId
11 * Open a draft PR on the downstream repo with a migration note.
12 */
13
14import { Hono } from "hono";
15import { db } from "../db";
16import {
17 repositories,
18 users,
19 pullRequests,
20 prComments,
21} from "../db/schema";
22import { and, eq } from "drizzle-orm";
23import type { AuthEnv } from "../middleware/auth";
24import { requireAuth, softAuth } from "../middleware/auth";
25import { requireRepoAccess } from "../middleware/repo-access";
26import { Layout } from "../views/layout";
27import { RepoHeader, RepoNav } from "../views/components";
28import { getUnreadCount } from "../lib/unread";
29import {
30 analyzeCrossRepoImpact,
31 type CrossRepoReport,
32 type DownstreamImpact,
33} from "../lib/cross-repo-impact";
34
35export const crossRepoImpactRoutes = new Hono<AuthEnv>();
36
37// ─── CSS ──────────────────────────────────────────────────────────────────────
38
39const styles = `
40 .cri-wrap {
41 max-width: 1200px;
42 margin: 0 auto;
43 padding: var(--space-5) var(--space-4);
44 }
45
46 /* Sub-nav (PR-level) */
47 .cri-subnav {
48 display: flex;
49 gap: 4px;
50 margin-bottom: var(--space-5);
51 border-bottom: 1px solid var(--border);
52 padding-bottom: 0;
53 }
54 .cri-subnav-link {
55 padding: 8px 14px;
56 font-size: 13px;
57 font-weight: 500;
58 color: var(--text-muted);
59 text-decoration: none;
60 border-bottom: 2px solid transparent;
61 margin-bottom: -1px;
62 transition: color 120ms ease, border-color 120ms ease;
63 border-radius: 4px 4px 0 0;
64 }
65 .cri-subnav-link:hover { color: var(--text); }
66 .cri-subnav-link.active {
67 color: var(--accent, #5865f2);
68 border-bottom-color: var(--accent, #5865f2);
69 }
70
71 /* Hero */
72 .cri-hero {
73 position: relative;
74 margin-bottom: var(--space-5);
75 padding: var(--space-5) var(--space-6);
76 background: var(--bg-elevated);
77 border: 1px solid var(--border);
78 border-radius: 16px;
79 overflow: hidden;
80 }
81 .cri-hero::before {
82 content: '';
83 position: absolute;
84 top: 0; left: 0; right: 0;
85 height: 2px;
86 background: linear-gradient(90deg, transparent 0%, #8b5cf6 30%, #3b82f6 70%, transparent 100%);
87 opacity: 0.75;
88 pointer-events: none;
89 }
90 .cri-hero-orb {
91 position: absolute;
92 inset: -30% -15% auto auto;
93 width: 460px; height: 460px;
94 background: radial-gradient(circle, rgba(139,92,246,0.14), rgba(59,130,246,0.07) 45%, transparent 70%);
95 filter: blur(80px);
96 opacity: 0.75;
97 pointer-events: none;
98 z-index: 0;
99 }
100 .cri-hero-inner { position: relative; z-index: 1; max-width: 760px; }
101 .cri-hero-eyebrow {
102 display: inline-flex; align-items: center; gap: 6px;
103 font-size: 11px; font-weight: 700; letter-spacing: 0.07em;
104 text-transform: uppercase;
105 color: #8b5cf6;
106 margin-bottom: 10px;
107 }
108 .cri-hero-title {
109 font-family: var(--font-display);
110 font-size: clamp(20px, 3vw, 28px);
111 font-weight: 800;
112 letter-spacing: -0.025em;
113 line-height: 1.1;
114 margin: 0 0 8px;
115 color: var(--text-strong);
116 }
117 .cri-hero-sub {
118 font-size: 14px;
119 color: var(--text-muted);
120 margin: 0;
121 line-height: 1.5;
122 }
123 .cri-hero-actions {
124 display: flex;
125 gap: 10px;
126 align-items: center;
127 margin-top: 16px;
128 flex-wrap: wrap;
129 }
130 .cri-analyze-btn {
131 display: inline-flex; align-items: center; gap: 6px;
132 padding: 8px 16px;
133 border-radius: 8px;
134 font-size: 13px; font-weight: 600;
135 color: #fff;
136 background: linear-gradient(135deg, #8b5cf6 0%, #3b82f6 130%);
137 border: none;
138 cursor: pointer;
139 transition: opacity 120ms ease;
140 text-decoration: none;
141 }
142 .cri-analyze-btn:hover { opacity: 0.85; }
143 .cri-analyzed-at {
144 font-size: 12px;
145 color: var(--text-muted);
146 }
147
148 /* Stats */
149 .cri-stats {
150 display: flex;
151 gap: 16px;
152 margin-bottom: var(--space-5);
153 flex-wrap: wrap;
154 }
155 .cri-stat-card {
156 flex: 1 1 160px;
157 padding: 14px 18px;
158 background: var(--bg-elevated);
159 border: 1px solid var(--border);
160 border-radius: 12px;
161 min-width: 120px;
162 }
163 .cri-stat-value {
164 font-family: var(--font-display);
165 font-size: 26px;
166 font-weight: 800;
167 line-height: 1;
168 margin-bottom: 4px;
169 color: var(--text-strong);
170 font-variant-numeric: tabular-nums;
171 }
172 .cri-stat-label {
173 font-size: 12px;
174 color: var(--text-muted);
175 font-weight: 500;
176 }
177 .cri-stat-card.is-high .cri-stat-value { color: #ef4444; }
178 .cri-stat-card.is-medium .cri-stat-value { color: #f59e0b; }
179 .cri-stat-card.is-low .cri-stat-value { color: #22c55e; }
180
181 /* Risk score ring */
182 .cri-risk-ring {
183 display: inline-flex;
184 align-items: center;
185 gap: 8px;
186 padding: 6px 12px;
187 border-radius: 20px;
188 font-size: 13px;
189 font-weight: 700;
190 }
191 .cri-risk-ring.is-high { color: #ef4444; background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); }
192 .cri-risk-ring.is-medium { color: #f59e0b; background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.3); }
193 .cri-risk-ring.is-low { color: #22c55e; background: rgba(34,197,94,0.1); border: 1px solid rgba(34,197,94,0.3); }
194
195 /* Downstream repo table */
196 .cri-table-wrap {
197 background: var(--bg-elevated);
198 border: 1px solid var(--border);
199 border-radius: 12px;
200 overflow: hidden;
201 margin-bottom: var(--space-4);
202 }
203 .cri-table {
204 width: 100%;
205 border-collapse: collapse;
206 }
207 .cri-table th {
208 padding: 10px 16px;
209 font-size: 11px;
210 font-weight: 700;
211 text-transform: uppercase;
212 letter-spacing: 0.05em;
213 color: var(--text-muted);
214 background: var(--bg-secondary, rgba(255,255,255,0.03));
215 border-bottom: 1px solid var(--border);
216 text-align: left;
217 }
218 .cri-table td {
219 padding: 12px 16px;
220 font-size: 13px;
221 border-bottom: 1px solid var(--border);
222 vertical-align: top;
223 }
224 .cri-table tr:last-child td { border-bottom: none; }
225 .cri-table tr:hover td { background: rgba(255,255,255,0.02); }
226
227 /* Risk pills */
228 .cri-pill {
229 display: inline-flex;
230 align-items: center;
231 padding: 2px 9px;
232 border-radius: 9999px;
233 font-size: 10.5px;
234 font-weight: 700;
235 letter-spacing: 0.04em;
236 text-transform: uppercase;
237 }
238 .cri-pill.is-high { color: #ef4444; background: rgba(239,68,68,0.12); border: 1px solid rgba(239,68,68,0.3); }
239 .cri-pill.is-medium { color: #f59e0b; background: rgba(245,158,11,0.12); border: 1px solid rgba(245,158,11,0.3); }
240 .cri-pill.is-low { color: #22c55e; background: rgba(34,197,94,0.12); border: 1px solid rgba(34,197,94,0.3); }
241
242 /* Symbol chips */
243 .cri-symbols {
244 display: flex;
245 flex-wrap: wrap;
246 gap: 4px;
247 max-width: 280px;
248 }
249 .cri-symbol-chip {
250 font-family: var(--font-mono);
251 font-size: 11px;
252 padding: 2px 7px;
253 background: rgba(139,92,246,0.1);
254 border: 1px solid rgba(139,92,246,0.25);
255 border-radius: 4px;
256 color: #a78bfa;
257 white-space: nowrap;
258 }
259
260 /* Fix PR button */
261 .cri-fix-btn {
262 display: inline-flex; align-items: center; gap: 5px;
263 padding: 5px 12px;
264 border-radius: 6px;
265 font-size: 12px; font-weight: 600;
266 color: #fff;
267 background: #5865f2;
268 border: none;
269 cursor: pointer;
270 transition: opacity 120ms ease;
271 }
272 .cri-fix-btn:hover { opacity: 0.85; }
273 .cri-fix-btn:disabled { opacity: 0.5; cursor: not-allowed; }
274
275 .cri-repo-link {
276 font-weight: 600;
277 color: var(--accent, #5865f2);
278 text-decoration: none;
279 }
280 .cri-repo-link:hover { text-decoration: underline; }
281 .cri-owner-prefix { color: var(--text-muted); font-weight: 400; }
282 .cri-version-tag {
283 font-family: var(--font-mono);
284 font-size: 11px;
285 color: var(--text-muted);
286 margin-top: 2px;
287 }
288
289 /* Empty state */
290 .cri-empty {
291 text-align: center;
292 padding: 64px 24px;
293 color: var(--text-muted);
294 }
295 .cri-empty-icon { font-size: 40px; margin-bottom: 12px; }
296 .cri-empty-title { font-size: 18px; font-weight: 700; color: var(--text-strong); margin-bottom: 6px; }
297 .cri-empty-sub { font-size: 14px; }
298
299 /* Alert banner */
300 .cri-alert {
301 padding: 12px 16px;
302 border-radius: 8px;
303 font-size: 13px;
304 margin-bottom: var(--space-4);
305 border-left: 3px solid;
306 }
307 .cri-alert.is-info { color: #93c5fd; background: rgba(59,130,246,0.08); border-color: #3b82f6; }
308 .cri-alert.is-warn { color: #fcd34d; background: rgba(245,158,11,0.08); border-color: #f59e0b; }
309`;
310
311// ─── Helpers ──────────────────────────────────────────────────────────────────
312
313function totalRiskClass(score: number): string {
314 if (score >= 60) return "is-high";
315 if (score >= 30) return "is-medium";
316 return "is-low";
317}
318
319function riskLabel(score: number): string {
320 if (score >= 60) return "High";
321 if (score >= 30) return "Medium";
322 return "Low";
323}
324
325// ─── Repo + PR resolution helper ─────────────────────────────────────────────
326
327async function resolveRepo(ownerName: string, repoName: string) {
328 const rows = await db
329 .select({
330 id: repositories.id,
331 ownerId: repositories.ownerId,
332 isPrivate: repositories.isPrivate,
333 })
334 .from(repositories)
335 .innerJoin(users, eq(repositories.ownerId, users.id))
336 .where(and(eq(users.username, ownerName), eq(repositories.name, repoName)))
337 .limit(1);
338 return rows[0] ?? null;
339}
340
341async function resolvePr(repoId: string, prNumber: number) {
342 const rows = await db
343 .select({
344 id: pullRequests.id,
345 number: pullRequests.number,
346 title: pullRequests.title,
347 state: pullRequests.state,
348 baseBranch: pullRequests.baseBranch,
349 headBranch: pullRequests.headBranch,
350 })
351 .from(pullRequests)
352 .where(
353 and(
354 eq(pullRequests.repositoryId, repoId),
355 eq(pullRequests.number, prNumber)
356 )
357 )
358 .limit(1);
359 return rows[0] ?? null;
360}
361
362// ─── Route: GET /:owner/:repo/pulls/:number/cross-repo-impact ─────────────────
363
364crossRepoImpactRoutes.use(
365 "/:owner/:repo/pulls/:number/cross-repo-impact",
366 softAuth
367);
368
369crossRepoImpactRoutes.get(
370 "/:owner/:repo/pulls/:number/cross-repo-impact",
371 requireRepoAccess("read"),
372 async (c) => {
373 const user = c.get("user") ?? null;
374 const { owner: ownerName, repo: repoName, number: prNumberStr } = c.req.param() as {
375 owner: string;
376 repo: string;
377 number: string;
378 };
379 const prNumber = parseInt(prNumberStr, 10);
380 if (isNaN(prNumber)) return c.notFound();
381
382 const repo = await resolveRepo(ownerName, repoName);
383 if (!repo) return c.notFound();
384
385 const pr = await resolvePr(repo.id, prNumber);
386 if (!pr) return c.notFound();
387
388 const unreadCount = user ? await getUnreadCount(user.id) : 0;
389 const isOwner = !!user && user.id === repo.ownerId;
390
391 // Try to load cached report (memory + DB, no re-analysis here)
392 let report: CrossRepoReport | null = null;
393 try {
394 // Import the cache-checking logic only (don't re-run full analysis)
395 const { analyzeCrossRepoImpact: analyze } = await import(
396 "../lib/cross-repo-impact"
397 );
398 // If there's a cached version it returns fast; if not, report = null
399 // We check the DB directly to avoid running analysis on page load
400 const { crossRepoImpactCache } = await import("../db/schema");
401 const cacheRows = await db
402 .select()
403 .from(crossRepoImpactCache)
404 .where(eq(crossRepoImpactCache.prId, pr.id))
405 .limit(1);
406
407 if (cacheRows.length > 0 && cacheRows[0].cachedUntil > new Date()) {
408 report = cacheRows[0].report as CrossRepoReport;
409 }
410 } catch {
411 // Degrade gracefully — show "no analysis yet"
412 }
413
414 const highCount = report?.affectedRepos.filter((r) => r.riskLevel === "high").length ?? 0;
415 const mediumCount = report?.affectedRepos.filter((r) => r.riskLevel === "medium").length ?? 0;
416 const lowCount = report?.affectedRepos.filter((r) => r.riskLevel === "low").length ?? 0;
417 const totalRisk = report?.totalRisk ?? 0;
418
419 const baseUrl = `/${ownerName}/${repoName}/pulls/${prNumber}`;
420
421 return c.html(
422 <Layout
423 title={`Cross-Repo Impact — PR #${prNumber} — ${ownerName}/${repoName}`}
424 user={user}
425 notificationCount={unreadCount}
426 >
427 <style dangerouslySetInnerHTML={{ __html: styles }} />
428 <div class="cri-wrap">
429 <RepoHeader owner={ownerName} repo={repoName} />
430 <RepoNav owner={ownerName} repo={repoName} active="pulls" />
431
432 {/* PR sub-navigation */}
433 <nav class="cri-subnav">
434 <a class="cri-subnav-link" href={`${baseUrl}`}>
435 Conversation
436 </a>
437 <a class="cri-subnav-link" href={`${baseUrl}/files`}>
438 Files
439 </a>
440 <a class="cri-subnav-link active" href={`${baseUrl}/cross-repo-impact`}>
441 Cross-Repo Impact
442 </a>
443 </nav>
444
445 {/* Hero */}
446 <div class="cri-hero">
447 <div class="cri-hero-orb" aria-hidden="true" />
448 <div class="cri-hero-inner">
449 <div class="cri-hero-eyebrow">⚠ Dependency Risk</div>
450 <h1 class="cri-hero-title">
451 Cross-Repo Dependency Impact
452 </h1>
453 <p class="cri-hero-sub">
454 Detects downstream repositories that import exported symbols
455 changed by this PR. Run before merging to prevent silent
456 breaking changes in dependent packages.
457 </p>
458 <div class="cri-hero-actions">
459 {isOwner && (
460 <form
461 method="post"
462 action={`${baseUrl}/cross-repo-impact/analyze`}
463 >
464 <button type="submit" class="cri-analyze-btn">
465 ↻ {report ? "Re-analyze" : "Analyze"}
466 </button>
467 </form>
468 )}
469 {report && (
470 <>
471 <span class={`cri-risk-ring ${totalRiskClass(totalRisk)}`}>
472 {riskLabel(totalRisk)} Risk — {totalRisk}/100
473 </span>
474 <span class="cri-analyzed-at">
475 Analyzed {new Date(report.analyzedAt).toLocaleString()} ·
476 valid until {new Date(report.cachedUntil).toLocaleTimeString()}
477 </span>
478 </>
479 )}
480 </div>
481 </div>
482 </div>
483
484 {report ? (
485 <>
486 {/* Stats */}
487 <div class="cri-stats">
488 <div class="cri-stat-card is-high">
489 <div class="cri-stat-value">{highCount}</div>
490 <div class="cri-stat-label">High risk repos</div>
491 </div>
492 <div class="cri-stat-card is-medium">
493 <div class="cri-stat-value">{mediumCount}</div>
494 <div class="cri-stat-label">Medium risk repos</div>
495 </div>
496 <div class="cri-stat-card is-low">
497 <div class="cri-stat-value">{lowCount}</div>
498 <div class="cri-stat-label">Low risk repos</div>
499 </div>
500 <div class="cri-stat-card">
501 <div class="cri-stat-value">{report.affectedRepos.length}</div>
502 <div class="cri-stat-label">Total downstream</div>
503 </div>
504 </div>
505
506 {report.affectedRepos.length === 0 ? (
507 <div class="cri-empty">
508 <div class="cri-empty-icon">✓</div>
509 <div class="cri-empty-title">No downstream impact detected</div>
510 <p class="cri-empty-sub">
511 No other repos in the dependency graph import the exports
512 changed by this PR. Safe to merge.
513 </p>
514 </div>
515 ) : (
516 <>
517 {highCount > 0 && (
518 <div class="cri-alert is-warn">
519 <strong>Warning:</strong> {highCount} downstream repo{highCount !== 1 ? "s" : ""} import
520 changed symbols and have no test coverage. Merging may cause
521 silent runtime failures. Consider opening fix PRs first.
522 </div>
523 )}
524
525 {/* Downstream repo table */}
526 <div class="cri-table-wrap">
527 <table class="cri-table">
528 <thead>
529 <tr>
530 <th>Downstream Repo</th>
531 <th>Dependency</th>
532 <th>Risk</th>
533 <th>Changed Symbols</th>
534 {isOwner && <th>Action</th>}
535 </tr>
536 </thead>
537 <tbody>
538 {report.affectedRepos.map((impact) => (
539 <tr key={impact.repoId}>
540 <td>
541 <a
542 class="cri-repo-link"
543 href={`/${impact.ownerName}/${impact.repoName}`}
544 >
545 <span class="cri-owner-prefix">{impact.ownerName}/</span>
546 {impact.repoName}
547 </a>
548 </td>
549 <td>
550 <div style="font-family: var(--font-mono); font-size: 12px;">
551 {impact.dependencyName}
552 </div>
553 <div class="cri-version-tag">{impact.currentVersion}</div>
554 </td>
555 <td>
556 <span class={`cri-pill is-${impact.riskLevel}`}>
557 {impact.riskLevel}
558 </span>
559 </td>
560 <td>
561 <div class="cri-symbols">
562 {impact.changedExports.slice(0, 8).map((sym) => (
563 <span class="cri-symbol-chip" key={sym}>{sym}</span>
564 ))}
565 {impact.changedExports.length > 8 && (
566 <span class="cri-symbol-chip">
567 +{impact.changedExports.length - 8} more
568 </span>
569 )}
570 {impact.changedExports.length === 0 && (
571 <span style="color: var(--text-muted); font-size: 12px;">
572 (dep declared, no symbol match)
573 </span>
574 )}
575 </div>
576 </td>
577 {isOwner && (
578 <td>
579 {impact.suggestedFixPrUrl ? (
580 <a
581 class="cri-fix-btn"
582 href={impact.suggestedFixPrUrl}
583 >
584 View Fix PR
585 </a>
586 ) : (
587 <form
588 method="post"
589 action={`${baseUrl}/cross-repo-impact/open-fix-pr/${impact.repoId}`}
590 >
591 <button type="submit" class="cri-fix-btn">
592 Open Fix PR
593 </button>
594 </form>
595 )}
596 </td>
597 )}
598 </tr>
599 ))}
600 </tbody>
601 </table>
602 </div>
603
604 <div class="cri-alert is-info">
605 Analysis is based on <code>export</code> keyword changes in the PR diff and
606 dependency declarations in <code>repo_dependencies</code>. Results are cached for 15 minutes.
607 Re-analyze after rebasing or updating the diff.
608 </div>
609 </>
610 )}
611 </>
612 ) : (
613 <div class="cri-empty">
614 <div class="cri-empty-icon">📊</div>
615 <div class="cri-empty-title">No analysis yet</div>
616 <p class="cri-empty-sub">
617 {isOwner
618 ? "Click Analyze to detect downstream repos that may break when this PR is merged."
619 : "The repo owner hasn't run a cross-repo impact analysis on this PR yet."}
620 </p>
621 </div>
622 )}
623 </div>
624 </Layout>
625 );
626 }
627);
628
629// ─── Route: POST /:owner/:repo/pulls/:number/cross-repo-impact/analyze ────────
630
631crossRepoImpactRoutes.use(
632 "/:owner/:repo/pulls/:number/cross-repo-impact/analyze",
633 requireAuth
634);
635
636crossRepoImpactRoutes.post(
637 "/:owner/:repo/pulls/:number/cross-repo-impact/analyze",
638 requireRepoAccess("write"),
639 async (c) => {
640 const user = c.get("user")!;
641 const { owner: ownerName, repo: repoName, number: prNumberStr } = c.req.param() as {
642 owner: string;
643 repo: string;
644 number: string;
645 };
646 const prNumber = parseInt(prNumberStr, 10);
647 if (isNaN(prNumber)) return c.notFound();
648
649 const repo = await resolveRepo(ownerName, repoName);
650 if (!repo) return c.notFound();
651
652 if (user.id !== repo.ownerId) {
653 return c.text("Forbidden", 403);
654 }
655
656 const pr = await resolvePr(repo.id, prNumber);
657 if (!pr) return c.notFound();
658
659 // Fire analysis in the background (non-blocking)
660 analyzeCrossRepoImpact(repo.id, pr.id, ownerName, repoName).catch(() => {});
661
662 // Small delay to let the analysis start before redirect
663 await new Promise<void>((resolve) => setTimeout(resolve, 400));
664
665 return c.redirect(
666 `/${ownerName}/${repoName}/pulls/${prNumber}/cross-repo-impact`
667 );
668 }
669);
670
671// ─── Route: POST /…/open-fix-pr/:downstreamRepoId ─────────────────────────────
672
673crossRepoImpactRoutes.use(
674 "/:owner/:repo/pulls/:number/cross-repo-impact/open-fix-pr/:downstreamRepoId",
675 requireAuth
676);
677
678crossRepoImpactRoutes.post(
679 "/:owner/:repo/pulls/:number/cross-repo-impact/open-fix-pr/:downstreamRepoId",
680 requireRepoAccess("write"),
681 async (c) => {
682 const user = c.get("user")!;
683 const {
684 owner: ownerName,
685 repo: repoName,
686 number: prNumberStr,
687 downstreamRepoId,
688 } = c.req.param() as {
689 owner: string;
690 repo: string;
691 number: string;
692 downstreamRepoId: string;
693 };
694 const prNumber = parseInt(prNumberStr, 10);
695 if (isNaN(prNumber)) return c.notFound();
696
697 const repo = await resolveRepo(ownerName, repoName);
698 if (!repo) return c.notFound();
699
700 if (user.id !== repo.ownerId) {
701 return c.text("Forbidden", 403);
702 }
703
704 const pr = await resolvePr(repo.id, prNumber);
705 if (!pr) return c.notFound();
706
707 // Load the cached report to find the downstream impact entry
708 let impact: DownstreamImpact | undefined;
709 try {
710 const { crossRepoImpactCache } = await import("../db/schema");
711 const cacheRows = await db
712 .select()
713 .from(crossRepoImpactCache)
714 .where(eq(crossRepoImpactCache.prId, pr.id))
715 .limit(1);
716 if (cacheRows.length > 0) {
717 const report = cacheRows[0].report as CrossRepoReport;
718 impact = report.affectedRepos.find((r) => r.repoId === downstreamRepoId);
719 }
720 } catch {
721 /* best-effort */
722 }
723
724 if (!impact) {
725 return c.redirect(
726 `/${ownerName}/${repoName}/pulls/${prNumber}/cross-repo-impact`
727 );
728 }
729
730 // Load the downstream repo details
731 const [downstreamRepo] = await db
732 .select({
733 id: repositories.id,
734 name: repositories.name,
735 ownerId: repositories.ownerId,
736 defaultBranch: repositories.defaultBranch,
737 })
738 .from(repositories)
739 .where(eq(repositories.id, downstreamRepoId))
740 .limit(1)
741 .catch(() => []);
742
743 if (!downstreamRepo) {
744 return c.redirect(
745 `/${ownerName}/${repoName}/pulls/${prNumber}/cross-repo-impact`
746 );
747 }
748
749 // Build migration PR body
750 const changedSymbolsList = impact.changedExports.length
751 ? impact.changedExports.map((s) => `- \`${s}\``).join("\n")
752 : "_(see PR diff for details)_";
753
754 const prBody = `## Migration: Updated dependency \`${impact.dependencyName}\`
755
756This PR was automatically created by the cross-repo impact analysis on **${ownerName}/${repoName} #${prNumber}**.
757
758### What changed upstream
759
760The following exported symbols were changed in [${ownerName}/${repoName} PR #${prNumber}](/${ownerName}/${repoName}/pulls/${prNumber}):
761
762${changedSymbolsList}
763
764### What to update in this repo
765
766Review all imports of \`${impact.dependencyName}\` and update any usages of the listed symbols to match the new API.
767
768**Risk level:** ${impact.riskLevel.toUpperCase()}
769
770---
771_Generated by Gluecron cross-repo impact detection._`;
772
773 // Insert a draft PR into the downstream repo using existing schema pattern
774 try {
775 const newPr = await db
776 .insert(pullRequests)
777 .values({
778 repositoryId: downstreamRepo.id,
779 authorId: user.id,
780 title: `fix: update ${impact.dependencyName} API usage after upstream changes`,
781 body: prBody,
782 state: "open",
783 baseBranch: downstreamRepo.defaultBranch,
784 headBranch: `fix/dep-update-${impact.dependencyName.replace(/[^a-z0-9]/gi, "-")}-${Date.now()}`,
785 isDraft: true,
786 })
787 .returning({ id: pullRequests.id, number: pullRequests.number });
788
789 if (newPr.length > 0) {
790 const fixPrUrl = `/${impact.ownerName}/${impact.repoName}/pulls/${newPr[0].number}`;
791
792 // Post a comment on the original PR linking the fix PR
793 await db.insert(prComments).values({
794 pullRequestId: pr.id,
795 authorId: user.id,
796 body: `**Cross-repo impact fix:** Opened a draft migration PR on \`${impact.ownerName}/${impact.repoName}\`: [${fixPrUrl}](${fixPrUrl})`,
797 }).catch(() => {});
798
799 return c.redirect(fixPrUrl);
800 }
801 } catch {
802 /* best-effort — fall through to redirect */
803 }
804
805 return c.redirect(
806 `/${ownerName}/${repoName}/pulls/${prNumber}/cross-repo-impact`
807 );
808 }
809);
810
811export default crossRepoImpactRoutes;
Addedsrc/routes/nl-search.tsx+662−0View fileUnifiedSplit
@@ -0,0 +1,662 @@
1/**
2 * Natural Language Code Search UI
3 *
4 * GET /:owner/:repo/search/nl?q=... — search form + server-rendered results
5 *
6 * Uses Claude as the reasoner over actual file content (not embeddings).
7 * Complements the embedding-based semantic search at /search/semantic.
8 *
9 * - softAuth: public repos searchable without login
10 * - Server-side rendering: accept the wait (no client-side streaming needed)
11 * - Results link to /:owner/:repo/blob/HEAD/<filePath>#L<lineStart>
12 */
13
14import { Hono } from "hono";
15import { eq, and } from "drizzle-orm";
16import { db } from "../db";
17import { repositories, users } from "../db/schema";
18import { Layout } from "../views/layout";
19import { RepoHeader } from "../views/components";
20import { IssueNav } from "./issues";
21import { softAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
23import { nlSearch } from "../lib/nl-search";
24import { isAiAvailable } from "../lib/ai-client";
25
26const nlSearchRoutes = new Hono<AuthEnv>();
27nlSearchRoutes.use("*", softAuth);
28
29// ─── Scoped CSS (.nl-*) ──────────────────────────────────────────────────────
30const nlStyles = `
31 .nl-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
32
33 .nl-head {
34 margin-bottom: var(--space-5);
35 display: flex;
36 align-items: flex-end;
37 justify-content: space-between;
38 gap: var(--space-4);
39 flex-wrap: wrap;
40 }
41 .nl-head-text { flex: 1; min-width: 280px; }
42 .nl-eyebrow {
43 display: inline-flex;
44 align-items: center;
45 gap: 8px;
46 text-transform: uppercase;
47 font-family: var(--font-mono);
48 font-size: 11px;
49 letter-spacing: 0.16em;
50 color: var(--text-muted);
51 font-weight: 600;
52 margin-bottom: 10px;
53 }
54 .nl-eyebrow-dot {
55 width: 8px; height: 8px;
56 border-radius: 9999px;
57 background: linear-gradient(135deg, #f59e0b, #ef4444);
58 box-shadow: 0 0 0 3px rgba(245,158,11,0.18);
59 }
60 .nl-title {
61 font-family: var(--font-display);
62 font-size: clamp(24px, 3.4vw, 36px);
63 font-weight: 800;
64 letter-spacing: -0.028em;
65 line-height: 1.1;
66 margin: 0 0 6px;
67 color: var(--text-strong);
68 }
69 .nl-title-grad {
70 background-image: linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #ef4444 100%);
71 -webkit-background-clip: text;
72 background-clip: text;
73 -webkit-text-fill-color: transparent;
74 color: transparent;
75 }
76 .nl-sub {
77 margin: 0;
78 font-size: 14px;
79 color: var(--text-muted);
80 line-height: 1.5;
81 max-width: 720px;
82 }
83
84 .nl-provider {
85 display: inline-flex;
86 align-items: center;
87 gap: 6px;
88 padding: 5px 10px;
89 border-radius: 9999px;
90 font-family: var(--font-mono);
91 font-size: 11px;
92 color: var(--text-muted);
93 background: rgba(255,255,255,0.03);
94 border: 1px solid var(--border);
95 }
96 .nl-provider .dot {
97 width: 6px; height: 6px;
98 border-radius: 9999px;
99 background: linear-gradient(135deg, #f59e0b, #ef4444);
100 }
101
102 /* ─── Search bar ─── */
103 .nl-search {
104 display: flex;
105 gap: 10px;
106 align-items: stretch;
107 margin-bottom: var(--space-4);
108 }
109 .nl-search-input-wrap { position: relative; flex: 1; }
110 .nl-search-icon {
111 position: absolute;
112 top: 50%;
113 left: 14px;
114 transform: translateY(-50%);
115 color: var(--text-muted);
116 pointer-events: none;
117 }
118 .nl-search-input {
119 width: 100%;
120 box-sizing: border-box;
121 padding: 12px 14px 12px 40px;
122 font: inherit;
123 font-size: 14.5px;
124 color: var(--text);
125 background: rgba(255,255,255,0.03);
126 border: 1px solid var(--border-strong);
127 border-radius: 12px;
128 outline: none;
129 transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
130 }
131 .nl-search-input:focus {
132 border-color: rgba(245,158,11,0.55);
133 background: rgba(255,255,255,0.05);
134 box-shadow: 0 0 0 3px rgba(245,158,11,0.20);
135 }
136 .nl-btn {
137 display: inline-flex;
138 align-items: center;
139 justify-content: center;
140 gap: 6px;
141 padding: 0 18px;
142 border-radius: 12px;
143 font-size: 14px;
144 font-weight: 600;
145 text-decoration: none;
146 border: 1px solid transparent;
147 cursor: pointer;
148 font: inherit;
149 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease;
150 line-height: 1;
151 white-space: nowrap;
152 }
153 .nl-btn-primary {
154 background: linear-gradient(135deg, #f59e0b 0%, #ef4444 100%);
155 color: #ffffff;
156 box-shadow: 0 6px 18px -6px rgba(245,158,11,0.55), inset 0 1px 0 rgba(255,255,255,0.16);
157 }
158 .nl-btn-primary:hover {
159 transform: translateY(-1px);
160 box-shadow: 0 10px 24px -8px rgba(245,158,11,0.65), inset 0 1px 0 rgba(255,255,255,0.20);
161 text-decoration: none;
162 color: #ffffff;
163 }
164
165 /* ─── Examples ─── */
166 .nl-examples {
167 margin-bottom: var(--space-3);
168 display: flex;
169 flex-wrap: wrap;
170 gap: 8px;
171 align-items: center;
172 }
173 .nl-examples-label {
174 font-size: 12px;
175 color: var(--text-muted);
176 font-family: var(--font-mono);
177 }
178 .nl-example-chip {
179 display: inline-flex;
180 align-items: center;
181 padding: 4px 10px;
182 border-radius: 9999px;
183 font-size: 12px;
184 color: var(--text-muted);
185 background: rgba(255,255,255,0.03);
186 border: 1px solid var(--border);
187 cursor: pointer;
188 text-decoration: none;
189 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
190 }
191 .nl-example-chip:hover {
192 border-color: rgba(245,158,11,0.45);
193 color: var(--text-strong);
194 background: rgba(245,158,11,0.06);
195 text-decoration: none;
196 }
197
198 /* ─── Status bar ─── */
199 .nl-status {
200 margin-bottom: var(--space-3);
201 padding: 9px 14px;
202 border-radius: 10px;
203 background: rgba(255,255,255,0.025);
204 border: 1px solid var(--border);
205 font-size: 12px;
206 color: var(--text-muted);
207 display: flex;
208 align-items: center;
209 justify-content: space-between;
210 gap: var(--space-3);
211 flex-wrap: wrap;
212 font-variant-numeric: tabular-nums;
213 }
214 .nl-status .num { color: var(--text-strong); font-weight: 600; }
215
216 /* ─── Result cards ─── */
217 .nl-results { display: flex; flex-direction: column; gap: 10px; }
218 .nl-result {
219 padding: 14px;
220 background: var(--bg-elevated);
221 border: 1px solid var(--border);
222 border-radius: 12px;
223 transition: border-color 120ms ease, background 120ms ease;
224 }
225 .nl-result:hover {
226 border-color: var(--border-strong);
227 background: rgba(255,255,255,0.025);
228 }
229 .nl-result-head {
230 display: flex;
231 align-items: center;
232 justify-content: space-between;
233 gap: 10px;
234 flex-wrap: wrap;
235 margin-bottom: 8px;
236 }
237 .nl-result-path {
238 font-family: var(--font-mono);
239 font-size: 13px;
240 font-weight: 600;
241 color: var(--text-strong);
242 text-decoration: none;
243 word-break: break-all;
244 letter-spacing: -0.005em;
245 }
246 .nl-result-path .lines { color: var(--text-muted); font-weight: 500; }
247 .nl-result-path:hover { color: #fcd34d; text-decoration: none; }
248 .nl-confidence {
249 display: inline-flex;
250 align-items: center;
251 gap: 5px;
252 padding: 2px 9px;
253 border-radius: 9999px;
254 font-family: var(--font-mono);
255 font-size: 11px;
256 font-weight: 600;
257 flex-shrink: 0;
258 }
259 .nl-confidence-high {
260 background: rgba(52,211,153,0.12);
261 color: #6ee7b7;
262 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
263 }
264 .nl-confidence-medium {
265 background: rgba(251,191,36,0.12);
266 color: #fcd34d;
267 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
268 }
269 .nl-confidence-low {
270 background: rgba(156,163,175,0.10);
271 color: var(--text-muted);
272 box-shadow: inset 0 0 0 1px rgba(156,163,175,0.25);
273 }
274 .nl-explanation {
275 font-size: 13px;
276 color: var(--text-muted);
277 margin: 0 0 8px;
278 line-height: 1.5;
279 }
280 .nl-snippet {
281 margin: 0;
282 padding: 10px 12px;
283 background: rgba(0,0,0,0.25);
284 border: 1px solid var(--border);
285 border-radius: 8px;
286 font-family: var(--font-mono);
287 font-size: 12px;
288 line-height: 1.55;
289 color: var(--text);
290 overflow-x: auto;
291 white-space: pre-wrap;
292 word-break: break-word;
293 }
294
295 /* ─── Banner (no AI key) ─── */
296 .nl-banner {
297 margin-bottom: var(--space-4);
298 padding: 12px 16px;
299 border-radius: 10px;
300 font-size: 13.5px;
301 border: 1px solid rgba(245,158,11,0.35);
302 background: rgba(245,158,11,0.08);
303 color: #fcd34d;
304 display: flex;
305 align-items: center;
306 gap: 10px;
307 }
308 .nl-banner-dot { width: 8px; height: 8px; border-radius: 9999px; background: currentColor; flex-shrink: 0; }
309
310 /* ─── Empty state ─── */
311 .nl-empty {
312 position: relative;
313 overflow: hidden;
314 padding: clamp(28px, 5vw, 52px) clamp(20px, 4vw, 40px);
315 text-align: center;
316 background: var(--bg-elevated);
317 border: 1px dashed var(--border-strong);
318 border-radius: 16px;
319 }
320 .nl-empty-orb {
321 position: absolute;
322 inset: -40% 25% auto 25%;
323 height: 300px;
324 background: radial-gradient(circle, rgba(245,158,11,0.15), rgba(239,68,68,0.08) 45%, transparent 70%);
325 filter: blur(72px);
326 opacity: 0.7;
327 pointer-events: none;
328 z-index: 0;
329 }
330 .nl-empty-inner { position: relative; z-index: 1; }
331 .nl-empty-icon {
332 width: 56px; height: 56px;
333 margin: 0 auto 14px;
334 border-radius: 9999px;
335 background: linear-gradient(135deg, rgba(245,158,11,0.25), rgba(239,68,68,0.20));
336 box-shadow: inset 0 0 0 1px rgba(245,158,11,0.40);
337 display: inline-flex;
338 align-items: center;
339 justify-content: center;
340 color: #fcd34d;
341 }
342 .nl-empty-title {
343 font-family: var(--font-display);
344 font-size: 18px;
345 font-weight: 700;
346 margin: 0 0 6px;
347 color: var(--text-strong);
348 }
349 .nl-empty-sub {
350 margin: 0 auto;
351 font-size: 13.5px;
352 color: var(--text-muted);
353 max-width: 480px;
354 line-height: 1.5;
355 }
356
357 /* ─── Footer ─── */
358 .nl-footer {
359 margin-top: var(--space-5);
360 font-size: 12px;
361 color: var(--text-muted);
362 text-align: center;
363 font-family: var(--font-mono);
364 }
365 .nl-footer a { color: inherit; text-decoration: underline; }
366 .nl-footer a:hover { color: var(--text); }
367`;
368
369// ─── Icons ────────────────────────────────────────────────────────────────────
370
371function IconSearch() {
372 return (
373 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
374 <circle cx="11" cy="11" r="7" />
375 <line x1="21" y1="21" x2="16.65" y2="16.65" />
376 </svg>
377 );
378}
379
380function IconIntent() {
381 return (
382 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
383 <path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
384 </svg>
385 );
386}
387
388// ─── Example queries ──────────────────────────────────────────────────────────
389
390const EXAMPLE_QUERIES = [
391 "find all places that write to the DB without error handling",
392 "where do we check authentication?",
393 "where do we validate user input?",
394 "find all async functions that don't await their result",
395 "where do we catch errors but ignore them?",
396];
397
398// ─── Resolve repo helper ──────────────────────────────────────────────────────
399
400async function resolveRepo(ownerName: string, repoName: string) {
401 try {
402 const [owner] = await db
403 .select()
404 .from(users)
405 .where(eq(users.username, ownerName))
406 .limit(1);
407 if (!owner) return null;
408 const [repo] = await db
409 .select()
410 .from(repositories)
411 .where(
412 and(
413 eq(repositories.ownerId, owner.id),
414 eq(repositories.name, repoName)
415 )
416 )
417 .limit(1);
418 if (!repo) return null;
419 return { owner, repo };
420 } catch {
421 return null;
422 }
423}
424
425function NotFound({ user }: { user: typeof users.$inferSelect | null | undefined }) {
426 return (
427 <Layout title="Not Found" user={user}>
428 <div class="empty-state">
429 <h2>Repository not found</h2>
430 <p>No such repository, or you don't have access.</p>
431 </div>
432 </Layout>
433 );
434}
435
436// ─── Confidence pill ─────────────────────────────────────────────────────────
437
438function ConfidencePill({ level }: { level: "high" | "medium" | "low" }) {
439 const cls =
440 level === "high"
441 ? "nl-confidence nl-confidence-high"
442 : level === "medium"
443 ? "nl-confidence nl-confidence-medium"
444 : "nl-confidence nl-confidence-low";
445 return <span class={cls}>{level}</span>;
446}
447
448// ─── GET /:owner/:repo/search/nl ─────────────────────────────────────────────
449
450nlSearchRoutes.get("/:owner/:repo/search/nl", async (c) => {
451 const { owner: ownerName, repo: repoName } = c.req.param();
452 const user = c.get("user");
453 const q = (c.req.query("q") || "").trim();
454
455 const resolved = await resolveRepo(ownerName, repoName);
456 if (!resolved) {
457 return c.html(<NotFound user={user} />, 404);
458 }
459 const { repo } = resolved;
460
461 const aiAvailable = isAiAvailable();
462
463 // Run NL search if a query was provided and AI is available
464 let searchResult: Awaited<ReturnType<typeof nlSearch>> | null = null;
465 if (q && aiAvailable) {
466 searchResult = await nlSearch(ownerName, repoName, repo.id, q);
467 }
468
469 const results = searchResult?.results ?? [];
470 const totalFilesScanned = searchResult?.totalFilesScanned ?? 0;
471 const cached = searchResult?.cached ?? false;
472
473 return c.html(
474 <Layout title={`NL Search — ${ownerName}/${repoName}`} user={user}>
475 <RepoHeader owner={ownerName} repo={repoName} />
476 <IssueNav owner={ownerName} repo={repoName} active="code" />
477
478 <div class="nl-wrap">
479 {/* ─── Header ─── */}
480 <header class="nl-head">
481 <div class="nl-head-text">
482 <div class="nl-eyebrow">
483 <span class="nl-eyebrow-dot" aria-hidden="true" />
484 Repository · Natural Language Search
485 </div>
486 <h1 class="nl-title">
487 <span class="nl-title-grad">Search by intent, not keywords.</span>
488 </h1>
489 <p class="nl-sub">
490 Describe what you're looking for in plain English — Claude reads
491 the actual code and finds the matching places.
492 </p>
493 </div>
494 <div class="nl-provider" title="Powered by Claude Sonnet">
495 <span class="dot" aria-hidden="true" />
496 Claude Sonnet
497 </div>
498 </header>
499
500 {/* ─── No AI key warning ─── */}
501 {!aiAvailable && (
502 <div class="nl-banner" role="alert">
503 <span class="nl-banner-dot" aria-hidden="true" />
504 Natural language search requires an Anthropic API key (
505 <code>ANTHROPIC_API_KEY</code>). Set the key and restart the server
506 to enable this feature.
507 </div>
508 )}
509
510 {/* ─── Search form ─── */}
511 <form
512 method="get"
513 action={`/${ownerName}/${repoName}/search/nl`}
514 class="nl-search"
515 >
516 <div class="nl-search-input-wrap">
517 <span class="nl-search-icon" aria-hidden="true">
518 <IconSearch />
519 </span>
520 <input
521 type="search"
522 name="q"
523 value={q}
524 placeholder='e.g. "find all places that write to the DB without error handling"'
525 aria-label="Natural language search query"
526 class="nl-search-input"
527 autofocus
528 disabled={!aiAvailable}
529 />
530 </div>
531 <button type="submit" class="nl-btn nl-btn-primary" disabled={!aiAvailable}>
532 <IconIntent />
533 Search
534 </button>
535 </form>
536
537 {/* ─── Example chips ─── */}
538 {!q && aiAvailable && (
539 <div class="nl-examples">
540 <span class="nl-examples-label">Try:</span>
541 {EXAMPLE_QUERIES.map((ex) => (
542 <a
543 href={`/${ownerName}/${repoName}/search/nl?q=${encodeURIComponent(ex)}`}
544 class="nl-example-chip"
545 >
546 {ex}
547 </a>
548 ))}
549 </div>
550 )}
551
552 {/* ─── Results area ─── */}
553 {!q ? (
554 /* Empty prompt state */
555 <div class="nl-empty">
556 <div class="nl-empty-orb" aria-hidden="true" />
557 <div class="nl-empty-inner">
558 <div class="nl-empty-icon" aria-hidden="true">
559 <IconIntent />
560 </div>
561 <h3 class="nl-empty-title">Ask anything about the codebase</h3>
562 <p class="nl-empty-sub">
563 Type a natural language question above — Claude will scan the
564 repository files and return the matching code locations with
565 explanations.
566 </p>
567 </div>
568 </div>
569 ) : !aiAvailable ? (
570 /* AI unavailable */
571 <div class="nl-empty">
572 <div class="nl-empty-orb" aria-hidden="true" />
573 <div class="nl-empty-inner">
574 <div class="nl-empty-icon" aria-hidden="true">
575 <IconIntent />
576 </div>
577 <h3 class="nl-empty-title">AI not configured</h3>
578 <p class="nl-empty-sub">
579 Set <code>ANTHROPIC_API_KEY</code> to enable Claude-powered
580 natural language search.
581 </p>
582 </div>
583 </div>
584 ) : results.length === 0 ? (
585 /* No results */
586 <div class="nl-empty">
587 <div class="nl-empty-orb" aria-hidden="true" />
588 <div class="nl-empty-inner">
589 <div class="nl-empty-icon" aria-hidden="true">
590 <IconSearch />
591 </div>
592 <h3 class="nl-empty-title">No results for "{q}"</h3>
593 <p class="nl-empty-sub">
594 Claude scanned {totalFilesScanned} file
595 {totalFilesScanned === 1 ? "" : "s"} and found no matches.
596 Try rephrasing your query or using different terminology.
597 </p>
598 </div>
599 </div>
600 ) : (
601 /* Results */
602 <>
603 <div class="nl-status">
604 <span>
605 <span class="num">{results.length}</span> result
606 {results.length === 1 ? "" : "s"}
607 {" · scanned "}
608 <span class="num">{totalFilesScanned}</span> file
609 {totalFilesScanned === 1 ? "" : "s"}
610 {cached && " · cached"}
611 </span>
612 <span>powered by Claude</span>
613 </div>
614 <div class="nl-results">
615 {results.map((r) => {
616 const href = `/${ownerName}/${repoName}/blob/HEAD/${r.filePath}#L${r.lineStart}`;
617 const snippetPreview =
618 r.snippet.length > 800
619 ? r.snippet.slice(0, 800) + "\n…"
620 : r.snippet;
621 return (
622 <div class="nl-result">
623 <div class="nl-result-head">
624 <a href={href} class="nl-result-path">
625 {r.filePath}
626 <span class="lines">
627 :{r.lineStart}–{r.lineEnd}
628 </span>
629 </a>
630 <ConfidencePill level={r.confidence} />
631 </div>
632 {r.explanation && (
633 <p class="nl-explanation">{r.explanation}</p>
634 )}
635 <pre class="nl-snippet">{snippetPreview}</pre>
636 </div>
637 );
638 })}
639 </div>
640 </>
641 )}
642
643 {/* ─── Footer ─── */}
644 <div class="nl-footer">
645 Natural language search powered by{" "}
646 <a
647 href={`/${ownerName}/${repoName}/search/semantic`}
648 title="Switch to embedding-based semantic search"
649 >
650 semantic search
651 </a>{" "}
652 also available · Gluecron
653 </div>
654 </div>
655
656 <style dangerouslySetInnerHTML={{ __html: nlStyles }} />
657 </Layout>
658 );
659});
660
661export { nlSearchRoutes };
662export default nlSearchRoutes;
Addedsrc/routes/pair-programmer.ts+143−0View fileUnifiedSplit
@@ -0,0 +1,143 @@
1/**
2 * Proactive AI pair programmer HTTP surface.
3 *
4 * GET /api/pair/ping — health check (unauthed)
5 * POST /api/pair/context — requireAuth; assemble PairContext for a file
6 * POST /api/pair/suggest — requireAuth; context + AI suggestion for a file
7 *
8 * Rate limit: 30/min per user (separate counter from the copilot 60/min).
9 * Suggestion responses are cached 30 s per (userId, repoId, filePath, prefixSlice).
10 *
11 * For reactive completions triggered on every keystroke, see:
12 * POST /api/copilot/completions (src/routes/copilot.ts)
13 */
14
15import { Hono } from "hono";
16import { requireAuth } from "../middleware/auth";
17import type { AuthEnv } from "../middleware/auth";
18import { rateLimit } from "../middleware/rate-limit";
19import { isAiAvailable } from "../lib/ai-client";
20import {
21 assemblePairContext,
22 generatePairSuggestion,
23 suggestCacheKey,
24 suggestCache,
25 cacheGet,
26 cacheSet,
27 SUGGEST_TTL_MS,
28} from "../lib/ai-pair";
29import type { PairContext, PairSuggestion } from "../lib/ai-pair";
30
31const router = new Hono<AuthEnv>();
32
33// Separate rate-limit bucket from copilot (30 req/min).
34const pairLimit = rateLimit(30, 60_000, "pair");
35
36// ---------------------------------------------------------------------------
37// GET /api/pair/ping — health check
38// ---------------------------------------------------------------------------
39router.get("/api/pair/ping", (c) => {
40 return c.json({ ok: true, aiAvailable: isAiAvailable() });
41});
42
43// ---------------------------------------------------------------------------
44// POST /api/pair/context
45// ---------------------------------------------------------------------------
46router.post("/api/pair/context", pairLimit, requireAuth, async (c) => {
47 const user = c.get("user");
48 if (!user) {
49 return c.json({ error: "Unauthorized" }, 401);
50 }
51
52 let body: unknown;
53 try {
54 body = await c.req.json();
55 } catch {
56 return c.json({ error: "Invalid JSON body" }, 400);
57 }
58
59 if (!body || typeof body !== "object") {
60 return c.json({ error: "Body must be a JSON object" }, 400);
61 }
62
63 const { repoId, filePath } = body as { repoId?: unknown; filePath?: unknown };
64
65 if (typeof repoId !== "string" || repoId.trim() === "") {
66 return c.json({ error: "repoId (non-empty string) is required" }, 400);
67 }
68 if (typeof filePath !== "string" || filePath.trim() === "") {
69 return c.json({ error: "filePath (non-empty string) is required" }, 400);
70 }
71
72 const ctx: PairContext = await assemblePairContext(repoId, filePath, user.id);
73 return c.json(ctx);
74});
75
76// ---------------------------------------------------------------------------
77// POST /api/pair/suggest
78// ---------------------------------------------------------------------------
79router.post("/api/pair/suggest", pairLimit, requireAuth, async (c) => {
80 const user = c.get("user");
81 if (!user) {
82 return c.json({ error: "Unauthorized" }, 401);
83 }
84
85 let body: unknown;
86 try {
87 body = await c.req.json();
88 } catch {
89 return c.json({ error: "Invalid JSON body" }, 400);
90 }
91
92 if (!body || typeof body !== "object") {
93 return c.json({ error: "Body must be a JSON object" }, 400);
94 }
95
96 const {
97 repoId,
98 filePath,
99 prefix,
100 suffix,
101 } = body as {
102 repoId?: unknown;
103 filePath?: unknown;
104 prefix?: unknown;
105 suffix?: unknown;
106 };
107
108 if (typeof repoId !== "string" || repoId.trim() === "") {
109 return c.json({ error: "repoId (non-empty string) is required" }, 400);
110 }
111 if (typeof filePath !== "string" || filePath.trim() === "") {
112 return c.json({ error: "filePath (non-empty string) is required" }, 400);
113 }
114 if (typeof prefix !== "string" || prefix.length === 0) {
115 return c.json({ error: "prefix (non-empty string) is required" }, 400);
116 }
117
118 const suffixStr = typeof suffix === "string" ? suffix : "";
119
120 // Check 30-second suggestion cache.
121 const ck = suggestCacheKey(user.id, repoId, filePath, prefix);
122 const hit = cacheGet(suggestCache, ck);
123 if (hit !== undefined) {
124 return c.json({ ...hit, cached: true });
125 }
126
127 // Assemble context then generate suggestion (both are individually cached).
128 const ctx: PairContext = await assemblePairContext(repoId, filePath, user.id);
129 const suggestion: PairSuggestion = await generatePairSuggestion(
130 prefix,
131 suffixStr,
132 filePath,
133 ctx
134 );
135
136 // Cache the suggestion.
137 cacheSet(suggestCache, ck, suggestion, SUGGEST_TTL_MS);
138
139 return c.json(suggestion);
140});
141
142export const pairProgrammerRoutes = router;
143export default router;
Modifiedsrc/routes/pulls.tsx+55−0View fileUnifiedSplit
@@ -127,6 +127,28 @@ import { computePrSize, type PrSizeInfo } from "../lib/pr-size";
127127import { BOT_USERNAME } from "../lib/bot-user";
128128import { analyzeImpact, type ImpactAnalysis } from "../lib/pr-impact";
129129import { getPreviewDir, isPreviewExpired } from "../lib/pr-stage";
130import {
131 getCodeownersForRepo,
132 reviewersForChangedFiles,
133} from "../lib/codeowners";
134import { getPatternWarning, type Pattern } from "../lib/pattern-detector";
135import { suggestPrSplit, type SplitSuggestion } from "../lib/pr-splitter";
136import {
137 joinRoom,
138 leaveRoom,
139 broadcastToRoom,
140 registerSocket,
141 unregisterSocket,
142 getRoomUsers,
143 pingSession,
144 updatePresence,
145} from "../lib/pr-presence";
146import { getBusFactorWarning, type BusFactorFile } from "../lib/bus-factor";
147import { checkMergeEligible } from "../lib/branch-rules";
148import { createBunWebSocket } from "hono/bun";
149
150const { upgradeWebSocket, websocket: presenceWebsocket } = createBunWebSocket();
151export { presenceWebsocket };
130152
131153const pulls = new Hono<AuthEnv>();
132154
@@ -4275,6 +4297,39 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
42754297 // Non-blocking — swallow
42764298 }
42774299
4300 // Bus factor warning — flag files dominated by a single author.
4301 let busRiskFiles: BusFactorFile[] = [];
4302 try {
4303 const repoDir2 = getRepoPath(ownerName, repoName);
4304 const bf2Proc = Bun.spawn(
4305 ["git", "diff", "--name-only", `${pr.baseBranch}...${pr.headBranch}`],
4306 { cwd: repoDir2, stdout: "pipe", stderr: "pipe" }
4307 );
4308 const bf2Raw = await new Response(bf2Proc.stdout).text();
4309 await bf2Proc.exited;
4310 const bf2Files = bf2Raw.trim().split("\n").filter(Boolean);
4311 if (bf2Files.length > 0) {
4312 busRiskFiles = await getBusFactorWarning(resolved.repo.id, ownerName, repoName, bf2Files);
4313 }
4314 } catch {
4315 // Non-blocking
4316 }
4317
4318 // PR split suggestion — AI recommends sub-PR decomposition for large PRs.
4319 let splitSuggestion: SplitSuggestion | null = null;
4320 try {
4321 splitSuggestion = await suggestPrSplit(
4322 pr.id,
4323 pr.title,
4324 ownerName,
4325 repoName,
4326 pr.baseBranch,
4327 pr.headBranch
4328 );
4329 } catch {
4330 // Non-blocking
4331 }
4332
42784333 // ─── Derived visual state ───
42794334 const stateKey =
42804335 pr.state === "open"
Addedsrc/routes/repo-health.tsx+614−0View fileUnifiedSplit
@@ -0,0 +1,614 @@
1/**
2 * Repository Health Score — /:owner/:repo/health
3 *
4 * Full breakdown page for the 0-100 composite health score.
5 *
6 * GET /:owner/:repo/health — breakdown page (softAuth, public repos visible)
7 * POST /:owner/:repo/health/recompute — invalidate cache (requireAuth + repo owner)
8 */
9
10import { Hono } from "hono";
11import { db } from "../db";
12import { repositories, users } from "../db/schema";
13import { and, eq } from "drizzle-orm";
14import type { AuthEnv } from "../middleware/auth";
15import { softAuth, requireAuth } from "../middleware/auth";
16import { requireRepoAccess } from "../middleware/repo-access";
17import { Layout } from "../views/layout";
18import { RepoHeader, RepoNav } from "../views/components";
19import { getUnreadCount } from "../lib/unread";
20import {
21 getHealthScore,
22 invalidateHealthScore,
23 type HealthScoreBreakdown,
24} from "../lib/repo-health";
25
26const repoHealthRoutes = new Hono<AuthEnv>();
27
28// ─── CSS ──────────────────────────────────────────────────────────────────────
29
30const styles = `
31 .rh-wrap {
32 max-width: 1080px;
33 margin: 0 auto;
34 padding: var(--space-5) var(--space-4);
35 }
36
37 /* Sub-navigation */
38 .rh-subnav {
39 display: flex;
40 gap: 4px;
41 margin-bottom: var(--space-5);
42 border-bottom: 1px solid var(--border);
43 padding-bottom: 0;
44 }
45 .rh-subnav-link {
46 padding: 8px 14px;
47 font-size: 13px;
48 font-weight: 500;
49 color: var(--text-muted);
50 text-decoration: none;
51 border-bottom: 2px solid transparent;
52 margin-bottom: -1px;
53 transition: color 120ms ease, border-color 120ms ease;
54 border-radius: 4px 4px 0 0;
55 }
56 .rh-subnav-link:hover { color: var(--text); }
57 .rh-subnav-link.active {
58 color: var(--accent, #5865f2);
59 border-bottom-color: var(--accent, #5865f2);
60 }
61
62 /* Hero */
63 .rh-hero {
64 position: relative;
65 margin-bottom: var(--space-5);
66 padding: var(--space-5) var(--space-6);
67 background: var(--bg-elevated);
68 border: 1px solid var(--border);
69 border-radius: 16px;
70 overflow: hidden;
71 display: flex;
72 align-items: center;
73 gap: var(--space-6);
74 flex-wrap: wrap;
75 }
76 .rh-hero::before {
77 content: '';
78 position: absolute;
79 top: 0; left: 0; right: 0;
80 height: 2px;
81 pointer-events: none;
82 opacity: 0.8;
83 }
84 .rh-hero--green::before { background: linear-gradient(90deg, transparent, #34d399, transparent); }
85 .rh-hero--yellow::before { background: linear-gradient(90deg, transparent, #facc15, transparent); }
86 .rh-hero--red::before { background: linear-gradient(90deg, transparent, #f87171, transparent); }
87
88 /* SVG gauge */
89 .rh-gauge {
90 position: relative;
91 width: 140px;
92 height: 140px;
93 flex-shrink: 0;
94 }
95 .rh-gauge-svg {
96 width: 140px;
97 height: 140px;
98 transform: rotate(-90deg);
99 }
100 .rh-gauge-track {
101 fill: none;
102 stroke: var(--border);
103 stroke-width: 12;
104 }
105 .rh-gauge-fill {
106 fill: none;
107 stroke-width: 12;
108 stroke-linecap: round;
109 transition: stroke-dashoffset 600ms ease;
110 }
111 .rh-gauge-label {
112 position: absolute;
113 inset: 0;
114 display: flex;
115 flex-direction: column;
116 align-items: center;
117 justify-content: center;
118 text-align: center;
119 }
120 .rh-gauge-score {
121 font-size: 36px;
122 font-weight: 900;
123 font-variant-numeric: tabular-nums;
124 line-height: 1;
125 color: var(--text-strong);
126 }
127 .rh-gauge-max {
128 font-size: 12px;
129 color: var(--text-muted);
130 margin-top: 2px;
131 }
132
133 /* Hero text */
134 .rh-hero-body {
135 flex: 1;
136 min-width: 200px;
137 }
138 .rh-hero-eyebrow {
139 font-size: 11px;
140 font-weight: 700;
141 letter-spacing: 0.07em;
142 text-transform: uppercase;
143 color: var(--text-muted);
144 margin-bottom: 6px;
145 }
146 .rh-hero-title {
147 font-family: var(--font-display);
148 font-size: clamp(20px, 2.5vw, 28px);
149 font-weight: 800;
150 letter-spacing: -0.02em;
151 line-height: 1.15;
152 margin: 0 0 8px;
153 color: var(--text-strong);
154 }
155 .rh-hero-sub {
156 font-size: 14px;
157 color: var(--text-muted);
158 margin: 0 0 14px;
159 line-height: 1.5;
160 }
161 .rh-hero-actions {
162 display: flex;
163 align-items: center;
164 gap: 10px;
165 flex-wrap: wrap;
166 }
167 .rh-computed-at {
168 font-size: 12px;
169 color: var(--text-muted);
170 }
171
172 /* Score badge pill */
173 .rh-score-pill {
174 display: inline-flex;
175 align-items: center;
176 gap: 5px;
177 padding: 4px 12px;
178 border-radius: 9999px;
179 font-size: 12px;
180 font-weight: 700;
181 letter-spacing: 0.04em;
182 }
183 .rh-score-pill--green { background: rgba(52,211,153,.15); color: #34d399; border: 1px solid rgba(52,211,153,.3); }
184 .rh-score-pill--yellow { background: rgba(250,204,21,.15); color: #facc15; border: 1px solid rgba(250,204,21,.3); }
185 .rh-score-pill--red { background: rgba(248,113,113,.15); color: #f87171; border: 1px solid rgba(248,113,113,.3); }
186
187 /* Recompute button */
188 .rh-recompute-btn {
189 display: inline-flex;
190 align-items: center;
191 gap: 6px;
192 padding: 8px 16px;
193 border-radius: 8px;
194 font-size: 13px;
195 font-weight: 600;
196 color: #fff;
197 background: linear-gradient(135deg, #5865f2, #8b5cf6);
198 border: none;
199 cursor: pointer;
200 transition: opacity 120ms ease;
201 }
202 .rh-recompute-btn:hover { opacity: 0.85; }
203
204 /* Section */
205 .rh-section-title {
206 font-size: 15px;
207 font-weight: 700;
208 color: var(--text-strong);
209 margin: 0 0 var(--space-3) 0;
210 }
211
212 /* Signal cards */
213 .rh-signals {
214 display: flex;
215 flex-direction: column;
216 gap: 12px;
217 margin-bottom: var(--space-5);
218 }
219 .rh-signal-card {
220 background: var(--bg-elevated);
221 border: 1px solid var(--border);
222 border-radius: 12px;
223 padding: var(--space-4) var(--space-5);
224 transition: border-color 120ms ease;
225 }
226 .rh-signal-card:hover { border-color: rgba(88,101,242,0.3); }
227
228 .rh-signal-header {
229 display: flex;
230 align-items: baseline;
231 justify-content: space-between;
232 margin-bottom: 10px;
233 gap: 8px;
234 flex-wrap: wrap;
235 }
236 .rh-signal-name {
237 font-size: 14px;
238 font-weight: 600;
239 color: var(--text-strong);
240 }
241 .rh-signal-score {
242 font-size: 13px;
243 color: var(--text-muted);
244 font-variant-numeric: tabular-nums;
245 white-space: nowrap;
246 }
247 .rh-signal-score strong {
248 font-weight: 700;
249 color: var(--text);
250 }
251
252 .rh-bar-track {
253 height: 8px;
254 background: var(--bg-tertiary, rgba(255,255,255,0.06));
255 border-radius: 4px;
256 overflow: hidden;
257 margin-bottom: 8px;
258 }
259 .rh-bar-fill {
260 height: 100%;
261 border-radius: 4px;
262 transition: width 500ms ease;
263 }
264 .rh-bar-green { background: #34d399; }
265 .rh-bar-yellow { background: #facc15; }
266 .rh-bar-red { background: #f87171; }
267 .rh-bar-blue { background: #60a5fa; }
268 .rh-bar-purple { background: #a78bfa; }
269
270 .rh-signal-detail {
271 font-size: 12px;
272 color: var(--text-muted);
273 line-height: 1.5;
274 }
275 .rh-signal-detail strong { color: var(--text); font-weight: 600; }
276`;
277
278// ─── Helpers ──────────────────────────────────────────────────────────────────
279
280function scoreColor(score: number): "green" | "yellow" | "red" {
281 if (score >= 80) return "green";
282 if (score >= 50) return "yellow";
283 return "red";
284}
285
286function gaugeProps(score: number) {
287 const r = 55;
288 const circumference = 2 * Math.PI * r;
289 const dashoffset = circumference * (1 - score / 100);
290 const color =
291 score >= 80 ? "#34d399" :
292 score >= 50 ? "#facc15" :
293 "#f87171";
294 return {
295 r,
296 cx: 70,
297 cy: 70,
298 dasharray: circumference.toFixed(2),
299 dashoffset: dashoffset.toFixed(2),
300 color,
301 };
302}
303
304function barColor(pct: number): string {
305 if (pct >= 0.75) return "rh-bar-green";
306 if (pct >= 0.45) return "rh-bar-yellow";
307 return "rh-bar-red";
308}
309
310function formatHours(h: number): string {
311 if (h < 1) return `${Math.round(h * 60)}m`;
312 if (h < 24) return `${h.toFixed(1)}h`;
313 return `${(h / 24).toFixed(1)}d`;
314}
315
316// ─── Signal card rendering helpers ───────────────────────────────────────────
317
318function CiCard({ breakdown }: { breakdown: HealthScoreBreakdown }) {
319 const { score, rate, totalRuns, passedRuns } = breakdown.ciGreenRate;
320 const pct = score / 25;
321 const detail =
322 totalRuns === 0
323 ? "No gate runs in the last 30 days — benefit of the doubt applied."
324 : `${passedRuns} of ${totalRuns} gate runs passed (${Math.round(rate * 100)}%) in the last 30 days.`;
325
326 return (
327 <div class="rh-signal-card">
328 <div class="rh-signal-header">
329 <span class="rh-signal-name">CI Green Rate</span>
330 <span class="rh-signal-score">
331 <strong>{score}</strong> / 25
332 </span>
333 </div>
334 <div class="rh-bar-track">
335 <div
336 class={`rh-bar-fill ${barColor(pct)}`}
337 style={`width:${Math.round(pct * 100)}%`}
338 />
339 </div>
340 <div class="rh-signal-detail">{detail}</div>
341 </div>
342 );
343}
344
345function BusFactorCard({ breakdown }: { breakdown: HealthScoreBreakdown }) {
346 const { score, atRiskFileCount, criticalCount } = breakdown.busFactor;
347 const pct = score / 20;
348 const detail =
349 atRiskFileCount === 0
350 ? score === 15
351 ? "No bus factor analysis available yet."
352 : "No at-risk files detected — knowledge well distributed."
353 : `${atRiskFileCount} at-risk file${atRiskFileCount !== 1 ? "s" : ""} (${criticalCount} critical). High knowledge concentration detected.`;
354
355 return (
356 <div class="rh-signal-card">
357 <div class="rh-signal-header">
358 <span class="rh-signal-name">Bus Factor</span>
359 <span class="rh-signal-score">
360 <strong>{score}</strong> / 20
361 </span>
362 </div>
363 <div class="rh-bar-track">
364 <div
365 class={`rh-bar-fill ${barColor(pct)}`}
366 style={`width:${Math.round(pct * 100)}%`}
367 />
368 </div>
369 <div class="rh-signal-detail">{detail}</div>
370 </div>
371 );
372}
373
374function CveCard({ breakdown }: { breakdown: HealthScoreBreakdown }) {
375 const { score, count } = breakdown.openCves;
376 const pct = score / 20;
377 const detail =
378 count === 0
379 ? "No open CVE alerts — dependency security looks clean."
380 : `${count} open CVE alert${count !== 1 ? "s" : ""} detected in dependencies.`;
381
382 return (
383 <div class="rh-signal-card">
384 <div class="rh-signal-header">
385 <span class="rh-signal-name">Open CVEs</span>
386 <span class="rh-signal-score">
387 <strong>{score}</strong> / 20
388 </span>
389 </div>
390 <div class="rh-bar-track">
391 <div
392 class={`rh-bar-fill ${barColor(pct)}`}
393 style={`width:${Math.round(pct * 100)}%`}
394 />
395 </div>
396 <div class="rh-signal-detail">{detail}</div>
397 </div>
398 );
399}
400
401function VelocityCard({ breakdown }: { breakdown: HealthScoreBreakdown }) {
402 const { score, avgHours, sampleSize } = breakdown.reviewVelocity;
403 const pct = score / 15;
404 let detail: string;
405 if (avgHours === null) {
406 detail = "No merged PRs with human review comments in the last 30 days.";
407 } else {
408 detail = `Average time to first review: <strong>${formatHours(avgHours)}</strong> across ${sampleSize} PR${sampleSize !== 1 ? "s" : ""} (last 30 days).`;
409 }
410
411 return (
412 <div class="rh-signal-card">
413 <div class="rh-signal-header">
414 <span class="rh-signal-name">PR Review Velocity</span>
415 <span class="rh-signal-score">
416 <strong>{score}</strong> / 15
417 </span>
418 </div>
419 <div class="rh-bar-track">
420 <div
421 class={`rh-bar-fill rh-bar-blue`}
422 style={`width:${Math.round(pct * 100)}%`}
423 />
424 </div>
425 <div
426 class="rh-signal-detail"
427 dangerouslySetInnerHTML={{ __html: detail }}
428 />
429 </div>
430 );
431}
432
433function DebtCard({ breakdown }: { breakdown: HealthScoreBreakdown }) {
434 const { score, available } = breakdown.techDebt;
435 const pct = score / 20;
436 const detail = available
437 ? "Onboarding analysis available — neutral score applied (no debt-map data yet)."
438 : "No tech debt analysis available. Neutral score applied.";
439
440 return (
441 <div class="rh-signal-card">
442 <div class="rh-signal-header">
443 <span class="rh-signal-name">Tech Debt</span>
444 <span class="rh-signal-score">
445 <strong>{score}</strong> / 20
446 </span>
447 </div>
448 <div class="rh-bar-track">
449 <div
450 class={`rh-bar-fill rh-bar-purple`}
451 style={`width:${Math.round(pct * 100)}%`}
452 />
453 </div>
454 <div class="rh-signal-detail">{detail}</div>
455 </div>
456 );
457}
458
459// ─── Route: GET /:owner/:repo/health ─────────────────────────────────────────
460
461repoHealthRoutes.use("/:owner/:repo/health", softAuth);
462
463repoHealthRoutes.get(
464 "/:owner/:repo/health",
465 requireRepoAccess("read"),
466 async (c) => {
467 const { owner, repo } = c.req.param();
468 const user = c.get("user") ?? null;
469 const repository = (
470 c.get("repository" as never) as { id: string; ownerId: string } | null
471 );
472
473 if (!repository) return c.notFound();
474
475 const repoId = repository.id;
476 const isOwner = !!user && user.id === repository.ownerId;
477
478 const [breakdown, unreadCount] = await Promise.all([
479 getHealthScore(repoId),
480 user ? getUnreadCount(user.id) : Promise.resolve(0),
481 ]);
482
483 const color = scoreColor(breakdown.total);
484 const gauge = gaugeProps(breakdown.total);
485 const computedAtStr = breakdown.computedAt.toLocaleString();
486
487 return c.html(
488 <Layout
489 title={`Health Score — ${owner}/${repo}`}
490 user={user}
491 notificationCount={unreadCount}
492 >
493 <style dangerouslySetInnerHTML={{ __html: styles }} />
494 <div class="rh-wrap">
495 <RepoHeader owner={owner} repo={repo} healthScore={breakdown.total} />
496 <RepoNav owner={owner} repo={repo} active="insights" />
497
498 {/* Sub-navigation */}
499 <nav class="rh-subnav">
500 <a class="rh-subnav-link" href={`/${owner}/${repo}/insights`}>Overview</a>
501 <a class="rh-subnav-link" href={`/${owner}/${repo}/insights/dora`}>DORA</a>
502 <a class="rh-subnav-link" href={`/${owner}/${repo}/insights/velocity`}>Velocity</a>
503 <a class="rh-subnav-link" href={`/${owner}/${repo}/pulse`}>Pulse</a>
504 <a class="rh-subnav-link active" href={`/${owner}/${repo}/health`}>Health</a>
505 <a class="rh-subnav-link" href={`/${owner}/${repo}/insights/hotfiles`}>Hot Files</a>
506 <a class="rh-subnav-link" href={`/${owner}/${repo}/insights/bus-factor`}>Bus Factor</a>
507 </nav>
508
509 {/* Hero */}
510 <div class={`rh-hero rh-hero--${color}`}>
511 {/* SVG circle gauge */}
512 <div class="rh-gauge">
513 <svg class="rh-gauge-svg" viewBox="0 0 140 140">
514 <circle
515 class="rh-gauge-track"
516 cx={gauge.cx}
517 cy={gauge.cy}
518 r={gauge.r}
519 />
520 <circle
521 class="rh-gauge-fill"
522 cx={gauge.cx}
523 cy={gauge.cy}
524 r={gauge.r}
525 stroke={gauge.color}
526 stroke-dasharray={gauge.dasharray}
527 stroke-dashoffset={gauge.dashoffset}
528 />
529 </svg>
530 <div class="rh-gauge-label">
531 <span class="rh-gauge-score">{breakdown.total}</span>
532 <span class="rh-gauge-max">/ 100</span>
533 </div>
534 </div>
535
536 <div class="rh-hero-body">
537 <div class="rh-hero-eyebrow">Repository Intelligence</div>
538 <h1 class="rh-hero-title">Health Score</h1>
539 <p class="rh-hero-sub">
540 Composite signal across CI reliability, bus factor, CVE exposure,
541 review velocity, and tech debt for{" "}
542 <strong>{owner}/{repo}</strong>.
543 </p>
544 <div class="rh-hero-actions">
545 <span class={`rh-score-pill rh-score-pill--${color}`}>
546 {breakdown.total >= 80 ? "Healthy" : breakdown.total >= 50 ? "Fair" : "Needs Attention"}
547 </span>
548 {isOwner && (
549 <form
550 method="post"
551 action={`/${owner}/${repo}/health/recompute`}
552 style="display:inline"
553 >
554 <button type="submit" class="rh-recompute-btn">
555 ↻ Recompute
556 </button>
557 </form>
558 )}
559 <span class="rh-computed-at">
560 Computed {computedAtStr}
561 </span>
562 </div>
563 </div>
564 </div>
565
566 {/* Signal breakdown */}
567 <h2 class="rh-section-title">Signal Breakdown</h2>
568 <div class="rh-signals">
569 <CiCard breakdown={breakdown} />
570 <BusFactorCard breakdown={breakdown} />
571 <CveCard breakdown={breakdown} />
572 <VelocityCard breakdown={breakdown} />
573 <DebtCard breakdown={breakdown} />
574 </div>
575 </div>
576 </Layout>
577 );
578 }
579);
580
581// ─── Route: POST /:owner/:repo/health/recompute ───────────────────────────────
582
583repoHealthRoutes.use("/:owner/:repo/health/recompute", requireAuth);
584
585repoHealthRoutes.post(
586 "/:owner/:repo/health/recompute",
587 requireRepoAccess("write"),
588 async (c) => {
589 const { owner, repo } = c.req.param();
590 const user = c.get("user")!;
591
592 // Only repo owner may force recompute
593 const repoRows = await db
594 .select({ id: repositories.id, ownerId: repositories.ownerId })
595 .from(repositories)
596 .innerJoin(users, eq(repositories.ownerId, users.id))
597 .where(
598 and(eq(users.username, owner), eq(repositories.name, repo))
599 )
600 .limit(1);
601
602 if (!repoRows.length) return c.notFound();
603 const repository = repoRows[0];
604
605 if (user.id !== repository.ownerId) {
606 return c.text("Forbidden", 403);
607 }
608
609 invalidateHealthScore(repository.id);
610 return c.redirect(`/${owner}/${repo}/health`);
611 }
612);
613
614export default repoHealthRoutes;
Modifiedsrc/views/components.tsx+29−1View fileUnifiedSplit
@@ -28,6 +28,8 @@ export const RepoHeader: FC<{
2828 isTemplate?: boolean;
2929 /** Most recent push info for Push Watch discoverability indicator. */
3030 recentPush?: RecentPush | null;
31 /** 0-100 health score badge rendered after the repo name. Optional — omit to hide. */
32 healthScore?: number;
3133}> = ({
3234 owner,
3335 repo,
@@ -39,12 +41,19 @@ export const RepoHeader: FC<{
3941 archived,
4042 isTemplate,
4143 recentPush,
44 healthScore,
4245}) => {
4346 const FIVE_MIN = 5 * 60 * 1000;
4447 const TWENTY_FOUR_HR = 24 * 60 * 60 * 1000;
4548 const isLive = recentPush != null && recentPush.ageMs < FIVE_MIN;
4649 const isRecent = recentPush != null && recentPush.ageMs < TWENTY_FOUR_HR;
4750
51 const healthColor =
52 healthScore === undefined ? null :
53 healthScore >= 80 ? "#34d399" :
54 healthScore >= 50 ? "#facc15" :
55 "#f87171";
56
4857 return (
4958 <div class="repo-header">
5059 <div>
@@ -72,6 +81,15 @@ export const RepoHeader: FC<{
7281 Template
7382 </span>
7483 )}
84 {healthScore !== undefined && healthColor && (
85 <a
86 href={`/${owner}/${repo}/health`}
87 title={`Repository health score: ${healthScore}/100 — click for breakdown`}
88 style={`display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:9999px;font-size:11px;font-weight:700;text-decoration:none;color:${healthColor};background:${healthColor}22;border:1px solid ${healthColor}44;`}
89 >
90 ♥ Health {healthScore}
91 </a>
92 )}
7593 {isLive && recentPush && (
7694 <a
7795 href={`/${owner}/${repo}/push/${recentPush.sha}`}
@@ -151,7 +169,10 @@ export const RepoNav: FC<{
151169 | "discussions"
152170 | "security"
153171 | "settings"
154 | "debt-map";
172 | "debt-map"
173 | "migrate"
174 | "deployments"
175 | "nl-search";
155176}> = ({ owner, repo, active }) => (
156177 <div class="repo-nav">
157178 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
@@ -266,6 +287,13 @@ export const RepoNav: FC<{
266287 >
267288 {"\u2593"} Debt Map
268289 </a>
290 <a
291 href={`/${owner}/${repo}/search/nl`}
292 class={`repo-nav-ai${active === "nl-search" ? " active" : ""}`}
293 title="Natural Language Search \u2014 search by intent, not keywords"
294 >
295 {"\u2728"} NL Search
296 </a>
269297 </div>
270298);
271299
272300