Commit441847dunknown_key
Merge pull request #125 from ccantynz-alt/claude/serene-edison-rj87we
Merge pull request #125 from ccantynz-alt/claude/serene-edison-rj87we Claude/serene edison rj87we
4 files changed+184−32441847d18a9a93728edaa297b7359a65b8ba6eb0
4 changed files+184−32
Modifiede2e/pulls.spec.ts+134−0View fileUnifiedSplit
@@ -9,6 +9,8 @@
99 */
1010
1111import { test, expect } from "@playwright/test";
12import * as path from "path";
13import * as fs from "fs/promises";
1214import {
1315 uid,
1416 TEST_PASSWORD,
@@ -262,3 +264,135 @@ test.describe("Pull request close", () => {
262264 });
263265 });
264266});
267
268// ---------------------------------------------------------------------------
269// PR command center — /pulls smoke test
270// ---------------------------------------------------------------------------
271
272test.describe("PR command center (/pulls)", () => {
273 test("page loads without 500 when unauthenticated", async ({ page }) => {
274 const res = await page.goto("/pulls");
275 // Unauthenticated users get redirected to login — either way, no 500
276 expect(res?.status()).not.toBe(500);
277 await expect(page.locator("body")).not.toContainText(/500|Internal Server Error/i);
278 });
279
280 test("page loads without 500 when authenticated", async ({ page }) => {
281 const user = uid("pdash");
282 await page.goto("/register");
283 await page.fill('input[name="username"]', user);
284 await page.fill('input[name="email"]', `${user}@test.example`);
285 await page.fill('input[name="password"]', TEST_PASSWORD);
286 await page.click('button[type="submit"]');
287 await page.waitForURL(/\/(dashboard|[a-z])/);
288
289 const res = await page.goto("/pulls");
290 expect(res?.status()).not.toBe(500);
291 await expect(page.locator("body")).not.toContainText(/500|Internal Server Error/i);
292 });
293});
294
295// ---------------------------------------------------------------------------
296// Regression: PR detail must not 500 when git repo dir is missing
297// (Fixes: 500 on McCracken/Gluecron.com #1 — bare repo missing from FS)
298// ---------------------------------------------------------------------------
299
300test.describe("PR detail — missing git repo (regression)", () => {
301 let prUrl: string;
302 let ghostOwner: string;
303 let ghostRepo: string;
304 let gitRepoPath: string;
305 let backupPath: string;
306 let ghostTmpDir: string;
307
308 test.beforeAll(async ({ browser }) => {
309 ghostOwner = uid("ghostuser");
310 ghostRepo = uid("ghostrepo");
311 const branch = uid("ghost-feat-");
312
313 const page = await browser.newPage();
314
315 // Register
316 await page.goto("/register");
317 await page.fill('input[name="username"]', ghostOwner);
318 await page.fill('input[name="email"]', `${ghostOwner}@test.example`);
319 await page.fill('input[name="password"]', TEST_PASSWORD);
320 await page.click('button[type="submit"]');
321 await page.waitForURL(/\/(dashboard|[a-z])/);
322
323 // Create repo
324 await page.goto("/new");
325 await page.fill('input[name="name"]', ghostRepo);
326 await page.click('button[type="submit"]');
327 await page.waitForURL(new RegExp(`/${ghostOwner}/${ghostRepo}`));
328 await page.close();
329
330 // Push initial commit + feature branch
331 ghostTmpDir = await pushTestCommit({
332 owner: ghostOwner,
333 repo: ghostRepo,
334 username: ghostOwner,
335 password: TEST_PASSWORD,
336 fileName: "README.md",
337 fileContent: `# ${ghostRepo}\n`,
338 commitMsg: "Initial commit",
339 });
340 await pushFeatureBranch({
341 repoDir: ghostTmpDir,
342 username: ghostOwner,
343 branchName: branch,
344 fileName: "ghost.md",
345 fileContent: "Ghost feature.\n",
346 commitMsg: "Add ghost feature",
347 });
348
349 // Create the PR via UI
350 const prPage = await browser.newPage();
351 await prPage.goto("/login");
352 await prPage.fill('input[name="username"]', ghostOwner);
353 await prPage.fill('input[name="password"]', TEST_PASSWORD);
354 await prPage.click('button[type="submit"]');
355 await prPage.waitForURL(/\/(dashboard|[a-z])/);
356
357 await prPage.goto(`/${ghostOwner}/${ghostRepo}/compare/main...${branch}`);
358 const titleInput = prPage.locator('input[name="title"]');
359 await titleInput.waitFor({ timeout: 8_000 });
360 await titleInput.fill(`Ghost PR ${uid("gp")}`);
361 await prPage.click('button[type="submit"]');
362 await prPage.waitForURL(new RegExp(`/${ghostOwner}/${ghostRepo}/pulls/\\d+`));
363 prUrl = prPage.url();
364 await prPage.close();
365
366 // Simulate missing git repo directory — rename it out of the way
367 const reposRoot =
368 process.env.GIT_REPOS_PATH ?? path.join(process.cwd(), "repos");
369 gitRepoPath = path.join(reposRoot, ghostOwner, `${ghostRepo}.git`);
370 backupPath = `${gitRepoPath}.missing`;
371 await fs.rename(gitRepoPath, backupPath).catch(() => {
372 // If rename fails (e.g. repo dir doesn't exist for some reason), continue
373 });
374 });
375
376 test.afterAll(async () => {
377 // Restore the git repo directory so the test environment stays clean
378 if (backupPath && gitRepoPath) {
379 await fs.rename(backupPath, gitRepoPath).catch(() => {});
380 }
381 await cleanupDir(ghostTmpDir);
382 });
383
384 test("PR detail renders without 500 when git repo is missing from disk", async ({
385 page,
386 }) => {
387 const res = await page.goto(prUrl);
388 // Must not be a 500
389 expect(res?.status()).not.toBe(500);
390 await expect(page.locator("body")).not.toContainText(
391 /500|Internal Server Error/i
392 );
393 // PR data from DB should still be visible (title / state)
394 await expect(page.locator("body")).toContainText(/open|ghost/i, {
395 timeout: 8_000,
396 });
397 });
398});
Modifiedsrc/git/repository.ts+12−6View fileUnifiedSplit
@@ -43,12 +43,18 @@ async function exec(
4343 cmd: string[],
4444 opts?: { cwd?: string; env?: Record<string, string> }
4545): Promise<{ stdout: string; stderr: string; exitCode: number }> {
46 const proc = Bun.spawn(cmd, {
47 cwd: opts?.cwd,
48 env: { ...process.env, ...opts?.env },
49 stdout: "pipe",
50 stderr: "pipe",
51 });
46 let proc: ReturnType<typeof Bun.spawn>;
47 try {
48 proc = Bun.spawn(cmd, {
49 cwd: opts?.cwd,
50 env: { ...process.env, ...opts?.env },
51 stdout: "pipe",
52 stderr: "pipe",
53 });
54 } catch {
55 // cwd doesn't exist or the binary is missing — treat as a non-zero exit
56 return { stdout: "", stderr: "", exitCode: 128 };
57 }
5258 const [stdout, stderr] = await Promise.all([
5359 new Response(proc.stdout).text(),
5460 new Response(proc.stderr).text(),
Modifiedsrc/lib/gate.ts+20−12View fileUnifiedSplit
@@ -238,19 +238,27 @@ export async function checkMergeability(
238238 const { getRepoPath } = await import("../git/repository");
239239 const repoDir = getRepoPath(owner, repo);
240240
241 const ffCheck = Bun.spawn(
242 ["git", "merge-base", "--is-ancestor", baseBranch, headBranch],
243 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
244 );
241 function spawnGit(args: string[]) {
242 try {
243 return Bun.spawn(["git", ...args], { cwd: repoDir, stdout: "pipe", stderr: "pipe" });
244 } catch {
245 return null;
246 }
247 }
248
249 const ffCheck = spawnGit(["merge-base", "--is-ancestor", baseBranch, headBranch]);
250 if (!ffCheck) {
251 return { name: "Merge check", passed: false, skipped: true, details: "Repository not accessible" };
252 }
245253 const ffExit = await ffCheck.exited;
246254 if (ffExit === 0) {
247255 return { name: "Merge check", passed: true, details: "Fast-forward merge possible" };
248256 }
249257
250 const mergeBase = Bun.spawn(
251 ["git", "merge-base", baseBranch, headBranch],
252 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
253 );
258 const mergeBase = spawnGit(["merge-base", baseBranch, headBranch]);
259 if (!mergeBase) {
260 return { name: "Merge check", passed: false, details: "Branches have no common ancestor" };
261 }
254262 const baseOut = await new Response(mergeBase.stdout).text();
255263 const baseExit = await mergeBase.exited;
256264
@@ -258,10 +266,10 @@ export async function checkMergeability(
258266 return { name: "Merge check", passed: false, details: "Branches have no common ancestor" };
259267 }
260268
261 const mergeTree = Bun.spawn(
262 ["git", "merge-tree", baseOut.trim(), baseBranch, headBranch],
263 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
264 );
269 const mergeTree = spawnGit(["merge-tree", baseOut.trim(), baseBranch, headBranch]);
270 if (!mergeTree) {
271 return { name: "Merge check", passed: false, details: "Branches have no common ancestor" };
272 }
265273 const treeOut = await new Response(mergeTree.stdout).text();
266274 await mergeTree.exited;
267275
Modifiedsrc/routes/pulls.tsx+18−14View fileUnifiedSplit
@@ -4085,20 +4085,24 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
40854085 let gateChecks: GateCheckResult[] = [];
40864086 let ciStatuses: CommitStatus[] = [];
40874087 if (pr.state === "open") {
4088 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
4089 if (headSha) {
4090 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
4091 const aiApproved = aiComments.length === 0 || aiComments.some(
4092 ({ comment }) => comment.body.includes("**Approved**")
4093 );
4094 const [gateResult, fetchedCiStatuses] = await Promise.all([
4095 runAllGateChecks(
4096 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
4097 ),
4098 listStatuses(resolved.repo.id, headSha).catch(() => [] as CommitStatus[]),
4099 ]);
4100 gateChecks = gateResult.checks;
4101 ciStatuses = fetchedCiStatuses;
4088 try {
4089 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
4090 if (headSha) {
4091 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
4092 const aiApproved = aiComments.length === 0 || aiComments.some(
4093 ({ comment }) => comment.body.includes("**Approved**")
4094 );
4095 const [gateResult, fetchedCiStatuses] = await Promise.all([
4096 runAllGateChecks(
4097 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
4098 ).catch(() => ({ allPassed: false, checks: [] as GateCheckResult[] })),
4099 listStatuses(resolved.repo.id, headSha).catch(() => [] as CommitStatus[]),
4100 ]);
4101 gateChecks = gateResult.checks;
4102 ciStatuses = fetchedCiStatuses;
4103 }
4104 } catch {
4105 // git repo missing or unreachable — show PR without gate status
41024106 }
41034107 }
41044108
41054109