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.
- package/CHANGELOG.md +19 -0
- package/bin/adapter/entry-points/console/consoleReadApi.js +1 -1
- package/bin/adapter/entry-points/console/consoleReadApi.js.map +1 -1
- package/bin/adapter/repositories/githubGraphqlClient.js +90 -6
- package/bin/adapter/repositories/githubGraphqlClient.js.map +1 -1
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +88 -1
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/console/consoleReadApi.test.ts +27 -9
- package/src/adapter/entry-points/console/consoleReadApi.ts +1 -1
- package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +16 -0
- package/src/adapter/entry-points/console/webServer.test.ts +1 -5
- package/src/adapter/repositories/githubGraphqlClient.test.ts +173 -17
- package/src/adapter/repositories/githubGraphqlClient.ts +123 -9
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +165 -0
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +140 -0
- package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +1 -1
- package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +13 -0
- package/types/adapter/repositories/githubGraphqlClient.d.ts +11 -1
- package/types/adapter/repositories/githubGraphqlClient.d.ts.map +1 -1
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +4 -1
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +10 -0
- 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
|
-
|
|
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
|
-
|
|
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(
|
|
146
|
-
|
|
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(
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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(
|
|
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(
|
|
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>();
|