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'); + }); +});