Skip to content

Commit 3c082c1

Browse files
committed
Add failed test and log cleanup tools
1 parent 83713f5 commit 3c082c1

7 files changed

Lines changed: 311 additions & 5 deletions

File tree

README.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@ Minimal MCP server for discovering Xcode Cloud products and workflows, then retr
1717
- List recent workflow runs with `list_build_runs`.
1818
- Retrieve build issue counts with `get_build_issues`.
1919
- Retrieve and summarize text-like build logs with `get_build_logs`.
20+
- Materialize build logs into a local temp directory with `materialize_build_logs`.
2021
- Save extracted logs to a local temporary directory and return file paths for agent-side inspection.
2122
- Retrieve test summaries with `get_test_results`.
23+
- Retrieve only detected failed tests with `get_failed_tests`.
2224
- Retrieve screenshots, videos, result bundles, and test products with `get_test_artifacts`.
25+
- Clean up saved local log directories with `cleanup_saved_logs`.
2326

2427
Build lookup is workflow-scoped. Retrieval tools accept a direct `buildRunId`, or a `workflowId` plus `buildNumber`, or a `workflowId` plus `buildSelector: "latest" | "latestFailing"`.
2528

@@ -71,8 +74,11 @@ codex mcp add xcode-cloud \
7174
- `list_build_runs(workflowId, limit?, status?)`
7275
- `get_build_issues(buildRunId? workflowId? buildNumber? buildSelector?)`
7376
- `get_build_logs(buildRunId? workflowId? buildNumber? buildSelector?, maxCharacters?)`
77+
- `materialize_build_logs(buildRunId? workflowId? buildNumber? buildSelector?)`
7478
- `get_test_results(buildRunId? workflowId? buildNumber? buildSelector?)`
79+
- `get_failed_tests(buildRunId? workflowId? buildNumber? buildSelector?)`
7580
- `get_test_artifacts(buildRunId? workflowId? buildNumber? buildSelector?)`
81+
- `cleanup_saved_logs(buildRunId?, maxAgeHours?)`
7682

7783
## Log Retrieval Behavior
7884

@@ -83,6 +89,13 @@ codex mcp add xcode-cloud \
8389
- it returns a compact `failedTests` summary, `highlights`, and a capped `excerpt`
8490
- even if a caller passes a very large `maxCharacters`, the inline excerpt is clamped to avoid oversized MCP responses
8591

92+
Recommended agent workflow:
93+
94+
1. Call `get_failed_tests` or `get_build_logs`.
95+
2. Read `savedLogsDirectory`.
96+
3. Use `rg` inside that directory to inspect the exact failing test or assertion.
97+
4. If needed, call `cleanup_saved_logs` when the investigation is done.
98+
8699
Temporary logs are written under the system temp directory in a path like:
87100

88101
```text
@@ -94,8 +107,8 @@ On macOS this typically resolves to a path under `/var/folders/.../T/`.
94107
Cleanup policy:
95108

96109
- each new call for the same `buildRunId` deletes and recreates that build-specific temp directory first
97-
- older build directories are not explicitly garbage-collected by the server yet
98-
- they are left in the system temp area, where the OS may eventually clean them up
110+
- older build directories are pruned automatically when they are older than 24 hours
111+
- you can also call `cleanup_saved_logs` directly for one `buildRunId` or for all directories older than a chosen retention window
99112

100113
## Example Prompts
101114

@@ -107,6 +120,10 @@ Retrieve logs of the latest failing build for workflow abc123.
107120
Retrieve logs of build 81, then inspect the returned savedLogsDirectory and grep for Expectation failed.
108121
```
109122

123+
```text
124+
Get the failed tests for build 81, then open the saved logs directory and inspect the failing test in context.
125+
```
126+
110127
```text
111128
Retrieve logs of build number 42 for workflow abc123.
112129
```

