Skip to content

Commit fe65065

Browse files
authored
Merge pull request #2923 from protoLabsAI/staging
Promote staging to main (v0.77.6)
2 parents ac7453b + 195a315 commit fe65065

File tree

35 files changed

+718
-120
lines changed

35 files changed

+718
-120
lines changed

apps/server/package.json

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@protolabsai/server",
3-
"version": "0.77.5",
3+
"version": "0.77.6",
44
"description": "Backend server for protoLabs Studio - provides API for both web and Electron modes",
55
"author": "protoLabs Studio",
66
"license": "SEE LICENSE IN LICENSE",
@@ -46,18 +46,18 @@
4646
"@opentelemetry/sdk-node": "^0.212.0",
4747
"@opentelemetry/sdk-trace-base": "^2.0.0",
4848
"@protolabsai/context-engine": "^0.68.9",
49-
"@protolabsai/dependency-resolver": "^0.77.5",
49+
"@protolabsai/dependency-resolver": "^0.77.6",
5050
"@protolabsai/error-tracking": "^0.53.24",
51-
"@protolabsai/flows": "^0.77.5",
52-
"@protolabsai/git-utils": "^0.77.5",
53-
"@protolabsai/model-resolver": "^0.77.5",
54-
"@protolabsai/observability": "^0.77.5",
55-
"@protolabsai/platform": "^0.77.5",
56-
"@protolabsai/prompts": "^0.77.5",
51+
"@protolabsai/flows": "^0.77.6",
52+
"@protolabsai/git-utils": "^0.77.6",
53+
"@protolabsai/model-resolver": "^0.77.6",
54+
"@protolabsai/observability": "^0.77.6",
55+
"@protolabsai/platform": "^0.77.6",
56+
"@protolabsai/prompts": "^0.77.6",
5757
"@protolabsai/templates": "^0.56.0",
58-
"@protolabsai/tools": "^0.77.5",
59-
"@protolabsai/types": "^0.77.5",
60-
"@protolabsai/utils": "^0.77.5",
58+
"@protolabsai/tools": "^0.77.6",
59+
"@protolabsai/types": "^0.77.6",
60+
"@protolabsai/utils": "^0.77.6",
6161
"@twurple/api": "^8.0.3",
6262
"@twurple/auth": "^8.0.3",
6363
"@twurple/chat": "^8.0.3",

apps/server/src/services/pr-status-checker.ts

