From 4f308e5a390e47af1cd32f493eca72beced62050 Mon Sep 17 00:00:00 2001 From: tomsmith8 Date: Fri, 12 Jun 2026 17:13:36 +0000 Subject: [PATCH] Generated with Hive: Add GET api endpoint for repository auto-merge and seed test data --- scripts/helpers/seed-database.ts | 20 +- .../api/github/repository/auto-merge.test.ts | 207 ++++++++++++++++++ .../api/github/repository/auto-merge/route.ts | 104 +++++++++ 3 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/unit/api/github/repository/auto-merge.test.ts create mode 100644 src/app/api/github/repository/auto-merge/route.ts diff --git a/scripts/helpers/seed-database.ts b/scripts/helpers/seed-database.ts index b5cff7a54c..9e4085b0d6 100644 --- a/scripts/helpers/seed-database.ts +++ b/scripts/helpers/seed-database.ts @@ -1122,8 +1122,26 @@ export async function seedAutoMergeTestScenarios( }); console.log(`✓ Created 4 edge case tasks with PR artifacts`); + + // Stamp allowAutoMerge: true on the first repository in this workspace so the + // canvas auto-merge path is exercisable in dev without a live GitHub API call. + const firstRepo = await prisma.repository.findFirst({ + where: { workspaceId: workspace.id }, + select: { id: true, name: true }, + }); + + if (firstRepo) { + await prisma.repository.update({ + where: { id: firstRepo.id }, + data: { allowAutoMerge: true }, + }); + console.log(`✓ Stamped allowAutoMerge: true on repository "${firstRepo.name}" (${firstRepo.id})`); + } else { + console.log("⚠ No repository found in workspace — skipping allowAutoMerge seed"); + } + console.log( - `\n✓ Auto-merge test data seeding complete:\n - 2 features with dependency chains\n - 6 core tasks (4 with autoMerge: true, 2 with autoMerge: false)\n - 4 edge case tasks with PR artifacts\n - Total: 10 tasks for comprehensive testing`, + `\n✓ Auto-merge test data seeding complete:\n - 2 features with dependency chains\n - 6 core tasks (4 with autoMerge: true, 2 with autoMerge: false)\n - 4 edge case tasks with PR artifacts\n - 1 repository stamped with allowAutoMerge: true\n - Total: 10 tasks for comprehensive testing`, ); } diff --git a/src/__tests__/unit/api/github/repository/auto-merge.test.ts b/src/__tests__/unit/api/github/repository/auto-merge.test.ts new file mode 100644 index 0000000000..a1025f29d9 --- /dev/null +++ b/src/__tests__/unit/api/github/repository/auto-merge.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; +import { NextRequest } from "next/server"; + +// ── Hoisted mocks ───────────────────────────────────────────────────────── +const { + mockDbRepositoryFindUnique, + mockDbRepositoryUpdate, + mockDbWorkspaceMemberFindFirst, +} = vi.hoisted(() => ({ + mockDbRepositoryFindUnique: vi.fn(), + mockDbRepositoryUpdate: vi.fn(), + mockDbWorkspaceMemberFindFirst: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + db: { + repository: { + findUnique: mockDbRepositoryFindUnique, + update: mockDbRepositoryUpdate, + }, + workspaceMember: { + findFirst: mockDbWorkspaceMemberFindFirst, + }, + }, +})); + +vi.mock("next-auth/next", () => ({ + getServerSession: vi.fn(), +})); + +vi.mock("@/lib/auth/nextauth", () => ({ + authOptions: {}, +})); + +vi.mock("@/lib/github", () => ({ + parsePRUrl: vi.fn(), + getOctokitForWorkspace: vi.fn(), + checkRepoAllowsAutoMerge: vi.fn(), +})); + +vi.mock("@/lib/logger", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +// ── Imports after mocks ─────────────────────────────────────────────────── +import { GET } from "@/app/api/github/repository/auto-merge/route"; +import { getServerSession } from "next-auth/next"; +import { parsePRUrl, getOctokitForWorkspace, checkRepoAllowsAutoMerge } from "@/lib/github"; + +const mockGetServerSession = getServerSession as Mock; +const mockParsePRUrl = parsePRUrl as Mock; +const mockGetOctokitForWorkspace = getOctokitForWorkspace as Mock; +const mockCheckRepoAllowsAutoMerge = checkRepoAllowsAutoMerge as Mock; + +// ── Helpers ─────────────────────────────────────────────────────────────── +const makeRequest = (repositoryId?: string) => + new NextRequest( + `http://localhost/api/github/repository/auto-merge${repositoryId ? `?repositoryId=${repositoryId}` : ""}` + ); + +const SESSION = { user: { id: "user-123", email: "test@example.com" } }; + +const REPO_BASE = { + id: "repo-abc", + repositoryUrl: "https://github.com/acme/myrepo", + allowAutoMerge: false, + workspaceId: "ws-001", + workspace: { ownerId: "owner-999" }, +}; + +// ── Tests ───────────────────────────────────────────────────────────────── +describe("GET /api/github/repository/auto-merge", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetServerSession.mockResolvedValue(SESSION); + mockDbWorkspaceMemberFindFirst.mockResolvedValue({ id: "mem-1" }); + mockDbRepositoryUpdate.mockResolvedValue({}); + mockParsePRUrl.mockReturnValue({ owner: "acme", repo: "myrepo", prNumber: 1 }); + mockGetOctokitForWorkspace.mockResolvedValue({ rest: {} }); + mockCheckRepoAllowsAutoMerge.mockResolvedValue({ allowed: true }); + }); + + // ── Auth ──────────────────────────────────────────────────────────────── + it("returns 401 when unauthenticated", async () => { + mockGetServerSession.mockResolvedValue(null); + const res = await GET(makeRequest("repo-abc")); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toMatch(/unauthorized/i); + }); + + // ── Validation ────────────────────────────────────────────────────────── + it("returns 400 when repositoryId is missing", async () => { + const res = await GET(makeRequest()); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/repositoryId/i); + }); + + // ── Not found ─────────────────────────────────────────────────────────── + it("returns 404 when repository is not found", async () => { + mockDbRepositoryFindUnique.mockResolvedValue(null); + const res = await GET(makeRequest("repo-abc")); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toMatch(/not found/i); + }); + + // ── IDOR ──────────────────────────────────────────────────────────────── + it("returns 403 when user is not a workspace member or owner", async () => { + mockDbRepositoryFindUnique.mockResolvedValue(REPO_BASE); + // user-123 is not the owner (owner-999) and not a member + mockDbWorkspaceMemberFindFirst.mockResolvedValue(null); + const res = await GET(makeRequest("repo-abc")); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toMatch(/forbidden/i); + }); + + it("allows workspace owner without a WorkspaceMember record", async () => { + mockDbRepositoryFindUnique.mockResolvedValue({ + ...REPO_BASE, + workspace: { ownerId: "user-123" }, // session user is the owner + }); + mockDbWorkspaceMemberFindFirst.mockResolvedValue(null); + mockCheckRepoAllowsAutoMerge.mockResolvedValue({ allowed: false }); + + const res = await GET(makeRequest("repo-abc")); + expect(res.status).toBe(200); + // membership check should NOT have been called + expect(mockDbWorkspaceMemberFindFirst).not.toHaveBeenCalled(); + }); + + // ── Cache hit ─────────────────────────────────────────────────────────── + it("returns { allowed: true } immediately on cache hit without hitting GitHub", async () => { + mockDbRepositoryFindUnique.mockResolvedValue({ ...REPO_BASE, allowAutoMerge: true }); + + const res = await GET(makeRequest("repo-abc")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.allowed).toBe(true); + + // No GitHub calls should have been made + expect(mockCheckRepoAllowsAutoMerge).not.toHaveBeenCalled(); + expect(mockGetOctokitForWorkspace).not.toHaveBeenCalled(); + }); + + // ── Cache miss, GitHub allows ──────────────────────────────────────────── + it("returns { allowed: true } and caches the result when GitHub permits auto-merge", async () => { + mockDbRepositoryFindUnique.mockResolvedValue(REPO_BASE); + mockCheckRepoAllowsAutoMerge.mockResolvedValue({ allowed: true }); + + const res = await GET(makeRequest("repo-abc")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.allowed).toBe(true); + + expect(mockCheckRepoAllowsAutoMerge).toHaveBeenCalledWith( + expect.anything(), + "acme", + "myrepo" + ); + // Should cache the positive result + expect(mockDbRepositoryUpdate).toHaveBeenCalledWith({ + where: { id: "repo-abc" }, + data: { allowAutoMerge: true }, + }); + }); + + // ── Cache miss, GitHub denies ──────────────────────────────────────────── + it("returns { allowed: false } and does NOT cache when GitHub denies auto-merge", async () => { + mockDbRepositoryFindUnique.mockResolvedValue(REPO_BASE); + mockCheckRepoAllowsAutoMerge.mockResolvedValue({ allowed: false }); + + const res = await GET(makeRequest("repo-abc")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.allowed).toBe(false); + + // Should not cache a negative result + expect(mockDbRepositoryUpdate).not.toHaveBeenCalled(); + }); + + // ── Fail-safe: no Octokit ──────────────────────────────────────────────── + it("returns { allowed: false } when Octokit token is unavailable", async () => { + mockDbRepositoryFindUnique.mockResolvedValue(REPO_BASE); + mockGetOctokitForWorkspace.mockResolvedValue(null); + + const res = await GET(makeRequest("repo-abc")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.allowed).toBe(false); + expect(mockCheckRepoAllowsAutoMerge).not.toHaveBeenCalled(); + }); + + // ── Fail-safe: bad repo URL ────────────────────────────────────────────── + it("returns { allowed: false } when repositoryUrl cannot be parsed", async () => { + mockDbRepositoryFindUnique.mockResolvedValue(REPO_BASE); + mockParsePRUrl.mockReturnValue(null); + + const res = await GET(makeRequest("repo-abc")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.allowed).toBe(false); + expect(mockCheckRepoAllowsAutoMerge).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/github/repository/auto-merge/route.ts b/src/app/api/github/repository/auto-merge/route.ts new file mode 100644 index 0000000000..e470a491ad --- /dev/null +++ b/src/app/api/github/repository/auto-merge/route.ts @@ -0,0 +1,104 @@ +import { authOptions } from "@/lib/auth/nextauth"; +import { db } from "@/lib/db"; +import { checkRepoAllowsAutoMerge, getOctokitForWorkspace, parsePRUrl } from "@/lib/github"; +import { logger } from "@/lib/logger"; +import { getServerSession } from "next-auth/next"; +import { NextRequest, NextResponse } from "next/server"; + +export const runtime = "nodejs"; + +const LOG_PREFIX = "[AutoMergeRoute]"; + +export async function GET(request: NextRequest) { + try { + // 1. Authenticate + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const userId = session.user.id; + + // 2. Parse repositoryId from query params + const { searchParams } = new URL(request.url); + const repositoryId = searchParams.get("repositoryId"); + if (!repositoryId) { + return NextResponse.json( + { error: "repositoryId query parameter is required" }, + { status: 400 } + ); + } + + // 3. Load repository from DB + const repository = await db.repository.findUnique({ + where: { id: repositoryId }, + select: { + id: true, + repositoryUrl: true, + allowAutoMerge: true, + workspaceId: true, + workspace: { + select: { ownerId: true }, + }, + }, + }); + + if (!repository) { + return NextResponse.json({ error: "Repository not found" }, { status: 404 }); + } + + // 4. IDOR: verify the authenticated user belongs to repository's workspace + const isOwner = repository.workspace.ownerId === userId; + if (!isOwner) { + const membership = await db.workspaceMember.findFirst({ + where: { + workspaceId: repository.workspaceId, + userId, + leftAt: null, + }, + }); + if (!membership) { + return NextResponse.json( + { error: "Forbidden" }, + { status: 403 } + ); + } + } + + // 5. Cache hit — skip GitHub call + if (repository.allowAutoMerge === true) { + return NextResponse.json({ allowed: true }); + } + + // 6. Parse owner/repo from repositoryUrl + const parsed = parsePRUrl(`${repository.repositoryUrl}/pull/1`); + if (!parsed) { + logger.warn(`${LOG_PREFIX} Could not parse repository URL`, repository.repositoryUrl); + return NextResponse.json({ allowed: false }); + } + const { owner, repo } = parsed; + + // 7. Get authenticated Octokit client + const octokit = await getOctokitForWorkspace(userId, owner); + if (!octokit) { + logger.warn(`${LOG_PREFIX} No Octokit token for user`, userId, owner); + return NextResponse.json({ allowed: false }); + } + + // 8. Live GitHub check + const result = await checkRepoAllowsAutoMerge(octokit, owner, repo); + + // 9. Cache positive result + if (result.allowed) { + await db.repository.update({ + where: { id: repositoryId }, + data: { allowAutoMerge: true }, + }); + } + + // 10. Return result + return NextResponse.json({ allowed: result.allowed }); + } catch (error) { + logger.error(`${LOG_PREFIX} Unexpected error`, undefined, { error }); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +}