diff --git a/README.md b/README.md index 38ecc21..eb5857c 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ A Visual Studio Code extension to **manage, monitor, and trigger Xcode Cloud bui ### šŸ“Š Build Actions & Logs - **Action Inspection**: Inspect individual build steps (actions) and their specific status. - **Timing & Progress**: View detailed start times, durations, and execution progress. +- **Build Logs Viewer**: View detailed build logs for any completed build action directly in VS Code, similar to GitHub Actions or CircleCI. +- **Real-time Log Access**: Download and display logs from Xcode Cloud artifacts with automatic file size formatting. - **Diagnostic Reporting**: Access detailed issue reports and logs directly from build actions. ### šŸŽ›ļø Quick Actions @@ -61,8 +63,9 @@ Your credentials are stored securely using VS Code's **Secret Storage** (OS keyc 1. Expand a workflow to see its **Build Runs**. 2. Expand a build run to see its **Build Actions**. -3. Expand any finished action to see **Issues** (Errors, Warnings, Analyzer results). -4. For **TEST** actions, expand them further to see individual **Test Results** including failure details. +3. Right-click any completed build action and select **"View Build Logs"** to see the full logs in the Output panel. +4. Expand any finished action to see **Issues** (Errors, Warnings, Analyzer results). +5. For **TEST** actions, expand them further to see individual **Test Results** including failure details. ## āš™ļø Commands @@ -74,6 +77,7 @@ Your credentials are stored securely using VS Code's **Secret Storage** (OS keyc | `Xcode Cloud: Delete Workflow` | Permanently remove a workflow | | `Xcode Cloud: Trigger Build` | Start a new build with ref selection | | `Xcode Cloud: Cancel Build` | Stop an active build run | +| `Xcode Cloud: View Build Logs` | Display detailed logs for a build action | | `Xcode Cloud: View Workflow Details` | Show metadata in the details panel | | `Xcode Cloud: Toggle Sort Order` | Switch between ASC/DESC build history | | `Xcode Cloud: Open in App Store Connect` | Open the dashboard in your browser | @@ -92,6 +96,8 @@ This extension utilizes the [App Store Connect API v1](https://developer.apple.c - `GET /v1/ciWorkflows` - Workflow CRUD operations. - `GET /v1/ciBuildRuns` - Build history and tracking. - `GET /v1/ciBuildActions` - Action/step monitoring. +- `GET /v1/ciBuildActions/{id}/artifacts` - Fetch build artifacts and logs. +- `GET /v1/ciArtifacts/{id}` - Get artifact download URLs. - `GET /v1/ciBuildActions/{id}/testResults` - Individual test result analysis. - `GET /v1/ciBuildActions/{id}/issues` - Xcode Cloud build issues reporting. - `GET /v1/scmRepositories` & `/v1/scmGitReferences` - Repository and branch management. diff --git a/package.json b/package.json index 44d3432..02a2821 100644 --- a/package.json +++ b/package.json @@ -174,6 +174,16 @@ "when": "view == xcodecloudWorkflowRuns && viewItem == buildRunActive", "group": "1_actions@1" }, + { + "command": "xcodecloud.viewBuildLogs", + "when": "view == xcodecloudWorkflowRuns && viewItem == buildAction", + "group": "1_actions@2" + }, + { + "command": "xcodecloud.viewBuildLogs", + "when": "view == xcodecloudWorkflowRuns && viewItem == buildActionTest", + "group": "1_actions@2" + }, { "command": "xcodecloud.openInBrowser", "when": "view == xcodecloudWorkflowRuns", diff --git a/src/extension.ts b/src/extension.ts index 39bf0bb..2741eb8 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,7 +2,7 @@ import * as vscode from 'vscode'; import { AppStoreConnectClient } from './lib/appstoreconnect/client'; import { BuildMonitor } from './lib/buildMonitor'; import { ensureCredentials } from './lib/credentials'; -import { BuildRunNode, UnifiedWorkflowTreeDataProvider, WorkflowNode } from './lib/views/UnifiedWorkflowTree'; +import { BuildActionNode, BuildRunNode, UnifiedWorkflowTreeDataProvider, WorkflowNode } from './lib/views/UnifiedWorkflowTree'; import { WorkflowDetailsTreeDataProvider } from './lib/views/WorkflowDetailsTree'; import { WorkflowEditorPanel } from './lib/views/WorkflowEditorPanel'; @@ -11,10 +11,14 @@ let unifiedProvider: UnifiedWorkflowTreeDataProvider | null = null; let workflowDetailsProvider: WorkflowDetailsTreeDataProvider | null = null; let buildMonitor: BuildMonitor | null = null; let statusBarItem: vscode.StatusBarItem | null = null; +let logsOutputChannel: vscode.OutputChannel | null = null; export async function activate(context: vscode.ExtensionContext) { client = new AppStoreConnectClient(context.secrets); + // Create output channel for build logs + logsOutputChannel = vscode.window.createOutputChannel('Xcode Cloud Logs'); + unifiedProvider = new UnifiedWorkflowTreeDataProvider(client); workflowDetailsProvider = new WorkflowDetailsTreeDataProvider(client); @@ -38,6 +42,7 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push( statusBarItem, + logsOutputChannel, workflowsTreeView, vscode.window.registerTreeDataProvider('xcodecloudWorkflowDetails', workflowDetailsProvider), @@ -113,6 +118,111 @@ export async function activate(context: vscode.ExtensionContext) { } }), + // View build logs + vscode.commands.registerCommand('xcodecloud.viewBuildLogs', async (buildActionNode?: BuildActionNode) => { + if (!client || !logsOutputChannel) { return; } + + try { + let actionId: string | undefined; + let actionName: string | undefined; + + if (buildActionNode?.actionId) { + actionId = buildActionNode.actionId; + actionName = buildActionNode.actionName; + } else { + // If no node provided, let user select from tree + vscode.window.showWarningMessage('Select a build action to view its logs.'); + return; + } + + // Show output channel + logsOutputChannel.clear(); + logsOutputChannel.show(true); + logsOutputChannel.appendLine(`Fetching logs for: ${actionName || 'Build Action'}`); + logsOutputChannel.appendLine(`Action ID: ${actionId}`); + logsOutputChannel.appendLine('='.repeat(80)); + logsOutputChannel.appendLine(''); + + // Fetch artifacts for this build action + const artifactsResponse = await client.getBuildActionArtifacts(actionId); + const artifacts = artifactsResponse?.data || []; + + if (artifacts.length === 0) { + logsOutputChannel.appendLine('No artifacts (logs) found for this build action.'); + logsOutputChannel.appendLine(''); + logsOutputChannel.appendLine('Note: Logs may not be available for actions that are:'); + logsOutputChannel.appendLine(' - Still running or pending'); + logsOutputChannel.appendLine(' - Skipped or canceled'); + logsOutputChannel.appendLine(' - Too old (logs are retained for a limited time)'); + return; + } + + // Find log artifacts (typically named like "build-log", "test-log", etc.) + const logArtifacts = artifacts.filter((artifact: any) => { + const fileType = artifact?.attributes?.fileType || ''; + const name = artifact?.attributes?.fileName || ''; + return fileType.toLowerCase() === 'log' || name.toLowerCase().includes('log'); + }); + + if (logArtifacts.length === 0) { + logsOutputChannel.appendLine(`Found ${artifacts.length} artifact(s), but none are log files.`); + logsOutputChannel.appendLine(''); + logsOutputChannel.appendLine('Available artifacts:'); + for (const artifact of artifacts) { + const name = artifact?.attributes?.fileName || 'Unknown'; + const type = artifact?.attributes?.fileType || 'Unknown'; + logsOutputChannel.appendLine(` - ${name} (${type})`); + } + return; + } + + // Download and display each log artifact + for (const artifact of logArtifacts) { + const artifactId = artifact.id; + const fileName = artifact?.attributes?.fileName || 'log'; + const sizeBytes = artifact?.attributes?.fileSizeBytes || 0; + + if (!logsOutputChannel) { return; } + logsOutputChannel.appendLine(`\nšŸ“„ ${fileName} (${formatFileSize(sizeBytes)})`); + logsOutputChannel.appendLine('-'.repeat(80)); + + try { + // Get download URL + const artifactDetails = await client.getArtifact(artifactId); + const downloadUrl = artifactDetails?.data?.attributes?.downloadUrl; + + if (!downloadUrl) { + logsOutputChannel.appendLine('Error: Download URL not available for this artifact.'); + continue; + } + + // Download log content + logsOutputChannel.appendLine('Downloading...'); + const logContent = await client.downloadArtifactContent(downloadUrl); + + // Display log content + logsOutputChannel.appendLine(''); + logsOutputChannel.appendLine(logContent); + logsOutputChannel.appendLine(''); + logsOutputChannel.appendLine('-'.repeat(80)); + + } catch (err: any) { + logsOutputChannel.appendLine(`Error downloading ${fileName}: ${err?.message || String(err)}`); + } + } + + logsOutputChannel.appendLine(''); + logsOutputChannel.appendLine('='.repeat(80)); + logsOutputChannel.appendLine('āœ… Logs loaded successfully'); + + } catch (err: any) { + logsOutputChannel?.appendLine(''); + logsOutputChannel?.appendLine('āŒ Error fetching logs:'); + logsOutputChannel?.appendLine(err?.message || String(err)); + vscode.window.showErrorMessage(`Failed to fetch logs: ${err?.message || String(err)}`); + } + }), + // View workflow details vscode.commands.registerCommand('xcodecloud.viewWorkflowDetails', async (workflowNode?: WorkflowNode) => { if (!workflowNode?.workflowId) { @@ -290,4 +400,13 @@ async function updateStatusBar() { export function deactivate() { buildMonitor?.dispose(); statusBarItem?.dispose(); + logsOutputChannel?.dispose(); +} + +function formatFileSize(bytes: number): string { + if (bytes === 0) { return '0 B'; } + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; } \ No newline at end of file diff --git a/src/lib/appstoreconnect/client.ts b/src/lib/appstoreconnect/client.ts index 0e8bfe4..89ca4e3 100644 --- a/src/lib/appstoreconnect/client.ts +++ b/src/lib/appstoreconnect/client.ts @@ -231,6 +231,16 @@ export class AppStoreConnectClient { return this.get(`/ciArtifacts/${artifactId}`); } + // Xcode Cloud: download artifact content (logs) from a download URL + async downloadArtifactContent(downloadUrl: string): Promise { + const res = await request(downloadUrl, { method: 'GET' }); + if (res.statusCode >= 400) { + const body = await res.body.text(); + throw new Error(`Failed to download artifact (${res.statusCode}): ${body}`); + } + return await res.body.text(); + } + // Get a single build run async getBuildRun(buildRunId: string) { return this.get(`/ciBuildRuns/${buildRunId}`);