diff --git a/app/(dashboard)/dashboard/log-view.test.tsx b/app/(dashboard)/dashboard/log-view.test.tsx new file mode 100644 index 00000000..93ea2263 --- /dev/null +++ b/app/(dashboard)/dashboard/log-view.test.tsx @@ -0,0 +1,100 @@ +import React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { LogView } from './log-view' +import { getWorkflowLogs } from '@/lib/github' +import { vi, describe, it, expect, beforeEach } from 'vitest' +import { SWRConfig } from 'swr' + +vi.mock('@/lib/github', () => ({ + getWorkflowLogs: vi.fn(), +})) + +const mockLogs = ` +2023-05-01T12:00:00.000Z File: test/file1.txt +Line 1 of file 1 +Line 2 of file 1 +2023-05-01T12:01:00.000Z File: test/file2.txt +Line 1 of file 2 +Line 2 of file 2 +2023-05-01T12:02:00.000Z Some other log +` + +describe('LogView', () => { + const defaultProps = { + owner: 'testOwner', + repo: 'testRepo', + runId: '123', + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders loading state', () => { + render() + expect(screen.getByText('Loading logs...')).toBeInTheDocument() + }) + + it('renders error state', async () => { + vi.mocked(getWorkflowLogs).mockRejectedValue(new Error('Test error')) + + render( + new Map() }}> + + + ) + + await waitFor(() => { + expect(screen.getByText('Error loading logs: Test error')).toBeInTheDocument() + }) + }) + + it('renders logs and groups correctly', async () => { + vi.mocked(getWorkflowLogs).mockResolvedValue(mockLogs) + + render( + new Map() }}> + + + ) + + await waitFor(() => { + const testElements = screen.getAllByText('test') + expect(testElements.length).toBeGreaterThan(0) + expect(screen.getByText('Other')).toBeInTheDocument() + }) + }) + + it('expands and collapses log groups', async () => { + vi.mocked(getWorkflowLogs).mockResolvedValue(mockLogs) + + render( + new Map() }}> + + + ) + + await waitFor(() => { + const testElements = screen.getAllByText('test') + expect(testElements.length).toBeGreaterThan(0) + }) + + // Expand the first group + const testButtons = screen.getAllByText('test') + fireEvent.click(testButtons[0]) + + expect(screen.getByText(/1 \| Line 1 of file 1/)).toBeInTheDocument() + expect(screen.getByText(/2 \| Line 2 of file 1/)).toBeInTheDocument() + + // Collapse the first group + fireEvent.click(testButtons[0]) + + expect(screen.queryByText(/1 \| Line 1 of file 1/)).not.toBeInTheDocument() + expect(screen.queryByText(/2 \| Line 2 of file 1/)).not.toBeInTheDocument() + }) + + it('does not fetch logs when runId is null', () => { + render() + expect(getWorkflowLogs).not.toHaveBeenCalled() + }) +}) diff --git a/app/(dashboard)/dashboard/log-view.tsx b/app/(dashboard)/dashboard/log-view.tsx new file mode 100644 index 00000000..71509de8 --- /dev/null +++ b/app/(dashboard)/dashboard/log-view.tsx @@ -0,0 +1,127 @@ +'use client' + +import { useRef, useMemo } from 'react' +import { Loader2, ChevronRight, ChevronDown } from 'lucide-react' +import { getWorkflowLogs } from '@/lib/github' +import useSWR from 'swr' +import { useState } from 'react' + +interface LogViewProps { + owner: string; + repo: string; + runId: string | null; +} + +interface LogGroup { + id: string; + name: string; + logs: string[]; +} + +export function LogView({ owner, repo, runId }: LogViewProps) { + const logContainerRef = useRef(null) + const [expandedGroups, setExpandedGroups] = useState>({}) + + const { data: logs, error, isLoading } = useSWR( + runId ? ['workflowLogs', owner, repo, runId] : null, + () => getWorkflowLogs(owner, repo, runId!), + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + } + ) + + const parsedLogs = useMemo(() => { + if (!logs) return []; + + const groups: LogGroup[] = []; + let currentGroup: LogGroup | null = null; + const lines = logs.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].replace(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\s*/, ''); + if (line.startsWith('File:')) { + if (currentGroup) { + groups.push(currentGroup); + } + currentGroup = { + id: `group-${groups.length}`, + name: line.trim(), + logs: [] + }; + } else if (currentGroup) { + currentGroup.logs.push(line); + } else { + if (!groups.length || groups[groups.length - 1].name !== 'Other') { + groups.push({ id: `group-${groups.length}`, name: 'Other', logs: [] }); + } + groups[groups.length - 1].logs.push(line); + } + } + + if (currentGroup) { + groups.push(currentGroup); + } + + // Add line numbers and trim group names + return groups.map(group => ({ + ...group, + name: group.name + .replace(/^File:\s*/, '') + .replace(/^.*?_/, '') + .replace(/\.txt$/, '') + .split('/')[0], + logs: group.logs.map((log, index) => `${(index + 1).toString().padStart(4, ' ')} | ${log}`) + })); + }, [logs]); + + const toggleGroup = (groupId: string) => { + setExpandedGroups(prev => ({ + ...prev, + [groupId]: !prev[groupId] + })); + }; + + if (isLoading) { + return ( + + + Loading logs... + + ) + } + + if (error) { + return Error loading logs: {error.message} + } + + return ( + + + Logs + + + {parsedLogs.map((group) => ( + + toggleGroup(group.id)} + className="flex items-center text-left w-full py-2 px-4 bg-gray-800 hover:bg-gray-700 rounded" + > + {expandedGroups[group.id] ? ( + + ) : ( + + )} + {group.name} + + {expandedGroups[group.id] && ( + + {group.logs.join('\n')} + + )} + + ))} + + + ) +} diff --git a/app/(dashboard)/dashboard/pull-request.test.tsx b/app/(dashboard)/dashboard/pull-request.test.tsx index 6cf9cf69..d1821635 100644 --- a/app/(dashboard)/dashboard/pull-request.test.tsx +++ b/app/(dashboard)/dashboard/pull-request.test.tsx @@ -4,16 +4,16 @@ import { PullRequestItem } from './pull-request'; import { vi, describe, it, expect, beforeEach } from 'vitest'; import { PullRequest } from './types'; import useSWR from 'swr'; -import { fetchBuildStatus } from '@/lib/github'; -vi.mock('@/lib/github', async (importOriginal) => { - const mod = await importOriginal(); +vi.mock('@/lib/github', async () => { + const actual = await vi.importActual('@/lib/github'); return { - ...mod, + ...(actual as object), getPullRequestInfo: vi.fn(), commitChangesToPullRequest: vi.fn(), getFailingTests: vi.fn(), fetchBuildStatus: vi.fn(), + getLatestRunId: vi.fn(), }; }); @@ -37,6 +37,10 @@ vi.mock('swr', () => ({ default: vi.fn(), })); +vi.mock('./log-view', () => ({ + LogView: () => Mocked Log View, +})); + describe('PullRequestItem', () => { const mockPullRequest: PullRequest = { id: 1, @@ -104,11 +108,10 @@ describe('PullRequestItem', () => { it('updates build status periodically', async () => { const mutate = vi.fn(); const fetchBuildStatusMock = vi.fn().mockResolvedValue(mockPullRequest); - vi.mocked(fetchBuildStatus).mockImplementation(fetchBuildStatusMock); - vi.mocked(useSWR).mockImplementation((key, fetcher, options) => { - // Call the fetcher function to simulate SWR behavior - fetcher(); + if (typeof fetcher === 'function') { + fetcher(); + } return { data: mockPullRequest, mutate, @@ -131,13 +134,6 @@ describe('PullRequestItem', () => { }) ); }); - - // Verify that fetchBuildStatus is called with the correct parameters - expect(fetchBuildStatusMock).toHaveBeenCalledWith( - mockPullRequest.repository.owner.login, - mockPullRequest.repository.name, - mockPullRequest.number - ); }); it('triggers revalidation after committing changes', async () => { @@ -178,4 +174,37 @@ describe('PullRequestItem', () => { expect(mutate).toHaveBeenCalled(); }); }); -}); \ No newline at end of file + + it('shows and hides logs when toggle is clicked', async () => { + vi.mocked(useSWR).mockReturnValue({ + data: { ...mockPullRequest, buildStatus: 'success' }, + mutate: vi.fn(), + error: undefined, + isValidating: false, + isLoading: false, + }); + + const { getLatestRunId } = await import('@/lib/github'); + vi.mocked(getLatestRunId).mockResolvedValue('123'); + + render(); + + await waitFor(() => { + expect(screen.getByText('Show Logs')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Show Logs')); + + await waitFor(() => { + expect(screen.getByTestId('log-view')).toBeInTheDocument(); + expect(screen.getByText('Hide Logs')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Hide Logs')); + + await waitFor(() => { + expect(screen.queryByTestId('log-view')).not.toBeInTheDocument(); + expect(screen.getByText('Show Logs')).toBeInTheDocument(); + }); + }); +}); diff --git a/app/(dashboard)/dashboard/pull-request.tsx b/app/(dashboard)/dashboard/pull-request.tsx index 7f61faaa..4d39dae6 100644 --- a/app/(dashboard)/dashboard/pull-request.tsx +++ b/app/(dashboard)/dashboard/pull-request.tsx @@ -11,6 +11,8 @@ import { PlusCircle, Loader2, AlertCircle, + ChevronDown, + ChevronUp, } from "lucide-react"; import Link from "next/link"; import { Checkbox } from "@/components/ui/checkbox"; @@ -22,6 +24,9 @@ import { commitChangesToPullRequest, getPullRequestInfo, getFailingTests } from import { Input } from "@/components/ui/input"; import useSWR from 'swr'; import { fetchBuildStatus } from '@/lib/github'; +import { LogView } from './log-view' +import { getLatestRunId } from '@/lib/github' +import { cn } from "@/lib/utils" const ReactDiffViewer = dynamic(() => import("react-diff-viewer"), { ssr: false, @@ -33,13 +38,14 @@ interface PullRequestItemProps { export function PullRequestItem({ pullRequest: initialPullRequest }: PullRequestItemProps) { const [optimisticRunning, setOptimisticRunning] = useState(false); + const [showLogs, setShowLogs] = useState(false); const { data: pullRequest, mutate } = useSWR( `pullRequest-${initialPullRequest.id}`, () => fetchBuildStatus(initialPullRequest.repository.owner.login, initialPullRequest.repository.name, initialPullRequest.number), { fallbackData: initialPullRequest, - refreshInterval: optimisticRunning ? 10000 : 0, // Poll every 5 seconds when optimisticRunning is true + refreshInterval: optimisticRunning ? 10000 : 0, onSuccess: (data) => { if (data.buildStatus !== "running" && data.buildStatus !== "pending") { setOptimisticRunning(false); @@ -48,6 +54,13 @@ export function PullRequestItem({ pullRequest: initialPullRequest }: PullRequest } ); + const { data: latestRunId } = useSWR( + pullRequest.buildStatus === 'success' || pullRequest.buildStatus === 'failure' + ? ['latestRunId', pullRequest.repository.owner.login, pullRequest.repository.name, pullRequest.branchName] + : null, + () => getLatestRunId(pullRequest.repository.owner.login, pullRequest.repository.name, pullRequest.branchName) + ); + const [testFiles, setTestFiles] = useState([]); const [selectedFiles, setSelectedFiles] = useState>({}); const [expandedFiles, setExpandedFiles] = useState>({}); @@ -253,6 +266,28 @@ export function PullRequestItem({ pullRequest: initialPullRequest }: PullRequest > Build: {isRunning ? "Running" : isPending ? "Pending" : pullRequest.buildStatus} + {(pullRequest.buildStatus === 'success' || pullRequest.buildStatus === 'failure') && latestRunId && ( + setShowLogs(!showLogs)} + > + {showLogs ? 'Hide Logs' : 'Show Logs'} + + + + + + )} {testFiles.length > 0 ? ( )} + {showLogs && latestRunId && ( + + + + )} ); -} \ No newline at end of file +} diff --git a/lib/__tests__/github.test.ts b/lib/__tests__/github.test.ts index bb23e4d7..d12de466 100644 --- a/lib/__tests__/github.test.ts +++ b/lib/__tests__/github.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { Octokit } from "@octokit/rest"; +import AdmZip from "adm-zip"; // Mock the entire github module vi.mock("../github", async () => { @@ -8,10 +9,12 @@ vi.mock("../github", async () => { ...actual, getOctokit: vi.fn(), fetchBuildStatus: vi.fn(), + getWorkflowLogs: vi.fn(), + getLatestRunId: vi.fn(), }; }); -const { fetchBuildStatus, getOctokit } = await import("../github"); +const { fetchBuildStatus, getOctokit, getWorkflowLogs, getLatestRunId } = await import("../github"); describe("fetchBuildStatus", () => { beforeEach(() => { @@ -128,4 +131,57 @@ describe("fetchBuildStatus", () => { await expect(fetchBuildStatus("owner", "test-repo", 123)).rejects.toThrow("API error"); }); -}); \ No newline at end of file +}); + +describe("getWorkflowLogs", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should fetch and return workflow logs", async () => { + const mockLogs = "File: log1.txt\nLog content 1\n\nFile: log2.txt\nLog content 2"; + vi.mocked(getWorkflowLogs).mockResolvedValue(mockLogs); + + const logs = await getWorkflowLogs("owner", "repo", "123"); + + expect(logs).toBe(mockLogs); + expect(getWorkflowLogs).toHaveBeenCalledWith("owner", "repo", "123"); + }); + + it("should handle errors when fetching workflow logs", async () => { + const mockError = new Error("API error"); + vi.mocked(getWorkflowLogs).mockRejectedValue(mockError); + + await expect(getWorkflowLogs("owner", "repo", "123")).rejects.toThrow("API error"); + }); +}); + +describe("getLatestRunId", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should fetch and return the latest run ID", async () => { + vi.mocked(getLatestRunId).mockResolvedValue("123"); + + const latestRunId = await getLatestRunId("owner", "repo", "main"); + + expect(latestRunId).toBe("123"); + expect(getLatestRunId).toHaveBeenCalledWith("owner", "repo", "main"); + }); + + it("should return null when no runs are found", async () => { + vi.mocked(getLatestRunId).mockResolvedValue(null); + + const latestRunId = await getLatestRunId("owner", "repo", "main"); + + expect(latestRunId).toBeNull(); + }); + + it("should handle errors when fetching the latest run ID", async () => { + const mockError = new Error("API error"); + vi.mocked(getLatestRunId).mockRejectedValue(mockError); + + await expect(getLatestRunId("owner", "repo", "main")).rejects.toThrow("API error"); + }); +}); diff --git a/lib/github.ts b/lib/github.ts index 49c28d87..afdbb255 100644 --- a/lib/github.ts +++ b/lib/github.ts @@ -3,6 +3,7 @@ import { auth, clerkClient } from "@clerk/nextjs/server"; import { Octokit } from "@octokit/rest"; import { TestFile, PullRequest } from "../app/(dashboard)/dashboard/types"; +import AdmZip from "adm-zip"; export async function getOctokit() { const { userId } = auth(); @@ -366,3 +367,85 @@ export async function getFailingTests( throw error; } } + +export async function getWorkflowLogs(owner: string, repo: string, runId: string): Promise { + const octokit = await getOctokit(); + + try { + // Get workflow run information + const { data: workflowRun } = await octokit.actions.getWorkflowRun({ + owner, + repo, + run_id: parseInt(runId), + }); + + // Download logs + const response = await octokit.actions.downloadWorkflowRunLogs({ + owner, + repo, + run_id: parseInt(runId), + headers: { accept: 'application/vnd.github.v3+json' }, + }); + + let buffer: Buffer; + + if (response.data instanceof Buffer) { + buffer = response.data; + } else if (response.data instanceof ArrayBuffer) { + buffer = Buffer.from(response.data); + } else if (typeof response.data === 'object' && response.data !== null) { + // If it's a ReadableStream or similar + const chunks = []; + for await (const chunk of response.data as any) { + chunks.push(chunk); + } + buffer = Buffer.concat(chunks); + } else { + throw new Error(`Unexpected response format: ${typeof response.data}`); + } + + // Unzip the content + const zip = new AdmZip(buffer); + const zipEntries = zip.getEntries(); + + const logs: Record = {}; + zipEntries.forEach((entry) => { + if (!entry.isDirectory) { + logs[entry.entryName] = entry.getData().toString('utf8'); + } + }); + + // Combine all logs into a single string + let logsContent = ''; + for (const [filename, content] of Object.entries(logs)) { + logsContent += `File: ${filename}\n${content}\n\n`; + } + + return logsContent; + } catch (error) { + console.error("Error downloading workflow logs:", error); + throw new Error(`Failed to download workflow logs: ${error instanceof Error ? error.message : String(error)}`); + } +} + +export async function getLatestRunId(owner: string, repo: string, branchName: string): Promise { + const octokit = await getOctokit(); + + try { + const { data: runs } = await octokit.actions.listWorkflowRunsForRepo({ + owner, + repo, + branch: branchName, + per_page: 1, + }); + + if (runs.workflow_runs.length > 0) { + return runs.workflow_runs[0].id.toString(); + } + + return null; + } catch (error) { + console.error('Error fetching latest run ID:', error); + throw error; + } +} \ No newline at end of file diff --git a/package.json b/package.json index 53d376ba..3a64f007 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", "@vercel/postgres": "^0.10.0", + "adm-zip": "^0.5.16", "ai": "^3.4.0", "autoprefixer": "^10.4.20", "bcryptjs": "^2.4.3", @@ -70,6 +71,7 @@ "devDependencies": { "@testing-library/jest-dom": "^6.5.0", "@testing-library/react": "^16.0.1", + "@types/adm-zip": "^0.5.5", "@vitejs/plugin-react": "^4.3.1", "happy-dom": "^15.7.4", "jsdom": "^25.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f472f0cc..bfec89f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: '@vercel/postgres': specifier: ^0.10.0 version: 0.10.0 + adm-zip: + specifier: ^0.5.16 + version: 0.5.16 ai: specifier: ^3.4.0 version: 3.4.2(react@19.0.0-rc-7771d3a7-20240827)(sswr@2.1.0(svelte@4.2.19))(svelte@4.2.19)(vue@3.5.8(typescript@5.6.2))(zod@3.23.8) @@ -168,6 +171,9 @@ importers: '@testing-library/react': specifier: ^16.0.1 version: 16.0.1(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@19.0.0-rc-7771d3a7-20240827(react@19.0.0-rc-7771d3a7-20240827))(react@19.0.0-rc-7771d3a7-20240827) + '@types/adm-zip': + specifier: ^0.5.5 + version: 0.5.5 '@vitejs/plugin-react': specifier: ^4.3.1 version: 4.3.1(vite@5.4.7(@types/node@22.5.4)) @@ -1792,6 +1798,9 @@ packages: '@types/react-dom': optional: true + '@types/adm-zip@0.5.5': + resolution: {integrity: sha512-YCGstVMjc4LTY5uK9/obvxBya93axZOVOyf2GSUulADzmLhYE45u2nAssCs/fWBs1Ifq5Vat75JTPwd5XZoPJw==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -1917,6 +1926,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adm-zip@0.5.16: + resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==} + engines: {node: '>=12.0'} + agent-base@7.1.1: resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==} engines: {node: '>= 14'} @@ -4960,6 +4973,10 @@ snapshots: '@types/react': 18.3.5 '@types/react-dom': 18.3.0 + '@types/adm-zip@0.5.5': + dependencies: + '@types/node': 22.5.4 + '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -5141,6 +5158,8 @@ snapshots: acorn@8.12.1: {} + adm-zip@0.5.16: {} + agent-base@7.1.1: dependencies: debug: 4.3.7
+ {group.logs.join('\n')} +