diff --git a/scripts/github/src/index.ts b/scripts/github/src/index.ts index b184f7ce143e1..e62e713263d39 100644 --- a/scripts/github/src/index.ts +++ b/scripts/github/src/index.ts @@ -1,4 +1,9 @@ export { fluentRepoDetails } from './constants'; export type { IGetPullRequestFromCommitParams } from './pullRequests'; -export { getPullRequestForCommit, processPullRequestApiResponse } from './pullRequests'; +export { + getPullRequestForCommit, + hasGitHubAuthFailed, + processPullRequestApiResponse, + resetGitHubAuthFailure, +} from './pullRequests'; export type { IPullRequest, IRepoDetails, IUser } from './types'; diff --git a/scripts/github/src/pullRequests.ts b/scripts/github/src/pullRequests.ts index 4d38ed109c51d..efc72584cee37 100644 --- a/scripts/github/src/pullRequests.ts +++ b/scripts/github/src/pullRequests.ts @@ -1,5 +1,58 @@ import type { Octokit, RestEndpointMethodTypes } from '@octokit/rest'; -import { IPullRequest, IRepoDetails } from './types'; +import type { IPullRequest, IRepoDetails } from './types'; + +/** + * Why further PR lookups are being skipped, if they are. + * + * Once GitHub rejects our credentials every subsequent request will be rejected too, and once we + * are rate limited the remaining lookups will only make it worse - in both cases the useful move is + * to stop asking. The reason is kept because the two need very different advice. + * + * Context: during the 2026-06-30 release an expired PAT produced ~180 near-identical 403 stack + * traces, which buried the single line that actually mattered (the failed git push). + */ +let lookupsDisabledReason: 'auth' | 'rate-limit' | undefined; + +/** True if a previous GitHub API call failed with an authentication/authorization error. */ +export function hasGitHubAuthFailed(): boolean { + return lookupsDisabledReason === 'auth'; +} + +/** Reset the cached failure state (intended for tests). */ +export function resetGitHubAuthFailure(): void { + lookupsDisabledReason = undefined; +} + +/** + * Separate a genuine credential problem from rate limiting. + * + * GitHub overloads 403: it means "forbidden" for a bad or policy-blocked token, but also for + * primary and secondary rate limits. Telling someone to rotate a perfectly good PAT in the middle + * of a release is exactly the kind of misdirection this logging is meant to prevent, so the two are + * told apart by the rate-limit headers GitHub sends. + */ +function classifyFailure(ex: unknown): 'auth' | 'rate-limit' | undefined { + const status = (ex as { status?: number }).status; + + if (status === 429) { + return 'rate-limit'; + } + + if (status !== 401 && status !== 403) { + return undefined; + } + + const headers = ((ex as { response?: { headers?: Record } }).response?.headers ?? {}) as Record< + string, + unknown + >; + + if (headers['retry-after'] !== undefined || String(headers['x-ratelimit-remaining']) === '0') { + return 'rate-limit'; + } + + return 'auth'; +} // eslint-disable-next-line @typescript-eslint/naming-convention export interface IGetPullRequestFromCommitParams { @@ -21,6 +74,12 @@ export async function getPullRequestForCommit( ): Promise { const { github, repoDetails, commit, authorEmail, verbose } = params; + // Skip the request entirely if we already know it cannot succeed. Without this, a bad token + // causes one failed request (plus a full stack trace) for every single changelog entry. + if (lookupsDisabledReason) { + return; + } + verbose && console.log(`Looking for the PR containing ${commit}...`); try { @@ -44,6 +103,38 @@ export async function getPullRequestForCommit( return processPullRequestApiResponse(prs[0], authorEmail); } } catch (ex) { + const status = (ex as { status?: number }).status; + const reason = classifyFailure(ex); + + // A rejected token or a rate limit means retrying for every remaining commit is pure noise. + // Log once with the actionable detail, then degrade gracefully: changelog entries fall back to + // commit links instead of PR links. + if (reason) { + lookupsDisabledReason = reason; + const message = (ex as { message?: string }).message ?? 'Unknown error'; + const headline = + reason === 'auth' + ? `GitHub API authentication failed (HTTP ${status}) while building changelogs.` + : `GitHub API rate limit reached (HTTP ${status}) while building changelogs.`; + const advice = + reason === 'auth' + ? ' Check that the pipeline token is valid and has not expired.' + : ' This is a rate limit, not a token problem - the token does not need rotating.'; + + console.warn( + [ + '', + `##vso[task.logissue type=warning]${headline}`, + ` ${message}`, + advice, + ' Changelog entries will link to commits instead of pull requests.', + ' Further PR lookups are skipped for this run.', + '', + ].join('\n'), + ); + return; + } + console.warn(`Error finding PR for ${commit}`, ex); return; }