github-issue-tower-defence-management 1.154.1 → 1.156.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 (24) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/bin/adapter/entry-points/console/consoleReadApi.js +1 -1
  3. package/bin/adapter/entry-points/console/consoleReadApi.js.map +1 -1
  4. package/bin/adapter/repositories/githubGraphqlClient.js +90 -6
  5. package/bin/adapter/repositories/githubGraphqlClient.js.map +1 -1
  6. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +88 -1
  7. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/adapter/entry-points/console/consoleReadApi.test.ts +27 -9
  10. package/src/adapter/entry-points/console/consoleReadApi.ts +1 -1
  11. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +16 -0
  12. package/src/adapter/entry-points/console/webServer.test.ts +1 -5
  13. package/src/adapter/repositories/githubGraphqlClient.test.ts +173 -17
  14. package/src/adapter/repositories/githubGraphqlClient.ts +123 -9
  15. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +165 -0
  16. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +140 -0
  17. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +1 -1
  18. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +13 -0
  19. package/types/adapter/repositories/githubGraphqlClient.d.ts +11 -1
  20. package/types/adapter/repositories/githubGraphqlClient.d.ts.map +1 -1
  21. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +4 -1
  22. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  23. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +10 -0
  24. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
@@ -19,6 +19,7 @@ import {
19
19
  GITHUB_GRAPHQL_ENDPOINT,
20
20
  GITHUB_GRAPHQL_REQUEST_TIMEOUT_MS,
21
21
  RATE_LIMIT_SELECTION,
22
+ extractGraphqlCallSite,
22
23
  extractGraphqlOperationName,
23
24
  fetchGithubGraphql,
24
25
  injectRateLimitSelection,
@@ -117,39 +118,194 @@ describe('githubGraphqlClient', () => {
117
118
  });
118
119
  });
119
120
 
