github-issue-tower-defence-management 1.123.3 → 1.125.0

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.
Files changed (28) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/bin/adapter/entry-points/cli/projectConfig.js +6 -11
  3. package/bin/adapter/entry-points/cli/projectConfig.js.map +1 -1
  4. package/bin/adapter/repositories/GraphqlProjectRepository.js +21 -36
  5. package/bin/adapter/repositories/GraphqlProjectRepository.js.map +1 -1
  6. package/bin/adapter/repositories/githubGraphqlClient.js +99 -0
  7. package/bin/adapter/repositories/githubGraphqlClient.js.map +1 -0
  8. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +278 -304
  9. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  10. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js +79 -142
  11. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/adapter/entry-points/cli/projectConfig.ts +6 -11
  14. package/src/adapter/repositories/GraphqlProjectRepository.ts +81 -77
  15. package/src/adapter/repositories/githubGraphqlClient.test.ts +251 -0
  16. package/src/adapter/repositories/githubGraphqlClient.ts +122 -0
  17. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +663 -328
  18. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +465 -449
  19. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +62 -0
  20. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +208 -237
  21. package/types/adapter/entry-points/cli/projectConfig.d.ts.map +1 -1
  22. package/types/adapter/repositories/GraphqlProjectRepository.d.ts.map +1 -1
  23. package/types/adapter/repositories/githubGraphqlClient.d.ts +21 -0
  24. package/types/adapter/repositories/githubGraphqlClient.d.ts.map +1 -0
  25. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +6 -0
  26. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  27. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts +2 -0
  28. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
@@ -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,90 +2251,214 @@ 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
- },
2153
- },
2154
- }),
2155
- { status: 200, headers: { 'Content-Type': 'application/json' } },
2156
- ),
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',
2157
2269
  );
2158
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' }],
2290
+ },
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
+
2159
2305
  const { repository } = createApiV3CheerioRestIssueRepository();
2160
2306
  const result = await repository.getOpenPullRequest(
2161
2307
  'https://github.com/HiromiShikata/test-repository/pull/31',
2162
2308
  );
2163
2309
 
2164
2310
  expect(result).not.toBeNull();
2165
- expect(result?.isCiStateSuccess).toBe(false);
2311
+ expect(result?.missingRequiredCheckNames).toEqual(['classic-check']);
2312
+ expect(result?.isCiStateSuccess).toBe(true);
2313
+ expect(result?.isPassedAllCiJob).toBe(false);
2166
2314
  });
2167
2315
 