src/tools/results.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import {
88
} from '../utils/build-locator.js';
99
import { collectBuildRunArtifacts } from '../utils/build-artifacts.js';
1010
import { summarizeLogTexts } from '../utils/log-analysis.js';
11-
import { storeLogArtifacts } from '../utils/log-storage.js';
11+
import {
12+
cleanupSavedLogs,
13+
removeSavedLogsForBuildRun,
14+
storeLogArtifacts,
15+
} from '../utils/log-storage.js';
1216
import { errorResponse, jsonResponse } from '../utils/tool-response.js';
1317

1418
interface BuildLookupInput {
@@ -87,6 +91,68 @@ export function registerResultTools(
8791
}
8892
},
8993
);
94+
95+
server.registerTool(
96+
'materialize_build_logs',
97+
{
98+
description:
99+
'Resolve a build, download and extract text-like log artifacts into a local temporary directory, and return saved file paths for grep or cat based investigation.',
100+
inputSchema: buildLookupSchema(),
101+
},
102+
async (input: BuildLookupInput) => {
103+
try {
104+
const buildRun = await resolveBuildLocator(client, input);
105+
const groupedArtifacts = await collectBuildRunArtifacts(client, buildRun.id);
106+
const storedLogs = await storeLogArtifacts(
107+
client,
108+
buildRun.id,
109+
groupedArtifacts.logs,
110+
);
111+
112+
return jsonResponse({
113+
buildRun: formatBuildRun(buildRun),
114+
artifacts: groupedArtifacts.logs.map(formatArtifact),
115+
savedLogsDirectory: storedLogs.directoryPath,
116+
savedLogs: storedLogs.savedLogFiles,
117+
});
118+
} catch (error) {
119+
return errorResponse(error);
120+
}
121+
},
122+
);
123+
124+
server.registerTool(
125+
'cleanup_saved_logs',
126+
{
127+
description:
128+
'Remove saved local log directories either for one build run or for all directories older than a retention window.',
129+
inputSchema: {
130+
buildRunId: z.string().optional(),
131+
maxAgeHours: z.number().positive().max(24 * 30).optional(),
132+
},
133+
},
134+
async ({
135+
buildRunId,
136+
maxAgeHours,
137+
}: {
138+
buildRunId?: string;
139+
maxAgeHours?: number;
140+
}) => {
141+
try {
142+
if (buildRunId) {
143+
const cleanupResult = await removeSavedLogsForBuildRun(buildRunId);
144+
145+
return jsonResponse(cleanupResult);
146+
}
147+
148+
const cleanupResult = await cleanupSavedLogs({ maxAgeHours });
149+
150+
return jsonResponse(cleanupResult);
151+
} catch (error) {
152+
return errorResponse(error);
153+
}
154+
},
155+
);
90156
}
91157

92158
function buildLookupSchema() {

src/tools/tests.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,41 @@ export function registerTestTools(
7575
},
7676
);
7777

