Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion scripts/github/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
93 changes: 92 additions & 1 deletion scripts/github/src/pullRequests.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> } }).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 {
Expand All @@ -21,6 +74,12 @@ export async function getPullRequestForCommit(
): Promise<IPullRequest | undefined> {
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 {
Expand All @@ -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;
}
Expand Down
Loading