2168
- it('returns isCiStateSuccess false when statusCheckRollup is null', async () => {
2169
- jest.spyOn(global, 'fetch').mockResolvedValueOnce(
2170
- new Response(
2171
- 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
- },
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' }],
2192
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
+
2341
+ const { repository } = createApiV3CheerioRestIssueRepository();
2342
+ const result = await repository.getOpenPullRequest(
2343
+ 'https://github.com/HiromiShikata/test-repository/pull/31',
2344
+ );
2345
+
2346
+ expect(result).not.toBeNull();
2347
+ expect(result?.missingRequiredCheckNames).toEqual([]);
2348
+ expect(result?.isPassedAllCiJob).toBe(true);
2349
+ });
2350
+ });
2351
+
2352
+ describe('required check names TTL cache', () => {
2353
+ afterEach(() => {
2354
+ jest.restoreAllMocks();
2355
+ });
2356
+
2357
+ it('fetches branch rules only once for repeated calls on the same base branch within the TTL', async () => {
2358
+ const fetchSpy = mockFetchRoutes({
2359
+ slimPullRequest: () => buildSlimPullRequestResponse(),
2360
+ });
2361
+
2362
+ const { repository, dateRepository } =
2363
+ createApiV3CheerioRestIssueRepository();
2364
+ dateRepository.now.mockResolvedValue(
2365
+ new Date('2026-01-01T00:00:00.000Z'),
2366
+ );
2367
+ await repository.getOpenPullRequest(
2368
+ 'https://github.com/HiromiShikata/test-repository/pull/31',
2369
+ );
2370
+ await repository.getOpenPullRequest(
2371
+ 'https://github.com/HiromiShikata/test-repository/pull/31',
2372
+ );
2373
+
2374
+ expect(
2375
+ countCallsMatching(fetchSpy, (url) => url.includes('/rules/branches/')),
2376
+ ).toBe(1);
2377
+ expect(
2378
+ countCallsMatching(fetchSpy, (url) => /\/branches\/[^/?]+$/.test(url)),
2379
+ ).toBe(1);
2380
+ });
2381
+
2382
+ it('fetches branch rules again after the TTL has expired', async () => {
2383
+ const fetchSpy = mockFetchRoutes({
2384
+ slimPullRequest: () => buildSlimPullRequestResponse(),
2385
+ });
2386
+
2387
+ const { repository, dateRepository } =
2388
+ createApiV3CheerioRestIssueRepository();
2389
+ const baseTimeMs = new Date('2026-01-01T00:00:00.000Z').getTime();
2390
+ dateRepository.now
2391
+ .mockResolvedValueOnce(new Date(baseTimeMs))
2392
+ .mockResolvedValueOnce(
2393
+ new Date(baseTimeMs + REQUIRED_CHECKS_CACHE_TTL_MS + 1),
2394
+ );
2395
+ await repository.getOpenPullRequest(
2396
+ 'https://github.com/HiromiShikata/test-repository/pull/31',
2397
+ );
2398
+ await repository.getOpenPullRequest(
2399
+ 'https://github.com/HiromiShikata/test-repository/pull/31',
2400
+ );
2401
+
2402
+ expect(
2403
+ countCallsMatching(fetchSpy, (url) => url.includes('/rules/branches/')),
2404
+ ).toBe(2);
2405
+ });
2406
+
2407
+ it('fetches branch rules separately for different base branches (cache miss)', async () => {
2408
+ const fetchSpy = mockFetchRoutes({
2409
+ slimPullRequest: (variables) =>
2410
+ buildSlimPullRequestResponse({
2411
+ url: `https://github.com/HiromiShikata/test-repository/pull/${variables.prNumber}`,
2412
+ baseRefName: variables.prNumber === 31 ? 'main' : 'develop',
2193
2413
  }),
2194
- { status: 200, headers: { 'Content-Type': 'application/json' } },
2195
- ),
2414
+ });
2415
+
2416
+ const { repository, dateRepository } =
2417
+ createApiV3CheerioRestIssueRepository();
2418
+ dateRepository.now.mockResolvedValue(
2419
+ new Date('2026-01-01T00:00:00.000Z'),
2196
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/32',
2426
+ );
2427
+
2428
+ expect(
2429
+ countCallsMatching(fetchSpy, (url) =>
2430
+ url.includes('/rules/branches/main'),
2431
+ ),
2432
+ ).toBe(1);
2433
+ expect(
2434
+ countCallsMatching(fetchSpy, (url) =>
2435
+ url.includes('/rules/branches/develop'),
2436
+ ),
2437
+ ).toBe(1);
2438
+ });
2439
+ });
2440
+
2441
+ describe('getOpenPullRequest review thread paging', () => {
2442
+ afterEach(() => {
2443
+ jest.restoreAllMocks();
2444
+ });
2445
+
2446
+ it('fetches every review thread page and evaluates resolution over all pages', async () => {
2447
+ const fetchSpy = mockFetchRoutes({
2448
+ slimPullRequest: (variables) =>
2449
+ buildSlimPullRequestResponse({
2450
+ reviewThreads:
2451
+ variables.reviewThreadsAfter === null
2452
+ ? {
2453
+ pageInfo: { endCursor: 'cursor-1', hasNextPage: true },
2454
+ nodes: [{ isResolved: true }],
2455
+ }
2456
+ : {
2457
+ pageInfo: { endCursor: null, hasNextPage: false },
2458
+ nodes: [{ isResolved: false }],
2459
+ },
2460
+ }),
2461
+ });
2197
2462
 
2198
2463
  const { repository } = createApiV3CheerioRestIssueRepository();
2199
2464
  const result = await repository.getOpenPullRequest(
@@ -2201,8 +2466,25 @@ describe('ApiV3CheerioRestIssueRepository', () => {
2201
2466
  );
2202
2467
 
2203
2468
  expect(result).not.toBeNull();
2204
- expect(result?.isCiStateSuccess).toBe(false);
2205
- expect(result?.isPassedAllCiJob).toBe(false);
2469
+ expect(result?.isResolvedAllReviewComments).toBe(false);
2470
+ expect(
2471
+ countCallsMatching(
2472
+ fetchSpy,
2473
+ (url, body) =>
2474
+ url === 'https://api.github.com/graphql' &&
2475
+ body.includes('headRefOid'),
2476
+ ),
2477
+ ).toBe(2);
2478
+ const secondSlimCall = fetchSpy.mock.calls
2479
+ .map(([input, init]) =>
2480
+ requestUrlOf(input) === 'https://api.github.com/graphql'
2481
+ ? parseGraphqlRequestBody(init)
2482
+ : null,
2483
+ )
2484
+ .filter(
2485
+ (body) => body !== null && body.query.includes('headRefOid'),
2486
+ )[1];
2487
+ expect(secondSlimCall?.variables.reviewThreadsAfter).toBe('cursor-1');
2206
2488
  });
2207
2489
  });
2208
2490
 
@@ -2211,37 +2493,33 @@ describe('ApiV3CheerioRestIssueRepository', () => {
2211
2493
  jest.restoreAllMocks();
2212
2494
  });
2213
2495
 
2496
+ const buildPullRequestTimelineNode = (
2497
+ prNumber: number,
2498
+ mergeable: string,
2499
+ ) => ({
2500
+ __typename: 'CrossReferencedEvent',
2501
+ willCloseTarget: true,
2502
+ source: {
2503
+ __typename: 'PullRequest',
2504
+ url: `https://github.com/HiromiShikata/test-repository/pull/${prNumber}`,
2505
+ number: prNumber,
2506
+ state: 'OPEN',
2507
+ createdAt: '2024-01-01T00:00:00Z',
2508
+ isDraft: false,
2509
+ mergeable,
2510
+ headRefName: 'feature-branch',
2511
+ baseRefName: 'main',
2512
+ baseRef: { name: 'main' },
2513
+ },
2514
+ });
2515
+
2214
2516
  const buildTimelineResponse = (mergeable: string) => ({
2215
2517
  data: {
2216
2518
  repository: {
2217
2519
  issue: {
2218
2520
  timelineItems: {
2219
2521
  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
- ],
2522
+ nodes: [buildPullRequestTimelineNode(11148, mergeable)],
2245
2523
  },
2246
2524
  },
2247
2525
  },
@@ -2262,56 +2540,35 @@ describe('ApiV3CheerioRestIssueRepository', () => {
2262
2540
  },
2263
2541
  });
2264
2542
 
2543
+ const slimForPrNumber = (variables: { prNumber: number }) =>
2544
+ buildSlimPullRequestResponse({
2545
+ url: `https://github.com/HiromiShikata/test-repository/pull/${variables.prNumber}`,
2546
+ });
2547
+
2265
2548
  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
- );
2549
+ const fetchSpy = mockFetchRoutes({
2550
+ timeline: () => buildTimelineResponse('UNKNOWN'),
2551
+ mergeability: () => buildMergeabilityResponse('CONFLICTING', 'DIRTY'),
2552
+ slimPullRequest: slimForPrNumber,
2553
+ });
2283
2554
 
2284
2555
  const { repository } = createApiV3CheerioRestIssueRepository();
2285
2556
  const result = await repository.findRelatedOpenPRs(
2286
2557
  'https://github.com/HiromiShikata/test-repository/issues/11194',
2287
2558
  );
2288
2559
 
2289
- expect(fetchSpy).toHaveBeenCalledTimes(2);
2560
+ expect(countMergeabilityQueries(fetchSpy)).toBe(1);
2290
2561
  expect(result).toHaveLength(1);
2291
2562
  expect(result[0].isConflicted).toBe(true);
2292
2563
  expect(result[0].mergeable).toBe('CONFLICTING');
2293
2564
  });
