github-issue-tower-defence-management 1.124.0 → 1.125.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,8 @@
1
1
  import { mock } from 'jest-mock-extended';
2
- import { ApiV3CheerioRestIssueRepository } from './ApiV3CheerioRestIssueRepository';
2
+ import {
3
+ ApiV3CheerioRestIssueRepository,
4
+ REQUIRED_CHECKS_CACHE_TTL_MS,
5
+ } from './ApiV3CheerioRestIssueRepository';
3
6
  import { ApiV3IssueRepository } from './ApiV3IssueRepository';
4
7
  import { RestIssueRepository } from './RestIssueRepository';
5
8
  import {
@@ -1965,74 +1968,202 @@ describe('ApiV3CheerioRestIssueRepository', () => {
1965
1968
  });
1966
1969
  });
1967
1970
 
1971
+ const jsonResponse = (body: object): Response =>
1972
+ new Response(JSON.stringify(body), {
1973
+ status: 200,
1974
+ headers: { 'Content-Type': 'application/json' },
1975
+ });
1976
+
1977
+ const toResponse = (value: Response | object): Response =>
1978
+ value instanceof Response ? value : jsonResponse(value);
1979
+
1980
+ type FetchRoutes = {
1981
+ timeline?: () => Response | object;
1982
+ mergeability?: () => Response | object;
1983
+ slimPullRequest?: (variables: {
1984
+ prNumber: number;
1985
+ reviewThreadsAfter: string | null;
1986
+ }) => Response | object;
1987
+ branchRules?: (url: string) => Response | object;
1988
+ branchDetail?: (url: string) => Response | object;
1989
+ checkRuns?: (url: string) => Response | object;
1990
+ combinedStatus?: (url: string) => Response | object;
1991
+ };
1992
+
1993
+ const requestUrlOf = (input: RequestInfo | URL): string =>
1994
+ typeof input === 'string'
1995
+ ? input
1996
+ : input instanceof URL
1997
+ ? input.toString()
1998
+ : input.url;
1999
+
2000
+ const requestBodyOf = (init?: RequestInit): string =>
2001
+ typeof init?.body === 'string' ? init.body : '';
2002
+
2003
+ type GraphqlRequestBody = {
2004
+ query: string;
2005
+ variables: {
2006
+ prNumber: number;
2007
+ reviewThreadsAfter: string | null;
2008
+ };
2009
+ };
2010
+
2011
+ const parseGraphqlRequestBody = (init?: RequestInit): GraphqlRequestBody => {
2012
+ const parsed: unknown = JSON.parse(requestBodyOf(init));
2013
+ if (
2014
+ typeof parsed !== 'object' ||
2015
+ parsed === null ||
2016
+ !('query' in parsed) ||
2017
+ typeof parsed.query !== 'string'
2018
+ ) {
2019
+ throw new Error('Unexpected GraphQL request body in test');
2020
+ }
2021
+ const rawVariables: unknown =
2022
+ 'variables' in parsed ? parsed.variables : null;
2023
+ const variables =
2024
+ typeof rawVariables === 'object' && rawVariables !== null
2025
+ ? rawVariables
2026
+ : {};
2027
+ const prNumber =
2028
+ 'prNumber' in variables && typeof variables.prNumber === 'number'
2029
+ ? variables.prNumber
2030
+ : 0;
2031
+ const reviewThreadsAfter =
2032
+ 'reviewThreadsAfter' in variables &&
2033
+ typeof variables.reviewThreadsAfter === 'string'
2034
+ ? variables.reviewThreadsAfter
2035
+ : null;
2036
+ return { query: parsed.query, variables: { prNumber, reviewThreadsAfter } };
2037
+ };
2038
+
2039
+ const mockFetchRoutes = (routes: FetchRoutes) =>
2040
+ jest
2041
+ .spyOn(global, 'fetch')
2042
+ .mockImplementation(
2043
+ async (input: RequestInfo | URL, init?: RequestInit) => {
2044
+ const url = requestUrlOf(input);
2045
+ if (url === 'https://api.github.com/graphql') {
2046
+ const body = parseGraphqlRequestBody(init);
2047
+ if (body.query.includes('timelineItems') && routes.timeline) {
2048
+ return toResponse(routes.timeline());
2049
+ }
2050
+ if (
2051
+ body.query.includes('mergeStateStatus') &&
2052
+ routes.mergeability
2053
+ ) {
2054
+ return toResponse(routes.mergeability());
2055
+ }
2056
+ if (body.query.includes('headRefOid') && routes.slimPullRequest) {
2057
+ return toResponse(routes.slimPullRequest(body.variables));
2058
+ }
2059
+ throw new Error(`Unexpected GraphQL query in test: ${body.query}`);
2060
+ }
2061
+ if (url.includes('/rules/branches/')) {
2062
+ return toResponse(
2063
+ routes.branchRules ? routes.branchRules(url) : [],
2064
+ );
2065
+ }
2066
+ if (/\/branches\/[^/?]+$/.test(url)) {
2067
+ return toResponse(
2068
+ routes.branchDetail ? routes.branchDetail(url) : {},
2069
+ );
2070
+ }
2071
+ if (url.includes('/check-runs')) {
2072
+ return toResponse(
2073
+ routes.checkRuns
2074
+ ? routes.checkRuns(url)
2075
+ : { total_count: 0, check_runs: [] },
2076
+ );
2077
+ }
2078
+ if (url.includes('/status?')) {
2079
+ return toResponse(
2080
+ routes.combinedStatus
2081
+ ? routes.combinedStatus(url)
2082
+ : { statuses: [] },
2083
+ );
2084
+ }
2085
+ throw new Error(`Unexpected fetch URL in test: ${url}`);
2086
+ },
2087
+ );
2088
+
2089
+ type FetchSpy = ReturnType<typeof mockFetchRoutes>;
2090
+
2091
+ const countCallsMatching = (
2092
+ fetchSpy: FetchSpy,
2093
+ predicate: (url: string, body: string) => boolean,
2094
+ ): number =>
2095
+ fetchSpy.mock.calls.filter(([input, init]) =>
2096
+ predicate(requestUrlOf(input), requestBodyOf(init)),
2097
+ ).length;
2098
+
2099
+ const countMergeabilityQueries = (fetchSpy: FetchSpy): number =>
2100
+ countCallsMatching(
2101
+ fetchSpy,
2102
+ (url, body) =>
2103
+ url === 'https://api.github.com/graphql' &&
2104
+ body.includes('mergeStateStatus'),
2105
+ );
2106
+
2107
+ const buildSlimPullRequestResponse = (
2108
+ overrides: {
2109
+ url?: string;
2110
+ state?: string;
2111
+ isDraft?: boolean;
2112
+ headRefName?: string;
2113
+ baseRefName?: string;
2114
+ mergeable?: string;
2115
+ headRefOid?: string;
2116
+ reviewThreads?: {
2117
+ pageInfo: { endCursor: string | null; hasNextPage: boolean };
2118
+ nodes: Array<{ isResolved: boolean }>;
2119
+ };
2120
+ } = {},
2121
+ ) => ({
2122
+ data: {
2123
+ repository: {
2124
+ pullRequest: {
2125
+ url:
2126
+ overrides.url ??
2127
+ 'https://github.com/HiromiShikata/test-repository/pull/31',
2128
+ state: overrides.state ?? 'OPEN',
2129
+ isDraft: overrides.isDraft ?? false,
2130
+ headRefName: overrides.headRefName ?? 'feature-branch',
2131
+ baseRefName: overrides.baseRefName ?? 'main',
2132
+ mergeable: overrides.mergeable ?? 'MERGEABLE',
2133
+ headRefOid: overrides.headRefOid ?? 'headsha123',
2134
+ reviewThreads: overrides.reviewThreads ?? {
2135
+ pageInfo: { endCursor: null, hasNextPage: false },
2136
+ nodes: [],
2137
+ },
2138
+ },
2139
+ },
2140
+ },
2141
+ });
2142
+
1968
2143
  describe('getOpenPullRequest CI state computation', () => {
1969
2144
  afterEach(() => {
1970
2145
  jest.restoreAllMocks();
1971
2146
  });
1972
2147
 
1973
- const buildGraphqlPrResponse = (
1974
- checkRunNodes: Array<{
1975
- __typename: 'CheckRun';
1976
- databaseId: number;
1977
- name: string;
1978
- conclusion: string | null;
1979
- }>,
1980
- ) => ({
1981
- data: {
1982
- repository: {
1983
- pullRequest: {
1984
- url: 'https://github.com/HiromiShikata/test-repository/pull/31',
1985
- state: 'OPEN',
1986
- isDraft: false,
1987
- headRefName: 'dependabot/npm_and_yarn/some-package-2.0.0',
1988
- baseRefName: 'main',
1989
- mergeable: 'MERGEABLE',
1990
- baseRepository: {
1991
- branchProtectionRules: { nodes: [] },
1992
- defaultBranchRef: { name: 'main' },
1993
- rulesets: { nodes: [] },
2148
+ it('returns isCiStateSuccess true when the latest check run per name is success even though an older run has failure', async () => {
2149
+ mockFetchRoutes({
2150
+ slimPullRequest: () => buildSlimPullRequestResponse(),
2151
+ checkRuns: () => ({
2152
+ total_count: 2,
2153
+ check_runs: [
2154
+ {
2155
+ id: 100,
2156
+ name: 'check_pull_requests_to_link_issues',
2157
+ conclusion: 'failure',
1994
2158
  },
1995
- commits: {
1996
- nodes: [
1997
- {
1998
- commit: {
1999
- statusCheckRollup: {
2000
- contexts: {
2001
- nodes: checkRunNodes,
2002
- },
2003
- },
2004
- },
2005
- },
2006
- ],
2159
+ {
2160
+ id: 200,
2161
+ name: 'check_pull_requests_to_link_issues',
2162
+ conclusion: 'success',
2007
2163
  },
2008
- reviewThreads: { nodes: [] },
2009
- },
2010
- },
2011
- },
2012
- });
2013
-
2014
- it('returns isCiStateSuccess true when latest CheckRun per name is SUCCESS even though an older run has FAILURE', async () => {
2015
- jest.spyOn(global, 'fetch').mockResolvedValueOnce(
2016
- new Response(
2017
- JSON.stringify(
2018
- buildGraphqlPrResponse([
2019
- {
2020
- __typename: 'CheckRun',
2021
- databaseId: 100,
2022
- name: 'check_pull_requests_to_link_issues',
2023
- conclusion: 'FAILURE',
2024
- },
2025
- {
2026
- __typename: 'CheckRun',
2027
- databaseId: 200,
2028
- name: 'check_pull_requests_to_link_issues',
2029
- conclusion: 'SUCCESS',
2030
- },
2031
- ]),
2032
- ),
2033
- { status: 200, headers: { 'Content-Type': 'application/json' } },
2034
- ),
2035
- );
2164
+ ],
2165
+ }),
2166
+ });
2036
2167
 
2037
2168
  const { repository } = createApiV3CheerioRestIssueRepository();
2038
2169
  const result = await repository.getOpenPullRequest(
@@ -2044,29 +2175,40 @@ describe('ApiV3CheerioRestIssueRepository', () => {
2044
2175
  expect(result?.isPassedAllCiJob).toBe(true);
2045
2176
  });
2046
2177
 
2047
- it('returns isCiStateSuccess false when latest CheckRun per name has FAILURE conclusion', async () => {
2048
- jest.spyOn(global, 'fetch').mockResolvedValueOnce(
2049
- new Response(
2050
- JSON.stringify(
2051
- buildGraphqlPrResponse([
2052
- {
2053
- __typename: 'CheckRun',
2054
- databaseId: 100,
2055
- name: 'ci',
2056
- conclusion: 'SUCCESS',
2057
- },
2058
- {
2059
- __typename: 'CheckRun',
2060
- databaseId: 200,
2061
- name: 'ci',
2062
- conclusion: 'FAILURE',
2063
- },
2064
- ]),
2065
- ),
2066
- { status: 200, headers: { 'Content-Type': 'application/json' } },
2067
- ),
2178
+ it('returns isCiStateSuccess false when the latest check run per name has failure conclusion', async () => {
2179
+ mockFetchRoutes({
2180
+ slimPullRequest: () => buildSlimPullRequestResponse(),
2181
+ checkRuns: () => ({
2182
+ total_count: 2,
2183
+ check_runs: [
2184
+ { id: 100, name: 'ci', conclusion: 'success' },
2185
+ { id: 200, name: 'ci', conclusion: 'failure' },
2186
+ ],
2187
+ }),
2188
+ });
2189
+
2190
+ const { repository } = createApiV3CheerioRestIssueRepository();
2191
+ const result = await repository.getOpenPullRequest(
2192
+ 'https://github.com/HiromiShikata/test-repository/pull/31',
2068
2193
  );
2069
2194
 
2195
+ expect(result).not.toBeNull();
2196
+ expect(result?.isCiStateSuccess).toBe(false);
2197
+ expect(result?.isPassedAllCiJob).toBe(false);
2198
+ });
2199
+
2200
+ it('returns isCiStateSuccess false when the latest check run per name has null conclusion (still running)', async () => {
2201
+ mockFetchRoutes({
2202
+ slimPullRequest: () => buildSlimPullRequestResponse(),
2203
+ checkRuns: () => ({
2204
+ total_count: 2,
2205
+ check_runs: [
2206
+ { id: 100, name: 'ci', conclusion: 'failure' },
2207
+ { id: 200, name: 'ci', conclusion: null },
2208
+ ],
2209
+ }),
2210
+ });
2211
+
2070
2212
  const { repository } = createApiV3CheerioRestIssueRepository();
2071
2213
  const result = await repository.getOpenPullRequest(
2072
2214
  'https://github.com/HiromiShikata/test-repository/pull/31',
@@ -2077,29 +2219,28 @@ describe('ApiV3CheerioRestIssueRepository', () => {
2077
2219
  expect(result?.isPassedAllCiJob).toBe(false);
2078
2220
  });
2079
2221
 
2080
- it('returns isCiStateSuccess false when latest CheckRun per name has null conclusion (still running)', async () => {
2081
- jest.spyOn(global, 'fetch').mockResolvedValueOnce(
2082
- new Response(
2083
- JSON.stringify(
2084
- buildGraphqlPrResponse([
2085
- {
2086
- __typename: 'CheckRun',
2087
- databaseId: 100,
2088
- name: 'ci',
2089
- conclusion: 'FAILURE',
2090
- },
2091
- {
2092
- __typename: 'CheckRun',
2093
- databaseId: 200,
2094
- name: 'ci',
2095
- conclusion: null,
2096
- },
2097
- ]),
2098
- ),
2099
- { status: 200, headers: { 'Content-Type': 'application/json' } },
2100
- ),
2222
+ it('returns isCiStateSuccess false when a commit status context has failure state', async () => {
2223
+ mockFetchRoutes({
2224
+ slimPullRequest: () => buildSlimPullRequestResponse(),
2225
+ combinedStatus: () => ({
2226
+ statuses: [{ context: 'external-ci', state: 'failure' }],
2227
+ }),
2228
+ });
2229
+
2230
+ const { repository } = createApiV3CheerioRestIssueRepository();
2231
+ const result = await repository.getOpenPullRequest(
2232
+ 'https://github.com/HiromiShikata/test-repository/pull/31',
2101
2233
  );
2102
2234
 
2235
+ expect(result).not.toBeNull();
2236
+ expect(result?.isCiStateSuccess).toBe(false);
2237
+ });
2238
+
2239
+ it('returns isCiStateSuccess false when the head commit has no check runs and no commit statuses', async () => {
2240
+ mockFetchRoutes({
2241
+ slimPullRequest: () => buildSlimPullRequestResponse(),
2242
+ });
2243
+
2103
2244
  const { repository } = createApiV3CheerioRestIssueRepository();
2104
2245
  const result = await repository.getOpenPullRequest(
2105
2246
  'https://github.com/HiromiShikata/test-repository/pull/31',
@@ -2110,99 +2251,374 @@ describe('ApiV3CheerioRestIssueRepository', () => {
2110
2251
  expect(result?.isPassedAllCiJob).toBe(false);
2111
2252
  });
2112
2253
 
2113
- it('returns isCiStateSuccess false when a StatusContext has FAILURE state', async () => {
2114
- jest.spyOn(global, 'fetch').mockResolvedValueOnce(
2115
- new Response(
2116
- JSON.stringify({
2117
- data: {
2118
- repository: {
2119
- pullRequest: {
2120
- url: 'https://github.com/HiromiShikata/test-repository/pull/31',
2121
- state: 'OPEN',
2122
- isDraft: false,
2123
- headRefName: 'feature-branch',
2124
- baseRefName: 'main',
2125
- mergeable: 'MERGEABLE',
2126
- baseRepository: {
2127
- branchProtectionRules: { nodes: [] },
2128
- defaultBranchRef: { name: 'main' },
2129
- rulesets: { nodes: [] },
2130
- },
2131
- commits: {
2132
- nodes: [
2133
- {
2134
- commit: {
2135
- statusCheckRollup: {
2136
- contexts: {
2137
- nodes: [
2138
- {
2139
- __typename: 'StatusContext',
2140
- context: 'external-ci',
2141
- state: 'FAILURE',
2142
- },
2143
- ],
2144
- },
2145
- },
2146
- },
2147
- },
2148
- ],
2149
- },
2150
- reviewThreads: { nodes: [] },
2151
- },
2152
- },
2254
+ it('combines REST check runs and commit statuses into one CI success evaluation', async () => {
2255
+ mockFetchRoutes({
2256
+ slimPullRequest: () => buildSlimPullRequestResponse(),
2257
+ checkRuns: () => ({
2258
+ total_count: 1,
2259
+ check_runs: [{ id: 1, name: 'unit-test', conclusion: 'success' }],
2260
+ }),
2261
+ combinedStatus: () => ({
2262
+ statuses: [{ context: 'external-ci', state: 'success' }],
2263
+ }),
2264
+ });
2265
+
2266
+ const { repository } = createApiV3CheerioRestIssueRepository();
2267
+ const result = await repository.getOpenPullRequest(
2268
+ 'https://github.com/HiromiShikata/test-repository/pull/31',
2269
+ );
2270
+
2271
+ expect(result).not.toBeNull();
2272
+ expect(result?.isCiStateSuccess).toBe(true);
2273
+ expect(result?.isPassedAllCiJob).toBe(true);
2274
+ });
2275
+ });
2276
+
2277
+ describe('getOpenPullRequest required check resolution via REST', () => {
2278
+ afterEach(() => {
2279
+ jest.restoreAllMocks();
2280
+ });
2281
+
2282
+ it('reports missing required checks merged from branch rules and classic branch protection', async () => {
2283
+ mockFetchRoutes({
2284
+ slimPullRequest: () => buildSlimPullRequestResponse(),
2285
+ branchRules: () => [
2286
+ {
2287
+ type: 'required_status_checks',
2288
+ parameters: {
2289
+ required_status_checks: [{ context: 'ruleset-check' }],
2153
2290
  },
2154
- }),
2155
- { status: 200, headers: { 'Content-Type': 'application/json' } },
2156
- ),
2291
+ },
2292
+ { type: 'deletion' },
2293
+ ],
2294
+ branchDetail: () => ({
2295
+ protection: {
2296
+ required_status_checks: { contexts: ['classic-check'] },
2297
+ },
2298
+ }),
2299
+ checkRuns: () => ({
2300
+ total_count: 1,
2301
+ check_runs: [{ id: 1, name: 'ruleset-check', conclusion: 'success' }],
2302
+ }),
2303
+ });
2304
+
2305
+ const { repository } = createApiV3CheerioRestIssueRepository();
2306
+ const result = await repository.getOpenPullRequest(
2307
+ 'https://github.com/HiromiShikata/test-repository/pull/31',
2157
2308
  );
2158
2309
 
2310
+ expect(result).not.toBeNull();
2311
+ expect(result?.missingRequiredCheckNames).toEqual(['classic-check']);
2312
+ expect(result?.isCiStateSuccess).toBe(true);
2313
+ expect(result?.isPassedAllCiJob).toBe(false);
2314
+ });
2315
+
2316
+ it('passes all required checks when every required check name has reported on the head commit', async () => {
2317
+ mockFetchRoutes({
2318
+ slimPullRequest: () => buildSlimPullRequestResponse(),
2319
+ branchRules: () => [
2320
+ {
2321
+ type: 'required_status_checks',
2322
+ parameters: {
2323
+ required_status_checks: [{ context: 'ruleset-check' }],
2324
+ },
2325
+ },
2326
+ ],
2327
+ branchDetail: () => ({
2328
+ protection: {
2329
+ required_status_checks: { contexts: ['classic-check'] },
2330
+ },
2331
+ }),
2332
+ checkRuns: () => ({
2333
+ total_count: 2,
2334
+ check_runs: [
2335
+ { id: 1, name: 'ruleset-check', conclusion: 'success' },
2336
+ { id: 2, name: 'classic-check', conclusion: 'success' },
2337
+ ],
2338
+ }),
2339
+ });
2340
+
2159
2341
  const { repository } = createApiV3CheerioRestIssueRepository();
2160
2342
  const result = await repository.getOpenPullRequest(
2161
2343
  'https://github.com/HiromiShikata/test-repository/pull/31',
2162
2344
  );
2163
2345
 
2164
2346
  expect(result).not.toBeNull();
2165
- expect(result?.isCiStateSuccess).toBe(false);
2347
+ expect(result?.missingRequiredCheckNames).toEqual([]);
2348
+ expect(result?.isPassedAllCiJob).toBe(true);
2166
2349
  });
2167
2350
 
2168
- it('returns isCiStateSuccess false when statusCheckRollup is null', async () => {
2169
- jest.spyOn(global, 'fetch').mockResolvedValueOnce(
2351
+ it('treats HTTP 403 from branch rules and branch detail as no required checks and keeps evaluating the PR', async () => {
2352
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
2353
+ const planLimitationMessage =
2354
+ 'Upgrade to GitHub Pro or make this repository public to enable this feature.';
2355
+ mockFetchRoutes({
2356
+ slimPullRequest: () => buildSlimPullRequestResponse(),
2357
+ branchRules: () =>
2358
+ new Response(JSON.stringify({ message: planLimitationMessage }), {
2359
+ status: 403,
2360
+ headers: { 'Content-Type': 'application/json' },
2361
+ }),
2362
+ branchDetail: () =>
2363
+ new Response(JSON.stringify({ message: planLimitationMessage }), {
2364
+ status: 403,
2365
+ headers: { 'Content-Type': 'application/json' },
2366
+ }),
2367
+ checkRuns: () => ({
2368
+ total_count: 1,
2369
+ check_runs: [{ id: 1, name: 'unit-test', conclusion: 'success' }],
2370
+ }),
2371
+ });
2372
+
2373
+ const { repository } = createApiV3CheerioRestIssueRepository();
2374
+ const result = await repository.getOpenPullRequest(
2375
+ 'https://github.com/HiromiShikata/test-repository/pull/31',
2376
+ );
2377
+
2378
+ expect(result).not.toBeNull();
2379
+ expect(result?.missingRequiredCheckNames).toEqual([]);
2380
+ expect(result?.isCiStateSuccess).toBe(true);
2381
+ expect(result?.isPassedAllCiJob).toBe(true);
2382
+ const warnMessages = warnSpy.mock.calls.map((call) => String(call[0]));
2383
+ expect(
2384
+ warnMessages.some(
2385
+ (message) =>
2386
+ message.includes('branch rules are not accessible') &&
2387
+ message.includes(planLimitationMessage),
2388
+ ),
2389
+ ).toBe(true);
2390
+ expect(
2391
+ warnMessages.some(
2392
+ (message) =>
2393
+ message.includes(
2394
+ 'branch detail (classic protection) is not accessible',
2395
+ ) && message.includes(planLimitationMessage),
2396
+ ),
2397
+ ).toBe(true);
2398
+ });
2399
+
2400
+ it('caches the empty result from an HTTP 403 so the branch rules endpoint is not fetched again within the TTL', async () => {
2401
+ jest.spyOn(console, 'warn').mockImplementation(() => {});
2402
+ const forbidden = () =>
2170
2403
  new Response(
2171
2404
  JSON.stringify({
2172
- data: {
2173
- repository: {
2174
- pullRequest: {
2175
- url: 'https://github.com/HiromiShikata/test-repository/pull/31',
2176
- state: 'OPEN',
2177
- isDraft: false,
2178
- headRefName: 'feature-branch',
2179
- baseRefName: 'main',
2180
- mergeable: 'MERGEABLE',
2181
- baseRepository: {
2182
- branchProtectionRules: { nodes: [] },
2183
- defaultBranchRef: { name: 'main' },
2184
- rulesets: { nodes: [] },
2185
- },
2186
- commits: {
2187
- nodes: [{ commit: { statusCheckRollup: null } }],
2188
- },
2189
- reviewThreads: { nodes: [] },
2190
- },
2191
- },
2192
- },
2405
+ message:
2406
+ 'Upgrade to GitHub Pro or make this repository public to enable this feature.',
2193
2407
  }),
2194
- { status: 200, headers: { 'Content-Type': 'application/json' } },
2408
+ { status: 403, headers: { 'Content-Type': 'application/json' } },
2409
+ );
2410
+ const fetchSpy = mockFetchRoutes({
2411
+ slimPullRequest: () => buildSlimPullRequestResponse(),
2412
+ branchRules: forbidden,
2413
+ branchDetail: forbidden,
2414
+ });
2415
+
2416
+ const { repository, dateRepository } =
2417
+ createApiV3CheerioRestIssueRepository();
2418
+ dateRepository.now.mockResolvedValue(
2419
+ new Date('2026-01-01T00:00:00.000Z'),
2420
+ );
2421
+ await repository.getOpenPullRequest(
2422
+ 'https://github.com/HiromiShikata/test-repository/pull/31',
2423
+ );
2424
+ await repository.getOpenPullRequest(
2425
+ 'https://github.com/HiromiShikata/test-repository/pull/31',
2426
+ );
2427
+
2428
+ expect(
2429
+ countCallsMatching(fetchSpy, (url) => url.includes('/rules/branches/')),
2430
+ ).toBe(1);
2431
+ expect(
2432
+ countCallsMatching(fetchSpy, (url) => /\/branches\/[^/?]+$/.test(url)),
2433
+ ).toBe(1);
2434
+ });
2435
+
2436
+ it('treats HTTP 404 from branch rules and branch detail as no required checks without warning', async () => {
2437
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
2438
+ mockFetchRoutes({
2439
+ slimPullRequest: () => buildSlimPullRequestResponse(),
2440
+ branchRules: () =>
2441
+ new Response(JSON.stringify({ message: 'Not Found' }), {
2442
+ status: 404,
2443
+ headers: { 'Content-Type': 'application/json' },
2444
+ }),
2445
+ branchDetail: () =>
2446
+ new Response(JSON.stringify({ message: 'Branch not found' }), {
2447
+ status: 404,
2448
+ headers: { 'Content-Type': 'application/json' },
2449
+ }),
2450
+ checkRuns: () => ({
2451
+ total_count: 1,
2452
+ check_runs: [{ id: 1, name: 'unit-test', conclusion: 'success' }],
2453
+ }),
2454
+ });
2455
+
2456
+ const { repository } = createApiV3CheerioRestIssueRepository();
2457
+ const result = await repository.getOpenPullRequest(
2458
+ 'https://github.com/HiromiShikata/test-repository/pull/31',
2459
+ );
2460
+
2461
+ expect(result).not.toBeNull();
2462
+ expect(result?.missingRequiredCheckNames).toEqual([]);
2463
+ expect(result?.isPassedAllCiJob).toBe(true);
2464
+ expect(warnSpy).not.toHaveBeenCalled();
2465
+ });
2466
+
2467
+ it('still throws when branch rules fetch fails with a non-403 non-404 status', async () => {
2468
+ mockFetchRoutes({
2469
+ slimPullRequest: () => buildSlimPullRequestResponse(),
2470
+ branchRules: () =>
2471
+ new Response(JSON.stringify({ message: 'Server Error' }), {
2472
+ status: 500,
2473
+ headers: { 'Content-Type': 'application/json' },
2474
+ }),
2475
+ });
2476
+
2477
+ const { repository } = createApiV3CheerioRestIssueRepository();
2478
+ await expect(
2479
+ repository.getOpenPullRequest(
2480
+ 'https://github.com/HiromiShikata/test-repository/pull/31',
2195
2481
  ),
2482
+ ).rejects.toThrow(/Failed to fetch branch rules/);
2483
+ });
2484
+ });
2485
+
2486
+ describe('required check names TTL cache', () => {
2487
+ afterEach(() => {
2488
+ jest.restoreAllMocks();
2489
+ });
2490
+
2491
+ it('fetches branch rules only once for repeated calls on the same base branch within the TTL', async () => {
2492
+ const fetchSpy = mockFetchRoutes({
2493
+ slimPullRequest: () => buildSlimPullRequestResponse(),
2494
+ });
2495
+
2496
+ const { repository, dateRepository } =
2497
+ createApiV3CheerioRestIssueRepository();
2498
+ dateRepository.now.mockResolvedValue(
2499
+ new Date('2026-01-01T00:00:00.000Z'),
2500
+ );
2501
+ await repository.getOpenPullRequest(
2502
+ 'https://github.com/HiromiShikata/test-repository/pull/31',
2503
+ );
2504
+ await repository.getOpenPullRequest(
2505
+ 'https://github.com/HiromiShikata/test-repository/pull/31',
2506
+ );
2507
+
2508
+ expect(
2509
+ countCallsMatching(fetchSpy, (url) => url.includes('/rules/branches/')),
2510
+ ).toBe(1);
2511
+ expect(
2512
+ countCallsMatching(fetchSpy, (url) => /\/branches\/[^/?]+$/.test(url)),
2513
+ ).toBe(1);
2514
+ });
2515
+
2516
+ it('fetches branch rules again after the TTL has expired', async () => {
2517
+ const fetchSpy = mockFetchRoutes({
2518
+ slimPullRequest: () => buildSlimPullRequestResponse(),
2519
+ });
2520
+
2521
+ const { repository, dateRepository } =
2522
+ createApiV3CheerioRestIssueRepository();
2523
+ const baseTimeMs = new Date('2026-01-01T00:00:00.000Z').getTime();
2524
+ dateRepository.now
2525
+ .mockResolvedValueOnce(new Date(baseTimeMs))
2526
+ .mockResolvedValueOnce(
2527
+ new Date(baseTimeMs + REQUIRED_CHECKS_CACHE_TTL_MS + 1),
2528
+ );
2529
+ await repository.getOpenPullRequest(
2530
+ 'https://github.com/HiromiShikata/test-repository/pull/31',
2531
+ );
2532
+ await repository.getOpenPullRequest(
2533
+ 'https://github.com/HiromiShikata/test-repository/pull/31',
2196
2534
  );
2197
2535
 
2536
+ expect(
2537
+ countCallsMatching(fetchSpy, (url) => url.includes('/rules/branches/')),
2538
+ ).toBe(2);
2539
+ });
2540
+
2541
+ it('fetches branch rules separately for different base branches (cache miss)', async () => {
2542
+ const fetchSpy = mockFetchRoutes({
2543
+ slimPullRequest: (variables) =>
2544
+ buildSlimPullRequestResponse({
2545
+ url: `https://github.com/HiromiShikata/test-repository/pull/${variables.prNumber}`,
2546
+ baseRefName: variables.prNumber === 31 ? 'main' : 'develop',
2547
+ }),
2548
+ });
2549
+
2550
+ const { repository, dateRepository } =
2551
+ createApiV3CheerioRestIssueRepository();
2552
+ dateRepository.now.mockResolvedValue(
2553
+ new Date('2026-01-01T00:00:00.000Z'),
2554
+ );
2555
+ await repository.getOpenPullRequest(
2556
+ 'https://github.com/HiromiShikata/test-repository/pull/31',
2557
+ );
2558
+ await repository.getOpenPullRequest(
2559
+ 'https://github.com/HiromiShikata/test-repository/pull/32',
2560
+ );
2561
+
2562
+ expect(
2563
+ countCallsMatching(fetchSpy, (url) =>
2564
+ url.includes('/rules/branches/main'),
2565
+ ),
2566
+ ).toBe(1);
2567
+ expect(
2568
+ countCallsMatching(fetchSpy, (url) =>
2569
+ url.includes('/rules/branches/develop'),
2570
+ ),
2571
+ ).toBe(1);
2572
+ });
2573
+ });
2574
+
2575
+ describe('getOpenPullRequest review thread paging', () => {
2576
+ afterEach(() => {
2577
+ jest.restoreAllMocks();
2578
+ });
2579
+
2580
+ it('fetches every review thread page and evaluates resolution over all pages', async () => {
2581
+ const fetchSpy = mockFetchRoutes({
2582
+ slimPullRequest: (variables) =>
2583
+ buildSlimPullRequestResponse({
2584
+ reviewThreads:
2585
+ variables.reviewThreadsAfter === null
2586
+ ? {
2587
+ pageInfo: { endCursor: 'cursor-1', hasNextPage: true },
2588
+ nodes: [{ isResolved: true }],
2589
+ }
2590
+ : {
2591
+ pageInfo: { endCursor: null, hasNextPage: false },
2592
+ nodes: [{ isResolved: false }],
2593
+ },
2594
+ }),
2595
+ });
2596
+
2198
2597
  const { repository } = createApiV3CheerioRestIssueRepository();
2199
2598
  const result = await repository.getOpenPullRequest(
2200
2599
  'https://github.com/HiromiShikata/test-repository/pull/31',
2201
2600
  );
2202
2601
 
2203
2602
  expect(result).not.toBeNull();
2204
- expect(result?.isCiStateSuccess).toBe(false);
2205
- expect(result?.isPassedAllCiJob).toBe(false);
2603
+ expect(result?.isResolvedAllReviewComments).toBe(false);
2604
+ expect(
2605
+ countCallsMatching(
2606
+ fetchSpy,
2607
+ (url, body) =>
2608
+ url === 'https://api.github.com/graphql' &&
2609
+ body.includes('headRefOid'),
2610
+ ),
2611
+ ).toBe(2);
2612
+ const secondSlimCall = fetchSpy.mock.calls
2613
+ .map(([input, init]) =>
2614
+ requestUrlOf(input) === 'https://api.github.com/graphql'
2615
+ ? parseGraphqlRequestBody(init)
2616
+ : null,
2617
+ )
2618
+ .filter(
2619
+ (body) => body !== null && body.query.includes('headRefOid'),
2620
+ )[1];
2621
+ expect(secondSlimCall?.variables.reviewThreadsAfter).toBe('cursor-1');
2206
2622
  });
2207
2623
  });
2208
2624
 
@@ -2211,37 +2627,33 @@ describe('ApiV3CheerioRestIssueRepository', () => {
2211
2627
  jest.restoreAllMocks();
2212
2628
  });
2213
2629
 
2630
+ const buildPullRequestTimelineNode = (
2631
+ prNumber: number,
2632
+ mergeable: string,
2633
+ ) => ({
2634
+ __typename: 'CrossReferencedEvent',
2635
+ willCloseTarget: true,
2636
+ source: {
2637
+ __typename: 'PullRequest',
2638
+ url: `https://github.com/HiromiShikata/test-repository/pull/${prNumber}`,
2639
+ number: prNumber,
2640
+ state: 'OPEN',
2641
+ createdAt: '2024-01-01T00:00:00Z',
2642
+ isDraft: false,
2643
+ mergeable,
2644
+ headRefName: 'feature-branch',
2645
+ baseRefName: 'main',
2646
+ baseRef: { name: 'main' },
2647
+ },
2648
+ });
2649
+
2214
2650
  const buildTimelineResponse = (mergeable: string) => ({
2215
2651
  data: {
2216
2652
  repository: {
2217
2653
  issue: {
2218
2654
  timelineItems: {
2219
2655
  pageInfo: { endCursor: null, hasNextPage: false },
2220
- nodes: [
2221
- {
2222
- __typename: 'CrossReferencedEvent',
2223
- willCloseTarget: true,
2224
- source: {
2225
- __typename: 'PullRequest',
2226
- url: 'https://github.com/HiromiShikata/test-repository/pull/11148',
2227
- number: 11148,
2228
- state: 'OPEN',
2229
- createdAt: '2024-01-01T00:00:00Z',
2230
- isDraft: false,
2231
- mergeable,
2232
- headRefName: 'feature-branch',
2233
- baseRefName: 'main',
2234
- baseRepository: {
2235
- branchProtectionRules: { nodes: [] },
2236
- defaultBranchRef: { name: 'main' },
2237
- rulesets: { nodes: [] },
2238
- },
2239
- commits: { nodes: [] },
2240
- reviewThreads: { nodes: [] },
2241
- baseRef: { name: 'main' },
2242
- },
2243
- },
2244
- ],
2656
+ nodes: [buildPullRequestTimelineNode(11148, mergeable)],
2245
2657
  },
2246
2658
  },
2247
2659
  },
@@ -2262,56 +2674,35 @@ describe('ApiV3CheerioRestIssueRepository', () => {
2262
2674
  },
2263
2675
  });
2264
2676
 
2677
+ const slimForPrNumber = (variables: { prNumber: number }) =>
2678
+ buildSlimPullRequestResponse({
2679
+ url: `https://github.com/HiromiShikata/test-repository/pull/${variables.prNumber}`,
2680
+ });
2681
+
2265
2682
  it('resolves isConflicted true via a direct query when the timeline node reports mergeable UNKNOWN but the direct query reports CONFLICTING', async () => {
2266
- const fetchSpy = jest
2267
- .spyOn(global, 'fetch')
2268
- .mockResolvedValueOnce(
2269
- new Response(JSON.stringify(buildTimelineResponse('UNKNOWN')), {
2270
- status: 200,
2271
- headers: { 'Content-Type': 'application/json' },
2272
- }),
2273
- )
2274
- .mockResolvedValueOnce(
2275
- new Response(
2276
- JSON.stringify(buildMergeabilityResponse('CONFLICTING', 'DIRTY')),
2277
- {
2278
- status: 200,
2279
- headers: { 'Content-Type': 'application/json' },
2280
- },
2281
- ),
2282
- );
2683
+ const fetchSpy = mockFetchRoutes({
2684
+ timeline: () => buildTimelineResponse('UNKNOWN'),
2685
+ mergeability: () => buildMergeabilityResponse('CONFLICTING', 'DIRTY'),
2686
+ slimPullRequest: slimForPrNumber,
2687
+ });
2283
2688
 
2284
2689
  const { repository } = createApiV3CheerioRestIssueRepository();
2285
2690
  const result = await repository.findRelatedOpenPRs(
2286
2691
  'https://github.com/HiromiShikata/test-repository/issues/11194',
2287
2692
  );
2288
2693
 
2289
- expect(fetchSpy).toHaveBeenCalledTimes(2);
2694
+ expect(countMergeabilityQueries(fetchSpy)).toBe(1);
2290
2695
  expect(result).toHaveLength(1);
2291
2696
  expect(result[0].isConflicted).toBe(true);
2292
2697
  expect(result[0].mergeable).toBe('CONFLICTING');
2293
2698
  });
2294
2699
 
2295
2700
  it('resolves isConflicted true when the direct query returns mergeable UNKNOWN but mergeStateStatus DIRTY', async () => {
2296
- jest
2297
- .spyOn(global, 'fetch')
2298
- .mockResolvedValueOnce(
2299
- new Response(JSON.stringify(buildTimelineResponse('UNKNOWN')), {
2300
- status: 200,
2301
- headers: { 'Content-Type': 'application/json' },
2302
- }),
2303
- )
2304
- .mockImplementation(() =>
2305
- Promise.resolve(
2306
- new Response(
2307
- JSON.stringify(buildMergeabilityResponse('UNKNOWN', 'DIRTY')),
2308
- {
2309
- status: 200,
2310
- headers: { 'Content-Type': 'application/json' },
2311
- },
2312
- ),
2313
- ),
2314
- );
2701
+ mockFetchRoutes({
2702
+ timeline: () => buildTimelineResponse('UNKNOWN'),
2703
+ mergeability: () => buildMergeabilityResponse('UNKNOWN', 'DIRTY'),
2704
+ slimPullRequest: slimForPrNumber,
2705
+ });
2315
2706
 
2316
2707
  const { repository } = createApiV3CheerioRestIssueRepository();
2317
2708
  const result = await repository.findRelatedOpenPRs(
@@ -2323,82 +2714,39 @@ describe('ApiV3CheerioRestIssueRepository', () => {
2323
2714
  });
2324
2715
 
2325
2716
  it('keeps isConflicted false when mergeability stays UNKNOWN after the bounded retries', async () => {
2326
- const fetchSpy = jest
2327
- .spyOn(global, 'fetch')
2328
- .mockResolvedValueOnce(
2329
- new Response(JSON.stringify(buildTimelineResponse('UNKNOWN')), {
2330
- status: 200,
2331
- headers: { 'Content-Type': 'application/json' },
2332
- }),
2333
- )
2334
- .mockImplementation(() =>
2335
- Promise.resolve(
2336
- new Response(
2337
- JSON.stringify(buildMergeabilityResponse('UNKNOWN', 'UNKNOWN')),
2338
- {
2339
- status: 200,
2340
- headers: { 'Content-Type': 'application/json' },
2341
- },
2342
- ),
2343
- ),
2344
- );
2717
+ const fetchSpy = mockFetchRoutes({
2718
+ timeline: () => buildTimelineResponse('UNKNOWN'),
2719
+ mergeability: () => buildMergeabilityResponse('UNKNOWN', 'UNKNOWN'),
2720
+ slimPullRequest: slimForPrNumber,
2721
+ });
2345
2722
 
2346
2723
  const { repository } = createApiV3CheerioRestIssueRepository();
2347
2724
  const result = await repository.findRelatedOpenPRs(
2348
2725
  'https://github.com/HiromiShikata/test-repository/issues/11194',
2349
2726
  );
2350
2727
 
2351
- expect(fetchSpy).toHaveBeenCalledTimes(4);
2728
+ expect(countMergeabilityQueries(fetchSpy)).toBe(3);
2352
2729
  expect(result).toHaveLength(1);
2353
2730
  expect(result[0].isConflicted).toBe(false);
2354
2731
  });
2355
2732
 
2356
2733
  it('does not issue a direct mergeability query when the timeline node already reports a definitive mergeable value', async () => {
2357
- const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValueOnce(
2358
- new Response(JSON.stringify(buildTimelineResponse('MERGEABLE')), {
2359
- status: 200,
2360
- headers: { 'Content-Type': 'application/json' },
2361
- }),
2362
- );
2734
+ const fetchSpy = mockFetchRoutes({
2735
+ timeline: () => buildTimelineResponse('MERGEABLE'),
2736
+ slimPullRequest: slimForPrNumber,
2737
+ });
2363
2738
 
2364
2739
  const { repository } = createApiV3CheerioRestIssueRepository();
2365
2740
  const result = await repository.findRelatedOpenPRs(
2366
2741
  'https://github.com/HiromiShikata/test-repository/issues/11194',
2367
2742
  );
2368
2743
 
2369
- expect(fetchSpy).toHaveBeenCalledTimes(1);
2744
+ expect(countMergeabilityQueries(fetchSpy)).toBe(0);
2370
2745
  expect(result).toHaveLength(1);
2371
2746
  expect(result[0].isConflicted).toBe(false);
2372
2747
  expect(result[0].mergeable).toBe('MERGEABLE');
2373
2748
  });
2374
2749
 
2375
- const buildPullRequestTimelineNode = (
2376
- prNumber: number,
2377
- mergeable: string,
2378
- ) => ({
2379
- __typename: 'CrossReferencedEvent',
2380
- willCloseTarget: true,
2381
- source: {
2382
- __typename: 'PullRequest',
2383
- url: `https://github.com/HiromiShikata/test-repository/pull/${prNumber}`,
2384
- number: prNumber,
2385
- state: 'OPEN',
2386
- createdAt: '2024-01-01T00:00:00Z',
2387
- isDraft: false,
2388
- mergeable,
2389
- headRefName: 'feature-branch',
2390
- baseRefName: 'main',
2391
- baseRepository: {
2392
- branchProtectionRules: { nodes: [] },
2393
- defaultBranchRef: { name: 'main' },
2394
- rulesets: { nodes: [] },
2395
- },
2396
- commits: { nodes: [] },
2397
- reviewThreads: { nodes: [] },
2398
- baseRef: { name: 'main' },
2399
- },
2400
- });
2401
-
2402
2750
  const buildTwoPrTimelineResponse = () => ({
2403
2751
  data: {
2404
2752
  repository: {
@@ -2419,33 +2767,21 @@ describe('ApiV3CheerioRestIssueRepository', () => {
2419
2767
  const consoleInfoSpy = jest
2420
2768
  .spyOn(console, 'info')
2421
2769
  .mockImplementation(() => undefined);
2422
- jest
2423
- .spyOn(global, 'fetch')
2424
- .mockResolvedValueOnce(
2425
- new Response(JSON.stringify(buildTwoPrTimelineResponse()), {
2426
- status: 200,
2427
- headers: { 'Content-Type': 'application/json' },
2428
- }),
2429
- )
2430
- .mockResolvedValueOnce(
2431
- new Response(
2432
- JSON.stringify({
2433
- data: { repository: { pullRequest: null } },
2434
- errors: [
2435
- {
2436
- type: 'NOT_FOUND',
2437
- path: ['repository', 'pullRequest'],
2438
- message:
2439
- 'Could not resolve to a PullRequest with the number of 11148.',
2440
- },
2441
- ],
2442
- }),
2770
+ mockFetchRoutes({
2771
+ timeline: () => buildTwoPrTimelineResponse(),
2772
+ mergeability: () => ({
2773
+ data: { repository: { pullRequest: null } },
2774
+ errors: [
2443
2775
  {
2444
- status: 200,
2445
- headers: { 'Content-Type': 'application/json' },
2776
+ type: 'NOT_FOUND',
2777
+ path: ['repository', 'pullRequest'],
2778
+ message:
2779
+ 'Could not resolve to a PullRequest with the number of 11148.',
2446
2780
  },
2447
- ),
2448
- );
2781
+ ],
2782
+ }),
2783
+ slimPullRequest: slimForPrNumber,
2784
+ });
2449
2785
 
2450
2786
  const { repository } = createApiV3CheerioRestIssueRepository();
2451
2787
  const result = await repository.findRelatedOpenPRs(
@@ -2468,17 +2804,12 @@ describe('ApiV3CheerioRestIssueRepository', () => {
2468
2804
  const consoleWarnSpy = jest
2469
2805
  .spyOn(console, 'warn')
2470
2806
  .mockImplementation(() => undefined);
2471
- jest
2472
- .spyOn(global, 'fetch')
2473
- .mockResolvedValueOnce(
2474
- new Response(JSON.stringify(buildTwoPrTimelineResponse()), {
2475
- status: 200,
2476
- headers: { 'Content-Type': 'application/json' },
2477
- }),
2478
- )
2479
- .mockResolvedValueOnce(
2807
+ mockFetchRoutes({
2808
+ timeline: () => buildTwoPrTimelineResponse(),
2809
+ mergeability: () =>
2480
2810
  new Response('Internal Server Error', { status: 500 }),
2481
- );
2811
+ slimPullRequest: slimForPrNumber,
2812
+ });
2482
2813
 
2483
2814
  const { repository } = createApiV3CheerioRestIssueRepository();
2484
2815
  const result = await repository.findRelatedOpenPRs(
@@ -2498,6 +2829,143 @@ describe('ApiV3CheerioRestIssueRepository', () => {
2498
2829
  });
2499
2830
  });
2500
2831
 
2832
+ describe('findRelatedOpenPRs two-stage split', () => {
2833
+ afterEach(() => {
2834
+ jest.restoreAllMocks();
2835
+ });
2836
+
2837
+ const buildSlimTimelineResponse = () => ({
2838
+ data: {
2839
+ repository: {
2840
+ issue: {
2841
+ timelineItems: {
2842
+ pageInfo: { endCursor: null, hasNextPage: false },
2843
+ nodes: [
2844
+ {
2845
+ __typename: 'CrossReferencedEvent',
2846
+ willCloseTarget: true,
2847
+ source: {
2848
+ __typename: 'PullRequest',
2849
+ url: 'https://github.com/HiromiShikata/test-repository/pull/11148',
2850
+ number: 11148,
2851
+ state: 'OPEN',
2852
+ createdAt: '2024-01-01T00:00:00Z',
2853
+ isDraft: false,
2854
+ mergeable: 'MERGEABLE',
2855
+ headRefName: 'feature-branch',
2856
+ baseRefName: 'main',
2857
+ baseRef: { name: 'main' },
2858
+ },
2859
+ },
2860
+ ],
2861
+ },
2862
+ },
2863
+ },
2864
+ },
2865
+ });
2866
+
2867
+ it('produces the same evaluation inputs from the two-stage flow and sends no nested rules connections in any GraphQL query', async () => {
2868
+ const fetchSpy = mockFetchRoutes({
2869
+ timeline: () => buildSlimTimelineResponse(),
2870
+ slimPullRequest: () =>
2871
+ buildSlimPullRequestResponse({
2872
+ url: 'https://github.com/HiromiShikata/test-repository/pull/11148',
2873
+ reviewThreads: {
2874
+ pageInfo: { endCursor: null, hasNextPage: false },
2875
+ nodes: [{ isResolved: true }],
2876
+ },
2877
+ }),
2878
+ branchRules: () => [
2879
+ {
2880
+ type: 'required_status_checks',
2881
+ parameters: { required_status_checks: [{ context: 'ci' }] },
2882
+ },
2883
+ ],
2884
+ checkRuns: () => ({
2885
+ total_count: 1,
2886
+ check_runs: [{ id: 1, name: 'ci', conclusion: 'success' }],
2887
+ }),
2888
+ });
2889
+
2890
+ const { repository } = createApiV3CheerioRestIssueRepository();
2891
+ const result = await repository.findRelatedOpenPRs(
2892
+ 'https://github.com/HiromiShikata/test-repository/issues/11194',
2893
+ );
2894
+
2895
+ expect(result).toHaveLength(1);
2896
+ expect(result[0]).toEqual({
2897
+ url: 'https://github.com/HiromiShikata/test-repository/pull/11148',
2898
+ branchName: 'feature-branch',
2899
+ createdAt: new Date('2024-01-01T00:00:00Z'),
2900
+ isDraft: false,
2901
+ isConflicted: false,
2902
+ mergeable: 'MERGEABLE',
2903
+ isPassedAllCiJob: true,
2904
+ isCiStateSuccess: true,
2905
+ isResolvedAllReviewComments: true,
2906
+ isBranchOutOfDate: false,
2907
+ missingRequiredCheckNames: [],
2908
+ });
2909
+ expect(
2910
+ countCallsMatching(
2911
+ fetchSpy,
2912
+ (url, body) =>
2913
+ url === 'https://api.github.com/graphql' &&
2914
+ (body.includes('branchProtectionRules') ||
2915
+ body.includes('rulesets') ||
2916
+ body.includes('statusCheckRollup')),
2917
+ ),
2918
+ ).toBe(0);
2919
+ });
2920
+
2921
+ it('excludes a PR that is no longer open at the second stage', async () => {
2922
+ const consoleInfoSpy = jest
2923
+ .spyOn(console, 'info')
2924
+ .mockImplementation(() => undefined);
2925
+ mockFetchRoutes({
2926
+ timeline: () => buildSlimTimelineResponse(),
2927
+ slimPullRequest: () =>
2928
+ buildSlimPullRequestResponse({ state: 'MERGED' }),
2929
+ });
2930
+
2931
+ const { repository } = createApiV3CheerioRestIssueRepository();
2932
+ const result = await repository.findRelatedOpenPRs(
2933
+ 'https://github.com/HiromiShikata/test-repository/issues/11194',
2934
+ );
2935
+
2936
+ expect(result).toHaveLength(0);
2937
+ expect(consoleInfoSpy).toHaveBeenCalledWith(
2938
+ expect.stringContaining(
2939
+ 'https://github.com/HiromiShikata/test-repository/pull/11148',
2940
+ ),
2941
+ );
2942
+ });
2943
+
2944
+ it('skips a PR whose second-stage status fetch fails and logs one warning', async () => {
2945
+ const consoleWarnSpy = jest
2946
+ .spyOn(console, 'warn')
2947
+ .mockImplementation(() => undefined);
2948
+ mockFetchRoutes({
2949
+ timeline: () => buildSlimTimelineResponse(),
2950
+ slimPullRequest: () =>
2951
+ new Response('Internal Server Error', { status: 500 }),
2952
+ });
2953
+
2954
+ const { repository } = createApiV3CheerioRestIssueRepository();
2955
+ const result = await repository.findRelatedOpenPRs(
2956
+ 'https://github.com/HiromiShikata/test-repository/issues/11194',
2957
+ );
2958
+
2959
+ expect(result).toHaveLength(0);
2960
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
2961
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
2962
+ expect.stringContaining(
2963
+ 'https://github.com/HiromiShikata/test-repository/pull/11148',
2964
+ ),
2965
+ );
2966
+ });
2967
+ });
2968
+
2501
2969
  const createApiV3CheerioRestIssueRepository = () => {
2502
2970
  const apiV3IssueRepository = mock<ApiV3IssueRepository>();
2503
2971
  const restIssueRepository = mock<RestIssueRepository>();
@@ -2519,6 +2987,7 @@ describe('ApiV3CheerioRestIssueRepository', () => {
2519
2987
  'dummy',
2520
2988
  sleep,
2521
2989
  );
2990
+ dateRepository.now.mockResolvedValue(new Date('2026-01-01T00:00:00.000Z'));
2522
2991
  restIssueRepository.getIssue.mockResolvedValue({
2523
2992
  labels: [],
2524
2993
  assignees: [],