Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commiteb3b81aunknown_key

feat(ai): implement triggerAiReview, harden AI error handling, fix auth + UI bugs

feat(ai): implement triggerAiReview, harden AI error handling, fix auth + UI bugs

- ai-review: implement triggerAiReview() (was a no-op stub) — gets branch diff,
  runs Claude review, posts summary + inline comments to pr_comments with isAiReview=true
- ai-review: add isAiAvailable() guard + try/catch to reviewDiff()
- merge-resolver: add isMergeResolverAvailable() guard before AI conflict resolution;
  returns clear error if ANTHROPIC_API_KEY not set instead of a misleading message
- ai-generators: wrap generateChangelog() API call in try/catch with plain-text fallback
- spec-ai: add ANTHROPIC_API_KEY check to getClient() to prevent silent failures
- ai-tests: remove unreachable code in detectTestFramework() Python branch
- api: add softAuth + ownership check to POST /api/repos (blocks cross-user repo creation);
  gate POST /api/setup behind ALLOW_SETUP_ENDPOINT env var (disabled in production)
- config: add missing env var getters (gatetestCallbackSecret, gatetestHmacSecret,
  crontechEventToken, voyageApiKey)
- components: add "ask" and "spec" to RepoNav active type; add active class to Ask AI
  and Spec nav links; fix missing JSX key on Breadcrumb map