78+
server.registerTool(
79+
'get_failed_tests',
80+
{
81+
description:
82+
'Resolve a build, ensure logs are materialized locally, and return only the detected failed tests with their assertion messages when available.',
83+
inputSchema: buildLookupSchema(),
84+
},
85+
async (input: BuildLookupInput) => {
86+
try {
87+
const buildRun = await resolveBuildLocator(client, input);
88+
const groupedArtifacts = await collectBuildRunArtifacts(client, buildRun.id);
89+
const storedLogs = await storeLogArtifacts(
90+
client,
91+
buildRun.id,
92+
groupedArtifacts.logs,
93+
);
94+
const logSummary = summarizeLogTexts(storedLogs.parsedLogTexts, 1500);
95+
96+
return jsonResponse({
97+
buildRun: {
98+
id: buildRun.id,
99+
number: buildRun.attributes.number,
100+
workflowId: buildRun.relationships?.workflow?.data.id,
101+
completionStatus: buildRun.attributes.completionStatus,
102+
},
103+
savedLogsDirectory: storedLogs.directoryPath,
104+
savedLogs: storedLogs.savedLogFiles,
105+
failedTests: logSummary.failedTests,
106+
});
107+
} catch (error) {
108+
return errorResponse(error);
109+
}
110+
},
111+
);
112+
78113
server.registerTool(
79114
'get_test_artifacts',
80115
{

src/utils/log-storage.ts

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mkdir, rm, writeFile } from 'node:fs/promises';
1+
import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises';
22
import { tmpdir } from 'node:os';
33
import path from 'node:path';
44
import type { AppStoreConnectClient } from '../api/client.js';
@@ -19,15 +19,36 @@ export interface StoredLogs {
1919
savedLogFiles: SavedLogFile[];
2020
}
2121

22+
const DEFAULT_LOG_RETENTION_HOURS = 24;
23+
24+
/**
25+
* Return the root directory used for materialized log files.
26+
*/
27+
export function getSavedLogsRootDirectory(
28+
baseDirectory: string = tmpdir(),
29+
): string {
30+
return path.join(baseDirectory, 'xcode-cloud-mcp', 'build-logs');
31+
}
32+
2233
/**
2334
* Download log artifacts, persist them locally, and return extracted text when available.
2435
*/
2536
export async function storeLogArtifacts(
2637
client: AppStoreConnectClient,
2738
buildRunId: string,
2839
artifacts: CiArtifact[],
40+
options?: {
41+
baseDirectory?: string;
42+
retentionHours?: number;
43+
},
2944
): Promise<StoredLogs> {
30-
const directoryPath = path.join(tmpdir(), 'xcode-cloud-mcp', 'build-logs', buildRunId);
45+
const rootDirectory = getSavedLogsRootDirectory(options?.baseDirectory);
46+
await cleanupSavedLogs({
47+
rootDirectory,
48+
maxAgeHours: options?.retentionHours ?? DEFAULT_LOG_RETENTION_HOURS,
49+
});
50+
51+
const directoryPath = path.join(rootDirectory, buildRunId);
3152
await rm(directoryPath, { recursive: true, force: true });
3253
await mkdir(directoryPath, { recursive: true });
3354

@@ -82,3 +103,92 @@ export async function storeLogArtifacts(
82103
function sanitizeFileName(fileName: string): string {
83104
return fileName.replace(/[\\/:\s]+/g, '-');
84105
}
106+
107+
/**
108+
* Remove materialized logs for one build run.
109+
*/
110+
export async function removeSavedLogsForBuildRun(
111+
buildRunId: string,
112+
options?: {
113+
baseDirectory?: string;
114+
},
115+
): Promise<{ removedDirectory: string; removed: boolean }> {
116+
const directoryPath = path.join(
117+
getSavedLogsRootDirectory(options?.baseDirectory),
118+
buildRunId,
119+
);
120+
121+
const removed = await removeDirectoryIfPresent(directoryPath);
122+
123+
return {
124+
removedDirectory: directoryPath,
125+
removed,
126+
};
127+
}
128+
129+
/**
130+
* Remove old materialized log directories under the saved logs root.
131+
*/
132+
export async function cleanupSavedLogs(options?: {
133+
rootDirectory?: string;
134+
baseDirectory?: string;
135+
maxAgeHours?: number;
136+
}): Promise<{
137+
rootDirectory: string;
138+
removedDirectories: string[];
139+
}> {
140+
const rootDirectory =
141+
options?.rootDirectory ??
142+
getSavedLogsRootDirectory(options?.baseDirectory);
143+
const maxAgeHours = options?.maxAgeHours ?? DEFAULT_LOG_RETENTION_HOURS;
144+
const maxAgeMilliseconds = Math.max(1, maxAgeHours) * 60 * 60 * 1000;
145+
const removedDirectories: string[] = [];
146+
147+
try {
148+
const entries = await readdir(rootDirectory, { withFileTypes: true });
149+
150+
for (const entry of entries) {
151+
if (!entry.isDirectory()) {
152+
continue;
153+
}
154+
155+
const directoryPath = path.join(rootDirectory, entry.name);
156+
const directoryStats = await stat(directoryPath);
157+
const modifiedAgeMilliseconds =
158+
Date.now() - directoryStats.mtime.getTime();
159+
160+
if (modifiedAgeMilliseconds < maxAgeMilliseconds) {
161+
continue;
162+
}
163+
164+
await rm(directoryPath, { recursive: true, force: true });
165+
removedDirectories.push(directoryPath);
166+
}
167+
} catch (error) {
168+
const nodeError = error as NodeJS.ErrnoException;
169+
170+
if (nodeError.code !== 'ENOENT') {
171+
throw error;
172+
}
173+
}
174+
175+
return {
176+
rootDirectory,
177+
removedDirectories,
178+
};
179+
}
180+
181+
async function removeDirectoryIfPresent(directoryPath: string): Promise<boolean> {
182+
try {
183+
await rm(directoryPath, { recursive: true, force: false });
184+
return true;
185+
} catch (error) {
186+
const nodeError = error as NodeJS.ErrnoException;
187+
188+
if (nodeError.code === 'ENOENT') {
189+
return false;
190+
}
191+
192+
throw error;
193+
}
194+
}

tests/log-storage.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { mkdtemp, mkdir, stat, utimes } from 'node:fs/promises';
4+
import { tmpdir } from 'node:os';
5+
import path from 'node:path';
6+
import {
7+
cleanupSavedLogs,
8+
getSavedLogsRootDirectory,
9+
removeSavedLogsForBuildRun,
10+
} from '../src/utils/log-storage.js';
11+
12+
test('cleanupSavedLogs removes directories older than retention', async () => {
13+
const baseDirectory = await mkdtemp(
14+
path.join(tmpdir(), 'xcode-cloud-mcp-cleanup-test-'),
15+
);
16+
const rootDirectory = getSavedLogsRootDirectory(baseDirectory);
17+
const staleDirectory = path.join(rootDirectory, 'stale-build');
18+
const freshDirectory = path.join(rootDirectory, 'fresh-build');
19+
20+
await mkdir(staleDirectory, { recursive: true });
21+
await mkdir(freshDirectory, { recursive: true });
22+
23+
const staleDate = new Date(Date.now() - 48 * 60 * 60 * 1000);
24+
await utimes(staleDirectory, staleDate, staleDate);
25+
26+
const cleanupResult = await cleanupSavedLogs({
27+
rootDirectory,
28+
maxAgeHours: 24,
29+
});
30+
31+
assert.equal(cleanupResult.removedDirectories.includes(staleDirectory), true);
32+
await assert.rejects(stat(staleDirectory));
33+
await stat(freshDirectory);
34+
});
35+
36+
test('removeSavedLogsForBuildRun removes one build directory', async () => {
37+
const baseDirectory = await mkdtemp(
38+
path.join(tmpdir(), 'xcode-cloud-mcp-remove-test-'),
39+
);
40+
const rootDirectory = getSavedLogsRootDirectory(baseDirectory);
41+
const buildRunId = 'build-123';
42+
const buildDirectory = path.join(rootDirectory, buildRunId);
43+
44+
await mkdir(buildDirectory, { recursive: true });
45+
46+
const removalResult = await removeSavedLogsForBuildRun(buildRunId, {
47+
baseDirectory,
48+
});
49+
50+
assert.equal(removalResult.removed, true);
51+
assert.equal(removalResult.removedDirectory, buildDirectory);
52+
await assert.rejects(stat(buildDirectory));
53+
});

tests/smoke.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,16 @@ test('server starts over stdio and exposes the expected tools', async () => {
3232
const toolNames = tools.tools.map((tool) => tool.name).sort();
3333

3434
assert.deepEqual(toolNames, [
35+
'cleanup_saved_logs',
3536
'get_build_issues',
3637
'get_build_logs',
38+
'get_failed_tests',
3739
'get_test_artifacts',
3840
'get_test_results',
3941
'list_build_runs',
4042
'list_products',
4143
'list_workflows',
44+
'materialize_build_logs',
4245
]);
4346

4447
await client.close();

0 commit comments

Comments
 (0)