github-issue-tower-defence-management 1.155.0 → 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 +12 -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 +30 -4
- 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 +53 -0
- package/src/adapter/repositories/githubGraphqlClient.ts +47 -4
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +165 -0
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +140 -0
- package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +13 -0
- package/types/adapter/repositories/githubGraphqlClient.d.ts +1 -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
|
@@ -355,14 +355,10 @@ describe('consoleReadApi', () => {
|
|
|
355
355
|
describe('handlePullRequestStatus with the TTL cache', () => {
|
|
356
356
|
const openPullRequest = {
|
|
357
357
|
url: 'https://github.com/o/r/pull/1',
|
|
358
|
-
branchName: 'feature',
|
|
359
|
-
createdAt: new Date('2026-01-02T03:04:05Z'),
|
|
360
|
-
isDraft: false,
|
|
361
358
|
isConflicted: true,
|
|
362
359
|
mergeable: 'CONFLICTING',
|
|
363
360
|
isPassedAllCiJob: false,
|
|
364
361
|
isCiStateSuccess: false,
|
|
365
|
-
isResolvedAllReviewComments: false,
|
|
366
362
|
isBranchOutOfDate: true,
|
|
367
363
|
missingRequiredCheckNames: ['build', 'test'],
|
|
368
364
|
};
|
|
@@ -380,7 +376,9 @@ describe('consoleReadApi', () => {
|
|
|
380
376
|
|
|
381
377
|
it('serializes the open pull request status fields', async () => {
|
|
382
378
|
const issueRepository = mock<IssueRepository>();
|
|
383
|
-
issueRepository.
|
|
379
|
+
issueRepository.getOpenPullRequestCiStatus.mockResolvedValue(
|
|
380
|
+
openPullRequest,
|
|
381
|
+
);
|
|
384
382
|
const cache = new PullRequestStatusCache(() => 0);
|
|
385
383
|
const response = await handlePullRequestStatus(
|
|
386
384
|
issueRepository,
|
|
@@ -403,7 +401,7 @@ describe('consoleReadApi', () => {
|
|
|
403
401
|
|
|
404
402
|
it('reports not found when the repository returns no open pull request', async () => {
|
|
405
403
|
const issueRepository = mock<IssueRepository>();
|
|
406
|
-
issueRepository.
|
|
404
|
+
issueRepository.getOpenPullRequestCiStatus.mockResolvedValue(null);
|
|
407
405
|
const cache = new PullRequestStatusCache(() => 0);
|
|
408
406
|
const response = await handlePullRequestStatus(
|
|
409
407
|
issueRepository,
|
|
@@ -413,9 +411,25 @@ describe('consoleReadApi', () => {
|
|
|
413
411
|
expect(response.body).toEqual({ found: false, status: null });
|
|
414
412
|
});
|
|
415
413
|
|
|
414
|
+
it('does not reach the review thread read, which no field of this response needs', async () => {
|
|
415
|
+
const issueRepository = mock<IssueRepository>();
|
|
416
|
+
issueRepository.getOpenPullRequestCiStatus.mockResolvedValue(
|
|
417
|
+
openPullRequest,
|
|
418
|
+
);
|
|
419
|
+
const cache = new PullRequestStatusCache(() => 0);
|
|
420
|
+
await handlePullRequestStatus(
|
|
421
|
+
issueRepository,
|
|
422
|
+
cache,
|
|
423
|
+
openPullRequest.url,
|
|
424
|
+
);
|
|
425
|
+
expect(issueRepository.getOpenPullRequest).not.toHaveBeenCalled();
|
|
426
|
+
});
|
|
427
|
+
|
|
416
428
|
it('caches within the TTL and re-fetches after the TTL elapses', async () => {
|
|
417
429
|
const issueRepository = mock<IssueRepository>();
|
|
418
|
-
issueRepository.
|
|
430
|
+
issueRepository.getOpenPullRequestCiStatus.mockResolvedValue(
|
|
431
|
+
openPullRequest,
|
|
432
|
+
);
|
|
419
433
|
let now = 0;
|
|
420
434
|
const cache = new PullRequestStatusCache(() => now);
|
|
421
435
|
await handlePullRequestStatus(
|
|
@@ -429,14 +443,18 @@ describe('consoleReadApi', () => {
|
|
|
429
443
|
cache,
|
|
430
444
|
openPullRequest.url,
|
|
431
445
|
);
|
|
432
|
-
expect(issueRepository.
|
|
446
|
+
expect(issueRepository.getOpenPullRequestCiStatus).toHaveBeenCalledTimes(
|
|
447
|
+
1,
|
|
448
|
+
);
|
|
433
449
|
now = PULL_REQUEST_STATUS_CACHE_TTL_MS;
|
|
434
450
|
await handlePullRequestStatus(
|
|
435
451
|
issueRepository,
|
|
436
452
|
cache,
|
|
437
453
|
openPullRequest.url,
|
|
438
454
|
);
|
|
439
|
-
expect(issueRepository.
|
|
455
|
+
expect(issueRepository.getOpenPullRequestCiStatus).toHaveBeenCalledTimes(
|
|
456
|
+
2,
|
|
457
|
+
);
|
|
440
458
|
});
|
|
441
459
|
});
|
|
442
460
|
});
|
|
@@ -263,7 +263,7 @@ export const handlePullRequestStatus = async (
|
|
|
263
263
|
if (cached !== null) {
|
|
264
264
|
return ok(cached);
|
|
265
265
|
}
|
|
266
|
-
const pullRequest = await issueRepository.
|
|
266
|
+
const pullRequest = await issueRepository.getOpenPullRequestCiStatus(url);
|
|
267
267
|
const response: PullRequestStatusResponse =
|
|
268
268
|
pullRequest === null
|
|
269
269
|
? { found: false, status: null }
|
|
@@ -7,6 +7,7 @@ import type { Project } from '../../../../../domain/entities/Project';
|
|
|
7
7
|
import type {
|
|
8
8
|
IssueComment,
|
|
9
9
|
IssueRepository,
|
|
10
|
+
OpenPullRequestCiStatus,
|
|
10
11
|
PullRequestCommit,
|
|
11
12
|
PullRequestDetail,
|
|
12
13
|
PullRequestFile,
|
|
@@ -429,6 +430,21 @@ const createStubIssueRepository = (
|
|
|
429
430
|
url === CONSOLE_E2E_AWAITING_QUALITY_CHECK_PR_URL
|
|
430
431
|
? awaitingQualityCheckPullRequest
|
|
431
432
|
: null,
|
|
433
|
+
getOpenPullRequestCiStatus: async (
|
|
434
|
+
url: string,
|
|
435
|
+
): Promise<OpenPullRequestCiStatus | null> =>
|
|
436
|
+
url === CONSOLE_E2E_AWAITING_QUALITY_CHECK_PR_URL
|
|
437
|
+
? {
|
|
438
|
+
url: awaitingQualityCheckPullRequest.url,
|
|
439
|
+
isConflicted: awaitingQualityCheckPullRequest.isConflicted,
|
|
440
|
+
mergeable: awaitingQualityCheckPullRequest.mergeable,
|
|
441
|
+
isPassedAllCiJob: awaitingQualityCheckPullRequest.isPassedAllCiJob,
|
|
442
|
+
isCiStateSuccess: awaitingQualityCheckPullRequest.isCiStateSuccess,
|
|
443
|
+
isBranchOutOfDate: awaitingQualityCheckPullRequest.isBranchOutOfDate,
|
|
444
|
+
missingRequiredCheckNames:
|
|
445
|
+
awaitingQualityCheckPullRequest.missingRequiredCheckNames,
|
|
446
|
+
}
|
|
447
|
+
: null,
|
|
432
448
|
getOpenPullRequests: async (
|
|
433
449
|
urls: string[],
|
|
434
450
|
): Promise<Map<string, RelatedPullRequest | null>> =>
|
|
@@ -674,16 +674,12 @@ describe('webServer new routes integration', () => {
|
|
|
674
674
|
it('serves the pull request status read api when a status cache is injected', async () => {
|
|
675
675
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-server-'));
|
|
676
676
|
const issueRepository = mock<IssueRepository>();
|
|
677
|
-
issueRepository.
|
|
677
|
+
issueRepository.getOpenPullRequestCiStatus.mockResolvedValue({
|
|
678
678
|
url: 'https://github.com/o/r/pull/1',
|
|
679
|
-
branchName: 'feature',
|
|
680
|
-
createdAt: new Date('2026-06-18T03:21:00.000Z'),
|
|
681
|
-
isDraft: false,
|
|
682
679
|
isConflicted: true,
|
|
683
680
|
mergeable: 'CONFLICTING',
|
|
684
681
|
isPassedAllCiJob: false,
|
|
685
682
|
isCiStateSuccess: false,
|
|
686
|
-
isResolvedAllReviewComments: false,
|
|
687
683
|
isBranchOutOfDate: true,
|
|
688
684
|
missingRequiredCheckNames: ['build'],
|
|
689
685
|
});
|
|
@@ -200,6 +200,59 @@ describe('githubGraphqlClient', () => {
|
|
|
200
200
|
);
|
|
201
201
|
});
|
|
202
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
|
+
|
|
203
256
|
it('returns unknown when no stack is available', () => {
|
|
204
257
|
expect(extractGraphqlCallSite(undefined)).toBe('unknown');
|
|
205
258
|
});
|
|
@@ -76,13 +76,52 @@ const MODULE_FILE_EXTENSION_PATTERN = /\.(?:[cm]?[jt]sx?)$/;
|
|
|
76
76
|
|
|
77
77
|
const TEST_MODULE_SUFFIX_PATTERN = /\.(?:test|spec)$/;
|
|
78
78
|
|
|
79
|
-
const
|
|
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 => {
|
|
80
113
|
const match = frame.match(FRAME_LOCATION_PATTERN);
|
|
81
114
|
if (!match) {
|
|
82
115
|
return null;
|
|
83
116
|
}
|
|
84
117
|
const location = match[1];
|
|
85
|
-
if (location.startsWith('node:')
|
|
118
|
+
if (location.startsWith('node:')) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
if (
|
|
122
|
+
location.includes(NODE_MODULES_SEGMENT) &&
|
|
123
|
+
!isInsideOwnPackage(location, ownPackagePrefix)
|
|
124
|
+
) {
|
|
86
125
|
return null;
|
|
87
126
|
}
|
|
88
127
|
const fileName = location.split('/').slice(-1)[0];
|
|
@@ -95,15 +134,19 @@ const frameModuleName = (frame: string): string | null => {
|
|
|
95
134
|
return moduleName;
|
|
96
135
|
};
|
|
97
136
|
|
|
98
|
-
export const extractGraphqlCallSite = (
|
|
137
|
+
export const extractGraphqlCallSite = (
|
|
138
|
+
stack: string | undefined,
|
|
139
|
+
modulePath: string = __filename,
|
|
140
|
+
): string => {
|
|
99
141
|
if (!stack) {
|
|
100
142
|
return UNKNOWN_GRAPHQL_CALL_SITE;
|
|
101
143
|
}
|
|
144
|
+
const ownPackagePrefix = ownPackagePathPrefix(modulePath);
|
|
102
145
|
const moduleNames = stack
|
|
103
146
|
.split('\n')
|
|
104
147
|
.map((line) => line.trim())
|
|
105
148
|
.filter((line) => line.startsWith('at '))
|
|
106
|
-
.map(frameModuleName)
|
|
149
|
+
.map((frame) => frameModuleName(frame, ownPackagePrefix))
|
|
107
150
|
.filter((moduleName): moduleName is string => moduleName !== null)
|
|
108
151
|
.filter(
|
|
109
152
|
(moduleName, index, allModuleNames) =>
|
|
@@ -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>();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
IssueRepository,
|
|
3
3
|
RelatedPullRequest,
|
|
4
|
+
OpenPullRequestCiStatus,
|
|
4
5
|
IssueComment,
|
|
5
6
|
PullRequestDetail,
|
|
6
7
|
PullRequestFile,
|
|
@@ -297,6 +298,46 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
297
298
|
return typeof value === 'object' && value !== null;
|
|
298
299
|
}
|
|
299
300
|
|
|
301
|
+
type RestPullRequestCiStatusResponse = {
|
|
302
|
+
html_url: string;
|
|
303
|
+
state: string;
|
|
304
|
+
draft: boolean;
|
|
305
|
+
mergeable: boolean | null;
|
|
306
|
+
head: { ref: string; sha: string };
|
|
307
|
+
base: { ref: string };
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
function isRestPullRequestCiStatusResponse(
|
|
311
|
+
value: unknown,
|
|
312
|
+
): value is RestPullRequestCiStatusResponse {
|
|
313
|
+
if (!isRecord(value)) return false;
|
|
314
|
+
const head: unknown = value.head;
|
|
315
|
+
const base: unknown = value.base;
|
|
316
|
+
return (
|
|
317
|
+
typeof value.html_url === 'string' &&
|
|
318
|
+
typeof value.state === 'string' &&
|
|
319
|
+
typeof value.draft === 'boolean' &&
|
|
320
|
+
(value.mergeable === null || typeof value.mergeable === 'boolean') &&
|
|
321
|
+
isRecord(head) &&
|
|
322
|
+
typeof head.ref === 'string' &&
|
|
323
|
+
typeof head.sha === 'string' &&
|
|
324
|
+
isRecord(base) &&
|
|
325
|
+
typeof base.ref === 'string'
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export const graphqlMergeableFromRestMergeable = (
|
|
330
|
+
mergeable: boolean | null,
|
|
331
|
+
): string => {
|
|
332
|
+
if (mergeable === true) {
|
|
333
|
+
return 'MERGEABLE';
|
|
334
|
+
}
|
|
335
|
+
if (mergeable === false) {
|
|
336
|
+
return 'CONFLICTING';
|
|
337
|
+
}
|
|
338
|
+
return 'UNKNOWN';
|
|
339
|
+
};
|
|
340
|
+
|
|
300
341
|
function isNullableString(value: unknown): value is string | null {
|
|
301
342
|
return value === null || typeof value === 'string';
|
|
302
343
|
}
|
|
@@ -1742,6 +1783,105 @@ export class ApiV3CheerioRestIssueRepository
|
|
|
1742
1783
|
return this.buildRelatedPullRequestFromSlim(owner, repo, slimPullRequest);
|
|
1743
1784
|
};
|
|
1744
1785
|
|
|
1786
|
+
private fetchRestPullRequestCiStatus = async (
|
|
1787
|
+
owner: string,
|
|
1788
|
+
repo: string,
|
|
1789
|
+
prNumber: number,
|
|
1790
|
+
prUrl: string,
|
|
1791
|
+
): Promise<RestPullRequestCiStatusResponse | null> => {
|
|
1792
|
+
const maxAttempts = 3;
|
|
1793
|
+
const retryDelayMilliseconds = 1000;
|
|
1794
|
+
let lastPullRequest: RestPullRequestCiStatusResponse | null = null;
|
|
1795
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1796
|
+
if (attempt > 0) {
|
|
1797
|
+
await this.sleep(retryDelayMilliseconds);
|
|
1798
|
+
}
|
|
1799
|
+
const response = await this.fetchWithRateLimitRetry(() =>
|
|
1800
|
+
fetch(
|
|
1801
|
+
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${prNumber}`,
|
|
1802
|
+
{
|
|
1803
|
+
method: 'GET',
|
|
1804
|
+
headers: {
|
|
1805
|
+
Authorization: `Bearer ${this.ghToken}`,
|
|
1806
|
+
Accept: 'application/vnd.github+json',
|
|
1807
|
+
},
|
|
1808
|
+
},
|
|
1809
|
+
),
|
|
1810
|
+
);
|
|
1811
|
+
if (response.status === 404) {
|
|
1812
|
+
return null;
|
|
1813
|
+
}
|
|
1814
|
+
if (!response.ok) {
|
|
1815
|
+
const reason = await this.formatGitHubErrorWithStatus(response);
|
|
1816
|
+
throw new Error(
|
|
1817
|
+
`Failed to fetch pull request status for ${prUrl}: ${reason}`,
|
|
1818
|
+
);
|
|
1819
|
+
}
|
|
1820
|
+
const body: unknown = await response.json();
|
|
1821
|
+
if (!isRestPullRequestCiStatusResponse(body)) {
|
|
1822
|
+
throw new Error(
|
|
1823
|
+
`Unexpected response shape when fetching pull request status for ${prUrl}`,
|
|
1824
|
+
);
|
|
1825
|
+
}
|
|
1826
|
+
lastPullRequest = body;
|
|
1827
|
+
if (body.state !== 'open' || body.mergeable !== null) {
|
|
1828
|
+
return body;
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
return lastPullRequest;
|
|
1832
|
+
};
|
|
1833
|
+
|
|
1834
|
+
getOpenPullRequestCiStatus = async (
|
|
1835
|
+
prUrl: string,
|
|
1836
|
+
): Promise<OpenPullRequestCiStatus | null> => {
|
|
1837
|
+
const parsedUrl = this.parseIssueUrl(prUrl);
|
|
1838
|
+
if (!parsedUrl.isPr) {
|
|
1839
|
+
return null;
|
|
1840
|
+
}
|
|
1841
|
+
const { owner, repo, issueNumber: prNumber } = parsedUrl;
|
|
1842
|
+
|
|
1843
|
+
const pullRequest = await this.fetchRestPullRequestCiStatus(
|
|
1844
|
+
owner,
|
|
1845
|
+
repo,
|
|
1846
|
+
prNumber,
|
|
1847
|
+
prUrl,
|
|
1848
|
+
);
|
|
1849
|
+
if (!pullRequest || pullRequest.state !== 'open') {
|
|
1850
|
+
return null;
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
const requiredCheckNames = await this.getRequiredCheckNames(
|
|
1854
|
+
owner,
|
|
1855
|
+
repo,
|
|
1856
|
+
pullRequest.base.ref,
|
|
1857
|
+
);
|
|
1858
|
+
const ciContexts = await this.getCommitCiContexts(
|
|
1859
|
+
owner,
|
|
1860
|
+
repo,
|
|
1861
|
+
pullRequest.head.sha,
|
|
1862
|
+
);
|
|
1863
|
+
const status = this.computePrStatus(
|
|
1864
|
+
pullRequest.html_url,
|
|
1865
|
+
pullRequest.head.ref,
|
|
1866
|
+
{
|
|
1867
|
+
isDraft: pullRequest.draft,
|
|
1868
|
+
mergeable: graphqlMergeableFromRestMergeable(pullRequest.mergeable),
|
|
1869
|
+
requiredCheckNames,
|
|
1870
|
+
ciContexts,
|
|
1871
|
+
reviewThreads: [],
|
|
1872
|
+
},
|
|
1873
|
+
);
|
|
1874
|
+
return {
|
|
1875
|
+
url: status.url,
|
|
1876
|
+
isConflicted: status.isConflicted,
|
|
1877
|
+
mergeable: status.mergeable,
|
|
1878
|
+
isPassedAllCiJob: status.isPassedAllCiJob,
|
|
1879
|
+
isCiStateSuccess: status.isCiStateSuccess,
|
|
1880
|
+
isBranchOutOfDate: status.isBranchOutOfDate,
|
|
1881
|
+
missingRequiredCheckNames: status.missingRequiredCheckNames,
|
|
1882
|
+
};
|
|
1883
|
+
};
|
|
1884
|
+
|
|
1745
1885
|
// Resolves many pull requests with one GraphQL query per hundred instead of
|
|
1746
1886
|
// one query per pull request. A URL this cannot settle is left out of the
|
|
1747
1887
|
// returned map rather than mapped to null, so the caller falls back to
|
|
@@ -18,6 +18,16 @@ export type RelatedPullRequest = {
|
|
|
18
18
|
missingRequiredCheckNames: string[];
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
export type OpenPullRequestCiStatus = {
|
|
22
|
+
url: string;
|
|
23
|
+
isConflicted: boolean;
|
|
24
|
+
mergeable: string | null;
|
|
25
|
+
isPassedAllCiJob: boolean;
|
|
26
|
+
isCiStateSuccess: boolean;
|
|
27
|
+
isBranchOutOfDate: boolean;
|
|
28
|
+
missingRequiredCheckNames: string[];
|
|
29
|
+
};
|
|
30
|
+
|
|
21
31
|
export type IssueComment = {
|
|
22
32
|
author: string;
|
|
23
33
|
body: string;
|
|
@@ -141,6 +151,9 @@ export interface IssueRepository {
|
|
|
141
151
|
update: (issue: Issue, project: Project) => Promise<void>;
|
|
142
152
|
findRelatedOpenPRs: (issueUrl: string) => Promise<RelatedPullRequest[]>;
|
|
143
153
|
getOpenPullRequest: (prUrl: string) => Promise<RelatedPullRequest | null>;
|
|
154
|
+
getOpenPullRequestCiStatus: (
|
|
155
|
+
prUrl: string,
|
|
156
|
+
) => Promise<OpenPullRequestCiStatus | null>;
|
|
144
157
|
getOpenPullRequests: (
|
|
145
158
|
prUrls: string[],
|
|
146
159
|
) => Promise<Map<string, RelatedPullRequest | null>>;
|
|
@@ -11,7 +11,7 @@ export declare const injectRateLimitSelection: (query: string) => string;
|
|
|
11
11
|
export declare const GRAPHQL_CALL_SITE_FRAME_COUNT = 3;
|
|
12
12
|
export declare const GRAPHQL_CALL_SITE_SEPARATOR = "<-";
|
|
13
13
|
export declare const UNKNOWN_GRAPHQL_CALL_SITE = "unknown";
|
|
14
|
-
export declare const extractGraphqlCallSite: (stack: string | undefined) => string;
|
|
14
|
+
export declare const extractGraphqlCallSite: (stack: string | undefined, modulePath?: string) => string;
|
|
15
15
|
export declare const captureGraphqlCallSite: () => string;
|
|
16
16
|
export declare const logGithubGraphqlCost: (params: {
|
|
17
17
|
query: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"githubGraphqlClient.d.ts","sourceRoot":"","sources":["../../../src/adapter/repositories/githubGraphqlClient.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,uBAAuB,mCAAmC,CAAC;AAExE,eAAO,MAAM,iCAAiC,SAAU,CAAC;AAEzD,eAAO,MAAM,oBAAoB,iCAAiC,CAAC;AAEnE,MAAM,MAAM,sBAAsB,GAAG;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,eAAO,MAAM,mBAAmB,GAAI,OAAO,MAAM,KAAG,OACV,CAAC;AAE3C,eAAO,MAAM,2BAA2B,GAAI,OAAO,MAAM,KAAG,MAK3D,CAAC;AAEF,eAAO,MAAM,wBAAwB,GAAI,OAAO,MAAM,KAAG,MASxD,CAAC;AAgCF,eAAO,MAAM,6BAA6B,IAAI,CAAC;AAE/C,eAAO,MAAM,2BAA2B,OAAO,CAAC;AAEhD,eAAO,MAAM,yBAAyB,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"githubGraphqlClient.d.ts","sourceRoot":"","sources":["../../../src/adapter/repositories/githubGraphqlClient.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,uBAAuB,mCAAmC,CAAC;AAExE,eAAO,MAAM,iCAAiC,SAAU,CAAC;AAEzD,eAAO,MAAM,oBAAoB,iCAAiC,CAAC;AAEnE,MAAM,MAAM,sBAAsB,GAAG;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,eAAO,MAAM,mBAAmB,GAAI,OAAO,MAAM,KAAG,OACV,CAAC;AAE3C,eAAO,MAAM,2BAA2B,GAAI,OAAO,MAAM,KAAG,MAK3D,CAAC;AAEF,eAAO,MAAM,wBAAwB,GAAI,OAAO,MAAM,KAAG,MASxD,CAAC;AAgCF,eAAO,MAAM,6BAA6B,IAAI,CAAC;AAE/C,eAAO,MAAM,2BAA2B,OAAO,CAAC;AAEhD,eAAO,MAAM,yBAAyB,YAAY,CAAC;AAoEnD,eAAO,MAAM,sBAAsB,GACjC,OAAO,MAAM,GAAG,SAAS,EACzB,aAAY,MAAmB,KAC9B,MAqBF,CAAC;AAEF,eAAO,MAAM,sBAAsB,QAAO,MACC,CAAC;AAE5C,eAAO,MAAM,oBAAoB,GAAI,QAAQ;IAC3C,KAAK,EAAE,MAAM,CAAC;IACd,YAAY,EAAE,OAAO,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;CAClB,KAAG,IAUH,CAAC;AAEF,eAAO,MAAM,qBAAqB,GAAU,CAAC,EAAE,QAAQ;IACrD,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACrC,KAAG,OAAO,CAAC,CAAC,CAqBZ,CAAC;AAEF,eAAO,MAAM,kBAAkB,GAAU,QAAQ;IAC/C,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACpC,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,KAAG,OAAO,CAAC,QAAQ,CA4BnB,CAAC"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { IssueRepository, RelatedPullRequest, IssueComment, PullRequestDetail, PullRequestCommit, PullRequestReviewCommentSide, PullRequestReviewInlineLocation } from '../../../domain/usecases/adapter-interfaces/IssueRepository';
|
|
1
|
+
import { IssueRepository, RelatedPullRequest, OpenPullRequestCiStatus, IssueComment, PullRequestDetail, PullRequestCommit, PullRequestReviewCommentSide, PullRequestReviewInlineLocation } from '../../../domain/usecases/adapter-interfaces/IssueRepository';
|
|
2
2
|
import { Project } from '../../../domain/entities/Project';
|
|
3
3
|
import { Issue } from '../../../domain/entities/Issue';
|
|
4
4
|
import { SearchedIssue } from '../../../domain/entities/SearchedIssue';
|
|
@@ -16,6 +16,7 @@ import { Sleep } from './githubRateLimitRetry';
|
|
|
16
16
|
export declare const FULL_ISSUE_FETCH_INTERVAL_MS: number;
|
|
17
17
|
export declare const INCREMENTAL_FETCH_SKEW_BUFFER_MS: number;
|
|
18
18
|
export declare const REQUIRED_CHECKS_CACHE_TTL_MS: number;
|
|
19
|
+
export declare const graphqlMergeableFromRestMergeable: (mergeable: boolean | null) => string;
|
|
19
20
|
export declare class ApiV3CheerioRestIssueRepository extends BaseGitHubRepository implements IssueRepository {
|
|
20
21
|
readonly apiV3IssueRepository: Pick<ApiV3IssueRepository, 'searchIssue'>;
|
|
21
22
|
readonly restIssueRepository: Pick<RestIssueRepository, 'createNewIssue' | 'updateIssue' | 'updateIssueBody' | 'createComment' | 'getIssue' | 'updateLabels' | 'removeLabel' | 'updateAssigneeList' | 'searchIssues'>;
|
|
@@ -92,6 +93,8 @@ export declare class ApiV3CheerioRestIssueRepository extends BaseGitHubRepositor
|
|
|
92
93
|
getAllOpened: (project: Project) => Promise<Issue[]>;
|
|
93
94
|
getStoryObjectMap: (project: Project) => Promise<StoryObjectMap>;
|
|
94
95
|
getOpenPullRequest: (prUrl: string) => Promise<RelatedPullRequest | null>;
|
|
96
|
+
private fetchRestPullRequestCiStatus;
|
|
97
|
+
getOpenPullRequestCiStatus: (prUrl: string) => Promise<OpenPullRequestCiStatus | null>;
|
|
95
98
|
getOpenPullRequests: (prUrls: string[]) => Promise<Map<string, RelatedPullRequest | null>>;
|
|
96
99
|
private requireParsedPullRequest;
|
|
97
100
|
private fetchSlimPullRequestsInOneQuery;
|