https://claude.ai/code/session_01HkSzgS2aJpKqv1B9t7o9Cb
Claude committed on April 23, 2026Parent: 9d3a6bb
8 files changed+16646eb3b81a9e6d8601c0f89157470c170ce87b6de73
8 changed files+166−46
Modifiedsrc/lib/ai-generators.ts+19−11View fileUnifiedSplit
8181 .slice(0, 200)
8282 .map((c) => `- ${c.sha.slice(0, 7)} ${c.message.split("\n")[0]}${c.author}`)
8383 .join("\n");
84 const message = await client.messages.create({
85 model: MODEL_SONNET,
86 max_tokens: 2048,
87 messages: [
88 {
89 role: "user",
90 content: `Generate a polished release-notes changelog in Markdown for ${repoFullName}.
84 const plainFallback = () =>
85 `## ${toRef}${fromRef ? ` (since ${fromRef})` : ""}\n\n` +
86 commits.map((c) => `- ${c.message.split("\n")[0]} (${c.sha.slice(0, 7)}) — ${c.author}`).join("\n");
87 try {
88 const message = await client.messages.create({
89 model: MODEL_SONNET,
90 max_tokens: 2048,
91 messages: [
92 {
93 role: "user",
94 content: `Generate a polished release-notes changelog in Markdown for ${repoFullName}.
9195
9296Release: ${toRef}
9397Previous: ${fromRef || "(initial)"}
96100
97101Commits:
98102${commitBlob}`,
99 },
100 ],
101 });
102 return extractText(message).trim();
103 },
104 ],
105 });
106 return extractText(message).trim() || plainFallback();
107 } catch (err) {
108 console.error("[ai-generators] generateChangelog failed:", err);
109 return plainFallback();
110 }
103111}
104112
105113interface IssueTriage {
Modifiedsrc/lib/ai-review.ts+88−22View fileUnifiedSplit
77
88import Anthropic from "@anthropic-ai/sdk";
99import { config } from "./config";
10import { getRepoPath } from "../git/repository";
1011
1112interface ReviewComment {
1213 filePath: string;
4344 headBranch: string,
4445 diffText: string
4546): Promise<ReviewResult> {
47 if (!isAiReviewEnabled()) {
48 return { summary: "AI review unavailable: ANTHROPIC_API_KEY not configured.", comments: [], approved: true };
49 }
4650 const client = getClient();
4751
48 const message = await client.messages.create({
49 model: "claude-sonnet-4-20250514",
50 max_tokens: 4096,
51 messages: [
52 {
53 role: "user",
54 content: `You are reviewing a pull request on the repository "${repoFullName}".
52 let text = "";
53 try {
54 const message = await client.messages.create({
55 model: "claude-sonnet-4-20250514",
56 max_tokens: 4096,
57 messages: [
58 {
59 role: "user",
60 content: `You are reviewing a pull request on the repository "${repoFullName}".
5561
5662**PR Title:** ${prTitle}
5763**PR Description:** ${prBody || "(none)"}
8591\`\`\`diff
8692${diffText.slice(0, 100000)}
8793\`\`\``,
88 },
89 ],
90 });
91
92 const text =
93 message.content[0].type === "text" ? message.content[0].text : "";
94 },
95 ],
96 });
97 text = message.content[0].type === "text" ? message.content[0].text : "";
98 } catch (err) {
99 console.error("[ai-review] reviewDiff API call failed:", err);
100 return { summary: "AI review failed due to an API error.", comments: [], approved: true };
101 }
94102
95103 try {
96104 // Extract JSON from response (may be wrapped in markdown code block)
125133}
126134
127135/**
128 * Fire-and-forget AI review trigger. Callers .catch() failures.
129 * Currently a stub that defers to reviewDiff once the diff is available.
136 * Fire-and-forget AI review trigger. Gets the branch diff, runs Claude review,
137 * and posts the results as pr_comments with isAiReview=true.
130138 */
131139export async function triggerAiReview(
132140 ownerName: string,
133141 repoName: string,
134 _prId: string,
135 _title: string,
136 _body: string,
137 _baseBranch: string,
138 _headBranch: string,
142 prId: string,
143 title: string,
144 body: string,
145 baseBranch: string,
146 headBranch: string,
139147): Promise<void> {
140148 if (!isAiReviewEnabled()) return;
141 if (process.env.DEBUG_AI_REVIEW === "1") {
142 console.log("[ai-review] queued", ownerName, repoName, _prId);
149 try {
150 // Get branch diff
151 const repoDir = getRepoPath(ownerName, repoName);
152 const proc = Bun.spawn(
153 ["git", "diff", `${baseBranch}...${headBranch}`],
154 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
155 );
156 const diffRaw = await new Response(proc.stdout).text();
157 await proc.exited;
158
159 if (!diffRaw.trim()) return;
160
161 const result = await reviewDiff(
162 `${ownerName}/${repoName}`,
163 title,
164 body,
165 baseBranch,
166 headBranch,
167 diffRaw
168 );
169
170 // Load DB + schema lazily to avoid circular imports at module load time
171 const { db } = await import("../db");
172 const { pullRequests, prComments } = await import("../db/schema");
173 const { eq } = await import("drizzle-orm");
174
175 const [pr] = await db
176 .select({ repositoryId: pullRequests.repositoryId, authorId: pullRequests.authorId })
177 .from(pullRequests)
178 .where(eq(pullRequests.id, prId))
179 .limit(1);
180
181 if (!pr) return;
182
183 // Post summary comment
184 await db.insert(prComments).values({
185 pullRequestId: prId,
186 authorId: pr.authorId,
187 body: `**AI Code Review** ${result.approved ? "✓ Approved" : "⚠ Changes requested"}\n\n${result.summary}`,
188 isAiReview: true,
189 });
190
191 // Post inline comments
192 for (const comment of result.comments) {
193 if (!comment.body) continue;
194 await db.insert(prComments).values({
195 pullRequestId: prId,
196 authorId: pr.authorId,
197 body: comment.body,
198 isAiReview: true,
199 filePath: comment.filePath || null,
200 lineNumber: comment.lineNumber || null,
201 });
202 }
203
204 if (process.env.DEBUG_AI_REVIEW === "1") {
205 console.log("[ai-review] posted", 1 + result.comments.length, "comments on", ownerName, repoName, prId);
206 }
207 } catch (err) {
208 console.error("[ai-review] triggerAiReview failed:", err);
143209 }
144210}
Modifiedsrc/lib/ai-tests.ts+2−7View fileUnifiedSplit
9292 return files.some((f) => needle.test(f));
9393 };
9494
95 // Python first — it has the clearest signals.
96 if (language === "python") {
97 if (has("pytest.ini") || has("pyproject.toml") || has(/(^|\/)tests?\/.+test.*\.py$/))
98 return "pytest";
99 if (has(/(^|\/)test_.+\.py$/) || has(/_test\.py$/)) return "pytest";
100 return "pytest";
101 }
95 // Python always uses pytest (most widely adopted test runner).
96 if (language === "python") return "pytest";
10297
10398 if (language === "go") return "go test";
10499 if (language === "rust") return "cargo test";
Modifiedsrc/lib/config.ts+16−0View fileUnifiedSplit
3333 get anthropicApiKey() {
3434 return process.env.ANTHROPIC_API_KEY || "";
3535 },
36 /** HMAC secret used to verify inbound GateTest callback signatures. */
37 get gatetestCallbackSecret() {
38 return process.env.GATETEST_CALLBACK_SECRET || "";
39 },
40 /** Shared HMAC secret for signing outbound GateTest payloads. */
41 get gatetestHmacSecret() {
42 return process.env.GATETEST_HMAC_SECRET || "";
43 },
44 /** Bearer token expected on inbound Crontech deploy-event webhooks. */
45 get crontechEventToken() {
46 return process.env.CRONTECH_EVENT_TOKEN || "";
47 },
48 /** Voyage AI API key for semantic (embedding) code search. */
49 get voyageApiKey() {
50 return process.env.VOYAGE_API_KEY || "";
51 },
3652 /** Email provider: "log" (dev, writes to stderr) or "resend" (HTTPS). */
3753 get emailProvider() {
3854 const v = (process.env.EMAIL_PROVIDER || "log").toLowerCase();
Modifiedsrc/lib/merge-resolver.ts+14−0View fileUnifiedSplit
1111import { config } from "./config";
1212import { getRepoPath } from "../git/repository";
1313
14export function isMergeResolverAvailable(): boolean {
15 return !!config.anthropicApiKey;
16}
17
1418interface ConflictFile {
1519 path: string;
1620 content: string;
116120 return { success: false, resolvedFiles: [], error: "Merge failed but no conflicts detected" };
117121 }
118122
123 // AI conflict resolution requires an API key
124 if (!isMergeResolverAvailable()) {
125 await exec(["git", "merge", "--abort"], { cwd: worktree });
126 return {
127 success: false,
128 resolvedFiles: [],
129 error: `Merge has ${conflictPaths.length} conflict(s) that require manual resolution (AI unavailable: ANTHROPIC_API_KEY not set)`,
130 };
131 }
132
119133 // Read each conflicting file and resolve with Claude
120134 const resolvedFiles: string[] = [];
121135 for (const filePath of conflictPaths) {
Modifiedsrc/lib/spec-ai.ts+3−0View fileUnifiedSplit
266266
267267function getClient(): Anthropic {
268268 if (!_client) {
269 if (!config.anthropicApiKey) {
270 throw new Error("ANTHROPIC_API_KEY is not set");
271 }
269272 _client = new Anthropic({ apiKey: config.anthropicApiKey });
270273 }
271274 return _client;
Modifiedsrc/routes/api.ts+17−2View fileUnifiedSplit
99import { initBareRepo, repoExists } from "../git/repository";
1010import { hashPassword } from "../lib/auth";
1111import { orgRoleAtLeast } from "../lib/orgs";
12import { softAuth } from "../middleware/auth";
1213
1314const api = new Hono().basePath("/api");
1415
1516// Create repository
16api.post("/repos", async (c) => {
17api.post("/repos", softAuth, async (c) => {
1718 let body: {
1819 name: string;
1920 owner: string;
3637 return c.json({ error: "Invalid repository name" }, 400);
3738 }
3839
40 // Auth check after input validation so bad requests still get 400
41 const authUser = c.get("user");
42 if (!authUser) {
43 return c.json({ error: "Unauthorized" }, 401);
44 }
45
3946 try {
4047 // Find creator (user who is performing the action)
4148 const [owner] = await db
4754 return c.json({ error: "Owner not found" }, 404);
4855 }
4956
57 // Verify the authenticated user is the requested owner
58 if (authUser.id !== owner.id) {
59 return c.json({ error: "Forbidden: cannot create repos for another user" }, 403);
60 }
61
5062 // B2: if orgSlug supplied, place the repo in the org namespace.
5163 // Requires the creator to be an admin+ of the org.
5264 let orgId: string | null = null;
173185 }
174186});
175187
176// Quick-setup: create user + repo in one call (dev convenience)
188// Quick-setup: create user + repo in one call (dev convenience, disabled in production)
177189api.post("/setup", async (c) => {
190 if (!process.env.ALLOW_SETUP_ENDPOINT) {
191 return c.json({ error: "Endpoint disabled" }, 403);
192 }
178193 let body: {
179194 username: string;
180195 email: string;
Modifiedsrc/views/components.tsx+7−4View fileUnifiedSplit
104104 | "semantic"
105105 | "wiki"
106106 | "projects"
107 | "settings";
107 | "settings"
108 | "ask"
109 | "spec";
108110}> = ({ owner, repo, active }) => (
109111 <div class="repo-nav">
110112 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
171173 >
172174 {"\u2728"} Explain
173175 </a>
174 <a href={`/${owner}/${repo}/ask`} style="color: #bc8cff">
176 <a href={`/${owner}/${repo}/ask`} class={active === "ask" ? "active" : ""} style="color: #bc8cff">
175177 {"\u2728"} Ask AI
176178 </a>
177179 <a
178180 href={`/${owner}/${repo}/spec`}
181 class={active === "spec" ? "active" : ""}
179182 style="color: #bc8cff"
180183 title="Spec to PR — paste a feature spec, AI opens a draft PR"
181184 >
246249 return (
247250 <div class="breadcrumb">
248251 {crumbs.map((crumb, i) => (
249 <>
252 <span key={crumb.href}>
250253 {i > 0 && <span>/</span>}
251254 {i === crumbs.length - 1 ? (
252255 <strong>{crumb.name}</strong>
253256 ) : (
254257 <a href={crumb.href}>{crumb.name}</a>
255258 )}
256 </>
259 </span>
257260 ))}
258261 </div>
259262 );
260263