From c4b717dadcd06dc5823fb1fef20ac7fdfc6249bb Mon Sep 17 00:00:00 2001 From: "openai-code-agent[bot]" <242516109+Codex@users.noreply.github.com> Date: Sat, 7 Feb 2026 20:24:53 +0000 Subject: [PATCH 1/2] Initial plan From b475f06f85f318321934926b13150bca774e51a1 Mon Sep 17 00:00:00 2001 From: "openai-code-agent[bot]" <242516109+Codex@users.noreply.github.com> Date: Sat, 7 Feb 2026 20:30:07 +0000 Subject: [PATCH 2/2] feat: enable build log viewing --- package.json | 12 +++- src/extension.ts | 115 +++++++++++++++++++++++++++++- src/lib/appstoreconnect/client.ts | 24 ++++++- src/lib/views/BuildLogsPanel.ts | 14 ++++ src/test/buildLogs.test.ts | 15 ++++ 5 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 src/test/buildLogs.test.ts diff --git a/package.json b/package.json index 44d3432..c4c88a7 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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" } ] } @@ -229,4 +239,4 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.48.1" } -} \ No newline at end of file +} diff --git a/src/extension.ts b/src/extension.ts index 39bf0bb..2876dcb 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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'; @@ -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, () => { @@ -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) { @@ -290,4 +401,4 @@ async function updateStatusBar() { export function deactivate() { buildMonitor?.dispose(); statusBarItem?.dispose(); -} \ No newline at end of file +} diff --git a/src/lib/appstoreconnect/client.ts b/src/lib/appstoreconnect/client.ts index 0e8bfe4..91354d2 100644 --- a/src/lib/appstoreconnect/client.ts +++ b/src/lib/appstoreconnect/client.ts @@ -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}`); @@ -419,4 +441,4 @@ export class AppStoreConnectClient { if (options?.limit) { query['limit'] = String(options.limit); } return this.get(`/ciProducts/${productId}/primaryRepositories`, query); } -} \ No newline at end of file +} diff --git a/src/lib/views/BuildLogsPanel.ts b/src/lib/views/BuildLogsPanel.ts index 8a3d7ef..c80e5ff 100644 --- a/src/lib/views/BuildLogsPanel.ts +++ b/src/lib/views/BuildLogsPanel.ts @@ -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); diff --git a/src/test/buildLogs.test.ts b/src/test/buildLogs.test.ts new file mode 100644 index 0000000..332cf14 --- /dev/null +++ b/src/test/buildLogs.test.ts @@ -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']); + }); +});