Lines changed: 120 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -309,8 +309,14 @@ export class PRStatusChecker {
309309

310310
/**
311311
* Fetch only failed CI check runs for a commit SHA, with output details.
312+
* When ciProvider is 'github-actions' (default), fetches GHA job logs as a
313+
* fallback for checks where the API output is insufficient.
312314
*/
313-
async fetchFailedChecks(pr: TrackedPR, headSha: string): Promise<FailedCheck[]> {
315+
async fetchFailedChecks(
316+
pr: TrackedPR,
317+
headSha: string,
318+
ciProvider: string = 'github-actions'
319+
): Promise<FailedCheck[]> {
314320
try {
315321
const { stdout } = await execFileAsync(
316322
'gh',
@@ -323,6 +329,7 @@ export class PRStatusChecker {
323329
);
324330

325331
const checkRuns = JSON.parse(stdout) as Array<{
332+
id: number;
326333
name: string;
327334
status: string;
328335
conclusion: string;
@@ -333,22 +340,126 @@ export class PRStatusChecker {
333340
};
334341
}>;
335342

336-
return checkRuns
337-
.filter((check) => check.conclusion === 'failure')
338-
.map((check) => ({
343+
const failedRuns = checkRuns.filter((check) => check.conclusion === 'failure');
344+
const results: FailedCheck[] = [];
345+
346+
for (const check of failedRuns) {
347+
const apiOutput = [check.output?.title, check.output?.summary, check.output?.text]
348+
.filter(Boolean)
349+
.join('\n')
350+
.slice(0, 1000);
351+
352+
// Use GHA job logs as fallback when check run API output is insufficient
353+
let output = apiOutput;
354+
if ((!output || output.trim().length < 50) && ciProvider === 'github-actions') {
355+
const jobLogs = await this.fetchJobLogs(pr, check.id, ciProvider);
356+
if (jobLogs) {
357+
output = jobLogs;
358+
}
359+
}
360+
361+
results.push({
339362
name: check.name,
340363
conclusion: check.conclusion,
341-
output: [check.output?.title, check.output?.summary, check.output?.text]
342-
.filter(Boolean)
343-
.join('\n')
344-
.slice(0, 1000),
345-
}));
364+
output,
365+
});
366+
}
367+
368+
return results;
346369
} catch (error) {
347370
logger.debug(`Failed to fetch failed checks for ${headSha}: ${error}`);
348371
return [];
349372
}
350373
}
351374

375+
/**
376+
* Fetch GitHub Actions job logs for a specific check run (job ID).
377+
* Parses the GHA log format (##[group] / ##[endgroup] / ##[error] markers),
378+
* finds the failing step, and returns the last 200 lines of that step.
379+
* Returns null for non-GHA providers or on fetch failure.
380+
*/
381+
async fetchJobLogs(
382+
pr: TrackedPR,
383+
checkRunId: number,
384+
ciProvider: string = 'github-actions'
385+
): Promise<string | null> {
386+
if (ciProvider !== 'github-actions') {
387+
return null;
388+
}
389+
390+
try {
391+
const { stdout } = await execFileAsync(
392+
'gh',
393+
['api', `repos/{owner}/{repo}/actions/jobs/${checkRunId}/logs`],
394+
{
395+
cwd: pr.projectPath,
396+
timeout: 30_000,
397+
encoding: 'utf-8',
398+
maxBuffer: 10 * 1024 * 1024, // 10 MB — GHA logs can be large
399+
}
400+
);
401+
402+
return this.parseGHAJobLogs(stdout);
403+
} catch (error) {
404+
logger.debug(`Failed to fetch job logs for check run ${checkRunId}: ${error}`);
405+
return null;
406+
}
407+
}
408+
409+
/**
410+
* Parse GitHub Actions log format to extract the failing step output.
411+
*
412+
* GHA log lines look like: "2024-01-01T00:00:00.0000000Z ##[group]Step name"
413+
* Groups are delimited by ##[group] / ##[endgroup]. Errors appear as ##[error].
414+
*
415+
* Strategy:
416+
* 1. Find the last group (step) that contains a ##[error] line.
417+
* 2. Return the last 200 lines of that step's content.
418+
* 3. If no step with errors is found, return the last 200 lines of all content.
419+
*/
420+
private parseGHAJobLogs(rawLogs: string): string {
421+
const lines = rawLogs.split('\n');
422+
423+
// Strip the ISO timestamp prefix from a log line to get the raw content.
424+
const getContent = (line: string): string =>
425+
line.replace(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z /, '');
426+
427+
let currentGroupStart = -1;
428+
let failingGroupStart = -1;
429+
let failingGroupEnd = -1;
430+
431+
for (let i = 0; i < lines.length; i++) {
432+
const content = getContent(lines[i]);
433+
434+
if (content.startsWith('##[group]')) {
435+
currentGroupStart = i;
436+
} else if (content.startsWith('##[endgroup]')) {
437+
currentGroupStart = -1;
438+
} else if (content.startsWith('##[error]')) {
439+
// Mark the enclosing group as the failing step, or use a local window.
440+
if (currentGroupStart >= 0) {
441+
failingGroupStart = currentGroupStart;
442+
failingGroupEnd = i;
443+
} else {
444+
// Error outside a group — capture a window around it
445+
failingGroupStart = Math.max(0, i - 10);
446+
failingGroupEnd = i;
447+
}
448+
}
449+
}
450+
451+
let relevantLines: string[];
452+
if (failingGroupStart >= 0) {
453+
const end = failingGroupEnd >= 0 ? failingGroupEnd + 1 : lines.length;
454+
relevantLines = lines.slice(failingGroupStart, end);
455+
} else {
456+
relevantLines = lines;
457+
}
458+
459+
// Truncate to last 200 lines of the relevant section
460+
return relevantLines.slice(-200).join('\n');
461+
}
462+
352463
private async getRemoteUrl(projectPath: string): Promise<string> {
353464
const { stdout } = await execFileAsync('git', ['remote', 'get-url', 'origin'], {
354465
cwd: projectPath,
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
3+
// Mock child_process before importing the service so execFile is intercepted.
4+
// Mock util so promisify(execFile) === execFile (the mock).
5+
vi.mock('child_process', () => ({ execFile: vi.fn() }));
6+
vi.mock('util', () => ({ promisify: (fn: unknown) => fn }));
7+
8+
import { execFile } from 'child_process';
9+
import { PRStatusChecker, type TrackedPR } from '../../../src/services/pr-status-checker.js';
10+
11+
const mockExecFile = execFile as unknown as ReturnType<typeof vi.fn>;
12+
13+
const fakePR: TrackedPR = {
14+
featureId: 'feat-1',
15+
projectPath: '/fake/project',
16+
prNumber: 42,
17+
prUrl: 'https://github.com/owner/repo/pull/42',
18+
branchName: 'feature/test',
19+
lastCheckedAt: Date.now(),
20+
reviewState: 'pending',
21+
iterationCount: 0,
22+
};
23+
24+
describe('PRStatusChecker', () => {
25+
let checker: PRStatusChecker;
26+
27+
beforeEach(() => {
28+
checker = new PRStatusChecker();
29+
mockExecFile.mockReset();
30+
});
31+
32+
describe('fetchJobLogs', () => {
33+
it('returns null for non-GHA ciProvider without calling execFile', async () => {
34+
const result = await checker.fetchJobLogs(fakePR, 12345, 'other');
35+
expect(result).toBeNull();
36+
expect(mockExecFile).not.toHaveBeenCalled();
37+
});
38+
39+
it('returns null for unrecognized ciProvider', async () => {
40+
const result = await checker.fetchJobLogs(fakePR, 12345, 'jenkins');
41+
expect(result).toBeNull();
42+
});
43+
44+
it('calls gh api for GHA job logs', async () => {
45+
const rawLogs = [
46+
'2024-01-01T00:00:00.0000000Z ##[group]Run npm test',
47+
'2024-01-01T00:00:01.0000000Z npm test output line 1',
48+
'2024-01-01T00:00:02.0000000Z ##[error]Tests failed: 3 failures',
49+
'2024-01-01T00:00:03.0000000Z ##[endgroup]',
50+
].join('\n');
51+
52+
mockExecFile.mockResolvedValueOnce({ stdout: rawLogs, stderr: '' });
53+
54+
const result = await checker.fetchJobLogs(fakePR, 9999, 'github-actions');
55+
56+
expect(mockExecFile).toHaveBeenCalledWith(
57+
'gh',
58+
['api', 'repos/{owner}/{repo}/actions/jobs/9999/logs'],
59+
expect.objectContaining({ cwd: fakePR.projectPath })
60+
);
61+
expect(result).not.toBeNull();
62+
expect(result).toContain('##[group]Run npm test');
63+
expect(result).toContain('##[error]Tests failed: 3 failures');
64+
});
65+
66+
it('defaults to github-actions ciProvider when not specified', async () => {
67+
mockExecFile.mockResolvedValueOnce({ stdout: 'some log', stderr: '' });
68+
const result = await checker.fetchJobLogs(fakePR, 1);
69+
expect(mockExecFile).toHaveBeenCalled();
70+
expect(result).not.toBeNull();
71+
});
72+
73+
it('returns null on gh api failure', async () => {
74+
mockExecFile.mockRejectedValueOnce(new Error('gh: not found'));
75+
const result = await checker.fetchJobLogs(fakePR, 1, 'github-actions');
76+
expect(result).toBeNull();
77+
});
78+
79+
it('truncates to last 200 lines of failing step', async () => {
80+
// Build a group containing 300 lines after the ##[error] marker
81+
const logLines: string[] = [];
82+
logLines.push('2024-01-01T00:00:00.0000000Z ##[group]Run big step');
83+
logLines.push('2024-01-01T00:00:01.0000000Z ##[error]Something broke');
84+
for (let i = 0; i < 300; i++) {
85+
logLines.push(`2024-01-01T00:00:02.0000000Z output line ${i}`);
86+
}
87+
logLines.push('2024-01-01T00:00:03.0000000Z ##[endgroup]');
88+
89+
mockExecFile.mockResolvedValueOnce({ stdout: logLines.join('\n'), stderr: '' });
90+
91+
const result = await checker.fetchJobLogs(fakePR, 1, 'github-actions');
92+
expect(result).not.toBeNull();
93+
94+
const resultLines = result!.split('\n');
95+
expect(resultLines.length).toBeLessThanOrEqual(200);
96+
});
97+
98+
it('returns last 200 lines when no ##[error] marker found', async () => {
99+
const logLines: string[] = [];
100+
for (let i = 0; i < 500; i++) {
101+
logLines.push(`2024-01-01T00:00:00.0000000Z output line ${i}`);
102+
}
103+
104+
mockExecFile.mockResolvedValueOnce({ stdout: logLines.join('\n'), stderr: '' });
105+
106+
const result = await checker.fetchJobLogs(fakePR, 1, 'github-actions');
107+
expect(result).not.toBeNull();
108+
const resultLines = result!.split('\n');
109+
expect(resultLines.length).toBeLessThanOrEqual(200);
110+
});
111+
});
112+
113+
describe('fetchFailedChecks — GHA fallback', () => {
114+
it('uses job logs as fallback when check run output is empty', async () => {
115+
const checkRunsJson = JSON.stringify([
116+
{
117+
id: 777,
118+
name: 'test / unit',
119+
status: 'completed',
120+
conclusion: 'failure',
121+
output: { title: null, summary: null, text: null },
122+
},
123+
]);
124+
125+
const jobLogContent = [
126+
'2024-01-01T00:00:00.0000000Z ##[group]Run unit tests',
127+
'2024-01-01T00:00:01.0000000Z ##[error]assertion failed',
128+
'2024-01-01T00:00:02.0000000Z ##[endgroup]',
129+
].join('\n');
130+
131+
// First call: check-runs API; second call: actions/jobs logs API
132+
mockExecFile.mockResolvedValueOnce({ stdout: checkRunsJson, stderr: '' });
133+
mockExecFile.mockResolvedValueOnce({ stdout: jobLogContent, stderr: '' });
134+
135+
const results = await checker.fetchFailedChecks(fakePR, 'abc123', 'github-actions');
136+
137+
expect(results).toHaveLength(1);
138+
expect(results[0].name).toBe('test / unit');
139+
expect(results[0].output).toContain('##[error]assertion failed');
140+
});
141+
142+
it('does not fetch job logs for non-GHA provider', async () => {
143+
const checkRunsJson = JSON.stringify([
144+
{
145+
id: 888,
146+
name: 'build',
147+
status: 'completed',
148+
conclusion: 'failure',
149+
output: { title: null, summary: null, text: null },
150+
},
151+
]);
152+
153+
mockExecFile.mockResolvedValueOnce({ stdout: checkRunsJson, stderr: '' });
154+
155+
const results = await checker.fetchFailedChecks(fakePR, 'abc123', 'other');
156+
157+
expect(mockExecFile).toHaveBeenCalledTimes(1);
158+
expect(results).toHaveLength(1);
159+
expect(results[0].output).toBe('');
160+
});
161+
162+
it('skips fallback when API output is sufficient', async () => {
163+
const richSummary = 'A'.repeat(200);
164+
const checkRunsJson = JSON.stringify([
165+
{
166+
id: 999,
167+
name: 'lint',
168+
status: 'completed',
169+
conclusion: 'failure',
170+
output: { title: 'Lint failed', summary: richSummary, text: null },
171+
},
172+
]);
173+
174+
mockExecFile.mockResolvedValueOnce({ stdout: checkRunsJson, stderr: '' });
175+
176+
const results = await checker.fetchFailedChecks(fakePR, 'def456', 'github-actions');
177+
178+
expect(mockExecFile).toHaveBeenCalledTimes(1);
179+
expect(results[0].output).toContain('Lint failed');
180+
});
181+
});
182+
});

0 commit comments

Comments
 (0)