2294
2565
 
2295
2566
  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
- );
2567
+ mockFetchRoutes({
2568
+ timeline: () => buildTimelineResponse('UNKNOWN'),
2569
+ mergeability: () => buildMergeabilityResponse('UNKNOWN', 'DIRTY'),
2570
+ slimPullRequest: slimForPrNumber,
2571
+ });
2315
2572
 
2316
2573
  const { repository } = createApiV3CheerioRestIssueRepository();
2317
2574
  const result = await repository.findRelatedOpenPRs(
@@ -2323,82 +2580,39 @@ describe('ApiV3CheerioRestIssueRepository', () => {
2323
2580
  });
2324
2581
 
2325
2582
  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
- );
2583
+ const fetchSpy = mockFetchRoutes({
2584
+ timeline: () => buildTimelineResponse('UNKNOWN'),
2585
+ mergeability: () => buildMergeabilityResponse('UNKNOWN', 'UNKNOWN'),
2586
+ slimPullRequest: slimForPrNumber,
2587
+ });
2345
2588
 
2346
2589
  const { repository } = createApiV3CheerioRestIssueRepository();
2347
2590
  const result = await repository.findRelatedOpenPRs(
2348
2591
  'https://github.com/HiromiShikata/test-repository/issues/11194',
2349
2592
  );
