Skip to content
Merged
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
3 changes: 3 additions & 0 deletions src/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
31 changes: 29 additions & 2 deletions src/pull_request_toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand All @@ -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.');
Expand Down
57 changes: 57 additions & 0 deletions tests/pull_request_toolkit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<GitHubModel> = {}) {
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');
});
});
Loading