Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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 |
Expand All @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
121 changes: 120 additions & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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);

Expand All @@ -38,6 +42,7 @@ export async function activate(context: vscode.ExtensionContext) {

context.subscriptions.push(
statusBarItem,
logsOutputChannel,
workflowsTreeView,
vscode.window.registerTreeDataProvider('xcodecloudWorkflowDetails', workflowDetailsProvider),

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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]}`;
}
10 changes: 10 additions & 0 deletions src/lib/appstoreconnect/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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}`);
Expand Down