diff --git a/src/github-api.ts b/src/github-api.ts index 8c72c1032..7419d73a8 100644 --- a/src/github-api.ts +++ b/src/github-api.ts @@ -151,6 +151,49 @@ export type ReleaseIteratorOptions = ScmReleaseIteratorOptions; export const MAX_SLEEP_SECONDS = 20; export const MAX_ISSUE_BODY_SIZE = 65536; +const LABEL_ISSUE_NOT_FOUND_MAX_RETRIES = 3; +const LABEL_ISSUE_NOT_FOUND_INITIAL_DELAY_MS = 1000; + +/** + * True when GitHub returns 422 because a newly-created issue/PR is not yet + * indexed for label mutations (eventual consistency). + */ +export function isIssueNotFoundLabelError(err: unknown): boolean { + return ( + err instanceof RequestError && + err.status === 422 && + err.message.includes('Issue not found') + ); +} + +async function withRetryOnIssueNotFound( + fn: () => Promise, + logger: Logger, + operation: string +): Promise { + let lastError: Error | undefined; + for (let attempt = 0; attempt < LABEL_ISSUE_NOT_FOUND_MAX_RETRIES; attempt++) { + try { + return await fn(); + } catch (e) { + if ( + !isIssueNotFoundLabelError(e) || + attempt === LABEL_ISSUE_NOT_FOUND_MAX_RETRIES - 1 + ) { + throw e; + } + lastError = e as Error; + const delayMs = LABEL_ISSUE_NOT_FOUND_INITIAL_DELAY_MS * (attempt + 1); + logger.warn( + `${operation} failed with 422 Issue not found (attempt ${ + attempt + 1 + }/${LABEL_ISSUE_NOT_FOUND_MAX_RETRIES}), retrying in ${delayMs}ms...` + ); + await sleepInMs(delayMs); + } + } + throw lastError!; +} export class GitHubApi { readonly repository: Repository; @@ -781,12 +824,17 @@ export class GitHubApi { return; } this.logger.debug(`adding labels: ${labels} from issue/pull ${number}`); - await this.octokit.issues.addLabels({ - owner: this.repository.owner, - repo: this.repository.repo, - issue_number: number, - labels, - }); + await withRetryOnIssueNotFound( + () => + this.octokit.issues.addLabels({ + owner: this.repository.owner, + repo: this.repository.repo, + issue_number: number, + labels, + }), + this.logger, + `addIssueLabels(${number})` + ); } ); diff --git a/test/github-api.ts b/test/github-api.ts new file mode 100644 index 000000000..cf0fa9187 --- /dev/null +++ b/test/github-api.ts @@ -0,0 +1,136 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as nock from 'nock'; +import {expect} from 'chai'; +import {afterEach, beforeEach, describe, it} from 'mocha'; +import * as sinon from 'sinon'; +import {RequestError} from '@octokit/request-error'; + +import {GitHub} from '../src/github'; +import {isIssueNotFoundLabelError} from '../src/github-api'; +import {GitHubAPIError} from '../src/errors'; + +const fetch = require('node-fetch'); +const sandbox = sinon.createSandbox(); + +describe('isIssueNotFoundLabelError', () => { + it('returns true for 422 with Issue not found', () => { + const err = new RequestError('Validation Failed ... Issue not found', { + request: {method: 'POST', url: 'https://api.github.com', headers: {}}, + response: { + status: 422, + data: { + message: 'Validation Failed', + errors: [ + { + resource: 'Label', + code: 'unprocessable', + field: 'data', + message: 'Issue not found', + }, + ], + }, + headers: {}, + url: 'https://api.github.com', + }, + }); + expect(isIssueNotFoundLabelError(err)).to.be.true; + }); + + it('returns false for other 422 errors', () => { + const err = new RequestError('Validation Failed', { + request: {method: 'POST', url: 'https://api.github.com', headers: {}}, + response: { + status: 422, + data: { + message: 'Validation Failed', + errors: [ + { + resource: 'Release', + code: 'already_exists', + field: 'tag_name', + }, + ], + }, + headers: {}, + url: 'https://api.github.com', + }, + }); + expect(isIssueNotFoundLabelError(err)).to.be.false; + }); +}); + +describe('GitHub addIssueLabels retry', () => { + let github: GitHub; + let clock: sinon.SinonFakeTimers; + + beforeEach(async () => { + nock('https://api.github.com/') + .get('/repos/fake/fake') + .optionally() + .reply(200, {default_branch: 'main'}); + github = await GitHub.create({ + owner: 'fake', + repo: 'fake', + defaultBranch: 'main', + fetch, + }); + clock = sandbox.useFakeTimers(); + }); + + afterEach(() => { + sandbox.restore(); + nock.cleanAll(); + }); + + const issueNotFound422 = { + message: 'Validation Failed', + errors: [ + { + resource: 'Label', + code: 'unprocessable', + field: 'data', + message: 'Issue not found', + }, + ], + }; + + it('retries on 422 Issue not found then succeeds', async () => { + const scope = nock('https://api.github.com/') + .post('/repos/fake/fake/issues/42/labels', ['autorelease: pending']) + .reply(422, issueNotFound422) + .post('/repos/fake/fake/issues/42/labels', ['autorelease: pending']) + .reply(200, []); + + const promise = github.addIssueLabels(['autorelease: pending'], 42); + await clock.tickAsync(1000); + await promise; + scope.done(); + }); + + it('throws GitHubAPIError after retries are exhausted', async () => { + const scope = nock('https://api.github.com/') + .post('/repos/fake/fake/issues/42/labels') + .times(3) + .reply(422, issueNotFound422); + + const promise = github.addIssueLabels(['autorelease: pending'], 42); + const assertPromise = expect(promise).to.be.rejectedWith(GitHubAPIError); + await clock.tickAsync(1000); + await clock.tickAsync(2000); + await assertPromise; + scope.done(); + }); +});