diff --git a/.github/workflows/deployPR.yml b/.github/workflows/deployPR.yml index 5afde46212..a99098aa71 100644 --- a/.github/workflows/deployPR.yml +++ b/.github/workflows/deployPR.yml @@ -109,6 +109,12 @@ jobs: echo "$response" return 0 fi + # Fast-fail on permanent "not found" errors — retrying will not help + if echo "$response" | jq -r '.message // empty' 2>/dev/null | grep -qi "not found"; then + echo "⚠️ Neon API permanent error for $url — got: ${response:0:120}" >&2 + echo "$response" + return 1 + fi echo "⚠️ Neon API bad/empty response (attempt $attempt/5) for $url — got: ${response:0:120}" >&2 sleep 10 done @@ -192,7 +198,16 @@ jobs: # Wait until endpoint is ready (GET endpoint does not return credentials) for i in {1..30}; do - STATUS=$(neon_curl "https://console.neon.tech/api/v2/projects/$NEON_PROJECT_ID/endpoints/$ENDPOINT_ID" "endpoint") + STATUS=$(neon_curl "https://console.neon.tech/api/v2/projects/$NEON_PROJECT_ID/endpoints/$ENDPOINT_ID" "endpoint") || { + # Endpoint may have been deleted externally (race condition / concurrent run) + if echo "$STATUS" | grep -q "not found"; then + echo "⚠️ Endpoint $ENDPOINT_ID was deleted mid-wait, will recreate..." + ENDPOINT_ID="" + break + fi + echo "❌ Failed to poll endpoint status after retries" + exit 1 + } STATE=$(echo "$STATUS" | jq -r '.endpoint.current_state') DB_HOST=$(echo "$STATUS" | jq -r '.endpoint.host') if [ "$STATE" = "ready" ] && [ -n "$DB_HOST" ] && [ "$DB_HOST" != "null" ]; then @@ -202,6 +217,82 @@ jobs: sleep 3 done + # If endpoint disappeared during the wait loop, find or create a replacement + if [ -z "$ENDPOINT_ID" ] || [ "$ENDPOINT_ID" = "null" ]; then + echo "🔄 Re-fetching branches and endpoints to find/create a replacement..." + + # The branch itself may have been deleted — re-verify and recreate if needed + BRANCHES_JSON=$(neon_curl "https://console.neon.tech/api/v2/projects/$NEON_PROJECT_ID/branches" "branches") + BRANCH_ID=$(echo "$BRANCHES_JSON" | jq -r ".branches[] | select(.name==\"$BRANCH_NAME\") | .id") + + if [ -z "$BRANCH_ID" ] || [ "$BRANCH_ID" = "null" ]; then + echo "⚠️ Branch $BRANCH_NAME no longer exists — recreating..." + RESPONSE=$(neon_curl "https://console.neon.tech/api/v2/projects/$NEON_PROJECT_ID/branches" "branch" \ + -X POST \ + -H "Content-Type: application/json" \ + -d "{\"branch\":{\"name\":\"$BRANCH_NAME\",\"parent_id\":\"$PARENT_BRANCH_ID\",\"expires_at\":\"$EXPIRY\"}}") + BRANCH_ID=$(echo "$RESPONSE" | jq -r '.branch.id') + if [ -z "$BRANCH_ID" ] || [ "$BRANCH_ID" = "null" ]; then + echo "❌ Failed to recreate branch $BRANCH_NAME" + exit 1 + fi + echo "✅ Branch recreated: $BRANCH_ID" + # Wait for the new branch to be ready + for i in {1..20}; do + BRANCH_STATUS=$(neon_curl "https://console.neon.tech/api/v2/projects/$NEON_PROJECT_ID/branches/$BRANCH_ID" "branch") + STATE=$(echo "$BRANCH_STATUS" | jq -r '.branch.current_state') + if [ "$STATE" = "ready" ]; then break; fi + echo "Waiting for recreated branch... ($i)" + sleep 3 + done + fi + + ENDPOINTS_JSON=$(neon_curl "https://console.neon.tech/api/v2/projects/$NEON_PROJECT_ID/endpoints" "endpoints") + ENDPOINT_ID=$(echo "$ENDPOINTS_JSON" | jq -r ".endpoints[] | select(.branch_id==\"$BRANCH_ID\") | .id") + + if [ -z "$ENDPOINT_ID" ] || [ "$ENDPOINT_ID" = "null" ]; then + RESPONSE=$(curl -s -H "Authorization: Bearer $NEON_API_KEY" \ + -X POST -H "Content-Type: application/json" \ + -d "{\"endpoint\":{\"branch_id\":\"$BRANCH_ID\",\"type\":\"read_write\"}}" \ + "https://console.neon.tech/api/v2/projects/$NEON_PROJECT_ID/endpoints") + + if echo "$RESPONSE" | jq -e '.endpoint' > /dev/null 2>&1; then + ENDPOINT_ID=$(echo "$RESPONSE" | jq -r '.endpoint.id') + else + echo "⚠️ Endpoint creation failed: $(echo "$RESPONSE" | jq -r '.message // .code // empty')" + echo "♻️ Looking for a stale endpoint to reassign..." + STALE_ENDPOINT_ID=$(echo "$ENDPOINTS_JSON" | jq -r \ + --arg bid "$BRANCH_ID" \ + --arg pbid "$PARENT_BRANCH_ID" \ + '.endpoints[] | select(.branch_id != $bid and .branch_id != $pbid and .type == "read_write") | .id' \ + | head -1) + if [ -z "$STALE_ENDPOINT_ID" ] || [ "$STALE_ENDPOINT_ID" = "null" ]; then + echo "❌ No stale endpoint available to reassign" + exit 1 + fi + echo "♻️ Reassigning endpoint $STALE_ENDPOINT_ID to branch $BRANCH_ID..." + RESPONSE=$(neon_curl \ + "https://console.neon.tech/api/v2/projects/$NEON_PROJECT_ID/endpoints/$STALE_ENDPOINT_ID" "endpoint" \ + -X PATCH \ + -H "Content-Type: application/json" \ + -d "{\"endpoint\":{\"branch_id\":\"$BRANCH_ID\"}}") + ENDPOINT_ID=$(echo "$RESPONSE" | jq -r '.endpoint.id') + fi + fi + + # Wait for the replacement endpoint to be ready + for i in {1..30}; do + STATUS=$(neon_curl "https://console.neon.tech/api/v2/projects/$NEON_PROJECT_ID/endpoints/$ENDPOINT_ID" "endpoint") + STATE=$(echo "$STATUS" | jq -r '.endpoint.current_state') + DB_HOST=$(echo "$STATUS" | jq -r '.endpoint.host') + if [ "$STATE" = "ready" ] && [ -n "$DB_HOST" ] && [ "$DB_HOST" != "null" ]; then + break + fi + echo "Waiting for replacement endpoint... ($i)" + sleep 3 + done + fi + if [ -z "$DB_HOST" ] || [ "$DB_HOST" = "null" ]; then echo "❌ Endpoint never became ready" exit 1 @@ -426,16 +517,27 @@ jobs: uses: actions/github-script@v7 with: script: | - const deployment = await github.rest.repos.createDeployment({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: context.sha, - environment: `preview-${{ github.event.pull_request.number }}`, - auto_merge: false, - required_contexts: [], - description: 'PR preview deployment' - }); - return deployment.data.id; + const maxAttempts = 5; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const deployment = await github.rest.repos.createDeployment({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: context.sha, + environment: `preview-${{ github.event.pull_request.number }}`, + auto_merge: false, + required_contexts: [], + description: 'PR preview deployment' + }); + return deployment.data.id; + } catch (err) { + console.log(`Attempt ${attempt}/${maxAttempts} failed: ${err.status} ${err.message}`); + if (attempt === maxAttempts) throw err; + const wait = attempt * 15000; + console.log(`Waiting ${wait / 1000}s before retry...`); + await new Promise(r => setTimeout(r, wait)); + } + } - name: Mark Success if: success() && steps.deployment.outputs.result diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a4aaba41b2..a043259a66 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -162,7 +162,7 @@ jobs: run: npm ci - name: Download Playwright Blob Reports - uses: actions/download-artifact@v4 + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 continue-on-error: true with: pattern: playwright-blob-* diff --git a/src/__tests__/unit/hooks/useRepoBranches.test.ts b/src/__tests__/unit/hooks/useRepoBranches.test.ts index d968beb861..ff626b596d 100644 --- a/src/__tests__/unit/hooks/useRepoBranches.test.ts +++ b/src/__tests__/unit/hooks/useRepoBranches.test.ts @@ -10,6 +10,14 @@ const MOCK_BRANCHES = [ { name: "dev", sha: "def456" }, ]; +/** Build an array of N mock branches */ +function makeBranches(count: number, offset = 0) { + return Array.from({ length: count }, (_, i) => ({ + name: `branch-${offset + i}`, + sha: `sha-${offset + i}`, + })); +} + describe("useRepoBranches", () => { beforeEach(() => { vi.stubGlobal("fetch", vi.fn()); @@ -41,6 +49,11 @@ describe("useRepoBranches", () => { expect(result.current.branches).toEqual(MOCK_BRANCHES); expect(result.current.error).toBeNull(); + // Single page (< 100 items) — only one fetch call + expect(fetch).toHaveBeenCalledTimes(1); + const calledUrl = vi.mocked(fetch).mock.calls[0][0] as string; + expect(calledUrl).toContain("page=1"); + expect(calledUrl).toContain("per_page=100"); }); it("sets isLoading true during fetch and false after", async () => { @@ -155,4 +168,140 @@ describe("useRepoBranches", () => { await waitFor(() => expect(result.current.branches).toEqual(branches2)); expect(fetch).toHaveBeenCalledTimes(2); }); + + // --- Pagination tests --- + + it("fetches multiple pages and accumulates all branches (100 + 40 = 140)", async () => { + const page1 = makeBranches(100, 0); + const page2 = makeBranches(40, 100); + + vi.mocked(fetch) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ branches: page1 }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ branches: page2 }), + } as Response); + + const { result } = renderHook(() => + useRepoBranches(REPO_URL, WORKSPACE_SLUG), + ); + + act(() => { result.current.fetchBranches(); }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.branches).toHaveLength(140); + expect(result.current.branches).toEqual([...page1, ...page2]); + expect(fetch).toHaveBeenCalledTimes(2); + + const call1Url = vi.mocked(fetch).mock.calls[0][0] as string; + const call2Url = vi.mocked(fetch).mock.calls[1][0] as string; + expect(call1Url).toContain("page=1"); + expect(call2Url).toContain("page=2"); + expect(call1Url).toContain("per_page=100"); + expect(call2Url).toContain("per_page=100"); + }); + + it("continues fetching when page returns exactly 100 items, stops on empty next page", async () => { + const page1 = makeBranches(100, 0); + + vi.mocked(fetch) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ branches: page1 }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ branches: [] }), + } as Response); + + const { result } = renderHook(() => + useRepoBranches(REPO_URL, WORKSPACE_SLUG), + ); + + act(() => { result.current.fetchBranches(); }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.branches).toHaveLength(100); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("stops after one fetch when page 1 returns fewer than 100 items", async () => { + const page1 = makeBranches(42, 0); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ branches: page1 }), + } as Response); + + const { result } = renderHook(() => + useRepoBranches(REPO_URL, WORKSPACE_SLUG), + ); + + act(() => { result.current.fetchBranches(); }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.branches).toHaveLength(42); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("fetches three pages and accumulates all branches (100 + 100 + 50 = 250)", async () => { + const page1 = makeBranches(100, 0); + const page2 = makeBranches(100, 100); + const page3 = makeBranches(50, 200); + + vi.mocked(fetch) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ branches: page1 }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ branches: page2 }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ branches: page3 }), + } as Response); + + const { result } = renderHook(() => + useRepoBranches(REPO_URL, WORKSPACE_SLUG), + ); + + act(() => { result.current.fetchBranches(); }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.branches).toHaveLength(250); + expect(fetch).toHaveBeenCalledTimes(3); + + const call3Url = vi.mocked(fetch).mock.calls[2][0] as string; + expect(call3Url).toContain("page=3"); + }); + + it("does not re-fetch for the same repoUrl + workspaceSlug (cache)", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ branches: MOCK_BRANCHES }), + } as Response); + + const { result } = renderHook(() => + useRepoBranches(REPO_URL, WORKSPACE_SLUG), + ); + + act(() => { result.current.fetchBranches(); }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + // Second call — should be a no-op due to cache + act(() => { result.current.fetchBranches(); }); + + // Still only 1 fetch total + expect(fetch).toHaveBeenCalledTimes(1); + expect(result.current.branches).toEqual(MOCK_BRANCHES); + }); }); diff --git a/src/__tests__/unit/services/notifications.test.ts b/src/__tests__/unit/services/notifications.test.ts index 548f245a01..ae4fc43bd9 100644 --- a/src/__tests__/unit/services/notifications.test.ts +++ b/src/__tests__/unit/services/notifications.test.ts @@ -177,21 +177,21 @@ describe("createAndSendNotification", () => { findFirst.mockResolvedValue(null); create.mockResolvedValue(mockRecord); userFindUnique.mockResolvedValue(userWithPubkey); - update.mockResolvedValue({ ...mockRecord }); await createAndSendNotification(baseInput); - expect(create).toHaveBeenCalledOnce(); - expect(mockedSendDirectMessage).not.toHaveBeenCalled(); - expect(update).toHaveBeenCalledWith( + // sendAfter + message are now written atomically in the create call, so no + // separate update call should happen for deferred notification types. + expect(create).toHaveBeenCalledWith( expect.objectContaining({ - where: { id: "notif-1" }, data: expect.objectContaining({ sendAfter: expect.any(Date), message: baseInput.message, }), }) ); + expect(mockedSendDirectMessage).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); }); }); diff --git a/src/app/api/mock/github/repos/[owner]/[repo]/branches/route.ts b/src/app/api/mock/github/repos/[owner]/[repo]/branches/route.ts index a88603b93b..5476cf499c 100644 --- a/src/app/api/mock/github/repos/[owner]/[repo]/branches/route.ts +++ b/src/app/api/mock/github/repos/[owner]/[repo]/branches/route.ts @@ -30,7 +30,12 @@ export async function GET( repository = mockGitHubState.createRepository(owner, repo); } - const branches = mockGitHubState.getBranches(owner, repo); + const page = Math.max(1, parseInt(request.nextUrl.searchParams.get("page") ?? "1", 10)); + const perPage = Math.max(1, parseInt(request.nextUrl.searchParams.get("per_page") ?? "100", 10)); + + const allBranches = mockGitHubState.getBranches(owner, repo); + const start = (page - 1) * perPage; + const branches = allBranches.slice(start, start + perPage); return NextResponse.json(branches); } catch (error) { console.error("Mock GitHub branches error:", error); diff --git a/src/app/api/mock/seed-branches/route.ts b/src/app/api/mock/seed-branches/route.ts new file mode 100644 index 0000000000..a1a07a62a1 --- /dev/null +++ b/src/app/api/mock/seed-branches/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from "next/server"; +import { mockGitHubState } from "@/lib/mock/github-state"; + +export async function POST(request: NextRequest) { + const { owner, repo, branches } = await request.json(); + if (!mockGitHubState.getRepository(owner, repo)) { + mockGitHubState.createRepository(owner, repo); + } + for (const name of branches) { + mockGitHubState.createBranch(owner, repo, name, false); + } + const all = mockGitHubState.getBranches(owner, repo); + return NextResponse.json({ count: all.length, branches: all.map(b => b.name) }); +} diff --git a/src/hooks/useRepoBranches.ts b/src/hooks/useRepoBranches.ts index 0053efd0a3..ce628c501a 100644 --- a/src/hooks/useRepoBranches.ts +++ b/src/hooks/useRepoBranches.ts @@ -42,16 +42,33 @@ export function useRepoBranches( setError(null); try { - const params = new URLSearchParams({ repoUrl, workspaceSlug }); - const response = await fetch(`/api/github/repository/branches?${params}`); + const perPage = 100; + let page = 1; + const accumulated: RepoBranch[] = []; - if (!response.ok) { - throw new Error(`Failed to fetch branches: ${response.statusText}`); + while (true) { + const params = new URLSearchParams({ + repoUrl, + workspaceSlug, + page: String(page), + per_page: String(perPage), + }); + const response = await fetch(`/api/github/repository/branches?${params}`); + + if (!response.ok) { + throw new Error(`Failed to fetch branches: ${response.statusText}`); + } + + const result = await response.json(); + const pageBranches = (result.branches || result.data || result || []) as RepoBranch[]; + const pageArray = Array.isArray(pageBranches) ? pageBranches : []; + accumulated.push(...pageArray); + + if (pageArray.length < perPage) break; + page++; } - const result = await response.json(); - const fetchedBranches = (result.branches || result.data || result || []) as RepoBranch[]; - setBranches(Array.isArray(fetchedBranches) ? fetchedBranches : []); + setBranches(accumulated); lastFetchedRef.current = cacheKey; } catch (err) { const errorMessage = err instanceof Error ? err.message : "Unknown error"; diff --git a/src/lib/mock/github-state.ts b/src/lib/mock/github-state.ts index 3cf1c5adc6..9789b118ca 100644 --- a/src/lib/mock/github-state.ts +++ b/src/lib/mock/github-state.ts @@ -405,9 +405,6 @@ class MockGitHubStateManager { // Auto-create default branches this.createBranch(owner, name, defaultBranch, true); - if (defaultBranch !== "dev") { - this.createBranch(owner, name, "dev", false); - } return repository; } diff --git a/src/services/notifications.ts b/src/services/notifications.ts index eeb3c6bb4e..50bcfceb95 100644 --- a/src/services/notifications.ts +++ b/src/services/notifications.ts @@ -75,7 +75,12 @@ export async function createAndSendNotification(input: { : null; const dmReady = isDirectMessageConfigured() && !!decryptedPubkey; - // 4. Always insert a row — use SKIPPED when DM is not ready + // 4. For deferred types with DM ready, compute sendAfter upfront so it is + // written atomically in the initial create — avoids a race condition where + // a polling caller reads the record between create and the subsequent update. + const isDeferred = dmReady && DEFERRED_NOTIFICATION_TYPES.has(input.notificationType); + const deferredSendAfter = isDeferred ? new Date(Date.now() + DEFERRED_DELAY_MS) : null; + const record = await db.notificationTrigger.create({ data: { targetUserId: input.targetUserId, @@ -88,6 +93,7 @@ export async function createAndSendNotification(input: { : NotificationTriggerStatus.SKIPPED, notificationMethod: NotificationMethod.SPHINX, notificationTimestamps: [], + ...(isDeferred && { sendAfter: deferredSendAfter, message: input.message }), }, }); @@ -101,15 +107,10 @@ export async function createAndSendNotification(input: { return; } - // 6. Deferred types: store sendAfter + message, return without sending - if (DEFERRED_NOTIFICATION_TYPES.has(input.notificationType)) { - const sendAfter = new Date(Date.now() + DEFERRED_DELAY_MS); - await db.notificationTrigger.update({ - where: { id: record.id }, - data: { sendAfter, message: input.message }, - }); + // 6. Deferred types: sendAfter + message already set in create — just log and return + if (isDeferred) { logger.info( - `[Notifications] Deferred ${input.notificationType} — will dispatch after ${sendAfter.toISOString()}`, + `[Notifications] Deferred ${input.notificationType} — will dispatch after ${deferredSendAfter!.toISOString()}`, "NOTIFICATIONS", { recordId: record.id, targetUserId: input.targetUserId, taskId, featureId } );