121
+ describe('extractGraphqlCallSite', () => {
122
+ it('names the nearest modules outside the graphql client', () => {
123
+ const stack = [
124
+ 'Error',
125
+ ' at captureGraphqlCallSite (/app/bin/adapter/repositories/githubGraphqlClient.js:80:20)',
126
+ ' at postGithubGraphqlJson (/app/bin/adapter/repositories/githubGraphqlClient.js:120:22)',
127
+ ' at GraphqlProjectItemRepository.fetchProjectItemByUrl (/app/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js:1042:11)',
128
+ ' at ApiV3CheerioRestIssueRepository.getIssueByUrl (/app/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js:813:11)',
129
+ ' at async ConvertCheckboxToIssueInStoryIssueUseCase.run (/app/bin/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.js:63:31)',
130
+ ' at async HandleScheduledEventUseCase.runSlowSweepUseCases (/app/bin/domain/usecases/HandleScheduledEventUseCase.js:552:5)',
131
+ ].join('\n');
132
+
133
+ expect(extractGraphqlCallSite(stack)).toBe(
134
+ 'GraphqlProjectItemRepository<-ApiV3CheerioRestIssueRepository<-ConvertCheckboxToIssueInStoryIssueUseCase',
135
+ );
136
+ });
137
+
138
+ it('collapses the retry and fallback wrappers into their own module so the owning repository and use case are named', () => {
139
+ const stack = [
140
+ 'Error',
141
+ ' at postGithubGraphqlJson (/app/bin/adapter/repositories/githubGraphqlClient.js:120:22)',
142
+ ' at callWithRateLimitRetry (/app/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js:255:15)',
143
+ ' at callGraphql (/app/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js:280:12)',
144
+ ' at callGraphqlWithHalvingFallback (/app/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js:310:12)',
145
+ ' at GraphqlProjectItemRepository.fetchProjectItems (/app/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js:410:11)',
146
+ ' at async StartPreparationUseCase.run (/app/bin/domain/usecases/StartPreparationUseCase.js:456:11)',
147
+ ].join('\n');
148
+
149
+ expect(extractGraphqlCallSite(stack)).toBe(
150
+ 'GraphqlProjectItemRepository<-StartPreparationUseCase',
151
+ );
152
+ });
153
+
154
+ it('names the same call site whether or not the engine qualifies a nested helper with its class name', () => {
155
+ const frames = (nestedHelperFrameName: string): string =>
156
+ [
157
+ 'Error',
158
+ ' at postGithubGraphqlJson (/app/bin/adapter/repositories/githubGraphqlClient.js:120:22)',
159
+ ` at ${nestedHelperFrameName} (/app/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js:310:12)`,
160
+ ' at GraphqlProjectItemRepository.fetchProjectItems (/app/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js:410:11)',
161
+ ' at async StartPreparationUseCase.run (/app/bin/domain/usecases/StartPreparationUseCase.js:456:11)',
162
+ ].join('\n');
163
+
164
+ expect(
165
+ extractGraphqlCallSite(frames('callGraphqlWithHalvingFallback')),
166
+ ).toBe('GraphqlProjectItemRepository<-StartPreparationUseCase');
167
+ expect(
168
+ extractGraphqlCallSite(
169
+ frames('GraphqlProjectItemRepository.callGraphqlWithHalvingFallback'),
170
+ ),
171
+ ).toBe('GraphqlProjectItemRepository<-StartPreparationUseCase');
172
+ });
173
+
174
+ it('names the module of a frame that carries a file location instead of a function name', () => {
175
+ const stack = [
176
+ 'Error',
177
+ ' at postGithubGraphqlJson (/app/bin/adapter/repositories/githubGraphqlClient.js:120:22)',
178
+ ' at /app/bin/domain/usecases/AnonymousCaller.js:12:9',
179
+ ' at StartPreparationUseCase.run (/app/bin/domain/usecases/StartPreparationUseCase.js:456:11)',
180
+ ].join('\n');
181
+
182
+ expect(extractGraphqlCallSite(stack)).toBe(
183
+ 'AnonymousCaller<-StartPreparationUseCase',
184
+ );
185
+ });
186
+
187
+ it('skips test runner and node internal frames so the caller of a test run is still named', () => {
188
+ const stack = [
189
+ 'Error',
190
+ ' at postGithubGraphqlJson (/app/src/adapter/repositories/githubGraphqlClient.ts:139:42)',
191
+ ' at GraphqlProjectItemRepository.fetchProjectItems (/app/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts:566:26)',
192
+ ' at Object.<anonymous> (/app/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts:288:26)',
193
+ ' at Promise.finally.completed (/app/node_modules/jest-circus/build/jestAdapterInit.js:1561:28)',
194
+ ' at new Promise (<anonymous>)',
195
+ ' at processTicksAndRejections (node:internal/process/task_queues:104:5)',
196
+ ].join('\n');
197
+
198
+ expect(extractGraphqlCallSite(stack)).toBe(
199
+ 'GraphqlProjectItemRepository',
200
+ );
201
+ });
202
+
203
+ it('names the caller when the package runs from inside node_modules, as the published build does', () => {
204
+ const packageRoot =
205
+ '/home/user/.local/share/tdpm/current/node_modules/github-issue-tower-defence-management';
206
+ const stack = [
207
+ 'Error',
208
+ ` at captureGraphqlCallSite (${packageRoot}/bin/adapter/repositories/githubGraphqlClient.js:80:20)`,
209
+ ` at postGithubGraphqlJson (${packageRoot}/bin/adapter/repositories/githubGraphqlClient.js:120:22)`,
210
+ ` at GraphqlProjectItemRepository.fetchProjectItems (${packageRoot}/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js:410:11)`,
211
+ ` at async StartPreparationUseCase.run (${packageRoot}/bin/domain/usecases/StartPreparationUseCase.js:456:11)`,
212
+ ].join('\n');
213
+
214
+ expect(
215
+ extractGraphqlCallSite(
216
+ stack,
217
+ `${packageRoot}/bin/adapter/repositories/githubGraphqlClient.js`,
218
+ ),
219
+ ).toBe('GraphqlProjectItemRepository<-StartPreparationUseCase');
220
+ });
221
+
222
+ it('names the caller when the published package sits under a scoped directory', () => {
223
+ const packageRoot = '/srv/app/node_modules/@hiromishikata/tdpm';
224
+ const stack = [
225
+ 'Error',
226
+ ` at postGithubGraphqlJson (${packageRoot}/bin/adapter/repositories/githubGraphqlClient.js:120:22)`,
227
+ ` at GraphqlProjectItemRepository.fetchProjectItems (${packageRoot}/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js:410:11)`,
228
+ ].join('\n');
229
+
230
+ expect(
231
+ extractGraphqlCallSite(
232
+ stack,
233
+ `${packageRoot}/bin/adapter/repositories/githubGraphqlClient.js`,
234
+ ),
235
+ ).toBe('GraphqlProjectItemRepository');
236
+ });
237
+
238
+ it('skips a dependency nested inside the installed package so no third party module is named', () => {
239
+ const packageRoot =
240
+ '/home/user/.local/share/tdpm/current/node_modules/github-issue-tower-defence-management';
241
+ const stack = [
242
+ 'Error',
243
+ ` at postGithubGraphqlJson (${packageRoot}/bin/adapter/repositories/githubGraphqlClient.js:120:22)`,
244
+ ` at Object.ky (${packageRoot}/node_modules/ky/distribution/index.js:44:9)`,
245
+ ` at GraphqlProjectItemRepository.fetchProjectItems (${packageRoot}/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js:410:11)`,
246
+ ].join('\n');
247
+
248
+ expect(
249
+ extractGraphqlCallSite(
250
+ stack,
251
+ `${packageRoot}/bin/adapter/repositories/githubGraphqlClient.js`,
252
+ ),
253
+ ).toBe('GraphqlProjectItemRepository');
254
+ });
255
+
256
+ it('returns unknown when no stack is available', () => {
257
+ expect(extractGraphqlCallSite(undefined)).toBe('unknown');
258
+ });
259
+
260
+ it('returns unknown when every frame is inside the graphql client', () => {
261
+ const stack = [
262
+ 'Error',
263
+ ' at postGithubGraphqlJson (/app/bin/adapter/repositories/githubGraphqlClient.js:120:22)',
264
+ ].join('\n');
265
+
266
+ expect(extractGraphqlCallSite(stack)).toBe('unknown');
267
+ });
268
+ });
269
+
120
270
  describe('logGithubGraphqlCost', () => {
121
271
  it('logs one line with query name, cost and remaining', () => {
122
- logGithubGraphqlCost(
123
- 'query GetProjectItems { x }',
124
- { data: { rateLimit: { cost: 3, remaining: 4200 } } },
125
- () => new Date('2026-08-14T09:00:00.000Z'),
126
- );
272
+ logGithubGraphqlCost({
273
+ query: 'query GetProjectItems { x }',
274
+ responseBody: { data: { rateLimit: { cost: 3, remaining: 4200 } } },
275
+ callSite: 'GraphqlProjectItemRepository<-StartPreparationUseCase',
276
+ now: () => new Date('2026-08-14T09:00:00.000Z'),
277
+ });
127
278
  expect(consoleLogSpy).toHaveBeenCalledTimes(1);
128
279
  expect(consoleLogSpy).toHaveBeenCalledWith(
129
- '2026-08-14T09:00:00.000Z githubGraphqlClient: query=GetProjectItems cost=3 remaining=4200',
280
+ '2026-08-14T09:00:00.000Z githubGraphqlClient: query=GetProjectItems cost=3 remaining=4200 caller=GraphqlProjectItemRepository<-StartPreparationUseCase',
130
281
  );
131
282
  });
132
283
 
133
284
  it('opens the line with the time the cost was charged, so a point maps to a rate-limit window', () => {
134
- logGithubGraphqlCost(
135
- 'query GetProjectItems { x }',
136
- { data: { rateLimit: { cost: 4, remaining: 4200 } } },
137
- () => new Date('2026-08-14T23:45:12.345Z'),
138
- );
285
+ logGithubGraphqlCost({
286
+ query: 'query GetProjectItems { x }',
287
+ responseBody: { data: { rateLimit: { cost: 4, remaining: 4200 } } },
288
+ callSite: 'GraphqlProjectItemRepository<-StartPreparationUseCase',
289
+ now: () => new Date('2026-08-14T23:45:12.345Z'),
290
+ });
139
291
  expect(consoleLogSpy).toHaveBeenCalledWith(
140
- '2026-08-14T23:45:12.345Z githubGraphqlClient: query=GetProjectItems cost=4 remaining=4200',
292
+ '2026-08-14T23:45:12.345Z githubGraphqlClient: query=GetProjectItems cost=4 remaining=4200 caller=GraphqlProjectItemRepository<-StartPreparationUseCase',
141
293
  );
142
294
  });
143
295
 
144
296
  it('does not log when rateLimit is absent', () => {
145
- logGithubGraphqlCost('query GetProjectItems { x }', {
146
- data: { node: null },
297
+ logGithubGraphqlCost({
298
+ query: 'query GetProjectItems { x }',
299
+ responseBody: { data: { node: null } },
147
300
  });
148
301
  expect(consoleLogSpy).not.toHaveBeenCalled();
149
302
  });
150
303
 
151
304
  it('does not log when the body is not an object', () => {
152
- logGithubGraphqlCost('query GetProjectItems { x }', undefined);
305
+ logGithubGraphqlCost({
306
+ query: 'query GetProjectItems { x }',
307
+ responseBody: undefined,
308
+ });
153
309
  expect(consoleLogSpy).not.toHaveBeenCalled();
154
310
  });
155
311
  });
@@ -183,7 +339,7 @@ describe('githubGraphqlClient', () => {
183
339
  expect(headers.Authorization).toBe('Bearer token-a');
184
340
  expect(consoleLogSpy).toHaveBeenCalledWith(
185
341
  expect.stringMatching(
186
- /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z githubGraphqlClient: query=GetItem cost=1 remaining=4999$/,
342
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z githubGraphqlClient: query=GetItem cost=1 remaining=4999 caller=\S+$/,
187
343
  ),
188
344
  );
189
345
  });
@@ -249,7 +405,7 @@ describe('githubGraphqlClient', () => {
249
405
  expect(sentBody.variables).toEqual({ a: 1 });
250
406
  expect(consoleLogSpy).toHaveBeenCalledWith(
251
407
  expect.stringMatching(
252
- /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z githubGraphqlClient: query=PullRequestStatus cost=2 remaining=4321$/,
408
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z githubGraphqlClient: query=PullRequestStatus cost=2 remaining=4321 caller=\S+$/,
253
409
  ),
254
410
  );
255
411
  });
@@ -62,17 +62,121 @@ const extractRateLimit = (
62
62
  return { cost, remaining };
63
63
  };
64
64
 
65
- export const logGithubGraphqlCost = (
66
- query: string,
67
- responseBody: unknown,
68
- now: () => Date = () => new Date(),
69
- ): void => {
70
- const rateLimit = extractRateLimit(responseBody);
65
+ export const GRAPHQL_CALL_SITE_FRAME_COUNT = 3;
66
+
67
+ export const GRAPHQL_CALL_SITE_SEPARATOR = '<-';
68
+
69
+ export const UNKNOWN_GRAPHQL_CALL_SITE = 'unknown';
70
+
71
+ const GRAPHQL_CLIENT_MODULE_NAME = 'githubGraphqlClient';
72
+
73
+ const FRAME_LOCATION_PATTERN = /\(?([^()\s]+):\d+:\d+\)?$/;
74
+
75
+ const MODULE_FILE_EXTENSION_PATTERN = /\.(?:[cm]?[jt]sx?)$/;
76
+
77
+ const TEST_MODULE_SUFFIX_PATTERN = /\.(?:test|spec)$/;
78
+
79
+ const NODE_MODULES_SEGMENT = '/node_modules/';
80
+
81
+ const ownPackagePathPrefix = (modulePath: string): string | null => {
82
+ const segmentIndex = modulePath.lastIndexOf(NODE_MODULES_SEGMENT);
83
+ if (segmentIndex === -1) {
84
+ return null;
85
+ }
86
+ const packageRootIndex = segmentIndex + NODE_MODULES_SEGMENT.length;
87
+ const segments = modulePath.slice(packageRootIndex).split('/');
88
+ const nameSegmentCount = segments[0].startsWith('@') ? 2 : 1;
89
+ if (segments.length <= nameSegmentCount) {
90
+ return null;
91
+ }
92
+ return modulePath.slice(
93
+ 0,
94
+ packageRootIndex + segments.slice(0, nameSegmentCount).join('/').length + 1,
95
+ );
96
+ };
97
+
98
+ const isInsideOwnPackage = (
99
+ location: string,
100
+ ownPackagePrefix: string | null,
101
+ ): boolean => {
102
+ if (ownPackagePrefix === null || !location.startsWith(ownPackagePrefix)) {
103
+ return false;
104
+ }
105
+ const pathBelowPackageRoot = `/${location.slice(ownPackagePrefix.length)}`;
106
+ return !pathBelowPackageRoot.includes(NODE_MODULES_SEGMENT);
107
+ };
108
+
109
+ const frameModuleName = (
110
+ frame: string,
111
+ ownPackagePrefix: string | null,
112
+ ): string | null => {
113
+ const match = frame.match(FRAME_LOCATION_PATTERN);
114
+ if (!match) {
115
+ return null;
116
+ }
117
+ const location = match[1];
118
+ if (location.startsWith('node:')) {
119
+ return null;
120
+ }
121
+ if (
122
+ location.includes(NODE_MODULES_SEGMENT) &&
123
+ !isInsideOwnPackage(location, ownPackagePrefix)
124
+ ) {
125
+ return null;
126
+ }
127
+ const fileName = location.split('/').slice(-1)[0];
128
+ const moduleName = fileName
129
+ .replace(MODULE_FILE_EXTENSION_PATTERN, '')
130
+ .replace(TEST_MODULE_SUFFIX_PATTERN, '');
131
+ if (moduleName.length === 0 || moduleName === GRAPHQL_CLIENT_MODULE_NAME) {
132
+ return null;
133
+ }
134
+ return moduleName;
135
+ };
136
+
137
+ export const extractGraphqlCallSite = (
138
+ stack: string | undefined,
139
+ modulePath: string = __filename,
140
+ ): string => {
141
+ if (!stack) {
142
+ return UNKNOWN_GRAPHQL_CALL_SITE;
143
+ }
144
+ const ownPackagePrefix = ownPackagePathPrefix(modulePath);
145
+ const moduleNames = stack
146
+ .split('\n')
147
+ .map((line) => line.trim())
148
+ .filter((line) => line.startsWith('at '))
149
+ .map((frame) => frameModuleName(frame, ownPackagePrefix))
150
+ .filter((moduleName): moduleName is string => moduleName !== null)
151
+ .filter(
152
+ (moduleName, index, allModuleNames) =>
153
+ allModuleNames[index - 1] !== moduleName,
154
+ );
155
+ if (moduleNames.length === 0) {
156
+ return UNKNOWN_GRAPHQL_CALL_SITE;
157
+ }
158
+ return moduleNames
159
+ .slice(0, GRAPHQL_CALL_SITE_FRAME_COUNT)
160
+ .join(GRAPHQL_CALL_SITE_SEPARATOR);
161
+ };
162
+
163
+ export const captureGraphqlCallSite = (): string =>
164
+ extractGraphqlCallSite(new Error().stack);
165
+
166
+ export const logGithubGraphqlCost = (params: {
167
+ query: string;
168
+ responseBody: unknown;
169
+ callSite?: string;
170
+ now?: () => Date;
171
+ }): void => {
172
+ const rateLimit = extractRateLimit(params.responseBody);
71
173
  if (!rateLimit) {
72
174
  return;
73
175
  }
176
+ const now = params.now ?? (() => new Date());
177
+ const callSite = params.callSite ?? UNKNOWN_GRAPHQL_CALL_SITE;
74
178
  console.log(
75
- `${now().toISOString()} githubGraphqlClient: query=${extractGraphqlOperationName(query)} cost=${rateLimit.cost} remaining=${rateLimit.remaining}`,
179
+ `${now().toISOString()} githubGraphqlClient: query=${extractGraphqlOperationName(params.query)} cost=${rateLimit.cost} remaining=${rateLimit.remaining} caller=${callSite}`,
76
180
  );
77
181
  };
78
182
 
@@ -81,6 +185,7 @@ export const postGithubGraphqlJson = async <T>(params: {
81
185
  query: string;
82
186
  variables?: Record<string, unknown>;
83
187
  }): Promise<T> => {
188
+ const callSite = captureGraphqlCallSite();
84
189
  const response = await ky
85
190
  .post(GITHUB_GRAPHQL_ENDPOINT, {
86
191
  json: {
@@ -94,7 +199,11 @@ export const postGithubGraphqlJson = async <T>(params: {
94
199
  },
95
200
  })
96
201
  .json<T>();
97
- logGithubGraphqlCost(params.query, response);
202
+ logGithubGraphqlCost({
203
+ query: params.query,
204
+ responseBody: response,
205
+ callSite,
206
+ });
98
207
  return response;
99
208
  };
100
209
 
@@ -104,6 +213,7 @@ export const fetchGithubGraphql = async (params: {
104
213
  variables?: Record<string, unknown>;
105
214
  timeoutMs?: number;
106
215
  }): Promise<Response> => {
216
+ const callSite = captureGraphqlCallSite();
107
217
  const response = await fetch(GITHUB_GRAPHQL_ENDPOINT, {
108
218
  method: 'POST',
109
219
  headers: {
@@ -123,7 +233,11 @@ export const fetchGithubGraphql = async (params: {
123
233
  .clone()
124
234
  .json()
125
235
  .catch((): null => null);
126
- logGithubGraphqlCost(params.query, responseBody);
236
+ logGithubGraphqlCost({
237
+ query: params.query,
238
+ responseBody,
239
+ callSite,
240
+ });
127
241
  }
128
242
  return response;
129
243
  };
@@ -4010,6 +4010,171 @@ describe('ApiV3CheerioRestIssueRepository', () => {
4010
4010
  });
4011
4011
  });
4012
4012
 
4013
+ describe('getOpenPullRequestCiStatus', () => {
4014
+ afterEach(() => {
4015
+ jest.restoreAllMocks();
4016
+ });
4017
+
4018
+ const prUrl = 'https://github.com/HiromiShikata/test-repository/pull/42';
4019
+
4020
+ const jsonResponse = (body: unknown): Response =>
4021
+ new Response(JSON.stringify(body), {
4022
+ status: 200,
4023
+ headers: { 'Content-Type': 'application/json' },
4024
+ });
4025
+
4026
+ const openPullRequestBody = (mergeable: boolean | null): unknown => ({
4027
+ html_url: prUrl,
4028
+ state: 'open',
4029
+ draft: false,
4030
+ mergeable,
4031
+ head: { ref: 'feature/x', sha: 'sha-1' },
4032
+ base: { ref: 'main' },
4033
+ });
4034
+
4035
+ const requestedUrl = (input: RequestInfo | URL): string => {
4036
+ if (typeof input === 'string') {
4037
+ return input;
4038
+ }
4039
+ if (input instanceof URL) {
4040
+ return input.href;
4041
+ }
4042
+ return input.url;
4043
+ };
4044
+
4045
+ const mockGitHubRest = (pullRequestBodies: unknown[]): string[] => {
4046
+ const requestedUrls: string[] = [];
4047
+ let pullRequestReadCount = 0;
4048
+ jest
4049
+ .spyOn(global, 'fetch')
4050
+ .mockImplementation((input: RequestInfo | URL): Promise<Response> => {
4051
+ const url = requestedUrl(input);
4052
+ requestedUrls.push(url);
4053
+ if (url.includes('/pulls/42')) {
4054
+ const index = Math.min(
4055
+ pullRequestReadCount,
4056
+ pullRequestBodies.length - 1,
4057
+ );
4058
+ pullRequestReadCount += 1;
4059
+ return Promise.resolve(jsonResponse(pullRequestBodies[index]));
4060
+ }
4061
+ if (url.includes('/rules/branches/')) {
4062
+ return Promise.resolve(jsonResponse([]));
4063
+ }
4064
+ if (url.includes('/branches/')) {
4065
+ return Promise.resolve(jsonResponse({}));
4066
+ }
4067
+ if (url.includes('/check-runs')) {
4068
+ return Promise.resolve(
4069
+ jsonResponse({
4070
+ total_count: 1,
4071
+ check_runs: [{ name: 'test', conclusion: 'success', id: 1 }],
4072
+ }),
4073
+ );
4074
+ }
4075
+ if (url.includes('/status')) {
4076
+ return Promise.resolve(jsonResponse({ statuses: [] }));
4077
+ }
4078
+ return Promise.reject(new Error(`unexpected request: ${url}`));
4079
+ });
4080
+ return requestedUrls;
4081
+ };
4082
+
4083
+ it('resolves every status field over REST without issuing a GraphQL request', async () => {
4084
+ const requestedUrls = mockGitHubRest([openPullRequestBody(true)]);
4085
+
4086
+ const { repository } = createApiV3CheerioRestIssueRepository();
4087
+ const status = await repository.getOpenPullRequestCiStatus(prUrl);
4088
+
4089
+ expect(status).toEqual({
4090
+ url: prUrl,
4091
+ isConflicted: false,
4092
+ mergeable: 'MERGEABLE',
4093
+ isPassedAllCiJob: true,
4094
+ isCiStateSuccess: true,
4095
+ isBranchOutOfDate: false,
4096
+ missingRequiredCheckNames: [],
4097
+ });
4098
+ expect(
4099
+ requestedUrls.filter((url) => url.includes('/graphql')),
4100
+ ).toHaveLength(0);
4101
+ expect(requestedUrls).toContain(
4102
+ 'https://api.github.com/repos/HiromiShikata/test-repository/pulls/42',
4103
+ );
4104
+ });
4105
+
4106
+ it('reports a conflicting pull request as conflicted', async () => {
4107
+ mockGitHubRest([openPullRequestBody(false)]);
4108
+
4109
+ const { repository } = createApiV3CheerioRestIssueRepository();
4110
+ const status = await repository.getOpenPullRequestCiStatus(prUrl);
4111
+
4112
+ expect(status?.isConflicted).toBe(true);
4113
+ expect(status?.mergeable).toBe('CONFLICTING');
4114
+ });
4115
+
4116
+ it('re-reads while GitHub is still computing mergeability and returns the settled value', async () => {
4117
+ const requestedUrls = mockGitHubRest([
4118
+ openPullRequestBody(null),
4119
+ openPullRequestBody(true),
4120
+ ]);
4121
+
4122
+ const { repository, sleep } = createApiV3CheerioRestIssueRepository();
4123
+ const status = await repository.getOpenPullRequestCiStatus(prUrl);
4124
+
4125
+ expect(status?.mergeable).toBe('MERGEABLE');
4126
+ expect(sleep).toHaveBeenCalledTimes(1);
4127
+ const pullRequestReads = requestedUrls.filter((url) =>
4128
+ url.endsWith('/pulls/42'),
4129
+ );
4130
+ expect(pullRequestReads).toHaveLength(2);
4131
+ });
4132
+
4133
+ it('reports unknown mergeability rather than looping when GitHub never settles it', async () => {
4134
+ const requestedUrls = mockGitHubRest([openPullRequestBody(null)]);
4135
+
4136
+ const { repository } = createApiV3CheerioRestIssueRepository();
4137
+ const status = await repository.getOpenPullRequestCiStatus(prUrl);
4138
+
4139
+ expect(status?.mergeable).toBe('UNKNOWN');
4140
+ expect(status?.isConflicted).toBe(false);
4141
+ const pullRequestReads = requestedUrls.filter((url) =>
4142
+ url.endsWith('/pulls/42'),
4143
+ );
4144
+ expect(pullRequestReads).toHaveLength(3);
4145
+ });
4146
+
4147
+ it('returns null for a pull request that is no longer open', async () => {
4148
+ mockGitHubRest([
4149
+ {
4150
+ html_url: prUrl,
4151
+ state: 'closed',
4152
+ draft: false,
4153
+ mergeable: null,
4154
+ head: { ref: 'feature/x', sha: 'sha-1' },
4155
+ base: { ref: 'main' },
4156
+ },
4157
+ ]);
4158
+
4159
+ const { repository } = createApiV3CheerioRestIssueRepository();
4160
+
4161
+ expect(await repository.getOpenPullRequestCiStatus(prUrl)).toBeNull();
4162
+ });
4163
+
4164
+ it('returns null for a url that is not a pull request', async () => {
4165
+ const fetchSpy = jest.spyOn(global, 'fetch');
4166
+
4167
+ const { repository } = createApiV3CheerioRestIssueRepository();
4168
+
4169
+ expect(
4170
+ await repository.getOpenPullRequestCiStatus(
4171
+ 'https://github.com/HiromiShikata/test-repository/issues/42',
4172
+ ),
4173
+ ).toBeNull();
4174
+ expect(fetchSpy).not.toHaveBeenCalled();
4175
+ });
4176
+ });
4177
+
4013
4178
  const createApiV3CheerioRestIssueRepository = () => {
4014
4179
  const apiV3IssueRepository = mock<ApiV3IssueRepository>();
4015
4180
  const restIssueRepository = mock<RestIssueRepository>();