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
12 changes: 11 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,11 @@
"when": "view == xcodecloudWorkflowRuns && viewItem == buildRunActive",
"group": "1_actions@1"
},
{
"command": "xcodecloud.viewBuildLogs",
"when": "view == xcodecloudWorkflowRuns && (viewItem == buildRunActive || viewItem == buildRunComplete)",
"group": "1_actions@2"
},
{
"command": "xcodecloud.openInBrowser",
"when": "view == xcodecloudWorkflowRuns",
Expand All @@ -193,6 +198,11 @@
"command": "xcodecloud.deleteWorkflow",
"when": "view == xcodecloudWorkflowRuns && viewItem == workflow",
"group": "3_danger@1"
},
{
"command": "xcodecloud.viewBuildLogs",
"when": "view == xcodecloudWorkflowRuns && (viewItem == buildAction || viewItem == buildActionTest)",
"group": "inline"
}
]
}
Expand Down Expand Up @@ -229,4 +239,4 @@
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.1"
}
}
}
115 changes: 113 additions & 2 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ 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 { filterLogArtifacts } from './lib/views/BuildLogsPanel';
import { WorkflowDetailsTreeDataProvider } from './lib/views/WorkflowDetailsTree';
import { WorkflowEditorPanel } from './lib/views/WorkflowEditorPanel';

Expand All @@ -11,12 +12,20 @@ let unifiedProvider: UnifiedWorkflowTreeDataProvider | null = null;
let workflowDetailsProvider: WorkflowDetailsTreeDataProvider | null = null;
let buildMonitor: BuildMonitor | null = null;
let statusBarItem: vscode.StatusBarItem | null = null;
let logChannel: vscode.OutputChannel | null = null;

