mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
04613ae8d3
* ci: keep Bundle Size job green on transient GitHub comment failures The size measurement and job summary had already succeeded on PR #1789 (run 32050847506) when the PR comment write got a 503 during a GitHub incident and failed the whole lane. --post-comment now retries 5xx / 429 / network errors (4 attempts, 1s/2s/4s backoff) on both the list and write calls. If it still fails, it prints a ::warning::, appends a note to $GITHUB_STEP_SUMMARY, and exits 0. Other 4xx (bad token, missing permissions) stay fatal. * refactor: split GitHub response classification to satisfy fallow complexity gate * fix: reconcile uncertain comment creates instead of re-POSTing; add regressions Retry now wraps the whole list -> write cycle rather than each request, so a create whose response was lost (network error / 5xx) is re-listed on the next attempt and turned into a PATCH of the marker comment instead of a duplicate POST. Splits the retry/classify helpers under the fallow complexity gate. Adds scripts/__tests__/size-report-post-comment.test.ts (unit-core): spawns the real script against a stubbed fetch and pins uncertain-create reconciliation, transient exhaustion (warn + exit 0), and fatal 4xx (nonzero, no retry). SIZE_REPORT_RETRY_BASE_MS lets the tests skip real backoff.
115 lines
4.4 KiB
TypeScript
115 lines
4.4 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { execFile } from 'node:child_process';
|
|
import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { promisify } from 'node:util';
|
|
import { test } from 'vitest';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const ROOT = join(import.meta.dirname, '..', '..');
|
|
const SCRIPT = join(ROOT, 'scripts', 'size-report.mjs');
|
|
const MARKER = '<!-- agent-device-size-report -->';
|
|
|
|
// Each entry answers one fetch call, in order. `net` rejects the fetch (a
|
|
// dropped connection); a number is the HTTP status; `body` is the response
|
|
// text (defaults to `[]` for 200 so a list call sees no comments).
|
|
type StubResponse = { status: number | 'net'; body?: string };
|
|
|
|
const FETCH_STUB = `
|
|
import fs from 'node:fs';
|
|
const script = JSON.parse(process.env.SIZE_REPORT_FETCH_SCRIPT);
|
|
let index = 0;
|
|
globalThis.fetch = async (url, init) => {
|
|
const step = script[Math.min(index, script.length - 1)];
|
|
index += 1;
|
|
fs.appendFileSync(process.env.SIZE_REPORT_FETCH_LOG, \`\${init?.method ?? 'GET'} \${url}\\n\`);
|
|
if (step.status === 'net') throw new Error('ECONNRESET');
|
|
const body = step.body ?? (step.status === 200 ? '[]' : \`{"message":"status \${step.status}"}\`);
|
|
return new Response(body, { status: step.status });
|
|
};
|
|
`;
|
|
|
|
async function runPostComment(script: StubResponse[]) {
|
|
const dir = await mkdtemp(join(tmpdir(), 'size-report-post-comment-'));
|
|
const stubPath = join(dir, 'fetch-stub.mjs');
|
|
const reportPath = join(dir, 'report.md');
|
|
const logPath = join(dir, 'fetch.log');
|
|
const summaryPath = join(dir, 'summary.md');
|
|
await Promise.all([
|
|
writeFile(stubPath, FETCH_STUB),
|
|
writeFile(reportPath, `${MARKER}\n# report\n`),
|
|
writeFile(logPath, ''),
|
|
writeFile(summaryPath, ''),
|
|
]);
|
|
const env = {
|
|
...process.env,
|
|
GITHUB_TOKEN: 'token',
|
|
GITHUB_REPOSITORY: 'owner/repo',
|
|
GITHUB_PR_NUMBER: '1',
|
|
GITHUB_STEP_SUMMARY: summaryPath,
|
|
SIZE_REPORT_RETRY_BASE_MS: '1',
|
|
SIZE_REPORT_FETCH_SCRIPT: JSON.stringify(script),
|
|
SIZE_REPORT_FETCH_LOG: logPath,
|
|
};
|
|
let exitCode = 0;
|
|
let stdout = '';
|
|
let stderr = '';
|
|
try {
|
|
({ stdout, stderr } = await execFileAsync(
|
|
process.execPath,
|
|
['--import', stubPath, SCRIPT, '--post-comment', reportPath],
|
|
{ env },
|
|
));
|
|
} catch (error) {
|
|
const failure = error as { code?: number; stdout?: string; stderr?: string };
|
|
exitCode = failure.code ?? 1;
|
|
stdout = failure.stdout ?? '';
|
|
stderr = failure.stderr ?? '';
|
|
}
|
|
const calls = (await readFile(logPath, 'utf8')).trim().split('\n').filter(Boolean);
|
|
const summary = await readFile(summaryPath, 'utf8');
|
|
return { exitCode, stdout, stderr, calls, summary };
|
|
}
|
|
|
|
const COMMENTS_URL = 'https://api.github.com/repos/owner/repo/issues/1/comments';
|
|
const EXISTING_COMMENT_URL = `${COMMENTS_URL}/42`;
|
|
const existingMarkerComment = JSON.stringify([
|
|
{ url: EXISTING_COMMENT_URL, body: `${MARKER}\nold` },
|
|
]);
|
|
|
|
test('a create whose response was lost is reconciled into an update, not a duplicate', async () => {
|
|
const result = await runPostComment([
|
|
{ status: 200 }, // list: nothing yet
|
|
{ status: 'net' }, // create: connection dropped, but it landed server-side
|
|
{ status: 200, body: existingMarkerComment }, // re-list: marker comment now exists
|
|
{ status: 200 }, // update
|
|
]);
|
|
assert.equal(result.exitCode, 0, result.stderr);
|
|
assert.deepEqual(result.calls, [
|
|
`GET ${COMMENTS_URL}?per_page=100`,
|
|
`POST ${COMMENTS_URL}`,
|
|
`GET ${COMMENTS_URL}?per_page=100`,
|
|
`PATCH ${EXISTING_COMMENT_URL}`,
|
|
]);
|
|
});
|
|
|
|
test('transient failures that outlast the retries warn and exit 0', async () => {
|
|
const result = await runPostComment([{ status: 503 }]);
|
|
assert.equal(result.exitCode, 0, result.stderr);
|
|
assert.match(
|
|
result.stdout,
|
|
/^::warning::Skipping PR size comment after transient GitHub failure: .*503/m,
|
|
);
|
|
assert.match(result.summary, /Skipping PR size comment/);
|
|
assert.equal(result.calls.length, 4, 'one list call per attempt');
|
|
});
|
|
|
|
test('a non-transient 4xx is fatal and is not retried', async () => {
|
|
const result = await runPostComment([{ status: 200 }, { status: 401 }]);
|
|
assert.notEqual(result.exitCode, 0);
|
|
assert.match(result.stderr, /Failed to create PR comment: 401/);
|
|
assert.doesNotMatch(result.stdout, /::warning::/);
|
|
assert.equal(result.calls.length, 2, 'no retry after a fatal status');
|
|
});
|