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.
Files changed (23) hide show
  1. package/CHANGELOG.md +12 -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 +30 -4
  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 +53 -0
  14. package/src/adapter/repositories/githubGraphqlClient.ts +47 -4
  15. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +165 -0
  16. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +140 -0
  17. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +13 -0
  18. package/types/adapter/repositories/githubGraphqlClient.d.ts +1 -1
  19. package/types/adapter/repositories/githubGraphqlClient.d.ts.map +1 -1
  20. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +4 -1
  21. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  22. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +10 -0
  23. 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.getOpenPullRequest.mockResolvedValue(openPullRequest);
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.getOpenPullRequest.mockResolvedValue(null);
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.getOpenPullRequest.mockResolvedValue(openPullRequest);
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.getOpenPullRequest).toHaveBeenCalledTimes(1);
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.getOpenPullRequest).toHaveBeenCalledTimes(2);
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.getOpenPullRequest(url);
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.getOpenPullRequest.mockResolvedValue({
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 frameModuleName = (frame: string): string | null => {
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:') || location.includes('/node_modules/')) {
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 = (stack: string | undefined): string => {
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;AA6BnD,eAAO,MAAM,sBAAsB,GAAI,OAAO,MAAM,GAAG,SAAS,KAAG,MAoBlE,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
+ {"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;