diff --git a/docs/usage/self-hosted-experimental.md b/docs/usage/self-hosted-experimental.md index ba71bd0f1a0cc6d4993e1543e5fa8c723c287553..3b0cfb6d780d30a902497321f1647659596da83b 100644 --- a/docs/usage/self-hosted-experimental.md +++ b/docs/usage/self-hosted-experimental.md @@ -104,6 +104,14 @@ Allowed values: Default value: `asc`. +## `RENOVATE_X_REBASE_PAGINATION_LINKS` + +If set, Renovate will rewrite GitHub Enterprise Server's pagination responses to use the `endpoint` URL from the Renovate config. + +<!-- prettier-ignore --> +!!! note + For the GitHub Enterprise Server platform only. + ## `OTEL_EXPORTER_OTLP_ENDPOINT` If set, Renovate will export OpenTelemetry data to the supplied endpoint. diff --git a/lib/util/http/github.spec.ts b/lib/util/http/github.spec.ts index c3ef8ad22f315817fcbef084957cf6b25eeb71bc..f2383c4b715d35d1cf50c3a5bcb07a20de21a461 100644 --- a/lib/util/http/github.spec.ts +++ b/lib/util/http/github.spec.ts @@ -52,6 +52,7 @@ describe('util/http/github', () => { let repoCache: RepoCacheData = {}; beforeEach(() => { + delete process.env.RENOVATE_X_REBASE_PAGINATION_LINKS; githubApi = new GithubHttp(); setBaseUrl(githubApiHost); jest.resetAllMocks(); @@ -229,6 +230,75 @@ describe('util/http/github', () => { expect(res.body).toEqual(['a']); }); + it('rebases GHE Server pagination links', async () => { + process.env.RENOVATE_X_REBASE_PAGINATION_LINKS = '1'; + // The origin and base URL which Renovate uses (from its config) to reach GHE: + const baseUrl = 'http://ghe.alternative.domain.com/api/v3'; + setBaseUrl(baseUrl); + // The hostname from GHE settings, which users use through their browsers to reach GHE: + // https://docs.github.com/en/enterprise-server@3.5/admin/configuration/configuring-network-settings/configuring-a-hostname + const gheHostname = 'ghe.mycompany.com'; + // GHE replies to paginated requests with a Link response header whose URLs have this base + const gheBaseUrl = `https://${gheHostname}/api/v3`; + const apiUrl = '/some-url?per_page=2'; + httpMock + .scope(baseUrl) + .get(apiUrl) + .reply(200, ['a', 'b'], { + link: `<${gheBaseUrl}${apiUrl}&page=2>; rel="next", <${gheBaseUrl}${apiUrl}&page=3>; rel="last"`, + }) + .get(`${apiUrl}&page=2`) + .reply(200, ['c', 'd'], { + link: `<${gheBaseUrl}${apiUrl}&page=3>; rel="next", <${gheBaseUrl}${apiUrl}&page=3>; rel="last"`, + }) + .get(`${apiUrl}&page=3`) + .reply(200, ['e']); + const res = await githubApi.getJson(apiUrl, { paginate: true }); + expect(res.body).toEqual(['a', 'b', 'c', 'd', 'e']); + }); + + it('preserves pagination links by default', async () => { + const baseUrl = 'http://ghe.alternative.domain.com/api/v3'; + setBaseUrl(baseUrl); + const apiUrl = '/some-url?per_page=2'; + httpMock + .scope(baseUrl) + .get(apiUrl) + .reply(200, ['a', 'b'], { + link: `<${baseUrl}${apiUrl}&page=2>; rel="next", <${baseUrl}${apiUrl}&page=3>; rel="last"`, + }) + .get(`${apiUrl}&page=2`) + .reply(200, ['c', 'd'], { + link: `<${baseUrl}${apiUrl}&page=3>; rel="next", <${baseUrl}${apiUrl}&page=3>; rel="last"`, + }) + .get(`${apiUrl}&page=3`) + .reply(200, ['e']); + const res = await githubApi.getJson(apiUrl, { paginate: true }); + expect(res.body).toEqual(['a', 'b', 'c', 'd', 'e']); + }); + + it('preserves pagination links for github.com', async () => { + process.env.RENOVATE_X_REBASE_PAGINATION_LINKS = '1'; + const baseUrl = 'https://api.github.com/'; + + setBaseUrl(baseUrl); + const apiUrl = 'some-url?per_page=2'; + httpMock + .scope(baseUrl) + .get('/' + apiUrl) + .reply(200, ['a', 'b'], { + link: `<${baseUrl}${apiUrl}&page=2>; rel="next", <${baseUrl}${apiUrl}&page=3>; rel="last"`, + }) + .get(`/${apiUrl}&page=2`) + .reply(200, ['c', 'd'], { + link: `<${baseUrl}${apiUrl}&page=3>; rel="next", <${baseUrl}${apiUrl}&page=3>; rel="last"`, + }) + .get(`/${apiUrl}&page=3`) + .reply(200, ['e']); + const res = await githubApi.getJson(apiUrl, { paginate: true }); + expect(res.body).toEqual(['a', 'b', 'c', 'd', 'e']); + }); + describe('handleGotError', () => { async function fail( code: number, diff --git a/lib/util/http/github.ts b/lib/util/http/github.ts index 7b8c6ec345f29b4f81c64e820296235802812840..8e5481c6b7a4f14de9292a97d2155153353b2d6a 100644 --- a/lib/util/http/github.ts +++ b/lib/util/http/github.ts @@ -259,6 +259,11 @@ function setGraphqlPageSize(fieldName: string, newPageSize: number): void { } } +function replaceUrlBase(url: URL, baseUrl: string): URL { + const relativeUrl = `${url.pathname}${url.search}`; + return new URL(relativeUrl, baseUrl); +} + export class GithubHttp extends Http<GithubHttpOptions> { constructor(hostType = 'github', options?: GithubHttpOptions) { super(hostType, options); @@ -309,15 +314,27 @@ export class GithubHttp extends Http<GithubHttpOptions> { // Check if result is paginated const pageLimit = opts.pageLimit ?? 10; const linkHeader = parseLinkHeader(result?.headers?.link); - if (linkHeader?.next?.url && linkHeader?.last?.page) { + const next = linkHeader?.next; + if (next?.url && linkHeader?.last?.page) { let lastPage = parseInt(linkHeader.last.page, 10); // istanbul ignore else: needs a test if (!process.env.RENOVATE_PAGINATE_ALL && opts.paginate !== 'all') { lastPage = Math.min(pageLimit, lastPage); } + const baseUrl = opts.baseUrl; + const parsedUrl = new URL(next.url, baseUrl); + const rebasePagination = + !!baseUrl && + !!process.env.RENOVATE_X_REBASE_PAGINATION_LINKS && + // Preserve github.com URLs for use cases like release notes + parsedUrl.origin !== 'https://api.github.com'; + const firstPageUrl = rebasePagination + ? replaceUrlBase(parsedUrl, baseUrl) + : parsedUrl; const queue = [...range(2, lastPage)].map( (pageNumber) => (): Promise<HttpResponse<T>> => { - const nextUrl = new URL(linkHeader.next!.url, opts.baseUrl); + // copy before modifying searchParams + const nextUrl = new URL(firstPageUrl); nextUrl.searchParams.set('page', String(pageNumber)); return this.request<T>( nextUrl,