2350
2593
 
2351
- expect(fetchSpy).toHaveBeenCalledTimes(4);
2594
+ expect(countMergeabilityQueries(fetchSpy)).toBe(3);
2352
2595
  expect(result).toHaveLength(1);
2353
2596
  expect(result[0].isConflicted).toBe(false);
2354
2597
  });
2355
2598
 
2356
2599
  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
- );
2600
+ const fetchSpy = mockFetchRoutes({
2601
+ timeline: () => buildTimelineResponse('MERGEABLE'),
2602
+ slimPullRequest: slimForPrNumber,
2603
+ });
2363
2604
 
2364
2605
  const { repository } = createApiV3CheerioRestIssueRepository();
2365
2606
  const result = await repository.findRelatedOpenPRs(
2366
2607
  'https://github.com/HiromiShikata/test-repository/issues/11194',
2367
2608
  );
2368
2609
 
2369
- expect(fetchSpy).toHaveBeenCalledTimes(1);
2610
+ expect(countMergeabilityQueries(fetchSpy)).toBe(0);
2370
2611
  expect(result).toHaveLength(1);
2371
2612
  expect(result[0].isConflicted).toBe(false);
2372
2613
  expect(result[0].mergeable).toBe('MERGEABLE');
2373
2614
  });
2374
2615
 
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
2616
  const buildTwoPrTimelineResponse = () => ({
2403
2617
  data: {
2404
2618
  repository: {
@@ -2419,33 +2633,21 @@ describe('ApiV3CheerioRestIssueRepository', () => {
2419
2633
  const consoleInfoSpy = jest
2420
2634
  .spyOn(console, 'info')
2421
2635
  .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
- }),
2636
+ mockFetchRoutes({
2637
+ timeline: () => buildTwoPrTimelineResponse(),
2638
+ mergeability: () => ({
2639
+ data: { repository: { pullRequest: null } },
2640
+ errors: [
2443
2641
  {
2444
- status: 200,
2445
- headers: { 'Content-Type': 'application/json' },
2642
+ type: 'NOT_FOUND',
2643
+ path: ['repository', 'pullRequest'],
2644
+ message:
2645
+ 'Could not resolve to a PullRequest with the number of 11148.',
2446
2646
  },
2447
- ),
2448
- );
2647
+ ],
2648
+ }),
2649
+ slimPullRequest: slimForPrNumber,
2650
+ });
2449
2651
 
2450
2652
  const { repository } = createApiV3CheerioRestIssueRepository();
2451
2653
  const result = await repository.findRelatedOpenPRs(
@@ -2468,17 +2670,12 @@ describe('ApiV3CheerioRestIssueRepository', () => {
2468
2670
  const consoleWarnSpy = jest
2469
2671
  .spyOn(console, 'warn')
2470
2672
  .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(
2673
+ mockFetchRoutes({
2674
+ timeline: () => buildTwoPrTimelineResponse(),
2675
+ mergeability: () =>
2480
2676
  new Response('Internal Server Error', { status: 500 }),
2481
- );
2677
+ slimPullRequest: slimForPrNumber,
2678
+ });
2482
2679
 
2483
2680
  const { repository } = createApiV3CheerioRestIssueRepository();
2484
2681
  const result = await repository.findRelatedOpenPRs(
@@ -2498,6 +2695,143 @@ describe('ApiV3CheerioRestIssueRepository', () => {
2498
2695
  });
2499
2696
  });
2500
2697
 
2698
+ describe('findRelatedOpenPRs two-stage split', () => {
2699
+ afterEach(() => {
2700
+ jest.restoreAllMocks();
2701
+ });
2702
+
2703
+ const buildSlimTimelineResponse = () => ({
2704
+ data: {
2705
+ repository: {
2706
+ issue: {
2707
+ timelineItems: {
2708
+ pageInfo: { endCursor: null, hasNextPage: false },
2709
+ nodes: [
2710
+ {
2711
+ __typename: 'CrossReferencedEvent',
2712
+ willCloseTarget: true,
2713
+ source: {
2714
+ __typename: 'PullRequest',
2715
+ url: 'https://github.com/HiromiShikata/test-repository/pull/11148',
2716
+ number: 11148,
2717
+ state: 'OPEN',
2718
+ createdAt: '2024-01-01T00:00:00Z',
2719
+ isDraft: false,
2720
+ mergeable: 'MERGEABLE',
2721
+ headRefName: 'feature-branch',
2722
+ baseRefName: 'main',
2723
+ baseRef: { name: 'main' },
2724
+ },
2725
+ },
2726
+ ],
2727
+ },
2728
+ },
2729
+ },
2730
+ },
2731
+ });
2732
+
2733
+ it('produces the same evaluation inputs from the two-stage flow and sends no nested rules connections in any GraphQL query', async () => {
2734
+ const fetchSpy = mockFetchRoutes({
2735
+ timeline: () => buildSlimTimelineResponse(),
2736
+ slimPullRequest: () =>
2737
+ buildSlimPullRequestResponse({
2738
+ url: 'https://github.com/HiromiShikata/test-repository/pull/11148',
2739
+ reviewThreads: {
2740
+ pageInfo: { endCursor: null, hasNextPage: false },
2741
+ nodes: [{ isResolved: true }],
2742
+ },
2743
+ }),
2744
+ branchRules: () => [
2745
+ {
2746
+ type: 'required_status_checks',
2747
+ parameters: { required_status_checks: [{ context: 'ci' }] },
2748
+ },
2749
+ ],
2750
+ checkRuns: () => ({
2751
+ total_count: 1,
2752
+ check_runs: [{ id: 1, name: 'ci', conclusion: 'success' }],
2753
+ }),
2754
+ });
2755
+
2756
+ const { repository } = createApiV3CheerioRestIssueRepository();
2757
+ const result = await repository.findRelatedOpenPRs(
2758
+ 'https://github.com/HiromiShikata/test-repository/issues/11194',
2759
+ );
2760
+
2761
+ expect(result).toHaveLength(1);
2762
+ expect(result[0]).toEqual({
2763
+ url: 'https://github.com/HiromiShikata/test-repository/pull/11148',
2764
+ branchName: 'feature-branch',
2765
+ createdAt: new Date('2024-01-01T00:00:00Z'),
2766
+ isDraft: false,
2767
+ isConflicted: false,
2768
+ mergeable: 'MERGEABLE',
2769
+ isPassedAllCiJob: true,
2770
+ isCiStateSuccess: true,
2771
+ isResolvedAllReviewComments: true,
2772
+ isBranchOutOfDate: false,
2773
+ missingRequiredCheckNames: [],
2774
+ });
2775
+ expect(
2776
+ countCallsMatching(
2777
+ fetchSpy,
2778
+ (url, body) =>
2779
+ url === 'https://api.github.com/graphql' &&
2780
+ (body.includes('branchProtectionRules') ||
2781
+ body.includes('rulesets') ||
2782
+ body.includes('statusCheckRollup')),
2783
+ ),
2784
+ ).toBe(0);
2785
+ });
2786
+
2787
+ it('excludes a PR that is no longer open at the second stage', async () => {
2788
+ const consoleInfoSpy = jest
2789
+ .spyOn(console, 'info')
2790
+ .mockImplementation(() => undefined);
2791
+ mockFetchRoutes({
2792
+ timeline: () => buildSlimTimelineResponse(),
2793
+ slimPullRequest: () =>
2794
+ buildSlimPullRequestResponse({ state: 'MERGED' }),
2795
+ });
2796
+
2797
+ const { repository } = createApiV3CheerioRestIssueRepository();
2798
+ const result = await repository.findRelatedOpenPRs(
2799
+ 'https://github.com/HiromiShikata/test-repository/issues/11194',
2800
+ );
2801
+
2802
+ expect(result).toHaveLength(0);
2803
+ expect(consoleInfoSpy).toHaveBeenCalledWith(
2804
+ expect.stringContaining(
2805
+ 'https://github.com/HiromiShikata/test-repository/pull/11148',
2806
+ ),
2807
+ );
2808
+ });
2809
+
2810
+ it('skips a PR whose second-stage status fetch fails and logs one warning', async () => {
2811
+ const consoleWarnSpy = jest
2812
+ .spyOn(console, 'warn')
2813
+ .mockImplementation(() => undefined);
2814
+ mockFetchRoutes({
2815
+ timeline: () => buildSlimTimelineResponse(),
2816
+ slimPullRequest: () =>
2817
+ new Response('Internal Server Error', { status: 500 }),
2818
+ });
2819
+
2820
+ const { repository } = createApiV3CheerioRestIssueRepository();
2821
+ const result = await repository.findRelatedOpenPRs(
2822
+ 'https://github.com/HiromiShikata/test-repository/issues/11194',
2823
+ );
2824
+
2825
+ expect(result).toHaveLength(0);
2826
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
2827
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
2828
+ expect.stringContaining(
2829
+ 'https://github.com/HiromiShikata/test-repository/pull/11148',
2830
+ ),
2831
+ );
2832
+ });
2833
+ });
2834
+
2501
2835
  const createApiV3CheerioRestIssueRepository = () => {
2502
2836
  const apiV3IssueRepository = mock<ApiV3IssueRepository>();
2503
2837
  const restIssueRepository = mock<RestIssueRepository>();
@@ -2519,6 +2853,7 @@ describe('ApiV3CheerioRestIssueRepository', () => {
2519
2853
  'dummy',
2520
2854
  sleep,
2521
2855
  );
2856
+ dateRepository.now.mockResolvedValue(new Date('2026-01-01T00:00:00.000Z'));
2522
2857
  restIssueRepository.getIssue.mockResolvedValue({
2523
2858
  labels: [],
2524
2859
  assignees: [],