ci: harden GitHub API workflows (#3709)
This commit is contained in:
@@ -18,8 +18,8 @@ class PullRequestDocumentationWorkflowContractTest(unittest.TestCase):
|
||||
self.assertTrue(WORKFLOW.is_file(), "Documentation coverage workflow is missing")
|
||||
self.workflow = WORKFLOW.read_text(encoding="utf-8")
|
||||
|
||||
# @covers AC-CI-PR-DOCS-001.1, AC-CI-PR-DOCS-002.2
|
||||
def test_runs_for_all_pr_and_manual_retry_events(self) -> None:
|
||||
# @covers AC-CI-PR-DOCS-001.1, AC-CI-PR-DOCS-002.2, AC-CI-PR-DOCS-004.4
|
||||
def test_runs_for_relevant_pr_and_manual_retry_events(self) -> None:
|
||||
trigger = self.workflow.partition("on:\n")[2].partition("\nconcurrency:")[0]
|
||||
self.assertIn("pull_request_target:", trigger)
|
||||
for event in (
|
||||
@@ -31,13 +31,32 @@ class PullRequestDocumentationWorkflowContractTest(unittest.TestCase):
|
||||
"unlabeled",
|
||||
"ready_for_review",
|
||||
):
|
||||
self.assertIn(event, trigger)
|
||||
if event == "ready_for_review":
|
||||
self.assertNotIn(event, trigger)
|
||||
else:
|
||||
self.assertIn(event, trigger)
|
||||
self.assertIn("merge_group:", trigger)
|
||||
self.assertIn("checks_requested", trigger)
|
||||
self.assertIn("workflow_dispatch:", trigger)
|
||||
self.assertIn("pr_number:", trigger)
|
||||
self.assertNotIn("paths:", trigger)
|
||||
|
||||
# @covers AC-CI-PR-DOCS-002.2, AC-CI-PR-DOCS-004.4
|
||||
def test_unrelated_label_events_are_rejected_at_the_job_boundary(self) -> None:
|
||||
coverage_job = self.workflow.partition("jobs:\n")[2]
|
||||
self.assertIn("github.event_name != 'pull_request_target'", coverage_job)
|
||||
self.assertIn("github.event.action != 'labeled'", coverage_job)
|
||||
self.assertIn("github.event.action != 'unlabeled'", coverage_job)
|
||||
self.assertIn("github.event.label.name == 'no-docs-allow'", coverage_job)
|
||||
self.assertLess(coverage_job.index("github.event.label.name"), coverage_job.index("runs-on:"))
|
||||
|
||||
# @covers AC-CI-PR-DOCS-004.4
|
||||
def test_description_edits_are_rejected_but_base_retargets_run(self) -> None:
|
||||
coverage_job = self.workflow.partition("jobs:\n")[2]
|
||||
self.assertIn("github.event.action != 'edited'", coverage_job)
|
||||
self.assertIn("github.event.changes.base != null", coverage_job)
|
||||
self.assertLess(coverage_job.index("github.event.changes.base"), coverage_job.index("runs-on:"))
|
||||
|
||||
# @covers AC-CI-PR-DOCS-003.1, AC-CI-PR-DOCS-003.4
|
||||
def test_serializes_prs_without_cancelling_merge_group_runs(self) -> None:
|
||||
self.assertIn("group: >-", self.workflow)
|
||||
|
||||
+509
-58
@@ -67,10 +67,147 @@ const MAX_TOTAL_DOCUMENT_BYTES = 4 * 1024 * 1024;
|
||||
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_CHANGED_FILES = 3000;
|
||||
const REQUEST_TIMEOUT_MS = 30_000;
|
||||
const MAX_REQUEST_ATTEMPTS = 3;
|
||||
const RETRY_BASE_DELAY_MS = 250;
|
||||
const MAX_RETRY_SLEEP_MS = 180_000;
|
||||
const SECONDARY_RATE_LIMIT_DELAYS_MS = [60_000, 120_000];
|
||||
const NO_DOCS_LABEL = 'no-docs-allow';
|
||||
const STATUS_CONTEXT = 'PR documentation coverage';
|
||||
const GITHUB_API = 'https://api.github.com';
|
||||
|
||||
function defaultSleep(delay) {
|
||||
return new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
function defaultLog(message) {
|
||||
console.error(message);
|
||||
}
|
||||
|
||||
function responseHeader(response, name) {
|
||||
if (typeof response?.headers?.get === 'function') {
|
||||
return response.headers.get(name) ?? undefined;
|
||||
}
|
||||
if (!response?.headers || typeof response.headers !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const key = Object.keys(response.headers).find(header =>
|
||||
header.toLowerCase() === name.toLowerCase(),
|
||||
);
|
||||
return key === undefined ? undefined : String(response.headers[key]);
|
||||
}
|
||||
|
||||
function retryAfterDelay(response) {
|
||||
const value = responseHeader(response, 'retry-after');
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (/^\d+(?:\.\d+)?$/.test(trimmed)) {
|
||||
const seconds = Number(trimmed);
|
||||
return Number.isFinite(seconds) ? Math.max(0, seconds * 1000) : undefined;
|
||||
}
|
||||
const timestamp = Date.parse(trimmed);
|
||||
return Number.isFinite(timestamp) ? Math.max(0, timestamp - Date.now()) : undefined;
|
||||
}
|
||||
|
||||
function primaryRateLimitResetDelay(response) {
|
||||
const remaining = Number(responseHeader(response, 'x-ratelimit-remaining'));
|
||||
if (!Number.isFinite(remaining) || remaining > 0) {
|
||||
return undefined;
|
||||
}
|
||||
const reset = Number(responseHeader(response, 'x-ratelimit-reset'));
|
||||
if (!Number.isFinite(reset) || reset <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.max(0, reset * 1000 - Date.now());
|
||||
}
|
||||
|
||||
function isGraphqlRateLimitPayload(payload) {
|
||||
return Array.isArray(payload?.errors)
|
||||
&& payload.errors.some(error => error?.type === 'RATE_LIMITED');
|
||||
}
|
||||
|
||||
function isRateLimitResponse(response, payload, requestClass) {
|
||||
if (requestClass === 'merge-queue' && isGraphqlRateLimitPayload(payload)) {
|
||||
return true;
|
||||
}
|
||||
if (response?.status === 429) {
|
||||
return true;
|
||||
}
|
||||
if (response?.status !== 403) {
|
||||
return false;
|
||||
}
|
||||
const remaining = Number(responseHeader(response, 'x-ratelimit-remaining'));
|
||||
return (
|
||||
(Number.isFinite(remaining) && remaining <= 0)
|
||||
|| responseHeader(response, 'retry-after') !== undefined
|
||||
|| /rate limit|secondary rate limit|abuse detection|temporarily unavailable/i.test(
|
||||
String(payload?.message ?? ''),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function isRetryableResponse(response, payload, requestClass) {
|
||||
const status = response?.status;
|
||||
return (
|
||||
status === 408
|
||||
|| status === 429
|
||||
|| (Number.isInteger(status) && status >= 500 && status <= 599)
|
||||
|| isRateLimitResponse(response, payload, requestClass)
|
||||
);
|
||||
}
|
||||
|
||||
function retryPlan(response, payload, attemptIndex, category, requestClass) {
|
||||
const rateLimited = isRateLimitResponse(response, payload, requestClass);
|
||||
const serverDelay = retryAfterDelay(response);
|
||||
if (serverDelay !== undefined) {
|
||||
return { delay: serverDelay, reason: 'server-wait' };
|
||||
}
|
||||
if (rateLimited) {
|
||||
const resetDelay = primaryRateLimitResetDelay(response);
|
||||
if (resetDelay !== undefined) {
|
||||
return { delay: resetDelay, reason: 'primary-rate-limit-reset' };
|
||||
}
|
||||
return {
|
||||
delay: SECONDARY_RATE_LIMIT_DELAYS_MS[attemptIndex] ?? MAX_RETRY_SLEEP_MS,
|
||||
reason: 'secondary-rate-limit-fallback',
|
||||
};
|
||||
}
|
||||
if (category === 'transport' || category === 'timeout' || isRetryableResponse(response, payload)) {
|
||||
return {
|
||||
delay: Math.min(RETRY_BASE_DELAY_MS * (2 ** attemptIndex), 5_000),
|
||||
reason: 'short-exponential-backoff',
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function requestClassForEndpoint(endpoint, method) {
|
||||
const pathname = endpoint.split('?', 1)[0];
|
||||
if (method === 'POST' && pathname.includes('/statuses/')) {
|
||||
return 'commit-status';
|
||||
}
|
||||
if (pathname.includes('/pulls/') && pathname.endsWith('/files')) {
|
||||
return 'changed-files';
|
||||
}
|
||||
if (pathname === '/search/code') {
|
||||
return 'code-search';
|
||||
}
|
||||
if (pathname === '/graphql') {
|
||||
return 'merge-queue';
|
||||
}
|
||||
if (pathname.includes('/pulls/')) {
|
||||
return 'pull-request';
|
||||
}
|
||||
return 'github-api';
|
||||
}
|
||||
|
||||
function transportCategory(error) {
|
||||
return error?.name === 'TimeoutError' || error?.name === 'AbortError'
|
||||
? 'timeout'
|
||||
: 'transport';
|
||||
}
|
||||
|
||||
function normalizeRepoPath(value) {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new Error('repository path must be a non-empty string');
|
||||
@@ -775,7 +912,14 @@ function requirePullRequestNumber(value) {
|
||||
}
|
||||
|
||||
class GitHubClient {
|
||||
constructor({ owner, repo, token, fetchImpl = globalThis.fetch } = {}) {
|
||||
constructor({
|
||||
owner,
|
||||
repo,
|
||||
token,
|
||||
fetchImpl = globalThis.fetch,
|
||||
sleepImpl = defaultSleep,
|
||||
logImpl = defaultLog,
|
||||
} = {}) {
|
||||
if (typeof owner !== 'string' || typeof repo !== 'string' || owner === '' || repo === '') {
|
||||
throw new Error('GitHub repository owner and name are required');
|
||||
}
|
||||
@@ -785,16 +929,28 @@ class GitHubClient {
|
||||
if (typeof fetchImpl !== 'function') {
|
||||
throw new Error('fetch implementation is required');
|
||||
}
|
||||
if (typeof sleepImpl !== 'function') {
|
||||
throw new Error('sleep implementation is required');
|
||||
}
|
||||
if (typeof logImpl !== 'function') {
|
||||
throw new Error('log implementation is required');
|
||||
}
|
||||
this.owner = owner;
|
||||
this.repo = repo;
|
||||
this.token = token;
|
||||
this.fetchImpl = fetchImpl;
|
||||
this.sleepImpl = sleepImpl;
|
||||
this.logImpl = logImpl;
|
||||
this.sleptMs = 0;
|
||||
}
|
||||
|
||||
async request(endpoint, { method = 'GET', body } = {}) {
|
||||
async request(endpoint, { method = 'GET', body, requestClass: requestedClass } = {}) {
|
||||
if (typeof endpoint !== 'string' || !endpoint.startsWith('/')) {
|
||||
throw new Error('GitHub endpoint must be an absolute API path');
|
||||
}
|
||||
const requestClass = typeof requestedClass === 'string' && requestedClass.length > 0
|
||||
? requestedClass
|
||||
: requestClassForEndpoint(endpoint, method);
|
||||
const headers = {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
@@ -803,51 +959,252 @@ class GitHubClient {
|
||||
if (body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
const timeoutSignal = typeof AbortSignal === 'function'
|
||||
&& typeof AbortSignal.timeout === 'function'
|
||||
? AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
||||
: undefined;
|
||||
let response;
|
||||
try {
|
||||
response = await this.fetchImpl(`${GITHUB_API}${endpoint}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
...(timeoutSignal ? { signal: timeoutSignal } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.name === 'TimeoutError' || error?.name === 'AbortError') {
|
||||
throw new Error(`GitHub API request timed out after ${REQUEST_TIMEOUT_MS}ms`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!response || typeof response.text !== 'function') {
|
||||
throw new Error('GitHub API returned an invalid response');
|
||||
}
|
||||
const text = await response.text();
|
||||
if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_BYTES) {
|
||||
throw new Error(`GitHub API response exceeds the ${MAX_RESPONSE_BYTES}-byte limit`);
|
||||
}
|
||||
let payload = null;
|
||||
if (text.length > 0) {
|
||||
|
||||
const statusForLog = response => Number.isInteger(response?.status)
|
||||
? `HTTP ${response.status}`
|
||||
: 'none';
|
||||
const log = message => {
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
this.logImpl(message);
|
||||
} catch {
|
||||
throw new Error('GitHub API returned invalid JSON');
|
||||
// Diagnostics must not hide the request result.
|
||||
}
|
||||
};
|
||||
const terminalError = ({
|
||||
category,
|
||||
response,
|
||||
attempt,
|
||||
outcome,
|
||||
message,
|
||||
nextDelay,
|
||||
}) => {
|
||||
const status = statusForLog(response);
|
||||
const diagnostic = [
|
||||
`class=${requestClass}`,
|
||||
`category=${category}`,
|
||||
`status=${status}`,
|
||||
`attempts=${attempt}`,
|
||||
`outcome=${outcome}`,
|
||||
...(nextDelay === undefined ? [] : [`next_delay_ms=${nextDelay}`]),
|
||||
].join(' ');
|
||||
log(`GitHub API request stopped: ${diagnostic}`);
|
||||
if (message) {
|
||||
return new Error(`${message} (${diagnostic})`);
|
||||
}
|
||||
return new Error(`GitHub API request failed (${diagnostic})`);
|
||||
};
|
||||
const waitBeforeRetry = async ({ response, category, attempt, plan }) => {
|
||||
const remainingMs = MAX_RETRY_SLEEP_MS - this.sleptMs;
|
||||
if (plan.delay > remainingMs) {
|
||||
return false;
|
||||
}
|
||||
log(
|
||||
`GitHub API request retry: class=${requestClass} category=${category} `
|
||||
+ `status=${statusForLog(response)} attempt=${attempt + 1}/${MAX_REQUEST_ATTEMPTS} `
|
||||
+ `delay_ms=${plan.delay} reason=${plan.reason}`,
|
||||
);
|
||||
try {
|
||||
await this.sleepImpl(plan.delay);
|
||||
} catch {
|
||||
throw terminalError({
|
||||
category: 'sleep',
|
||||
response,
|
||||
attempt: attempt + 1,
|
||||
outcome: 'retry-aborted',
|
||||
});
|
||||
}
|
||||
this.sleptMs += plan.delay;
|
||||
return true;
|
||||
};
|
||||
|
||||
for (let attempt = 0; attempt < MAX_REQUEST_ATTEMPTS; attempt += 1) {
|
||||
const timeoutSignal = typeof AbortSignal === 'function'
|
||||
&& typeof AbortSignal.timeout === 'function'
|
||||
? AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
||||
: undefined;
|
||||
let response;
|
||||
try {
|
||||
response = await this.fetchImpl(`${GITHUB_API}${endpoint}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
...(timeoutSignal ? { signal: timeoutSignal } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
const category = transportCategory(error);
|
||||
const plan = retryPlan(undefined, undefined, attempt, category, requestClass);
|
||||
if (attempt + 1 < MAX_REQUEST_ATTEMPTS && plan) {
|
||||
if (await waitBeforeRetry({ category, attempt, plan })) {
|
||||
continue;
|
||||
}
|
||||
throw terminalError({
|
||||
category,
|
||||
attempt: attempt + 1,
|
||||
outcome: 'wait-budget-exhausted',
|
||||
nextDelay: plan.delay,
|
||||
});
|
||||
}
|
||||
throw terminalError({
|
||||
category,
|
||||
attempt: attempt + 1,
|
||||
message: category === 'timeout'
|
||||
? `GitHub API request timed out after ${REQUEST_TIMEOUT_MS}ms`
|
||||
: undefined,
|
||||
outcome: 'retry-exhausted',
|
||||
});
|
||||
}
|
||||
if (!response || typeof response.text !== 'function') {
|
||||
throw terminalError({
|
||||
category: 'protocol',
|
||||
attempt: attempt + 1,
|
||||
message: 'GitHub API returned an invalid response',
|
||||
outcome: 'permanent',
|
||||
});
|
||||
}
|
||||
|
||||
let text;
|
||||
try {
|
||||
text = await response.text();
|
||||
} catch (error) {
|
||||
const retryable = response.ok || isRetryableResponse(response, undefined, requestClass);
|
||||
const category = retryable ? transportCategory(error) : 'http';
|
||||
const plan = retryable
|
||||
? retryPlan(response, undefined, attempt, category, requestClass)
|
||||
: undefined;
|
||||
if (retryable && attempt + 1 < MAX_REQUEST_ATTEMPTS && plan) {
|
||||
if (await waitBeforeRetry({ response, category, attempt, plan })) {
|
||||
continue;
|
||||
}
|
||||
throw terminalError({
|
||||
category,
|
||||
response,
|
||||
attempt: attempt + 1,
|
||||
outcome: 'wait-budget-exhausted',
|
||||
nextDelay: plan.delay,
|
||||
});
|
||||
}
|
||||
throw terminalError({
|
||||
category,
|
||||
response,
|
||||
attempt: attempt + 1,
|
||||
outcome: retryable ? 'retry-exhausted' : 'permanent',
|
||||
});
|
||||
}
|
||||
if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_BYTES) {
|
||||
throw terminalError({
|
||||
category: 'response-too-large',
|
||||
response,
|
||||
attempt: attempt + 1,
|
||||
message: `GitHub API response exceeds the ${MAX_RESPONSE_BYTES}-byte limit`,
|
||||
outcome: 'permanent',
|
||||
});
|
||||
}
|
||||
let payload = null;
|
||||
if (text.length > 0) {
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
const retryable = !response.ok && isRetryableResponse(response, undefined, requestClass);
|
||||
// The body is unavailable after parsing fails, so headers are the only signal.
|
||||
const category = isRateLimitResponse(response, undefined, requestClass)
|
||||
? 'rate-limit'
|
||||
: response.status >= 500
|
||||
? 'server'
|
||||
: 'protocol';
|
||||
const plan = retryable
|
||||
? retryPlan(response, undefined, attempt, category, requestClass)
|
||||
: undefined;
|
||||
if (
|
||||
retryable
|
||||
&& attempt + 1 < MAX_REQUEST_ATTEMPTS
|
||||
&& plan
|
||||
) {
|
||||
if (await waitBeforeRetry({ response, category, attempt, plan })) {
|
||||
continue;
|
||||
}
|
||||
throw terminalError({
|
||||
category,
|
||||
response,
|
||||
attempt: attempt + 1,
|
||||
outcome: 'wait-budget-exhausted',
|
||||
nextDelay: plan.delay,
|
||||
});
|
||||
}
|
||||
throw terminalError({
|
||||
category,
|
||||
response,
|
||||
attempt: attempt + 1,
|
||||
message: response.ok
|
||||
? 'GitHub API returned invalid JSON'
|
||||
: `GitHub API request failed with HTTP ${response.status}: invalid JSON`,
|
||||
outcome: retryable ? 'retry-exhausted' : 'permanent',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (response.ok && isRateLimitResponse(response, payload, requestClass)) {
|
||||
const category = 'rate-limit';
|
||||
const plan = retryPlan(response, payload, attempt, category, requestClass);
|
||||
if (attempt + 1 < MAX_REQUEST_ATTEMPTS && plan) {
|
||||
if (await waitBeforeRetry({ response, category, attempt, plan })) {
|
||||
continue;
|
||||
}
|
||||
throw terminalError({
|
||||
category,
|
||||
response,
|
||||
attempt: attempt + 1,
|
||||
outcome: 'wait-budget-exhausted',
|
||||
nextDelay: plan.delay,
|
||||
});
|
||||
}
|
||||
throw terminalError({
|
||||
category,
|
||||
response,
|
||||
attempt: attempt + 1,
|
||||
outcome: 'retry-exhausted',
|
||||
});
|
||||
}
|
||||
if (!response.ok) {
|
||||
const category = isRateLimitResponse(response, payload, requestClass)
|
||||
? 'rate-limit'
|
||||
: response.status >= 500
|
||||
? 'server'
|
||||
: 'http';
|
||||
const plan = retryPlan(response, payload, attempt, category, requestClass);
|
||||
if (
|
||||
attempt + 1 < MAX_REQUEST_ATTEMPTS
|
||||
&& isRetryableResponse(response, payload, requestClass)
|
||||
&& plan
|
||||
) {
|
||||
if (await waitBeforeRetry({ response, category, attempt, plan })) {
|
||||
continue;
|
||||
}
|
||||
throw terminalError({
|
||||
category,
|
||||
response,
|
||||
attempt: attempt + 1,
|
||||
outcome: 'wait-budget-exhausted',
|
||||
nextDelay: plan.delay,
|
||||
});
|
||||
}
|
||||
throw terminalError({
|
||||
category,
|
||||
response,
|
||||
attempt: attempt + 1,
|
||||
message: `GitHub API request failed with HTTP ${response.status}`,
|
||||
outcome: isRetryableResponse(response, payload, requestClass)
|
||||
? 'retry-exhausted'
|
||||
: 'permanent',
|
||||
});
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail =
|
||||
payload && typeof payload.message === 'string' ? `: ${payload.message.slice(0, 200)}` : '';
|
||||
throw new Error(`GitHub API request failed with HTTP ${response.status}${detail}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async getPullRequest(number) {
|
||||
requirePullRequestNumber(number);
|
||||
const pullRequest = await this.request(
|
||||
`/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(this.repo)}/pulls/${number}`,
|
||||
{ requestClass: 'pull-request' },
|
||||
);
|
||||
if (!pullRequest || pullRequest.number !== number) {
|
||||
throw new Error('GitHub returned a pull request with the wrong number');
|
||||
@@ -876,6 +1233,7 @@ class GitHubClient {
|
||||
for (let page = 1; page <= 30; page += 1) {
|
||||
const pageFiles = await this.request(
|
||||
`/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(this.repo)}/pulls/${number}/files?per_page=100&page=${page}`,
|
||||
{ requestClass: 'changed-files' },
|
||||
);
|
||||
if (!Array.isArray(pageFiles)) {
|
||||
throw new Error('GitHub changed-file response is not a list');
|
||||
@@ -919,6 +1277,7 @@ class GitHubClient {
|
||||
const encodedPath = normalized.split('/').map(encodeURIComponent).join('/');
|
||||
const response = await this.request(
|
||||
`/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(this.repo)}/contents/${encodedPath}?ref=${encodeURIComponent(ref)}`,
|
||||
{ requestClass: 'file-content' },
|
||||
);
|
||||
if (typeof response?.path !== 'string' || normalizeRepoPath(response.path) !== normalized) {
|
||||
throw new Error(`${normalized} response has a mismatched returned path`);
|
||||
@@ -949,6 +1308,7 @@ class GitHubClient {
|
||||
const encodedPath = normalized.split('/').map(encodeURIComponent).join('/');
|
||||
const response = await this.request(
|
||||
`/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(this.repo)}/contents/${encodedPath}?ref=${encodeURIComponent(ref)}`,
|
||||
{ requestClass: 'directory-listing' },
|
||||
);
|
||||
if (!Array.isArray(response)) {
|
||||
throw new Error(`${normalized} is not a directory listing`);
|
||||
@@ -973,6 +1333,7 @@ class GitHubClient {
|
||||
const query = `"${text}" repo:${this.owner}/${this.repo} path:${normalizedDirectory}`;
|
||||
const response = await this.request(
|
||||
`/search/code?q=${encodeURIComponent(query)}&per_page=100`,
|
||||
{ requestClass: 'code-search' },
|
||||
);
|
||||
if (
|
||||
!response
|
||||
@@ -1008,6 +1369,7 @@ class GitHubClient {
|
||||
`/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(this.repo)}/statuses/${sha}`,
|
||||
{
|
||||
method: 'POST',
|
||||
requestClass: 'commit-status',
|
||||
body: {
|
||||
state: status.state,
|
||||
context: STATUS_CONTEXT,
|
||||
@@ -1021,6 +1383,7 @@ class GitHubClient {
|
||||
async graphql(query, variables) {
|
||||
const response = await this.request('/graphql', {
|
||||
method: 'POST',
|
||||
requestClass: 'merge-queue',
|
||||
body: { query, variables },
|
||||
});
|
||||
if (Array.isArray(response?.errors) && response.errors.length > 0) {
|
||||
@@ -1117,36 +1480,88 @@ function isMissingResourceError(error) {
|
||||
return /\bHTTP 404\b/.test(String(error?.message ?? error));
|
||||
}
|
||||
|
||||
async function loadCoverageContents({ client, changedFiles, headSha }) {
|
||||
function hasRequirementHeading(content, requirementId) {
|
||||
return typeof content === 'string' && requirementHeadingPattern(requirementId).test(content);
|
||||
}
|
||||
|
||||
function changedRequirementSources(changedFiles, requirementDirectory) {
|
||||
const sources = new Map();
|
||||
const prefix = `${requirementDirectory}/`;
|
||||
for (const change of changedFiles ?? []) {
|
||||
const currentPath = typeof change === 'string' ? change : change?.filename;
|
||||
if (typeof currentPath !== 'string') {
|
||||
continue;
|
||||
}
|
||||
let normalizedCurrent;
|
||||
try {
|
||||
normalizedCurrent = normalizeRepoPath(currentPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const status = typeof change === 'string' ? undefined : change?.status;
|
||||
const headPath = status === 'removed' ? undefined : normalizedCurrent;
|
||||
let basePath = normalizedCurrent;
|
||||
if (status === 'renamed' && typeof change.previous_filename === 'string') {
|
||||
try {
|
||||
basePath = normalizeRepoPath(change.previous_filename);
|
||||
} catch {
|
||||
basePath = undefined;
|
||||
}
|
||||
}
|
||||
const normalizedHead = headPath && headPath.startsWith(prefix) && headPath.endsWith('.md')
|
||||
? headPath
|
||||
: undefined;
|
||||
const normalizedBase = basePath && basePath.startsWith(prefix) && basePath.endsWith('.md')
|
||||
? basePath
|
||||
: undefined;
|
||||
if (!normalizedHead && !normalizedBase) {
|
||||
continue;
|
||||
}
|
||||
const key = normalizedHead ?? `base:${normalizedBase}`;
|
||||
if (!sources.has(key)) {
|
||||
sources.set(key, { basePath: normalizedBase, headPath: normalizedHead });
|
||||
}
|
||||
}
|
||||
return [...sources.values()];
|
||||
}
|
||||
|
||||
async function loadCoverageContents({ client, changedFiles, headSha, baseSha }) {
|
||||
if (!classifyChangedFiles(changedFiles).requiresCoverage) {
|
||||
return {};
|
||||
}
|
||||
const contents = {};
|
||||
const loaded = new Set();
|
||||
const loaded = new Map();
|
||||
const requirementSearches = new Map();
|
||||
const requirementDirectories = new Map();
|
||||
let documentCount = 0;
|
||||
let totalBytes = 0;
|
||||
async function load(pathname) {
|
||||
async function load(pathname, ref, targetContents) {
|
||||
const normalized = normalizeRepoPath(pathname);
|
||||
if (loaded.has(normalized)) {
|
||||
return contents[normalized];
|
||||
const cacheKey = `${ref}\u0000${normalized}`;
|
||||
if (loaded.has(cacheKey)) {
|
||||
const content = loaded.get(cacheKey);
|
||||
if (targetContents) {
|
||||
targetContents[normalized] = content;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
if (documentCount >= MAX_DOCUMENTS) {
|
||||
throw new Error(`Referenced documents exceed the ${MAX_DOCUMENTS}-document limit`);
|
||||
}
|
||||
let content;
|
||||
try {
|
||||
content = await client.getFile(normalized, headSha);
|
||||
content = await client.getFile(normalized, ref);
|
||||
} catch (error) {
|
||||
if (!isMissingResourceError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
loaded.add(normalized);
|
||||
loaded.set(cacheKey, content);
|
||||
documentCount += 1;
|
||||
if (targetContents) {
|
||||
targetContents[normalized] = content;
|
||||
}
|
||||
if (typeof content !== 'string' || content.trim().length === 0) {
|
||||
contents[normalized] = undefined;
|
||||
return undefined;
|
||||
}
|
||||
const size = Buffer.byteLength(content, 'utf8');
|
||||
@@ -1157,12 +1572,11 @@ async function loadCoverageContents({ client, changedFiles, headSha }) {
|
||||
if (totalBytes > MAX_TOTAL_DOCUMENT_BYTES) {
|
||||
throw new Error(`Referenced documents exceed the ${MAX_TOTAL_DOCUMENT_BYTES}-byte total limit`);
|
||||
}
|
||||
contents[normalized] = content;
|
||||
return content;
|
||||
}
|
||||
|
||||
for (const workOrderPath of selectChangedWorkOrders(changedFiles)) {
|
||||
const workOrderContent = await load(workOrderPath);
|
||||
const workOrderContent = await load(workOrderPath, headSha, contents);
|
||||
if (workOrderContent === undefined) {
|
||||
continue;
|
||||
}
|
||||
@@ -1178,7 +1592,7 @@ async function loadCoverageContents({ client, changedFiles, headSha }) {
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const planContent = await load(planPath);
|
||||
const planContent = await load(planPath, headSha, contents);
|
||||
if (planContent === undefined) {
|
||||
continue;
|
||||
}
|
||||
@@ -1201,7 +1615,7 @@ async function loadCoverageContents({ client, changedFiles, headSha }) {
|
||||
}
|
||||
}
|
||||
for (const designPath of designPaths) {
|
||||
const designContent = await load(designPath);
|
||||
const designContent = await load(designPath, headSha, contents);
|
||||
if (designContent === undefined) {
|
||||
continue;
|
||||
}
|
||||
@@ -1222,19 +1636,44 @@ async function loadCoverageContents({ client, changedFiles, headSha }) {
|
||||
}
|
||||
const requirementDirectory = `docs/specs/${system}/requirements`;
|
||||
const requirementPaths = new Set();
|
||||
const changedRepositoryPaths = changedPaths(changedFiles).paths;
|
||||
for (const pathname of changedRepositoryPaths) {
|
||||
if (pathname.startsWith(`${requirementDirectory}/`) && pathname.endsWith('.md')) {
|
||||
requirementPaths.add(pathname);
|
||||
const baseContentByHeadPath = new Map();
|
||||
for (const source of changedRequirementSources(changedFiles, requirementDirectory)) {
|
||||
if (source.headPath) {
|
||||
requirementPaths.add(source.headPath);
|
||||
await load(source.headPath, headSha, contents);
|
||||
}
|
||||
if (source.basePath) {
|
||||
const baseContent = await load(source.basePath, baseSha);
|
||||
if (source.headPath) {
|
||||
baseContentByHeadPath.set(source.headPath, baseContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
const unresolvedRequirementIds = [];
|
||||
const workOrderRequirements = new Set(workOrder.requirements ?? []);
|
||||
const referencedRequirementIds = designRequirements.filter(requirementId =>
|
||||
workOrderRequirements.has(requirementId)
|
||||
);
|
||||
const verifiedRequirementIds = new Set();
|
||||
for (const requirementId of referencedRequirementIds) {
|
||||
const definitions = requirementDefinitions(contents, requirementId, system);
|
||||
if (definitions.length !== 1) {
|
||||
continue;
|
||||
}
|
||||
const definition = definitions[0];
|
||||
const baseContent = baseContentByHeadPath.get(definition.pathname);
|
||||
if (
|
||||
hasRequirementHeading(definition.content, requirementId)
|
||||
&& hasRequirementHeading(baseContent, requirementId)
|
||||
) {
|
||||
verifiedRequirementIds.add(requirementId);
|
||||
}
|
||||
}
|
||||
const unresolvedRequirementIds = [];
|
||||
if (typeof client.searchCode === 'function') {
|
||||
for (const requirementId of referencedRequirementIds) {
|
||||
if (verifiedRequirementIds.has(requirementId)) {
|
||||
continue;
|
||||
}
|
||||
const searchKey = JSON.stringify([requirementDirectory, requirementId]);
|
||||
if (!requirementSearches.has(searchKey)) {
|
||||
requirementSearches.set(
|
||||
@@ -1251,7 +1690,9 @@ async function loadCoverageContents({ client, changedFiles, headSha }) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
unresolvedRequirementIds.push(...referencedRequirementIds);
|
||||
unresolvedRequirementIds.push(
|
||||
...referencedRequirementIds.filter(requirementId => !verifiedRequirementIds.has(requirementId)),
|
||||
);
|
||||
}
|
||||
if (unresolvedRequirementIds.length > 0 && typeof client.listDirectory === 'function') {
|
||||
if (!requirementDirectories.has(requirementDirectory)) {
|
||||
@@ -1277,7 +1718,7 @@ async function loadCoverageContents({ client, changedFiles, headSha }) {
|
||||
}
|
||||
}
|
||||
for (const pathname of requirementPaths) {
|
||||
const requirementContent = await load(pathname);
|
||||
const requirementContent = await load(pathname, headSha, contents);
|
||||
if (requirementContent === undefined) {
|
||||
continue;
|
||||
}
|
||||
@@ -1318,6 +1759,7 @@ async function evaluatePullRequestSnapshot({ client, pullRequest, expectedHeadSh
|
||||
}
|
||||
const changedFiles = await client.listFiles(pullRequest.number, pullRequest.changed_files);
|
||||
const fileContents = await loadCoverageContents({
|
||||
baseSha: pullRequest.base.sha,
|
||||
changedFiles,
|
||||
client,
|
||||
headSha: pullRequest.head.sha,
|
||||
@@ -1328,16 +1770,24 @@ async function evaluatePullRequestSnapshot({ client, pullRequest, expectedHeadSh
|
||||
};
|
||||
}
|
||||
|
||||
async function evaluatePullRequest({ client, pullNumber, expectedHeadSha, maxAttempts = 3 } = {}) {
|
||||
async function evaluatePullRequest({
|
||||
client,
|
||||
pullNumber,
|
||||
expectedHeadSha,
|
||||
maxAttempts = 3,
|
||||
initialPullRequest,
|
||||
} = {}) {
|
||||
requirePullRequestNumber(pullNumber);
|
||||
if (!Number.isInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 5) {
|
||||
throw new Error('maxAttempts must be an integer from 1 through 5');
|
||||
}
|
||||
let lastMetadata;
|
||||
let firstSnapshot = initialPullRequest;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
let pullRequest;
|
||||
try {
|
||||
pullRequest = await client.getPullRequest(pullNumber);
|
||||
pullRequest = firstSnapshot ?? await client.getPullRequest(pullNumber);
|
||||
firstSnapshot = undefined;
|
||||
const before = resultMetadata(pullRequest);
|
||||
if (expectedHeadSha && pullRequest.head.sha !== expectedHeadSha) {
|
||||
return errorCoverageResult(
|
||||
@@ -1734,6 +2184,7 @@ async function run({ client, env = process.env, event, eventName, writeSummary }
|
||||
});
|
||||
result = await evaluatePullRequest({
|
||||
client: apiClient,
|
||||
initialPullRequest: current,
|
||||
pullNumber,
|
||||
});
|
||||
await publishResult(apiClient, result.headSha ?? pendingSha, result, targetUrl);
|
||||
|
||||
@@ -730,6 +730,560 @@ test('GitHub client rejects incomplete pages and decodes bounded file contents',
|
||||
assert.equal(requests[1].options.headers.Authorization, 'Bearer token-is-not-logged');
|
||||
});
|
||||
|
||||
test('GitHub client retries a transient response with the server delay', async () => {
|
||||
let calls = 0;
|
||||
const delays = [];
|
||||
const client = new validator.GitHubClient({
|
||||
owner: 'kdlbs',
|
||||
repo: 'kandev',
|
||||
token: 'token',
|
||||
sleepImpl: async delay => delays.push(delay),
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 503,
|
||||
headers: { 'retry-after': '0' },
|
||||
async text() {
|
||||
return JSON.stringify({ message: 'Service Unavailable' });
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
async text() {
|
||||
return JSON.stringify({
|
||||
number: 42,
|
||||
head: { sha: SHA_B },
|
||||
base: { sha: SHA_A, ref: 'main' },
|
||||
labels: [],
|
||||
changed_files: 0,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await client.getPullRequest(42);
|
||||
|
||||
assert.equal(result.head.sha, SHA_B);
|
||||
assert.equal(calls, 2);
|
||||
assert.deepEqual(delays, [0]);
|
||||
});
|
||||
|
||||
test('GitHub client uses the documented secondary-limit delays without headers', async () => {
|
||||
let calls = 0;
|
||||
const delays = [];
|
||||
const logs = [];
|
||||
const client = new validator.GitHubClient({
|
||||
owner: 'kdlbs',
|
||||
repo: 'kandev',
|
||||
token: 'secret-token',
|
||||
sleepImpl: async delay => delays.push(delay),
|
||||
logImpl: message => logs.push(message),
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return {
|
||||
ok: false,
|
||||
status: 429,
|
||||
async text() {
|
||||
return JSON.stringify({ message: 'private response details' });
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(client.getPullRequest(42), /HTTP 429/);
|
||||
|
||||
assert.equal(calls, 3);
|
||||
assert.deepEqual(delays, [60_000, 120_000]);
|
||||
assert.equal(logs.some(log => log.includes('delay_ms=60000')), true);
|
||||
assert.equal(logs.some(log => log.includes('delay_ms=120000')), true);
|
||||
assert.equal(logs.every(log => !log.includes('secret-token')), true);
|
||||
assert.equal(logs.every(log => !log.includes('private response details')), true);
|
||||
assert.equal(logs.every(log => !log.includes('q=')), true);
|
||||
assert.equal(logs.some(log => log.includes('outcome=retry-exhausted')), true);
|
||||
});
|
||||
|
||||
test('GitHub client shares the rate-limit sleep budget across requests', async () => {
|
||||
let calls = 0;
|
||||
const delays = [];
|
||||
const client = new validator.GitHubClient({
|
||||
owner: 'kdlbs',
|
||||
repo: 'kandev',
|
||||
token: 'token',
|
||||
sleepImpl: async delay => delays.push(delay),
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
if (calls <= 2 || calls === 4) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 429,
|
||||
async text() {
|
||||
return JSON.stringify({ message: 'secondary rate limit' });
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
async text() {
|
||||
return JSON.stringify({
|
||||
number: 42,
|
||||
head: { sha: SHA_B },
|
||||
base: { sha: SHA_A, ref: 'main' },
|
||||
labels: [],
|
||||
changed_files: 0,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await client.getPullRequest(42);
|
||||
await assert.rejects(client.getPullRequest(42), /wait-budget-exhausted/);
|
||||
|
||||
assert.equal(calls, 4);
|
||||
assert.deepEqual(delays, [60_000, 120_000]);
|
||||
});
|
||||
|
||||
test('GitHub client retries rate-limited GraphQL error payloads', async () => {
|
||||
let calls = 0;
|
||||
const delays = [];
|
||||
const client = new validator.GitHubClient({
|
||||
owner: 'kdlbs',
|
||||
repo: 'kandev',
|
||||
token: 'token',
|
||||
sleepImpl: async delay => delays.push(delay),
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
async text() {
|
||||
return JSON.stringify({
|
||||
data: null,
|
||||
errors: [{ type: 'RATE_LIMITED', message: 'rate limit exceeded' }],
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
async text() {
|
||||
return JSON.stringify({
|
||||
data: { repository: { mergeQueue: { entries: { nodes: [] } } } },
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await client.graphql('query { repository { name } }', {});
|
||||
|
||||
assert.deepEqual(result, { repository: { mergeQueue: { entries: { nodes: [] } } } });
|
||||
assert.equal(calls, 2);
|
||||
assert.deepEqual(delays, [60_000]);
|
||||
});
|
||||
|
||||
test('GitHub client does not retry unrelated GraphQL errors', async () => {
|
||||
let calls = 0;
|
||||
const delays = [];
|
||||
const client = new validator.GitHubClient({
|
||||
owner: 'kdlbs',
|
||||
repo: 'kandev',
|
||||
token: 'token',
|
||||
sleepImpl: async delay => delays.push(delay),
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
async text() {
|
||||
return JSON.stringify({
|
||||
data: null,
|
||||
errors: [{ type: 'FORBIDDEN', message: 'permission denied' }],
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(client.graphql('query { repository { name } }', {}), /permission denied/);
|
||||
|
||||
assert.equal(calls, 1);
|
||||
assert.deepEqual(delays, []);
|
||||
});
|
||||
|
||||
test('GitHub client retries status writes with the original request payload', async () => {
|
||||
const requests = [];
|
||||
let calls = 0;
|
||||
const client = new validator.GitHubClient({
|
||||
owner: 'kdlbs',
|
||||
repo: 'kandev',
|
||||
token: 'token',
|
||||
sleepImpl: async () => {},
|
||||
logImpl: () => {},
|
||||
fetchImpl: async (url, options) => {
|
||||
calls += 1;
|
||||
requests.push({ url, body: options.body });
|
||||
return {
|
||||
ok: calls > 1,
|
||||
status: calls > 1 ? 201 : 503,
|
||||
headers: { 'retry-after': '0' },
|
||||
async text() {
|
||||
return calls > 1 ? '{}' : JSON.stringify({ message: 'temporary failure' });
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await client.createCommitStatus(SHA_B, {
|
||||
description: 'Coverage failed',
|
||||
state: 'failure',
|
||||
targetUrl: 'https://github.com/kdlbs/kandev/actions/runs/42',
|
||||
});
|
||||
|
||||
assert.equal(calls, 2);
|
||||
assert.deepEqual(requests[1], requests[0]);
|
||||
});
|
||||
|
||||
test('GitHub client uses a due primary rate-limit reset', async () => {
|
||||
let calls = 0;
|
||||
const delays = [];
|
||||
const client = new validator.GitHubClient({
|
||||
owner: 'kdlbs',
|
||||
repo: 'kandev',
|
||||
token: 'token',
|
||||
sleepImpl: async delay => delays.push(delay),
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
headers: {
|
||||
'x-ratelimit-remaining': '0',
|
||||
'x-ratelimit-reset': String(Math.floor(Date.now() / 1000)),
|
||||
},
|
||||
async text() {
|
||||
return JSON.stringify({ message: 'API rate limit exceeded' });
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
async text() {
|
||||
return JSON.stringify({
|
||||
number: 42,
|
||||
head: { sha: SHA_B },
|
||||
base: { sha: SHA_A, ref: 'main' },
|
||||
labels: [],
|
||||
changed_files: 0,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await client.getPullRequest(42);
|
||||
|
||||
assert.equal(result.number, 42);
|
||||
assert.equal(calls, 2);
|
||||
assert.deepEqual(delays, [0]);
|
||||
});
|
||||
|
||||
test('GitHub client stops before an explicit wait exceeds the sleep budget', async () => {
|
||||
let calls = 0;
|
||||
const delays = [];
|
||||
const logs = [];
|
||||
const client = new validator.GitHubClient({
|
||||
owner: 'kdlbs',
|
||||
repo: 'kandev',
|
||||
token: 'token',
|
||||
sleepImpl: async delay => delays.push(delay),
|
||||
logImpl: message => logs.push(message),
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return {
|
||||
ok: false,
|
||||
status: 503,
|
||||
headers: { 'retry-after': '181' },
|
||||
async text() {
|
||||
return JSON.stringify({ message: 'Service Unavailable' });
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(client.getPullRequest(42), /wait-budget-exhausted/);
|
||||
|
||||
assert.equal(calls, 1);
|
||||
assert.deepEqual(delays, []);
|
||||
assert.equal(logs.some(log => log.includes('next_delay_ms=181000')), true);
|
||||
});
|
||||
|
||||
test('GitHub client retries transport failures with short exponential backoff', async () => {
|
||||
let calls = 0;
|
||||
const delays = [];
|
||||
const client = new validator.GitHubClient({
|
||||
owner: 'kdlbs',
|
||||
repo: 'kandev',
|
||||
token: 'token',
|
||||
sleepImpl: async delay => delays.push(delay),
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
if (calls < 3) {
|
||||
throw new Error('network unavailable');
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
async text() {
|
||||
return JSON.stringify({
|
||||
number: 42,
|
||||
head: { sha: SHA_B },
|
||||
base: { sha: SHA_A, ref: 'main' },
|
||||
labels: [],
|
||||
changed_files: 0,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await client.getPullRequest(42);
|
||||
|
||||
assert.equal(result.number, 42);
|
||||
assert.equal(calls, 3);
|
||||
assert.deepEqual(delays, [250, 500]);
|
||||
});
|
||||
|
||||
test('GitHub client retries HTTP 408 and secondary rate-limit 403 responses', async t => {
|
||||
const cases = [
|
||||
{
|
||||
name: '408',
|
||||
response: { ok: false, status: 408 },
|
||||
expectedDelay: 250,
|
||||
},
|
||||
{
|
||||
name: 'secondary 403',
|
||||
response: {
|
||||
ok: false,
|
||||
status: 403,
|
||||
async text() {
|
||||
return JSON.stringify({ message: 'You have exceeded a secondary rate limit.' });
|
||||
},
|
||||
},
|
||||
expectedDelay: 60_000,
|
||||
},
|
||||
];
|
||||
|
||||
for (const scenario of cases) {
|
||||
await t.test(scenario.name, async () => {
|
||||
let calls = 0;
|
||||
const delays = [];
|
||||
const client = new validator.GitHubClient({
|
||||
owner: 'kdlbs',
|
||||
repo: 'kandev',
|
||||
token: 'token',
|
||||
sleepImpl: async delay => delays.push(delay),
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return {
|
||||
...scenario.response,
|
||||
async text() {
|
||||
return scenario.response.text
|
||||
? scenario.response.text()
|
||||
: JSON.stringify({ message: 'Request timed out' });
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
async text() {
|
||||
return JSON.stringify({
|
||||
number: 42,
|
||||
head: { sha: SHA_B },
|
||||
base: { sha: SHA_A, ref: 'main' },
|
||||
labels: [],
|
||||
changed_files: 0,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await client.getPullRequest(42);
|
||||
|
||||
assert.equal(result.number, 42);
|
||||
assert.equal(calls, 2);
|
||||
assert.deepEqual(delays, [scenario.expectedDelay]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('GitHub client retries an unreadable transient body and invalid retryable JSON', async t => {
|
||||
for (const mode of ['body', 'json']) {
|
||||
await t.test(mode, async () => {
|
||||
let calls = 0;
|
||||
const client = new validator.GitHubClient({
|
||||
owner: 'kdlbs',
|
||||
repo: 'kandev',
|
||||
token: 'token',
|
||||
sleepImpl: async () => {},
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 503,
|
||||
async text() {
|
||||
if (mode === 'body') {
|
||||
throw new Error('response body stream closed');
|
||||
}
|
||||
return '<html>temporarily unavailable</html>';
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
async text() {
|
||||
return JSON.stringify({
|
||||
number: 42,
|
||||
head: { sha: SHA_B },
|
||||
base: { sha: SHA_A, ref: 'main' },
|
||||
labels: [],
|
||||
changed_files: 0,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await client.getPullRequest(42);
|
||||
|
||||
assert.equal(result.number, 42);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('GitHub client fails permanent 4xx body and JSON errors without retry', async t => {
|
||||
for (const mode of ['body', 'json']) {
|
||||
await t.test(mode, async () => {
|
||||
let calls = 0;
|
||||
const delays = [];
|
||||
const logs = [];
|
||||
const client = new validator.GitHubClient({
|
||||
owner: 'kdlbs',
|
||||
repo: 'kandev',
|
||||
token: 'token',
|
||||
sleepImpl: async delay => delays.push(delay),
|
||||
logImpl: message => logs.push(message),
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
async text() {
|
||||
if (mode === 'body') {
|
||||
throw new Error('response body stream closed');
|
||||
}
|
||||
return '<html>not found</html>';
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(client.getPullRequest(42), /HTTP 404/);
|
||||
|
||||
assert.equal(calls, 1);
|
||||
assert.deepEqual(delays, []);
|
||||
assert.equal(logs.some(log => log.includes('status=HTTP 404')), true);
|
||||
assert.equal(logs.some(log => log.includes('attempts=1')), true);
|
||||
assert.equal(logs.some(log => log.includes('outcome=permanent')), true);
|
||||
assert.equal(logs.some(log => log.includes('request retry')), false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('GitHub client fails permanent responses after one attempt', async () => {
|
||||
let calls = 0;
|
||||
const delays = [];
|
||||
const logs = [];
|
||||
const client = new validator.GitHubClient({
|
||||
owner: 'kdlbs',
|
||||
repo: 'kandev',
|
||||
token: 'token',
|
||||
sleepImpl: async delay => delays.push(delay),
|
||||
logImpl: message => logs.push(message),
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
async text() {
|
||||
return JSON.stringify({ message: 'Not Found' });
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(client.getPullRequest(42), /HTTP 404/);
|
||||
|
||||
assert.equal(calls, 1);
|
||||
assert.deepEqual(delays, []);
|
||||
assert.equal(logs.some(log => log.includes('category=http')), true);
|
||||
assert.equal(logs.some(log => log.includes('outcome=permanent')), true);
|
||||
});
|
||||
|
||||
test('GitHub client retries request timeouts as transport failures', async () => {
|
||||
let calls = 0;
|
||||
const delays = [];
|
||||
const client = new validator.GitHubClient({
|
||||
owner: 'kdlbs',
|
||||
repo: 'kandev',
|
||||
token: 'token',
|
||||
sleepImpl: async delay => delays.push(delay),
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
const error = new Error('request timed out');
|
||||
error.name = 'TimeoutError';
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
async text() {
|
||||
return JSON.stringify({
|
||||
number: 42,
|
||||
head: { sha: SHA_B },
|
||||
base: { sha: SHA_A, ref: 'main' },
|
||||
labels: [],
|
||||
changed_files: 0,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await client.getPullRequest(42);
|
||||
|
||||
assert.equal(result.number, 42);
|
||||
assert.equal(calls, 2);
|
||||
assert.deepEqual(delays, [250]);
|
||||
});
|
||||
|
||||
test('GitHub client rejects malformed changed-file entries and mismatched content paths', async () => {
|
||||
const responses = [
|
||||
{
|
||||
@@ -804,6 +1358,8 @@ test('GitHub client converts a request timeout into a bounded error', async () =
|
||||
owner: 'kdlbs',
|
||||
repo: 'kandev',
|
||||
token: 'token',
|
||||
sleepImpl: async () => {},
|
||||
logImpl: () => {},
|
||||
fetchImpl: async (_url, options) => {
|
||||
signal = options.signal;
|
||||
const error = new Error('request timed out');
|
||||
@@ -859,6 +1415,119 @@ test('coverage loading searches only the referenced requirement files', async ()
|
||||
]);
|
||||
});
|
||||
|
||||
test('a changed requirement body with the same identity avoids code search', async () => {
|
||||
const baseContents = uiFixtureContents();
|
||||
const headContents = uiFixtureContents();
|
||||
const requirementPath = 'docs/specs/ui/requirements/ui-coverage.md';
|
||||
headContents[requirementPath] = headContents[requirementPath].replace(
|
||||
'Runtime changes have a work order.',
|
||||
'Runtime changes have an updated work order.',
|
||||
);
|
||||
const changed = [
|
||||
{ filename: 'apps/web/lib/runtime.ts', status: 'modified' },
|
||||
{
|
||||
filename: 'docs/plans/recovery/task-01-recovery.md',
|
||||
status: 'modified',
|
||||
additions: 1,
|
||||
changes: 1,
|
||||
},
|
||||
{ filename: requirementPath, status: 'modified', additions: 1, changes: 1 },
|
||||
];
|
||||
const loaded = [];
|
||||
const searches = [];
|
||||
const client = {
|
||||
async getPullRequest() {
|
||||
return pullRequest(42, SHA_B, [], changed.length);
|
||||
},
|
||||
async listFiles() {
|
||||
return changed;
|
||||
},
|
||||
async getFile(pathname, ref) {
|
||||
loaded.push({ pathname, ref });
|
||||
if (ref === SHA_A) {
|
||||
return baseContents[pathname];
|
||||
}
|
||||
if (Object.hasOwn(headContents, pathname)) {
|
||||
return headContents[pathname];
|
||||
}
|
||||
throw new Error('GitHub API request failed with HTTP 404: Not Found');
|
||||
},
|
||||
async searchCode(requirementId, directory) {
|
||||
searches.push({ requirementId, directory });
|
||||
throw new Error('code search should not run for an unchanged identity');
|
||||
},
|
||||
};
|
||||
|
||||
const result = await validator.evaluatePullRequest({ client, pullNumber: 42 });
|
||||
|
||||
assert.equal(result.status, 'covered', result.errors.join('; '));
|
||||
assert.deepEqual(searches, []);
|
||||
assert.deepEqual(
|
||||
loaded.filter(entry => entry.pathname === requirementPath),
|
||||
[
|
||||
{ pathname: requirementPath, ref: SHA_B },
|
||||
{ pathname: requirementPath, ref: SHA_A },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('a requirement rename can reuse its unchanged base identity', async () => {
|
||||
const requirementPath = 'docs/specs/ui/requirements/ui-coverage.md';
|
||||
const renamedPath = 'docs/specs/ui/requirements/renamed-coverage.md';
|
||||
const baseContents = uiFixtureContents();
|
||||
const headContents = uiFixtureContents();
|
||||
headContents[renamedPath] = headContents[requirementPath];
|
||||
delete headContents[requirementPath];
|
||||
const changed = [
|
||||
{ filename: 'apps/web/lib/runtime.ts', status: 'modified' },
|
||||
{
|
||||
filename: 'docs/plans/recovery/task-01-recovery.md',
|
||||
status: 'modified',
|
||||
additions: 1,
|
||||
changes: 1,
|
||||
},
|
||||
{
|
||||
filename: renamedPath,
|
||||
previous_filename: requirementPath,
|
||||
status: 'renamed',
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
changes: 2,
|
||||
},
|
||||
];
|
||||
const loaded = [];
|
||||
const client = {
|
||||
async getPullRequest() {
|
||||
return pullRequest(42, SHA_B, [], changed.length);
|
||||
},
|
||||
async listFiles() {
|
||||
return changed;
|
||||
},
|
||||
async getFile(pathname, ref) {
|
||||
loaded.push({ pathname, ref });
|
||||
const source = ref === SHA_A ? baseContents : headContents;
|
||||
if (Object.hasOwn(source, pathname)) {
|
||||
return source[pathname];
|
||||
}
|
||||
throw new Error('GitHub API request failed with HTTP 404: Not Found');
|
||||
},
|
||||
async searchCode() {
|
||||
throw new Error('code search should not run for an unchanged rename');
|
||||
},
|
||||
};
|
||||
|
||||
const result = await validator.evaluatePullRequest({ client, pullNumber: 42 });
|
||||
|
||||
assert.equal(result.status, 'covered', result.errors.join('; '));
|
||||
assert.deepEqual(
|
||||
loaded.filter(entry => entry.pathname === renamedPath || entry.pathname === requirementPath),
|
||||
[
|
||||
{ pathname: renamedPath, ref: SHA_B },
|
||||
{ pathname: requirementPath, ref: SHA_A },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
function repeatedCoverageFixture() {
|
||||
const contents = uiFixtureContents();
|
||||
const planPath = 'docs/plans/recovery/plan.md';
|
||||
@@ -899,7 +1568,13 @@ function coverageClient(contents, changed, overrides = {}) {
|
||||
return changed;
|
||||
},
|
||||
async getFile(pathname, ref) {
|
||||
assert.equal(ref, SHA_B);
|
||||
assert.ok([SHA_A, SHA_B].includes(ref));
|
||||
if (
|
||||
ref === SHA_A
|
||||
&& changed.some(change => change?.filename === pathname && change?.status === 'added')
|
||||
) {
|
||||
throw new Error('GitHub API request failed with HTTP 404: Not Found');
|
||||
}
|
||||
if (!Object.hasOwn(contents, pathname)) {
|
||||
throw new Error('GitHub API request failed with HTTP 404: Not Found');
|
||||
}
|
||||
@@ -1430,6 +2105,35 @@ test('run publishes pending and final status for the stable current pull-request
|
||||
assert.match(summaries[0], /docs\/guide\.md/);
|
||||
});
|
||||
|
||||
test('run reuses the snapshot used for the pending status', async () => {
|
||||
let metadataCalls = 0;
|
||||
const statuses = [];
|
||||
const client = {
|
||||
async getPullRequest() {
|
||||
metadataCalls += 1;
|
||||
return pullRequest(42, SHA_B);
|
||||
},
|
||||
async listFiles() {
|
||||
return [{ filename: 'docs/guide.md', status: 'modified' }];
|
||||
},
|
||||
async createCommitStatus(sha, status) {
|
||||
statuses.push({ sha, state: status.state });
|
||||
},
|
||||
};
|
||||
|
||||
const result = await validator.run({
|
||||
client,
|
||||
env: {},
|
||||
event: { pull_request: { number: 42 } },
|
||||
eventName: 'pull_request_target',
|
||||
writeSummary: () => {},
|
||||
});
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(metadataCalls, 2);
|
||||
assert.deepEqual(statuses.map(status => status.state), ['pending', 'success']);
|
||||
});
|
||||
|
||||
test('workflow dispatch reads the pull-request number from its input', async () => {
|
||||
const statuses = [];
|
||||
const client = {
|
||||
|
||||
@@ -290,11 +290,17 @@ class PullRequestSizeLabelWorkflowContractTest(unittest.TestCase):
|
||||
):
|
||||
self.assertRegex(self.workflow, rf"{name}: \{{")
|
||||
self.assertIn(f"color: '{color}'", self.workflow)
|
||||
self.assertIn("github.rest.issues.getLabel", self.workflow)
|
||||
self.assertIn("github.rest.issues.createLabel", self.workflow)
|
||||
self.assertIn("github.rest.issues.listLabelsOnIssue", self.workflow)
|
||||
self.assertIn("github.rest.issues.addLabels", self.workflow)
|
||||
self.assertIn("github.rest.issues.removeLabel", self.workflow)
|
||||
self.assertIn("isMissingLabelDefinitionError", self.workflow)
|
||||
self.assertNotIn(
|
||||
"for (const [name, definition] of Object.entries(sizeLabelDefinitions))",
|
||||
self.workflow,
|
||||
)
|
||||
self.assertEqual(self.workflow.count("github.rest.issues.listLabelsOnIssue"), 1)
|
||||
self.assertEqual(self.workflow.count("github.rest.issues.getLabel"), 0)
|
||||
|
||||
stale_labels = extract_function(self.workflow, "staleSizeLabels")
|
||||
self.assertEqual(
|
||||
@@ -333,10 +339,90 @@ class PullRequestSizeLabelWorkflowContractTest(unittest.TestCase):
|
||||
)
|
||||
)
|
||||
create_index = self.workflow.index("github.rest.issues.createLabel")
|
||||
retry_read_index = self.workflow.index(
|
||||
"await github.rest.issues.getLabel", create_index
|
||||
first_add_index = self.workflow.index("github.rest.issues.addLabels")
|
||||
retry_add_index = self.workflow.index(
|
||||
"github.rest.issues.addLabels", first_add_index + 1
|
||||
)
|
||||
list_index = self.workflow.index("github.rest.issues.listLabelsOnIssue")
|
||||
self.assertLess(list_index, first_add_index)
|
||||
self.assertLess(first_add_index, create_index)
|
||||
self.assertLess(create_index, retry_add_index)
|
||||
|
||||
missing_definition = extract_function(
|
||||
self.workflow, "isMissingLabelDefinitionError"
|
||||
)
|
||||
self.assertTrue(
|
||||
run_javascript(
|
||||
missing_definition,
|
||||
"isMissingLabelDefinitionError",
|
||||
[
|
||||
{
|
||||
"status": 422,
|
||||
"response": {
|
||||
"data": {
|
||||
"errors": [
|
||||
{
|
||||
"resource": "Label",
|
||||
"code": "invalid",
|
||||
"field": "name",
|
||||
"value": "small",
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
"small",
|
||||
],
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
run_javascript(
|
||||
missing_definition,
|
||||
"isMissingLabelDefinitionError",
|
||||
[
|
||||
{
|
||||
"status": 422,
|
||||
"response": {
|
||||
"data": {
|
||||
"errors": [
|
||||
{
|
||||
"resource": "Label",
|
||||
"code": "invalid",
|
||||
"field": "name",
|
||||
"value": "medium",
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
"small",
|
||||
],
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
run_javascript(
|
||||
missing_definition,
|
||||
"isMissingLabelDefinitionError",
|
||||
[
|
||||
{
|
||||
"status": 422,
|
||||
"response": {
|
||||
"data": {
|
||||
"errors": [
|
||||
{
|
||||
"resource": "Issue",
|
||||
"code": "invalid",
|
||||
"field": "name",
|
||||
"value": "small",
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
"small",
|
||||
],
|
||||
)
|
||||
)
|
||||
self.assertLess(create_index, retry_read_index)
|
||||
|
||||
# @covers AC-CI-PR-SIZE-001.5
|
||||
def test_summary_row_contains_two_string_cells(self) -> None:
|
||||
@@ -374,7 +460,6 @@ class PullRequestSizeLabelWorkflowContractTest(unittest.TestCase):
|
||||
limit_index = self.workflow.index("changedFiles.length >= 3000")
|
||||
self.assertIn("throw new Error", self.workflow[limit_index : limit_index + 240])
|
||||
for mutation in (
|
||||
"github.rest.issues.getLabel",
|
||||
"github.rest.issues.createLabel",
|
||||
"github.rest.issues.addLabels",
|
||||
"github.rest.issues.removeLabel",
|
||||
|
||||
Reference in New Issue
Block a user