diff --git a/lib/modules/platform/github/__snapshots__/index.spec.ts.snap b/lib/modules/platform/github/__snapshots__/index.spec.ts.snap index b117d19bd7ae69a1f936616b3e5cd80cd7bdb65c..c200deb87d269df4da92229f7d68e6b4a0ecb368 100644 --- a/lib/modules/platform/github/__snapshots__/index.spec.ts.snap +++ b/lib/modules/platform/github/__snapshots__/index.spec.ts.snap @@ -1,18 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`modules/platform/github/index getBranchPr(branchName) should reopen an autoclosed PR 1`] = ` -Object { - "displayNumber": "Pull Request #91", - "number": 91, - "sourceBranch": "somebranch", - "sourceRepo": "some/repo", - "state": "open", - "title": "Some title", -} -`; - -exports[`modules/platform/github/index getBranchPr(branchName) should return the PR object 1`] = ` +exports[`modules/platform/github/index getBranchPr(branchName) should cache and return the PR object 1`] = ` Object { + "body": "dummy body", "displayNumber": "Pull Request #91", "number": 91, "sourceBranch": "somebranch", @@ -22,8 +12,9 @@ Object { } `; -exports[`modules/platform/github/index getBranchPr(branchName) should return the PR object in fork mode 1`] = ` +exports[`modules/platform/github/index getBranchPr(branchName) should cache and return the PR object in fork mode 1`] = ` Object { + "body": "dummy body", "displayNumber": "Pull Request #90", "number": 90, "sourceBranch": "somebranch", @@ -33,33 +24,33 @@ Object { } `; -exports[`modules/platform/github/index getPr(prNo) should return PR from closed graphql result 1`] = ` +exports[`modules/platform/github/index getBranchPr(branchName) should reopen and cache autoclosed PR 1`] = ` Object { "body": "dummy body", - "displayNumber": "Pull Request #2499", - "number": 2499, - "sourceBranch": "renovate/delay-4.x", - "state": "merged", - "title": "build(deps): update dependency delay to v4.0.1", + "displayNumber": "Pull Request #91", + "number": 91, + "sourceBranch": "somebranch", + "sourceRepo": "some/repo", + "state": "open", + "title": "old title", } `; -exports[`modules/platform/github/index getPr(prNo) should return PR from graphql result 1`] = ` +exports[`modules/platform/github/index getPr(prNo) should return PR 1`] = ` Object { - "body": "Some body", + "body": "dummy body", "displayNumber": "Pull Request #2500", - "hasAssignees": true, - "hasReviewers": true, "number": 2500, "sourceBranch": "renovate/jest-monorepo", + "sourceRepo": "some/repo", "state": "open", - "targetBranch": "master", "title": "chore(deps): update dependency jest to v23.6.0", } `; exports[`modules/platform/github/index getPr(prNo) should return a PR object - 0 1`] = ` Object { + "body": "dummy body", "createdAt": "01-01-2022", "displayNumber": "Pull Request #1234", "hasAssignees": true, @@ -77,6 +68,7 @@ Object { exports[`modules/platform/github/index getPr(prNo) should return a PR object - 1 1`] = ` Object { + "body": "dummy body", "displayNumber": "Pull Request #1234", "hasAssignees": true, "hasReviewers": true, @@ -89,6 +81,7 @@ Object { exports[`modules/platform/github/index getPr(prNo) should return a PR object - 2 1`] = ` Object { + "body": "dummy body", "displayNumber": "Pull Request #1234", "number": 1234, "sourceBranch": "some/branch", diff --git a/lib/modules/platform/github/common.ts b/lib/modules/platform/github/common.ts index 7e13b3b0d9dfd3b1f31833bb6339832ac3f4ffe3..b7d2c52ed63a987b27c1c10fabd87cf2ae00fc31 100644 --- a/lib/modules/platform/github/common.ts +++ b/lib/modules/platform/github/common.ts @@ -1,44 +1,16 @@ import is from '@sindresorhus/is'; import { PrState } from '../../../types'; import type { Pr } from '../types'; -import type { GhGraphQlPr, GhRestPr } from './types'; +import type { GhRestPr } from './types'; /** - * @see https://developer.github.com/v4/object/pullrequest/ + * @see https://docs.github.com/en/rest/reference/pulls#list-pull-requests */ -export function coerceGraphqlPr(pr: GhGraphQlPr): Pr { - const result: Pr = { - number: pr.number, - displayNumber: `Pull Request #${pr.number}`, - title: pr.title, - state: pr.state ? pr.state.toLowerCase() : PrState.Open, - sourceBranch: pr.headRefName, - body: pr.body ? pr.body : 'dummy body', - }; - - if (pr.baseRefName) { - result.targetBranch = pr.baseRefName; +export function coerceRestPr(pr: GhRestPr | null | undefined): Pr | null { + if (!pr) { + return null; } - if (pr.assignees) { - result.hasAssignees = !!(pr.assignees.totalCount > 0); - } - - if (pr.reviewRequests) { - result.hasReviewers = !!(pr.reviewRequests.totalCount > 0); - } - - if (pr.labels?.nodes) { - result.labels = pr.labels.nodes.map((label) => label.name); - } - - return result; -} - -/** - * @see https://docs.github.com/en/rest/reference/pulls#list-pull-requests - */ -export function coerceRestPr(pr: GhRestPr): Pr { const result: Pr = { displayNumber: `Pull Request #${pr.number}`, number: pr.number, @@ -48,6 +20,7 @@ export function coerceRestPr(pr: GhRestPr): Pr { pr.state === PrState.Closed && is.string(pr.merged_at) ? PrState.Merged : pr.state, + body: pr.body ?? 'dummy body', }; if (pr.head?.sha) { diff --git a/lib/modules/platform/github/index.spec.ts b/lib/modules/platform/github/index.spec.ts index 8d945469f3b654e5e487e75d0a26751f1327f7a8..3c910a60a061bda99c53a8579066f782bb436130 100644 --- a/lib/modules/platform/github/index.spec.ts +++ b/lib/modules/platform/github/index.spec.ts @@ -1,16 +1,17 @@ import { DateTime } from 'luxon'; import * as httpMock from '../../../../test/http-mock'; -import { loadFixture, logger, mocked } from '../../../../test/util'; +import { logger, mocked } from '../../../../test/util'; import { REPOSITORY_NOT_FOUND, REPOSITORY_RENAMED, } from '../../../constants/error-messages'; import { BranchStatus, PrState, VulnerabilityAlert } from '../../../types'; +import * as repository from '../../../util/cache/repository'; import * as _git from '../../../util/git'; import * as _hostRules from '../../../util/host-rules'; import { setBaseUrl } from '../../../util/http/github'; import { toBase64 } from '../../../util/string'; -import type { CreatePRConfig } from '../types'; +import type { CreatePRConfig, UpdatePrConfig } from '../types'; import * as github from '.'; const githubApiHost = 'https://api.github.com'; @@ -38,12 +39,10 @@ describe('modules/platform/github/index', () => { hostRules.find.mockReturnValue({ token: '123test', }); - }); - const graphqlOpenPullRequests = loadFixture('graphql/pullrequests-open.json'); - const graphqlClosedPullRequests = loadFixture( - 'graphql/pullrequests-closed.json' - ); + const repoCache = repository.getCache(); + delete repoCache.platform; + }); describe('initPlatform()', () => { it('should throw if no token', async () => { @@ -551,11 +550,149 @@ describe('modules/platform/github/index', () => { }); }); + describe('getPrList()', () => { + const t = DateTime.fromISO('2000-01-01T00:00:00.000+00:00'); + const t1 = t.plus({ minutes: 1 }).toISO(); + const t2 = t.plus({ minutes: 2 }).toISO(); + const t3 = t.plus({ minutes: 3 }).toISO(); + const t4 = t.plus({ minutes: 4 }).toISO(); + + const pr1 = { + number: 1, + head: { ref: 'branch-1', repo: { full_name: 'some/repo' } }, + state: PrState.Open, + title: 'PR #1', + updated_at: t1, + }; + + const pr2 = { + number: 2, + head: { ref: 'branch-2', repo: { full_name: 'some/repo' } }, + state: PrState.Open, + title: 'PR #2', + updated_at: t2, + }; + + const pr3 = { + number: 3, + head: { ref: 'branch-3', repo: { full_name: 'some/repo' } }, + state: PrState.Open, + title: 'PR #3', + updated_at: t3, + }; + + const pagePath = (x: number) => + `/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=${x}`; + const pageLink = (x: number) => + `<${githubApiHost}${pagePath(x)}>; rel="next"`; + + it('fetches single page', async () => { + const scope = httpMock.scope(githubApiHost); + initRepoMock(scope, 'some/repo'); + scope.get(pagePath(1)).reply(200, [pr1]); + await github.initRepo({ repository: 'some/repo' } as never); + + const res = await github.getPrList(); + + expect(res).toMatchObject([{ number: 1, title: 'PR #1' }]); + }); + + it('fetches multiple pages', async () => { + const scope = httpMock.scope(githubApiHost); + initRepoMock(scope, 'some/repo'); + scope + .get(pagePath(1)) + .reply(200, [pr3], { + link: `${pageLink(2)}, ${pageLink(3).replace('next', 'last')}`, + }) + .get(pagePath(2)) + .reply(200, [pr2], { link: pageLink(3) }) + .get(pagePath(3)) + .reply(200, [pr1]); + await github.initRepo({ repository: 'some/repo' } as never); + + const res = await github.getPrList(); + + expect(res).toMatchObject([{ number: 1 }, { number: 2 }, { number: 3 }]); + }); + + it('uses ETag', async () => { + const scope = httpMock.scope(githubApiHost); + initRepoMock(scope, 'some/repo'); + initRepoMock(scope, 'some/repo'); + scope + .get(pagePath(1)) + .reply(200, [pr3, pr2, pr1], { etag: 'foobar' }) + .get(pagePath(1)) + .reply(304); + + await github.initRepo({ repository: 'some/repo' } as never); + const res1 = await github.getPrList(); + + await github.initRepo({ repository: 'some/repo' } as never); + const res2 = await github.getPrList(); + + expect(res1).toMatchObject([{ number: 1 }, { number: 2 }, { number: 3 }]); + expect(res1).toEqual(res2); + }); + + it('synchronizes cache', async () => { + const scope = httpMock.scope(githubApiHost); + initRepoMock(scope, 'some/repo'); + initRepoMock(scope, 'some/repo'); + + scope + .get(pagePath(1)) + .reply(200, [pr3], { + link: `${pageLink(2)}, ${pageLink(3).replace('next', 'last')}`, + etag: 'foo', + }) + .get(pagePath(2)) + .reply(200, [pr2]) + .get(pagePath(3)) + .reply(200, [pr1]); + + await github.initRepo({ repository: 'some/repo' } as never); + const res1 = await github.getPrList(); + + scope + .get(pagePath(1)) + .reply(200, [{ ...pr3, updated_at: t4, title: 'PR #3 (updated)' }], { + link: `${pageLink(2)}`, + etag: 'bar', + }) + .get(pagePath(2)) + .reply(200, [{ ...pr2, updated_at: t4, title: 'PR #2 (updated)' }], { + link: `${pageLink(3)}`, + }) + .get(pagePath(3)) + .reply(200, [{ ...pr1, updated_at: t4, title: 'PR #1 (updated)' }]); + + await github.initRepo({ repository: 'some/repo' } as never); + const res2 = await github.getPrList(); + + expect(res1).toMatchObject([ + { number: 1, title: 'PR #1' }, + { number: 2, title: 'PR #2' }, + { number: 3, title: 'PR #3' }, + ]); + expect(res2).toMatchObject([ + { number: 1, title: 'PR #1 (updated)' }, + { number: 2, title: 'PR #2 (updated)' }, + { number: 3, title: 'PR #3 (updated)' }, + ]); + }); + }); + describe('getBranchPr(branchName)', () => { it('should return null if no PR exists', async () => { const scope = httpMock.scope(githubApiHost); initRepoMock(scope, 'some/repo'); - scope.get('/repos/some/repo/pulls?per_page=100&state=all').reply(200, []); + scope + .get( + '/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1' + ) + .reply(200, []); await github.initRepo({ repository: 'some/repo', @@ -564,59 +701,46 @@ describe('modules/platform/github/index', () => { expect(pr).toBeNull(); }); - it('should return the PR object', async () => { + it('should cache and return the PR object', async () => { const scope = httpMock.scope(githubApiHost); initRepoMock(scope, 'some/repo'); scope - .post('/graphql') - .twice() // getOpenPrs() and getClosedPrs() - .reply(200, { - data: { repository: { pullRequests: { pageInfo: {} } } }, - }) - .get('/repos/some/repo/pulls?per_page=100&state=all') + .get( + '/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1' + ) .reply(200, [ { number: 90, head: { ref: 'somebranch', repo: { full_name: 'other/repo' } }, state: PrState.Open, + title: 'PR from another repo', }, { number: 91, + base: { sha: '1234' }, head: { ref: 'somebranch', repo: { full_name: 'some/repo' } }, state: PrState.Open, + title: 'Some title', }, - ]) - .get('/repos/some/repo/pulls/91') - .reply(200, { - number: 91, - additions: 1, - deletions: 1, - commits: 1, - base: { - sha: '1234', - }, - head: { ref: 'somebranch', repo: { full_name: 'some/repo' } }, - state: PrState.Open, - title: 'Some title', - }); - + ]); await github.initRepo({ repository: 'some/repo', } as any); + const pr = await github.getBranchPr('somebranch'); + const pr2 = await github.getBranchPr('somebranch'); + expect(pr).toMatchSnapshot(); + expect(pr2).toEqual(pr); }); - it('should reopen an autoclosed PR', async () => { + it('should reopen and cache autoclosed PR', async () => { const scope = httpMock.scope(githubApiHost); initRepoMock(scope, 'some/repo'); scope - .post('/graphql') - .twice() // getOpenPrs() and getClosedPrs() - .reply(200, { - data: { repository: { pullRequests: { pageInfo: {} } } }, - }) - .get('/repos/some/repo/pulls?per_page=100&state=all') + .get( + '/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1' + ) .reply(200, [ { number: 90, @@ -634,45 +758,45 @@ describe('modules/platform/github/index', () => { .post('/repos/some/repo/git/refs') .reply(201) .patch('/repos/some/repo/pulls/91') - .reply(201) - .get('/repos/some/repo/pulls/91') .reply(200, { number: 91, - additions: 1, - deletions: 1, - commits: 1, - base: { - sha: '1234', - }, + base: { sha: '1234' }, head: { ref: 'somebranch', repo: { full_name: 'some/repo' } }, state: PrState.Open, - title: 'Some title', + title: 'old title', }); - await github.initRepo({ repository: 'some/repo', } as any); + const pr = await github.getBranchPr('somebranch'); - expect(pr).toMatchSnapshot(); + const pr2 = await github.getBranchPr('somebranch'); + + expect(pr).toMatchSnapshot({ number: 91 }); + expect(pr2).toEqual(pr); }); it('aborts reopen if PR is too old', async () => { const scope = httpMock.scope(githubApiHost); initRepoMock(scope, 'some/repo'); - scope.get('/repos/some/repo/pulls?per_page=100&state=all').reply(200, [ - { - number: 90, - head: { ref: 'somebranch', repo: { full_name: 'other/repo' } }, - state: PrState.Open, - }, - { - number: 91, - head: { ref: 'somebranch', repo: { full_name: 'some/repo' } }, - title: 'old title - autoclosed', - state: PrState.Closed, - closed_at: DateTime.now().minus({ days: 7 }).toISO(), - }, - ]); + scope + .get( + '/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1' + ) + .reply(200, [ + { + number: 90, + head: { ref: 'somebranch', repo: { full_name: 'other/repo' } }, + state: PrState.Open, + }, + { + number: 91, + head: { ref: 'somebranch', repo: { full_name: 'some/repo' } }, + title: 'old title - autoclosed', + state: PrState.Closed, + closed_at: DateTime.now().minus({ days: 7 }).toISO(), + }, + ]); await github.initRepo({ repository: 'some/repo', @@ -685,7 +809,9 @@ describe('modules/platform/github/index', () => { const scope = httpMock.scope(githubApiHost); initRepoMock(scope, 'some/repo'); scope - .get('/repos/some/repo/pulls?per_page=100&state=all') + .get( + '/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1' + ) .reply(200, [ { number: 91, @@ -711,7 +837,9 @@ describe('modules/platform/github/index', () => { const scope = httpMock.scope(githubApiHost); initRepoMock(scope, 'some/repo'); scope - .get('/repos/some/repo/pulls?per_page=100&state=all') + .get( + '/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1' + ) .reply(200, [ { number: 91, @@ -731,49 +859,41 @@ describe('modules/platform/github/index', () => { expect(pr).toBeNull(); }); - it('should return the PR object in fork mode', async () => { + it('should cache and return the PR object in fork mode', async () => { const scope = httpMock.scope(githubApiHost); forkInitRepoMock(scope, 'some/repo', true); scope - .post('/graphql') - .twice() // getOpenPrs() and getClosedPrs() - .reply(200, { - data: { repository: { pullRequests: { pageInfo: {} } } }, - }) - .get('/repos/some/repo/pulls?per_page=100&state=all') + .patch('/repos/forked/repo/git/refs/heads/master') + .reply(200) + .get( + '/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1' + ) .reply(200, [ { number: 90, + base: { sha: '1234' }, head: { ref: 'somebranch', repo: { full_name: 'other/repo' } }, state: PrState.Open, + title: 'Some title', }, { number: 91, + base: { sha: '1234' }, head: { ref: 'somebranch', repo: { full_name: 'some/repo' } }, state: PrState.Open, + title: 'Wrong PR', }, - ]) - .get('/repos/some/repo/pulls/90') - .reply(200, { - number: 90, - additions: 1, - deletions: 1, - commits: 1, - base: { - sha: '1234', - }, - head: { ref: 'somebranch', repo: { full_name: 'other/repo' } }, - state: PrState.Open, - title: 'Some title', - }) - .patch('/repos/forked/repo/git/refs/heads/master') - .reply(200); + ]); await github.initRepo({ repository: 'some/repo', forkMode: true, } as any); + const pr = await github.getBranchPr('somebranch'); - expect(pr).toMatchSnapshot(); + const pr2 = await github.getBranchPr('somebranch'); + + expect(pr).toMatchSnapshot({ number: 90 }); + expect(pr2).toEqual(pr); }); }); @@ -1701,14 +1821,22 @@ describe('modules/platform/github/index', () => { const scope = httpMock.scope(githubApiHost); initRepoMock(scope, 'some/repo'); scope - .post('/graphql') - .reply(200, graphqlClosedPullRequests) + .get('/repos/some/repo/issues/2499/comments?per_page=100') + .reply(200, [ + { + id: 419928791, + body: '[](https://cla-assistant.io/renovatebot/renovate?pullRequest=2500) <br/>All committers have signed the CLA.', + }, + { + id: 420006957, + body: ':tada: This PR is included in version 13.63.5 :tada:\n\nThe release is available on:\n- [npm package (@latest dist-tag)](https://www.npmjs.com/package/renovate)\n- [GitHub release](https://github.com/renovatebot/renovate/releases/tag/13.63.5)\n\nYour **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:', + }, + ]) .post('/repos/some/repo/issues/2499/comments') .reply(200); await github.initRepo({ repository: 'some/repo', } as any); - await github.getClosedPrs(); await expect( github.ensureComment({ @@ -1823,7 +1951,9 @@ describe('modules/platform/github/index', () => { it('returns true if no title and all state', async () => { const scope = httpMock .scope(githubApiHost) - .get('/repos/some/repo/pulls?per_page=100&state=all') + .get( + '/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1' + ) .reply(200, [ { number: 2, @@ -1860,17 +1990,21 @@ describe('modules/platform/github/index', () => { }); it('returns true if not open', async () => { - httpMock - .scope(githubApiHost) - .get('/repos/undefined/pulls?per_page=100&state=all') + const scope = httpMock.scope(githubApiHost); + initRepoMock(scope, 'some/repo'); + scope + .get( + '/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1' + ) .reply(200, [ { number: 1, - head: { ref: 'branch-a' }, + head: { ref: 'branch-a', repo: { full_name: 'some/repo' } }, title: 'branch a pr', state: PrState.Closed, }, ]); + await github.initRepo({ repository: 'some/repo' } as never); const res = await github.findPr({ branchName: 'branch-a', @@ -1880,18 +2014,24 @@ describe('modules/platform/github/index', () => { }); it('caches pr list', async () => { - httpMock - .scope(githubApiHost) - .get('/repos/undefined/pulls?per_page=100&state=all') + const scope = httpMock.scope(githubApiHost); + initRepoMock(scope, 'some/repo'); + scope + .get( + '/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1' + ) .reply(200, [ { number: 1, - head: { ref: 'branch-a' }, + head: { ref: 'branch-a', repo: { full_name: 'some/repo' } }, title: 'branch a pr', state: PrState.Open, }, ]); + await github.initRepo({ repository: 'some/repo' } as never); + let res = await github.findPr({ branchName: 'branch-a' }); + expect(res).toBeDefined(); res = await github.findPr({ branchName: 'branch-a', @@ -2124,10 +2264,33 @@ describe('modules/platform/github/index', () => { expect(pr).toBeNull(); }); - it('should return PR from graphql result', async () => { + it('should return PR', async () => { const scope = httpMock.scope(githubApiHost); initRepoMock(scope, 'some/repo'); - scope.post('/graphql').reply(200, graphqlOpenPullRequests); + scope + .get( + '/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1' + ) + .reply(200, [ + { + number: 2499, + head: { + ref: 'renovate/delay-4.x', + repo: { full_name: 'some/repo' }, + }, + title: 'build(deps): update dependency delay to v4.0.1', + state: PrState.Closed, + }, + { + number: 2500, + head: { + ref: 'renovate/jest-monorepo', + repo: { full_name: 'some/repo' }, + }, + state: PrState.Open, + title: 'chore(deps): update dependency jest to v23.6.0', + }, + ]); await github.initRepo({ repository: 'some/repo', } as any); @@ -2136,31 +2299,67 @@ describe('modules/platform/github/index', () => { expect(pr).toMatchSnapshot(); }); - it('should return PR from closed graphql result', async () => { + it('should return closed PR', async () => { const scope = httpMock.scope(githubApiHost); initRepoMock(scope, 'some/repo'); scope - .post('/graphql') - .reply(200, graphqlOpenPullRequests) - .post('/graphql') - .reply(200, graphqlClosedPullRequests); - await github.initRepo({ - repository: 'some/repo', - } as any); - const pr = await github.getPr(2499); - expect(pr).toBeDefined(); - expect(pr).toMatchSnapshot(); + .get( + '/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1' + ) + .reply(200, [ + { + number: 2500, + head: { + ref: 'renovate/jest-monorepo', + repo: { full_name: 'some/repo' }, + }, + title: 'chore(deps): update dependency jest to v23.6.0', + state: PrState.Closed, + }, + ]); + await github.initRepo({ repository: 'some/repo' } as any); + + const pr = await github.getPr(2500); + + expect(pr).toMatchObject({ number: 2500, state: PrState.Closed }); + }); + + it('should return merged PR', async () => { + const scope = httpMock.scope(githubApiHost); + initRepoMock(scope, 'some/repo'); + scope + .get( + '/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1' + ) + .reply(200, [ + { + number: 2500, + head: { + ref: 'renovate/jest-monorepo', + repo: { full_name: 'some/repo' }, + }, + title: 'chore(deps): update dependency jest to v23.6.0', + state: PrState.Closed, + merged_at: DateTime.now().toISO(), + }, + ]); + await github.initRepo({ repository: 'some/repo' } as any); + + const pr = await github.getPr(2500); + + expect(pr).toMatchObject({ number: 2500, state: PrState.Merged }); }); it('should return null if no PR is returned from GitHub', async () => { const scope = httpMock.scope(githubApiHost); initRepoMock(scope, 'some/repo'); scope + .get( + '/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1' + ) + .reply(200, []) .get('/repos/some/repo/pulls/1234') - .reply(200) - .post('/graphql') - .twice() - .reply(404); + .reply(200); await github.initRepo({ repository: 'some/repo', token: 'token' } as any); const pr = await github.getPr(1234); expect(pr).toBeNull(); @@ -2170,6 +2369,10 @@ describe('modules/platform/github/index', () => { const scope = httpMock.scope(githubApiHost); initRepoMock(scope, 'some/repo'); scope + .get( + '/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1' + ) + .reply(200, []) .get('/repos/some/repo/pulls/1234') .reply(200, { number: 1234, @@ -2181,10 +2384,7 @@ describe('modules/platform/github/index', () => { labels: [{ name: 'foo' }, { name: 'bar' }], assignee: { login: 'foobar' }, created_at: '01-01-2022', - }) - .post('/graphql') - .twice() - .reply(404); + }); await github.initRepo({ repository: 'some/repo', token: 'token', @@ -2197,6 +2397,10 @@ describe('modules/platform/github/index', () => { const scope = httpMock.scope(githubApiHost); initRepoMock(scope, 'some/repo'); scope + .get( + '/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1' + ) + .reply(200, []) .get('/repos/some/repo/pulls/1234') .reply(200, { number: 1234, @@ -2208,10 +2412,7 @@ describe('modules/platform/github/index', () => { title: 'Some title', assignees: [{ login: 'foo' }], requested_reviewers: [{ login: 'bar' }], - }) - .post('/graphql') - .twice() - .reply(404); + }); await github.initRepo({ repository: 'some/repo', token: 'token', @@ -2224,6 +2425,10 @@ describe('modules/platform/github/index', () => { const scope = httpMock.scope(githubApiHost); initRepoMock(scope, 'some/repo'); scope + .get( + '/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1' + ) + .reply(200, []) .get('/repos/some/repo/pulls/1234') .reply(200, { number: 1234, @@ -2232,10 +2437,7 @@ describe('modules/platform/github/index', () => { head: { ref: 'some/branch' }, commits: 1, title: 'Some title', - }) - .post('/graphql') - .twice() - .reply(404); + }); await github.initRepo({ repository: 'some/repo', token: 'token', @@ -2247,32 +2449,32 @@ describe('modules/platform/github/index', () => { describe('updatePr(prNo, title, body)', () => { it('should update the PR', async () => { + const pr: UpdatePrConfig = { + number: 1234, + prTitle: 'The New Title', + prBody: 'Hello world again', + }; const scope = httpMock.scope(githubApiHost); initRepoMock(scope, 'some/repo'); - scope.patch('/repos/some/repo/pulls/1234').reply(200); await github.initRepo({ repository: 'some/repo', token: 'token' } as any); - await expect( - github.updatePr({ - number: 1234, - prTitle: 'The New Title', - prBody: 'Hello world again', - }) - ).toResolve(); + scope.patch('/repos/some/repo/pulls/1234').reply(200, pr); + + await expect(github.updatePr(pr)).toResolve(); }); it('should update and close the PR', async () => { + const pr: UpdatePrConfig = { + number: 1234, + prTitle: 'The New Title', + prBody: 'Hello world again', + state: PrState.Closed, + }; const scope = httpMock.scope(githubApiHost); initRepoMock(scope, 'some/repo'); - scope.patch('/repos/some/repo/pulls/1234').reply(200); await github.initRepo({ repository: 'some/repo', token: 'token' } as any); - await expect( - github.updatePr({ - number: 1234, - prTitle: 'The New Title', - prBody: 'Hello world again', - state: PrState.Closed, - }) - ).toResolve(); + scope.patch('/repos/some/repo/pulls/1234').reply(200, pr); + + await expect(github.updatePr(pr)).toResolve(); }); }); @@ -2280,20 +2482,33 @@ describe('modules/platform/github/index', () => { it('should merge the PR', async () => { const scope = httpMock.scope(githubApiHost); initRepoMock(scope, 'some/repo'); - scope.put('/repos/some/repo/pulls/1234/merge').reply(200); + scope + .get( + '/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1' + ) + .reply(200, [ + { + number: 1234, + base: { sha: '1234' }, + head: { ref: 'somebranch', repo: { full_name: 'some/repo' } }, + state: PrState.Open, + title: 'Some PR', + }, + ]) + .put('/repos/some/repo/pulls/1234/merge') + .reply(200); await github.initRepo({ repository: 'some/repo', token: 'token' } as any); - const pr = { - number: 1234, - head: { - ref: 'someref', - }, - }; - expect( - await github.mergePr({ - branchName: '', - id: pr.number, - }) - ).toBeTrue(); + + const prBefore = await github.getPr(1234); // fetched remotely + const mergeResult = await github.mergePr({ + id: 1234, + branchName: 'somebranch', + }); + const prAfter = await github.getPr(1234); // obtained from cache + + expect(mergeResult).toBeTrue(); + expect(prBefore.state).toBe(PrState.Open); + expect(prAfter.state).toBe(PrState.Merged); }); it('should handle merge error', async () => { diff --git a/lib/modules/platform/github/index.ts b/lib/modules/platform/github/index.ts index ddc6384dc80f816a432a2d54a4413d8fbb597980..7abcb41866be347fccbdcc17e0a1a6e38299f991 100644 --- a/lib/modules/platform/github/index.ts +++ b/lib/modules/platform/github/index.ts @@ -54,28 +54,25 @@ import type { UpdatePrConfig, } from '../types'; import { smartTruncate } from '../utils/pr-body'; -import { coerceGraphqlPr, coerceRestPr } from './common'; +import { coerceRestPr } from './common'; import { - closedPrsQuery, enableAutoMergeMutation, getIssuesQuery, - openPrsQuery, repoInfoQuery, vulnerabilityAlertsQuery, } from './graphql'; import { massageMarkdownLinks } from './massage-markdown-links'; +import { getPrCache } from './pr'; import type { BranchProtection, CombinedBranchStatus, Comment, GhAutomergeResponse, GhBranchStatus, - GhGraphQlPr, GhRepo, GhRestPr, LocalRepoConfig, PlatformConfig, - PrList, } from './types'; import { getUserDetails, getUserEmail } from './user'; @@ -359,10 +356,6 @@ export async function initRepo({ // This shouldn't be necessary, but occasional strange errors happened until it was added config.issueList = null; config.prList = null; - config.openPrList = null; - config.closedPrList = null; - config.branchPrs = []; - config.prComments = {}; config.forkMode = !!forkMode; if (forkMode) { @@ -557,81 +550,28 @@ export async function getRepoForceRebase(): Promise<boolean> { return config.repoForceRebase; } -export async function getClosedPrs(): Promise<PrList> { - if (!config.closedPrList) { - config.closedPrList = {}; - try { - // prettier-ignore - const nodes = await githubApi.queryRepoField<GhGraphQlPr>( - closedPrsQuery, - 'pullRequests', - { - variables: { - owner: config.repositoryOwner, - name: config.repositoryName, - }, - } - ); - const prNumbers: number[] = []; - // istanbul ignore if - if (!nodes?.length) { - logger.debug('getClosedPrs(): no graphql data'); - return {}; - } - for (const gqlPr of nodes) { - const pr = coerceGraphqlPr(gqlPr); - config.closedPrList[pr.number] = pr; - prNumbers.push(pr.number); - if (gqlPr.comments?.nodes) { - config.prComments[pr.number] = gqlPr.comments.nodes.map( - ({ databaseId: id, body }) => ({ id, body }) - ); - } +function cachePr(pr?: Pr): void { + config.prList ??= []; + if (pr) { + for (let idx = 0; idx < config.prList.length; idx += 1) { + const cachedPr = config.prList[idx]; + if (cachedPr.number === pr.number) { + config.prList[idx] = pr; + return; } - prNumbers.sort(); - logger.debug({ prNumbers }, 'Retrieved closed PR list with graphql'); - } catch (err) /* istanbul ignore next */ { - logger.warn({ err }, 'getClosedPrs(): error'); } + config.prList.push(pr); } - return config.closedPrList; } -async function getOpenPrs(): Promise<PrList> { - // The graphql query is supported in the current oldest GHE version 2.19 - if (!config.openPrList) { - config.openPrList = {}; - try { - // prettier-ignore - const nodes = await githubApi.queryRepoField<GhGraphQlPr>( - openPrsQuery, - 'pullRequests', - { - variables: { - owner: config.repositoryOwner, - name: config.repositoryName, - }, - acceptHeader: 'application/vnd.github.merge-info-preview+json', - } - ); - const prNumbers: number[] = []; - // istanbul ignore if - if (!nodes?.length) { - logger.debug('getOpenPrs(): no graphql data'); - return {}; - } - for (const gqlPr of nodes) { - const pr = coerceGraphqlPr(gqlPr); - config.openPrList[pr.number] = pr; - prNumbers.push(pr.number); - } - prNumbers.sort(); - logger.trace({ prNumbers }, 'Retrieved open PR list with graphql'); - } catch (err) /* istanbul ignore next */ { - logger.warn({ err }, 'getOpenPrs(): error'); - } - } - return config.openPrList; +// Fetch fresh Pull Request and cache it when possible +async function fetchPr(prNo: number): Promise<Pr | null> { + const { body: ghRestPr } = await githubApi.getJson<GhRestPr>( + `repos/${config.parentRepo || config.repository}/pulls/${prNo}` + ); + const result = coerceRestPr(ghRestPr); + cachePr(result); + return result; } // Gets details for a PR @@ -639,28 +579,13 @@ export async function getPr(prNo: number): Promise<Pr | null> { if (!prNo) { return null; } - const openPrs = await getOpenPrs(); - const openPr = openPrs[prNo]; - if (openPr) { - logger.debug('Returning from graphql open PR list'); - return openPr; - } - const closedPrs = await getClosedPrs(); - const closedPr = closedPrs[prNo]; - if (closedPr) { - logger.debug('Returning from graphql closed PR list'); - return closedPr; + const prList = await getPrList(); + let pr = prList.find(({ number }) => number === prNo); + if (pr) { + logger.debug('Returning PR from cache'); } - logger.debug( - { prNo }, - 'PR not found in open or closed PRs list - trying to fetch it directly' - ); - const ghRestPr = ( - await githubApi.getJson<GhRestPr>( - `repos/${config.parentRepo || config.repository}/pulls/${prNo}` - ) - ).body; - return ghRestPr ? coerceRestPr(ghRestPr) : null; + pr ??= await fetchPr(prNo); + return pr; } function matchesState(state: string, desiredState: string): boolean { @@ -674,35 +599,16 @@ function matchesState(state: string, desiredState: string): boolean { } export async function getPrList(): Promise<Pr[]> { - logger.trace('getPrList()'); if (!config.prList) { - logger.debug('Retrieving PR list'); - let prList: GhRestPr[]; - try { - prList = ( - await githubApi.getJson<GhRestPr[]>( - `repos/${ - config.parentRepo || config.repository - }/pulls?per_page=100&state=all`, - { paginate: true } - ) - ).body; - } catch (err) /* istanbul ignore next */ { - logger.debug({ err }, 'getPrList err'); - throw new ExternalHostError(err, PlatformId.Github); - } - config.prList = prList - .filter( - (pr) => - config.forkMode || - config.ignorePrAuthor || - (pr?.user?.login && config?.renovateUsername - ? pr.user.login === config.renovateUsername - : true) - ) - .map(coerceRestPr); - logger.debug(`Retrieved ${config.prList.length} Pull Requests`); + const repo = config.parentRepo ?? config.repository; + const username = + !config.forkMode && !config.ignorePrAuthor && config.renovateUsername + ? config.renovateUsername + : null; + const prCache = await getPrCache(githubApi, repo, username); + config.prList = Object.values(prCache); } + return config.prList; } @@ -730,19 +636,16 @@ const REOPEN_THRESHOLD_MILLIS = 1000 * 60 * 60 * 24 * 7; // Returns the Pull Request for a branch. Null if not exists. export async function getBranchPr(branchName: string): Promise<Pr | null> { - // istanbul ignore if - if (config.branchPrs[branchName]) { - return config.branchPrs[branchName]; - } logger.debug(`getBranchPr(${branchName})`); + const openPr = await findPr({ branchName, state: PrState.Open, }); if (openPr) { - config.branchPrs[branchName] = await getPr(openPr.number); - return config.branchPrs[branchName]; + return openPr; } + const autoclosedPr = await findPr({ branchName, state: PrState.Closed, @@ -771,24 +674,26 @@ export async function getBranchPr(branchName: string): Promise<Pr | null> { } try { const title = autoclosedPr.title.replace(regEx(/ - autoclosed$/), ''); - await githubApi.patchJson(`repos/${config.repository}/pulls/${number}`, { - body: { - state: 'open', - title, - }, - }); + const { body: ghPr } = await githubApi.patchJson<GhRestPr>( + `repos/${config.repository}/pulls/${number}`, + { + body: { + state: 'open', + title, + }, + } + ); logger.info( { branchName, title, number }, 'Successfully reopened autoclosed PR' ); + const result = coerceRestPr(ghPr); + cachePr(result); + return result; } catch (err) { logger.debug('Could not reopen autoclosed PR'); return null; } - delete config.openPrList; // So that it gets refreshed - delete config.closedPrList?.[number]; // So that it's no longer found in the closed list - config.branchPrs[branchName] = await getPr(number); - return config.branchPrs[branchName]; } return null; } @@ -1285,11 +1190,6 @@ async function deleteComment(commentId: number): Promise<void> { } async function getComments(issueNo: number): Promise<Comment[]> { - const cachedComments = config.prComments[issueNo]; - if (cachedComments) { - logger.debug('Returning closed PR list comments'); - return cachedComments; - } // GET /repos/:owner/:repo/issues/:number/comments logger.debug(`Getting comments for #${issueNo}`); const url = `repos/${ @@ -1472,25 +1372,22 @@ export async function createPr({ options.body.maintainer_can_modify = true; } logger.debug({ title, head, base, draft: draftPR }, 'Creating PR'); - const ghRestPr = ( + const ghPr = ( await githubApi.postJson<GhRestPr>( `repos/${config.parentRepo || config.repository}/pulls`, options ) ).body; logger.debug( - { branch: sourceBranch, pr: ghRestPr.number, draft: draftPR }, + { branch: sourceBranch, pr: ghPr.number, draft: draftPR }, 'PR created' ); - const { node_id } = ghRestPr; - const pr = coerceRestPr(ghRestPr); - // istanbul ignore if - if (config.prList) { - config.prList.push(pr); - } - await addLabels(pr.number, labels); - await tryPrAutomerge(pr.number, node_id, platformOptions); - return pr; + const { number, node_id } = ghPr; + await addLabels(number, labels); + await tryPrAutomerge(number, node_id, platformOptions); + const result = coerceRestPr(ghPr); + cachePr(result); + return result; } export async function updatePr({ @@ -1516,10 +1413,12 @@ export async function updatePr({ options.token = config.forkToken; } try { - await githubApi.patchJson( + const { body: ghPr } = await githubApi.patchJson<GhRestPr>( `repos/${config.parentRepo || config.repository}/pulls/${prNo}`, options ); + const result = coerceRestPr(ghPr); + cachePr(result); logger.debug({ pr: prNo }, 'PR updated'); } catch (err) /* istanbul ignore next */ { if (err instanceof ExternalHostError) { @@ -1618,6 +1517,10 @@ export async function mergePr({ { automergeResult: automergeResult.body, pr: prNo }, 'PR merged' ); + const cachedPr = config.prList?.find(({ number }) => number === prNo); + if (cachedPr) { + cachePr({ ...cachedPr, state: PrState.Merged }); + } return true; } diff --git a/lib/modules/platform/github/pr.ts b/lib/modules/platform/github/pr.ts new file mode 100644 index 0000000000000000000000000000000000000000..c931e876727590f69074cf936a6cffbbcef71930 --- /dev/null +++ b/lib/modules/platform/github/pr.ts @@ -0,0 +1,140 @@ +import { PlatformId } from '../../../constants'; +import { logger } from '../../../logger'; +import { ExternalHostError } from '../../../types/errors/external-host-error'; +import { getCache } from '../../../util/cache/repository'; +import type { GithubHttp, GithubHttpOptions } from '../../../util/http/github'; +import { parseLinkHeader } from '../../../util/url'; +import type { Pr } from '../types'; +import { ApiCache } from './api-cache'; +import { coerceRestPr } from './common'; +import type { ApiPageCache, GhRestPr } from './types'; + +function getPrApiCache(): ApiCache<GhRestPr> { + const repoCache = getCache(); + repoCache.platform ??= {}; + repoCache.platform.github ??= {}; + repoCache.platform.github.prCache ??= { items: {} }; + const prCache = new ApiCache( + repoCache.platform.github.prCache as ApiPageCache<GhRestPr> + ); + return prCache; +} + +/** + * Fetch and return Pull Requests from GitHub repository: + * + * 1. Synchronize long-term cache. + * + * 2. Store items in raw format, i.e. exactly what + * has been returned by GitHub REST API. + * + * 3. Convert items to the Renovate format and return. + * + * In order synchronize ApiCache properly, we handle 3 cases: + * + * a. We never fetched PR list for this repo before. + * This is detected by `etag` presense in the cache. + * + * In this case, we're falling back to quick fetch via + * `paginate=true` option (see `util/http/github.ts`). + * + * b. None of PRs has changed since last run. + * + * We detect this by setting `If-None-Match` HTTP header + * with the `etag` value from the previous run. + * + * c. Some of PRs had changed since last run. + * + * In this case, we sequentially fetch page by page + * until `ApiCache.coerce` function indicates that + * no more fresh items can be found in the next page. + * + * We expect to fetch just one page per run in average, + * since it's rare to have more than 100 updated PRs. + */ +export async function getPrCache( + http: GithubHttp, + repo: string, + username: string | null +): Promise<Record<number, Pr>> { + const prCache: Record<number, Pr> = {}; + const prApiCache = getPrApiCache(); + + try { + let requestsTotal = 0; + let apiQuotaAffected = false; + let needNextPageFetch = true; + let needNextPageSync = true; + + let pageIdx = 1; + while (needNextPageFetch && needNextPageSync) { + const urlPath = `repos/${repo}/pulls?per_page=100&state=all&sort=updated&direction=desc&page=${pageIdx}`; + + const opts: GithubHttpOptions = { paginate: false }; + if (pageIdx === 1) { + const oldEtag = prApiCache.etag; + if (oldEtag) { + opts.headers = { 'If-None-Match': oldEtag }; + } else { + // Speed up initial fetch + opts.paginate = true; + } + } + + const res = await http.getJson<GhRestPr[]>(urlPath, opts); + apiQuotaAffected = true; + requestsTotal += 1; + + if (pageIdx === 1 && res.statusCode === 304) { + apiQuotaAffected = false; + break; + } + + const { + headers: { link: linkHeader, etag: newEtag }, + } = res; + + let { body: page } = res; + + if (username) { + page = page.filter( + (ghPr) => ghPr?.user?.login && ghPr.user.login === username + ); + } + + needNextPageSync = prApiCache.reconcile(page); + needNextPageFetch = !!parseLinkHeader(linkHeader)?.next; + + if (pageIdx === 1) { + if (newEtag) { + prApiCache.etag = newEtag; + } + + needNextPageFetch &&= !opts.paginate; + } + + pageIdx += 1; + } + + logger.debug( + { + pullsTotal: prApiCache.getItems().length, + requestsTotal, + apiQuotaAffected, + }, + `getPrList success` + ); + } catch (err) /* istanbul ignore next */ { + logger.debug({ err }, 'getPrList err'); + throw new ExternalHostError(err, PlatformId.Github); + } + + for (const ghPr of prApiCache.getItems()) { + const pr = coerceRestPr(ghPr); + if (pr) { + prCache[ghPr.number] = pr; + } + } + + return prCache; +} diff --git a/lib/modules/platform/github/types.ts b/lib/modules/platform/github/types.ts index 49e116295c4ca2dbf853248d43358a3f2eaf228e..d7a3058f06ad56ebcfa684284db446b461df5e43 100644 --- a/lib/modules/platform/github/types.ts +++ b/lib/modules/platform/github/types.ts @@ -29,10 +29,12 @@ export interface GhRestPr { mergeable_state: string; number: number; title: string; + body: string; state: string; merged_at: string; created_at: string; closed_at: string; + updated_at: string; user?: { login?: string }; node_id: string; assignee?: { login?: string }; @@ -83,10 +85,7 @@ export interface LocalRepoConfig { parentRepo: string; forkMode?: boolean; forkToken?: string; - closedPrList: PrList | null; - openPrList: PrList | null; - prList: Pr[] | null; - prComments: Record<number, Comment[]>; + prList?: Pr[]; issueList: any[] | null; mergeMethod: 'rebase' | 'squash' | 'merge'; defaultBranch: string; @@ -95,13 +94,11 @@ export interface LocalRepoConfig { renovateUsername: string; productLinks: any; ignorePrAuthor: boolean; - branchPrs: Pr[]; autoMergeAllowed: boolean; hasIssuesEnabled: boolean; } export type BranchProtection = any; -export type PrList = Record<number, Pr>; export interface GhRepo { isFork: boolean; diff --git a/lib/util/cache/repository/types.ts b/lib/util/cache/repository/types.ts index 9ad84ab219d28b1e7fc1b98a363f6b428fb0dea1..a9b5e0ada98bcac9c82f294145d8442436a8a84f 100644 --- a/lib/util/cache/repository/types.ts +++ b/lib/util/cache/repository/types.ts @@ -32,11 +32,6 @@ export interface BranchCache { upgrades: BranchUpgradeCache[]; } -export interface GithubGraphqlPageCache { - pageLastResizedAt: string; - pageSize: number; -} - export interface Cache { configFileName?: string; semanticCommits?: 'enabled' | 'disabled'; @@ -47,9 +42,7 @@ export interface Cache { scan?: Record<string, BaseBranchCache>; lastPlatformAutomergeFailure?: string; platform?: { - github?: { - graphqlPageCache?: Record<string, GithubGraphqlPageCache>; - }; + github?: Record<string, unknown>; }; gitConflicts?: GitConflictsCache; prComments?: Record<number, Record<string, string>>; diff --git a/lib/util/http/github.spec.ts b/lib/util/http/github.spec.ts index 7afd84ce9a6b125d9a31550f22d1f320f1e91b07..b537d3acaa6a52b1575931a982db352e84cec908 100644 --- a/lib/util/http/github.spec.ts +++ b/lib/util/http/github.spec.ts @@ -528,7 +528,7 @@ describe('util/http/github', () => { expect(items).toHaveLength(3); expect( - repoCache?.platform?.github?.graphqlPageCache?.testItem?.pageSize + repoCache?.platform?.github?.graphqlPageCache?.['testItem']?.pageSize ).toBe(25); }); @@ -556,7 +556,7 @@ describe('util/http/github', () => { const items = await githubApi.queryRepoField(graphqlQuery, 'testItem'); expect(items).toHaveLength(3); expect( - repoCache?.platform?.github?.graphqlPageCache?.testItem?.pageSize + repoCache?.platform?.github?.graphqlPageCache?.['testItem']?.pageSize ).toBe(84); }); @@ -601,7 +601,7 @@ describe('util/http/github', () => { expect(items).toHaveLength(3); expect( - repoCache?.platform?.github?.graphqlPageCache?.testItem + repoCache?.platform?.github?.graphqlPageCache?.['testItem'] ).toBeUndefined(); }); diff --git a/lib/util/http/github.ts b/lib/util/http/github.ts index ebad865829caf1092d5a30e87eff8c4a02b2bab1..aa39594e043293b2710598664cb5120f04d00b8a 100644 --- a/lib/util/http/github.ts +++ b/lib/util/http/github.ts @@ -181,12 +181,20 @@ function constructAcceptString(input?: any): string { const MAX_GRAPHQL_PAGE_SIZE = 100; +interface GraphqlPageCacheItem { + pageLastResizedAt: string; + pageSize: number; +} + +type GraphqlPageCache = Record<string, GraphqlPageCacheItem>; + function getGraphqlPageSize( fieldName: string, defaultPageSize = MAX_GRAPHQL_PAGE_SIZE ): number { const cache = getCache(); - const graphqlPageCache = cache?.platform?.github?.graphqlPageCache; + const graphqlPageCache = cache?.platform?.github + ?.graphqlPageCache as GraphqlPageCache; const cachedRecord = graphqlPageCache?.[fieldName]; if (graphqlPageCache && cachedRecord) { @@ -243,7 +251,9 @@ function setGraphqlPageSize(fieldName: string, newPageSize: number): void { cache.platform ??= {}; cache.platform.github ??= {}; cache.platform.github.graphqlPageCache ??= {}; - cache.platform.github.graphqlPageCache[fieldName] = { + const graphqlPageCache = cache.platform.github + .graphqlPageCache as GraphqlPageCache; + graphqlPageCache[fieldName] = { pageLastResizedAt, pageSize: newPageSize, };