From 188e9280edf6acfd98c1d1d0b21ed4233f736e6a Mon Sep 17 00:00:00 2001 From: Kamil Stus Date: Wed, 30 Sep 2026 11:38:55 +0200 Subject: [PATCH] fix: continue when an issue already has the maximum number of linked pull requests GitHub rejects the 11th manual link with "Issue exceeds manual reference limit", which failed the whole toolkit run. The sidebar link is optional; the linking check reads the pull request body directly. Co-authored-by: Cursor --- src/consts.ts | 3 ++ src/pull_request_toolkit.ts | 31 ++++++++++++++-- tests/pull_request_toolkit.test.ts | 57 ++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/src/consts.ts b/src/consts.ts index 76fd024..78dfaa6 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -44,5 +44,8 @@ export const KNOWN_BOT_USERS: readonly string[] = [ // Excludes the team from correct linking and estimate checks. export const SKIP_LINKING_AND_ESTIMATE_CHECKS_FOR_TEAMS: readonly string[] = ['Docs']; +// Error returned by the `addCloseIssueReferences` mutation when the issue has too many manually linked pull requests. +export const MANUAL_REFERENCE_LIMIT_ERROR_MESSAGE = 'Issue exceeds manual reference limit'; + export const LINKING_CHECK_RETRIES = 8; export const LINKING_CHECK_DELAY_MILLIS = 15 * 1000; diff --git a/src/pull_request_toolkit.ts b/src/pull_request_toolkit.ts index 001142b..4d03f9d 100644 --- a/src/pull_request_toolkit.ts +++ b/src/pull_request_toolkit.ts @@ -2,6 +2,7 @@ import { PROJECT_FIELD_NAMES, KNOWN_BOT_USERS, LABELS, + MANUAL_REFERENCE_LIMIT_ERROR_MESSAGE, PRODUCT_ENGINEERING_TEAM_SLUG, TEAM_LABEL_PREFIX, TEAM_NAME_TO_LABEL, @@ -512,7 +513,21 @@ export class PullRequestToolkit { const mentionedIssues = await this.getIssuesMentionedInPullRequestBody(); // Native references are linked automatically by GitHub - const issuesToLink = mentionedIssues.filter((issue) => !issue.isNativeReference); + const nonNativeReferences = mentionedIssues.filter((issue) => !issue.isNativeReference); + if (nonNativeReferences.length === 0) return; + + const alreadyLinkedIssues = await this.githubModel.getNativelyLinkedIssuesForPullRequest( + this.pullRequestRepoOwner, + this.pullRequestRepoName, + this.pullRequestNumber, + ); + const issuesToLink = this.deduplicateIssues(nonNativeReferences).filter( + (issue) => + !alreadyLinkedIssues.some( + (linked) => + linked.owner === issue.owner && linked.repo === issue.repo && linked.number === issue.number, + ), + ); if (issuesToLink.length === 0) return; const pullRequest = await this.getPullRequest(); @@ -523,7 +538,19 @@ export class PullRequestToolkit { issueReference.repo, issueReference.number, ); - await this.githubModel.linkPullRequestToIssue(issue.node_id, pullRequest.node_id); + try { + await this.githubModel.linkPullRequestToIssue(issue.node_id, pullRequest.node_id); + } catch (error) { + // GitHub allows only a limited number of manually linked pull requests per issue. The link only + // affects the issue's Development sidebar; the linking check reads the body references directly. + if (error instanceof Error && error.message.includes(MANUAL_REFERENCE_LIMIT_ERROR_MESSAGE)) { + this.core.warning( + `Could not link issue ${issueReference.owner}/${issueReference.repo}#${issueReference.number}, it already has the maximum number of manually linked pull requests.`, + ); + continue; + } + throw error; + } } this.core.info('Linked issues mentioned in the pull request body.'); diff --git a/tests/pull_request_toolkit.test.ts b/tests/pull_request_toolkit.test.ts index 263bfc2..b38f3e1 100644 --- a/tests/pull_request_toolkit.test.ts +++ b/tests/pull_request_toolkit.test.ts @@ -7,6 +7,7 @@ import type { Core } from '../src/types.ts'; const mockCore = { info: vi.fn(), + warning: vi.fn(), error: vi.fn(), } as unknown as Core; @@ -107,3 +108,59 @@ describe('isCorrectlyLinkedAndEstimated', () => { expect(isEstimated).toBe(false); }); }); + +describe('linkIssuesMentionedInPullRequestBody', () => { + const partOfPullRequest = { ...basePullRequest, node_id: 'PR_1', body: 'Part of #100\nPart of #200' }; + + function makeLinkingGithubModel(overrides: Partial = {}) { + return makeGithubModel({ + getPullRequest: vi.fn().mockResolvedValue(partOfPullRequest), + getIssue: vi + .fn() + .mockImplementation((_owner: string, _repo: string, number: number) => + Promise.resolve({ node_id: `I_${number}` }), + ), + linkPullRequestToIssue: vi.fn().mockResolvedValue({}), + ...overrides, + }); + } + + test('skips issues that are already linked', async () => { + const githubModel = makeLinkingGithubModel({ + getNativelyLinkedIssuesForPullRequest: vi + .fn() + .mockResolvedValue([{ owner: 'apify', repo: 'apify-proxy', number: 100 }]), + }); + + await makeToolkit(githubModel).linkIssuesMentionedInPullRequestBody(); + + expect(githubModel.linkPullRequestToIssue).toHaveBeenCalledTimes(1); + expect(githubModel.linkPullRequestToIssue).toHaveBeenCalledWith('I_200', 'PR_1'); + }); + + test('continues with a warning when an issue exceeds the manual reference limit', async () => { + const githubModel = makeLinkingGithubModel({ + linkPullRequestToIssue: vi + .fn() + .mockRejectedValueOnce( + new Error( + 'Request failed due to following response errors:\n - Issue exceeds manual reference limit', + ), + ) + .mockResolvedValueOnce({}), + }); + + await expect(makeToolkit(githubModel).linkIssuesMentionedInPullRequestBody()).resolves.toBeUndefined(); + + expect(githubModel.linkPullRequestToIssue).toHaveBeenCalledTimes(2); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining('apify/apify-proxy#100')); + }); + + test('rethrows other linking errors', async () => { + const githubModel = makeLinkingGithubModel({ + linkPullRequestToIssue: vi.fn().mockRejectedValue(new Error('Something else')), + }); + + await expect(makeToolkit(githubModel).linkIssuesMentionedInPullRequestBody()).rejects.toThrow('Something else'); + }); +});