export async function activate(context: vscode.ExtensionContext) {
client = new AppStoreConnectClient(context.secrets);

unifiedProvider = new UnifiedWorkflowTreeDataProvider(client);
workflowDetailsProvider = new WorkflowDetailsTreeDataProvider(client);
const ensureLogChannel = () => {
if (!logChannel) {
logChannel = vscode.window.createOutputChannel('Xcode Cloud Logs');
context.subscriptions.push(logChannel);
}
return logChannel;
};

// Initialize build monitor for notifications
buildMonitor = new BuildMonitor(client, () => {
Expand Down Expand Up @@ -113,6 +122,108 @@ export async function activate(context: vscode.ExtensionContext) {
}
}),

// View build logs (artifacts)
vscode.commands.registerCommand('xcodecloud.viewBuildLogs', async (node?: BuildRunNode | BuildActionNode) => {
if (!client) { return; }
const activeClient = client;

const pickBuildRun = async (): Promise<{ id: string; label: string } | undefined> => {
const recent = await activeClient.listAllRecentBuilds({ limit: 20 });
const items = (recent?.data || []).map((run: any) => {
const attrs = run?.attributes || {};
const label = `#${attrs.number || run.id.slice(-6)}`;
const status = attrs.executionProgress || attrs.completionStatus || '';
return { label, description: status, id: run.id };
});
if (items.length === 0) {
vscode.window.showWarningMessage('No recent builds found.');
return undefined;
}
return vscode.window.showQuickPick(items, { placeHolder: 'Select a build run' }) as any;
};

const pickAction = async (buildRunId: string, preselectName?: string): Promise<{ id: string; label: string } | undefined> => {
const resp = await activeClient.getBuildActions(buildRunId);
const actions = resp?.data || [];
if (actions.length === 0) {
vscode.window.showWarningMessage('No build actions available yet for this run.');
return undefined;
}
const items = actions.map((action: any) => {
const attrs = action?.attributes || {};
const label = attrs.name || 'Unknown Action';
const description = attrs.executionProgress || attrs.completionStatus || '';
return { label, description, id: action.id };
});
if (preselectName) {
const found = items.find((i: { label: string }) => i.label === preselectName);
if (found) { return found; }
}
return vscode.window.showQuickPick(items, { placeHolder: 'Select a build action' }) as any;
};

const pickLogArtifact = async (actionId: string) => {
const artifactsResp = await activeClient.getBuildActionArtifacts(actionId);
const candidates = filterLogArtifacts(artifactsResp?.data || []);
if (candidates.length === 0) {
vscode.window.showWarningMessage('No log artifacts are available for this action yet.');
return undefined;
}
if (candidates.length === 1) { return candidates[0]; }

const items = candidates.map((artifact: any) => ({
label: artifact?.attributes?.fileName || 'Log artifact',
description: artifact?.attributes?.fileType || '',
id: artifact.id
}));
const choice = await vscode.window.showQuickPick(items, { placeHolder: 'Select a log artifact' }) as any;
if (!choice) { return undefined; }
return candidates.find((c: any) => c.id === choice?.id) || candidates[0];
};

try {
let buildRunId = node && 'buildRunId' in node ? node.buildRunId : undefined;
let buildLabel = node && 'runNumber' in node ? `#${node.runNumber}` : undefined;

if (!buildRunId) {
const picked = await pickBuildRun();
if (!picked) { return; }
buildRunId = picked.id;
buildLabel = picked.label;
}

let action: { id: string; label: string } | undefined;
if (node && 'actionId' in node) {
action = { id: node.actionId, label: node.actionName };
} else if (buildRunId) {
action = await pickAction(buildRunId);
}

if (!action || !buildRunId) { return; }

await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: 'Fetching Xcode Cloud build logs...'
}, async () => {
const artifact = await pickLogArtifact(action!.id);
if (!artifact) { return; }

const downloaded = await activeClient.downloadArtifactContent(artifact.id);
const channel = ensureLogChannel();
channel.clear();
channel.appendLine(`Build: ${buildLabel || buildRunId}`);
channel.appendLine(`Action: ${action!.label}`);
channel.appendLine(`Artifact: ${downloaded.fileName || artifact?.attributes?.fileName || artifact.id}`);
channel.appendLine(`Fetched: ${new Date().toLocaleString()}`);
channel.appendLine('------------------------------');
channel.append(downloaded.content || '');
channel.show(true);
});
} catch (err: any) {
vscode.window.showErrorMessage(`Failed to load build 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 +401,4 @@ async function updateStatusBar() {
export function deactivate() {
buildMonitor?.dispose();
statusBarItem?.dispose();
}
}
24 changes: 23 additions & 1 deletion src/lib/appstoreconnect/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,28 @@ export class AppStoreConnectClient {
return this.get(`/ciArtifacts/${artifactId}`);
}

async downloadArtifactContent(artifactId: string): Promise<{ content: string; fileName?: string; downloadUrl?: string; contentType?: string }> {
const artifact = await this.getArtifact(artifactId);
const attrs = artifact?.data?.attributes || {};
const downloadUrl = attrs?.downloadUrl || attrs?.fileUrl || attrs?.url;

if (!downloadUrl) {
throw new Error('No download URL available for this artifact.');
}

const res = await request(downloadUrl, { method: 'GET' });
if (res.statusCode >= 400) {
const body = await res.body.text();
throw new Error(`Download failed (${res.statusCode}): ${body}`);
}

const content = await res.body.text();
const contentTypeHeader = res.headers['content-type'];
const contentType = Array.isArray(contentTypeHeader) ? contentTypeHeader[0] : contentTypeHeader;

return { content, fileName: attrs?.fileName, downloadUrl, contentType };
}

// Get a single build run
async getBuildRun(buildRunId: string) {
return this.get(`/ciBuildRuns/${buildRunId}`);
Expand Down Expand Up @@ -419,4 +441,4 @@ export class AppStoreConnectClient {
if (options?.limit) { query['limit'] = String(options.limit); }
return this.get(`/ciProducts/${productId}/primaryRepositories`, query);
}
}
}
14 changes: 14 additions & 0 deletions src/lib/views/BuildLogsPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,20 @@ export class BuildActionItem extends vscode.TreeItem {
}
}

export function filterLogArtifacts(artifacts: any[]): any[] {
return (artifacts || []).filter(isLogArtifact);
}

function isLogArtifact(artifact: any): boolean {
const attrs = artifact?.attributes || {};
const fileType = (attrs.fileType || '').toUpperCase();
const fileName = (attrs.fileName || '').toLowerCase();

if (fileType.includes('LOG')) { return true; }
if (fileName.endsWith('.log') || fileName.endsWith('.txt')) { return true; }
return false;
}

function formatDuration(seconds: number): string {
if (seconds < 60) { return `${Math.round(seconds)}s`; }
const mins = Math.floor(seconds / 60);
Expand Down
15 changes: 15 additions & 0 deletions src/test/buildLogs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as assert from 'assert';
import { filterLogArtifacts } from '../lib/views/BuildLogsPanel';

suite('filterLogArtifacts', () => {
test('keeps log file types and extensions', () => {
const artifacts = [
{ id: '1', attributes: { fileType: 'LOG', fileName: 'build.log' } },
{ id: '2', attributes: { fileType: 'ARCHIVE', fileName: 'archive.xcresult' } },
{ id: '3', attributes: { fileType: 'TEXT', fileName: 'notes.txt' } }
];

const filtered = filterLogArtifacts(artifacts);
assert.deepStrictEqual(filtered.map(a => a.id), ['1', '3']);
});
});