github-issue-tower-defence-management 1.103.1 → 1.104.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 (39) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +8 -1
  3. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  4. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js +16 -0
  5. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
  6. package/bin/domain/usecases/SetDependedIssueUrlForOpenTaskPRsUseCase.js +30 -4
  7. package/bin/domain/usecases/SetDependedIssueUrlForOpenTaskPRsUseCase.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +1 -0
  10. package/src/adapter/entry-points/handlers/InTmuxByHumanSessionTokenCountHandler.test.ts +1 -0
  11. package/src/adapter/entry-points/handlers/inTmuxByHumanDataWriter.test.ts +1 -0
  12. package/src/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.test.ts +1 -0
  13. package/src/adapter/entry-points/handlers/situationFileWriter.test.ts +1 -0
  14. package/src/adapter/repositories/GitHubIssueCommentRepository.test.ts +1 -0
  15. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +9 -0
  16. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +10 -0
  17. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +24 -0
  18. package/src/adapter/repositories/issue/RestIssueRepository.test.ts +1 -0
  19. package/src/domain/entities/Issue.ts +1 -0
  20. package/src/domain/usecases/CheckIssueReviewReadinessUseCase.test.ts +1 -0
  21. package/src/domain/usecases/GetStoryObjectMapUseCase.test.ts +1 -0
  22. package/src/domain/usecases/InTmuxByHumanSessionTokenCountUseCase.test.ts +1 -0
  23. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +1 -0
  24. package/src/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.test.ts +1 -0
  25. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +1 -0
  26. package/src/domain/usecases/SetDependedIssueUrlForOpenTaskPRsUseCase.test.ts +148 -115
  27. package/src/domain/usecases/SetDependedIssueUrlForOpenTaskPRsUseCase.ts +35 -10
  28. package/src/domain/usecases/SetupTowerDefenceProjectUseCase.test.ts +1 -0
  29. package/src/domain/usecases/StartPreparationUseCase.test.ts +1 -0
  30. package/src/domain/usecases/console/GenerateConsoleListsUseCase.test.ts +1 -0
  31. package/src/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.test.ts +1 -0
  32. package/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.test.ts +1 -0
  33. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  34. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts +1 -0
  35. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
  36. package/types/domain/entities/Issue.d.ts +1 -0
  37. package/types/domain/entities/Issue.d.ts.map +1 -1
  38. package/types/domain/usecases/SetDependedIssueUrlForOpenTaskPRsUseCase.d.ts +3 -2
  39. package/types/domain/usecases/SetDependedIssueUrlForOpenTaskPRsUseCase.d.ts.map +1 -1
@@ -14,6 +14,7 @@ export type ProjectItem = {
14
14
  assignees: string[];
15
15
  createdAt: string;
16
16
  author: string;
17
+ closingIssueReferenceUrls: string[];
17
18
  customFields: {
18
19
  name: string;
19
20
  value: string | null;
@@ -214,6 +215,11 @@ query GetProjectItems($projectId: ID!, $after: String, $first: Int!) {
214
215
  repository {
215
216
  nameWithOwner
216
217
  }
218
+ closingIssuesReferences(first: 50) {
219
+ nodes {
220
+ url
221
+ }
222
+ }
217
223
  }
218
224
  }
219
225
  }
@@ -257,6 +263,7 @@ query GetProjectItems($projectId: ID!, $after: String, $first: Int!) {
257
263
  author: { login: string } | null;
258
264
  labels: { nodes: { name: string }[] };
259
265
  assignees: { nodes: { login: string }[] };
266
+ closingIssuesReferences?: { nodes: { url: string }[] };
260
267
  };
261
268
  }[];
262
269
  };
@@ -309,6 +316,7 @@ query GetProjectItems($projectId: ID!, $after: String, $first: Int!) {
309
316
  author: { login: string } | null;
310
317
  labels: { nodes: { name: string }[] };
311
318
  assignees: { nodes: { login: string }[] };
319
+ closingIssuesReferences?: { nodes: { url: string }[] };
312
320
  };
313
321
  }[];
314
322
  };
@@ -364,6 +372,7 @@ query GetProjectItems($projectId: ID!, $after: String, $first: Int!) {
364
372
  author: { login: string } | null;
365
373
  labels: { nodes: { name: string }[] };
366
374
  assignees: { nodes: { login: string }[] };
375
+ closingIssuesReferences?: { nodes: { url: string }[] };
367
376
  };
368
377
  }[];
369
378
  };
@@ -435,6 +444,7 @@ query GetProjectItems($projectId: ID!, $after: String, $first: Int!) {
435
444
  author: { login: string } | null;
436
445
  labels: { nodes: { name: string }[] };
437
446
  assignees: { nodes: { login: string }[] };
447
+ closingIssuesReferences?: { nodes: { url: string }[] };
438
448
  };
439
449
  }[] = pageNodes;
440
450
  projectItems.forEach((item) => {
@@ -453,6 +463,10 @@ query GetProjectItems($projectId: ID!, $after: String, $first: Int!) {
453
463
  assignees: item.content.assignees?.nodes?.map((a) => a.login) || [],
454
464
  createdAt: item.content.createdAt || new Date().toISOString(),
455
465
  author: item.content.author?.login || '',
466
+ closingIssueReferenceUrls:
467
+ item.content.closingIssuesReferences?.nodes
468
+ ?.map((node) => node.url)
469
+ .filter((url) => url.length > 0) || [],
456
470
  customFields: item.fieldValues.nodes
457
471
  .filter((field) => !!field.field)
458
472
  .map((field) => {
@@ -768,6 +782,11 @@ query GetProjectFields($owner: String!, $repository: String!, $issueNumber: Int!
768
782
  repository {
769
783
  nameWithOwner
770
784
  }
785
+ closingIssuesReferences(first: 50) {
786
+ nodes {
787
+ url
788
+ }
789
+ }
771
790
  projectItems(first: 10) {
772
791
  nodes {
773
792
  id
@@ -843,6 +862,7 @@ query GetProjectFields($owner: String!, $repository: String!, $issueNumber: Int!
843
862
  labels: { nodes: { name: string }[] };
844
863
  assignees: { nodes: { login: string }[] };
845
864
  repository: { nameWithOwner: string };
865
+ closingIssuesReferences?: { nodes: { url: string }[] };
846
866
  projectItems: {
847
867
  nodes: {
848
868
  id: string;
@@ -914,6 +934,10 @@ query GetProjectFields($owner: String!, $repository: String!, $issueNumber: Int!
914
934
  assignees: content.assignees?.nodes?.map((a) => a.login) || [],
915
935
  createdAt: content.createdAt || new Date().toISOString(),
916
936
  author: content.author?.login || '',
937
+ closingIssueReferenceUrls:
938
+ content.closingIssuesReferences?.nodes
939
+ ?.map((node) => node.url)
940
+ .filter((url) => url.length > 0) || [],
917
941
  customFields: item.fieldValues.nodes
918
942
  .filter((field) => !!field.field)
919
943
  .map((field) => {
@@ -50,6 +50,7 @@ const buildIssue = (overrides: Partial<Issue> = {}): Issue => ({
50
50
  isClosed: false,
51
51
  createdAt: new Date(),
52
52
  author: '',
53
+ closingIssueReferenceUrls: [],
53
54
  ...overrides,
54
55
  });
55
56
 
@@ -24,4 +24,5 @@ export type Issue = {
24
24
  isClosed: boolean;
25
25
  createdAt: Date;
26
26
  author: string;
27
+ closingIssueReferenceUrls: string[];
27
28
  };
@@ -27,6 +27,7 @@ const createMockIssue = (overrides: Partial<Issue> = {}): Issue => ({
27
27
  isClosed: false,
28
28
  createdAt: new Date('2000-01-01T00:00:00Z'),
29
29
  author: 'test-user',
30
+ closingIssueReferenceUrls: [],
30
31
  ...overrides,
31
32
  });
32
33
 
@@ -67,6 +67,7 @@ describe('GetStoryObjectMapUseCase', () => {
67
67
  isClosed: false,
68
68
  createdAt: new Date(),
69
69
  author: '',
70
+ closingIssueReferenceUrls: [],
70
71
  ...overrides,
71
72
  });
72
73
 
@@ -47,6 +47,7 @@ const issue = (url: string, status: string): Issue => ({
47
47
  isClosed: false,
48
48
  createdAt: new Date('2026-01-01T00:00:00Z'),
49
49
  author: 'hiromi',
50
+ closingIssueReferenceUrls: [],
50
51
  });
51
52
 
52
53
  describe('InTmuxByHumanSessionTokenCountUseCase', () => {
@@ -72,6 +72,7 @@ const createMockIssue = (overrides: Partial<Issue> = {}): Issue => ({
72
72
  isClosed: false,
73
73
  createdAt: new Date(),
74
74
  author: '',
75
+ closingIssueReferenceUrls: [],
75
76
  ...overrides,
76
77
  });
77
78
 
@@ -80,6 +80,7 @@ const createMockIssue = (overrides: Partial<Issue> = {}): Issue => ({
80
80
  isClosed: false,
81
81
  createdAt: new Date(),
82
82
  author: '',
83
+ closingIssueReferenceUrls: [],
83
84
  ...overrides,
84
85
  });
85
86
 
@@ -32,6 +32,7 @@ const createMockIssue = (overrides: Partial<Issue> = {}): Issue => ({
32
32
  isClosed: false,
33
33
  createdAt: new Date(),
34
34
  author: '',
35
+ closingIssueReferenceUrls: [],
35
36
  ...overrides,
36
37
  });
37
38
 
@@ -28,6 +28,7 @@ describe('SetDependedIssueUrlForOpenTaskPRsUseCase', () => {
28
28
  isPr: false,
29
29
  isClosed: false,
30
30
  state: 'OPEN',
31
+ closingIssueReferenceUrls: [],
31
32
  };
32
33
  const closedTaskIssue: Issue = {
33
34
  ...mock<Issue>(),
@@ -35,50 +36,28 @@ describe('SetDependedIssueUrlForOpenTaskPRsUseCase', () => {
35
36
  isPr: false,
36
37
  isClosed: true,
37
38
  state: 'CLOSED',
39
+ closingIssueReferenceUrls: [],
38
40
  };
39
- const prItem: Issue = {
41
+
42
+ const openPrClosingIssue1: Issue = {
40
43
  ...mock<Issue>(),
41
- url: 'https://github.com/owner/repo/pull/10',
44
+ url: 'https://github.com/owner/repo/pull/100',
42
45
  isPr: true,
43
46
  isClosed: false,
44
47
  state: 'OPEN',
48
+ closingIssueReferenceUrls: ['https://github.com/owner/repo/issues/1'],
45
49
  };
46
50
 
47
51
  beforeEach(() => {
48
52
  jest.clearAllMocks();
49
53
  });
50
54
 
51
- it('should call setDependedIssueUrl for each related open PR linked to an open task issue', async () => {
52
- mockIssueRepository.findRelatedOpenPRs.mockImplementation(
53
- async (issueUrl) => {
54
- if (issueUrl === openTaskIssue.url) {
55
- return [
56
- {
57
- url: 'https://github.com/owner/repo/pull/100',
58
- branchName: null,
59
- createdAt: new Date(0),
60
- isDraft: false,
61
- isConflicted: false,
62
- isPassedAllCiJob: true,
63
- isCiStateSuccess: true,
64
- isResolvedAllReviewComments: true,
65
- isBranchOutOfDate: false,
66
- missingRequiredCheckNames: [],
67
- },
68
- ];
69
- }
70
- return [];
71
- },
72
- );
73
-
55
+ it('should call setDependedIssueUrl for each open PR whose closing keyword targets an open task issue', async () => {
74
56
  await useCase.run({
75
57
  project: projectWithField,
76
- issues: [openTaskIssue],
58
+ issues: [openTaskIssue, openPrClosingIssue1],
77
59
  });
78
60
 
79
- expect(mockIssueRepository.findRelatedOpenPRs).toHaveBeenCalledWith(
80
- openTaskIssue.url,
81
- );
82
61
  expect(mockIssueRepository.setDependedIssueUrl).toHaveBeenCalledTimes(1);
83
62
  expect(mockIssueRepository.setDependedIssueUrl).toHaveBeenCalledWith(
84
63
  'https://github.com/owner/repo/pull/100',
@@ -87,41 +66,108 @@ describe('SetDependedIssueUrlForOpenTaskPRsUseCase', () => {
87
66
  );
88
67
  });
89
68
 
69
+ it('should never call the per-issue timeline lookup', async () => {
70
+ await useCase.run({
71
+ project: projectWithField,
72
+ issues: [openTaskIssue, openPrClosingIssue1],
73
+ });
74
+
75
+ expect(mockIssueRepository.findRelatedOpenPRs).not.toHaveBeenCalled();
76
+ });
77
+
90
78
  it('should skip closed task issues so that PRs linked only to a closed task are not touched', async () => {
91
- mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
79
+ const openPrClosingClosedIssue: Issue = {
80
+ ...mock<Issue>(),
81
+ url: 'https://github.com/owner/repo/pull/101',
82
+ isPr: true,
83
+ isClosed: false,
84
+ state: 'OPEN',
85
+ closingIssueReferenceUrls: ['https://github.com/owner/repo/issues/2'],
86
+ };
92
87
 
93
88
  await useCase.run({
94
89
  project: projectWithField,
95
- issues: [closedTaskIssue],
90
+ issues: [closedTaskIssue, openPrClosingClosedIssue],
96
91
  });
97
92
 
98
- expect(mockIssueRepository.findRelatedOpenPRs).not.toHaveBeenCalled();
99
93
  expect(mockIssueRepository.setDependedIssueUrl).not.toHaveBeenCalled();
100
94
  });
101
95
 
102
- it('should skip PR project items themselves and only iterate task issues', async () => {
103
- mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
96
+ it('should ignore closed PRs even when they declare a closing keyword for an open task issue', async () => {
97
+ const closedPrClosingIssue1: Issue = {
98
+ ...mock<Issue>(),
99
+ url: 'https://github.com/owner/repo/pull/102',
100
+ isPr: true,
101
+ isClosed: true,
102
+ state: 'CLOSED',
103
+ closingIssueReferenceUrls: ['https://github.com/owner/repo/issues/1'],
104
+ };
104
105
 
105
106
  await useCase.run({
106
107
  project: projectWithField,
107
- issues: [prItem],
108
+ issues: [openTaskIssue, closedPrClosingIssue1],
108
109
  });
109
110
 
110
- expect(mockIssueRepository.findRelatedOpenPRs).not.toHaveBeenCalled();
111
111
  expect(mockIssueRepository.setDependedIssueUrl).not.toHaveBeenCalled();
112
112
  });
113
113
 
114
- it('should not call setDependedIssueUrl when no related open PRs are found for an open task issue', async () => {
115
- mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
114
+ it('should ignore bare mentions because they are absent from the closing-keyword set', async () => {
115
+ const openPrMentioningButNotClosing: Issue = {
116
+ ...mock<Issue>(),
117
+ url: 'https://github.com/owner/repo/pull/103',
118
+ isPr: true,
119
+ isClosed: false,
120
+ state: 'OPEN',
121
+ closingIssueReferenceUrls: [],
122
+ };
116
123
 
117
124
  await useCase.run({
118
125
  project: projectWithField,
119
- issues: [openTaskIssue],
126
+ issues: [openTaskIssue, openPrMentioningButNotClosing],
120
127
  });
121
128
 
122
- expect(mockIssueRepository.findRelatedOpenPRs).toHaveBeenCalledWith(
123
- openTaskIssue.url,
129
+ expect(mockIssueRepository.setDependedIssueUrl).not.toHaveBeenCalled();
130
+ });
131
+
132
+ it('should map a cross-repo closing target by full issue URL', async () => {
133
+ const crossRepoOpenTaskIssue: Issue = {
134
+ ...mock<Issue>(),
135
+ url: 'https://github.com/owner/other-repo/issues/9',
136
+ isPr: false,
137
+ isClosed: false,
138
+ state: 'OPEN',
139
+ closingIssueReferenceUrls: [],
140
+ };
141
+ const openPrClosingCrossRepoIssue: Issue = {
142
+ ...mock<Issue>(),
143
+ url: 'https://github.com/owner/repo/pull/104',
144
+ isPr: true,
145
+ isClosed: false,
146
+ state: 'OPEN',
147
+ closingIssueReferenceUrls: [
148
+ 'https://github.com/owner/other-repo/issues/9',
149
+ ],
150
+ };
151
+
152
+ await useCase.run({
153
+ project: projectWithField,
154
+ issues: [crossRepoOpenTaskIssue, openPrClosingCrossRepoIssue],
155
+ });
156
+
157
+ expect(mockIssueRepository.setDependedIssueUrl).toHaveBeenCalledTimes(1);
158
+ expect(mockIssueRepository.setDependedIssueUrl).toHaveBeenCalledWith(
159
+ 'https://github.com/owner/repo/pull/104',
160
+ projectWithField,
161
+ crossRepoOpenTaskIssue.url,
124
162
  );
163
+ });
164
+
165
+ it('should not call setDependedIssueUrl when no open PR closes an open task issue', async () => {
166
+ await useCase.run({
167
+ project: projectWithField,
168
+ issues: [openTaskIssue],
169
+ });
170
+
125
171
  expect(mockIssueRepository.setDependedIssueUrl).not.toHaveBeenCalled();
126
172
  });
127
173
 
@@ -130,75 +176,72 @@ describe('SetDependedIssueUrlForOpenTaskPRsUseCase', () => {
130
176
 
131
177
  await useCase.run({
132
178
  project: projectWithoutField,
133
- issues: [openTaskIssue],
179
+ issues: [openTaskIssue, openPrClosingIssue1],
134
180
  });
135
181
 
136
- expect(mockIssueRepository.findRelatedOpenPRs).not.toHaveBeenCalled();
137
182
  expect(mockIssueRepository.setDependedIssueUrl).not.toHaveBeenCalled();
138
183
  expect(warnSpy).toHaveBeenCalled();
139
184
 
140
185
  warnSpy.mockRestore();
141
186
  });
142
187
 
143
- it('should iterate over each open task issue and request its related open PRs independently', async () => {
188
+ it('should set dependencies for multiple open task issues from their respective open PRs', async () => {
144
189
  const secondOpenTaskIssue: Issue = {
145
190
  ...mock<Issue>(),
146
191
  url: 'https://github.com/owner/repo/issues/3',
147
192
  isPr: false,
148
193
  isClosed: false,
149
194
  state: 'OPEN',
195
+ closingIssueReferenceUrls: [],
196
+ };
197
+ const openPrClosingIssue3: Issue = {
198
+ ...mock<Issue>(),
199
+ url: 'https://github.com/owner/repo/pull/200',
200
+ isPr: true,
201
+ isClosed: false,
202
+ state: 'OPEN',
203
+ closingIssueReferenceUrls: ['https://github.com/owner/repo/issues/3'],
150
204
  };
151
-
152
- mockIssueRepository.findRelatedOpenPRs.mockImplementation(
153
- async (issueUrl) => {
154
- if (issueUrl === openTaskIssue.url) {
155
- return [
156
- {
157
- url: 'https://github.com/owner/repo/pull/100',
158
- branchName: null,
159
- createdAt: new Date(0),
160
- isDraft: false,
161
- isConflicted: false,
162
- isPassedAllCiJob: true,
163
- isCiStateSuccess: true,
164
- isResolvedAllReviewComments: true,
165
- isBranchOutOfDate: false,
166
- missingRequiredCheckNames: [],
167
- },
168
- ];
169
- }
170
- if (issueUrl === secondOpenTaskIssue.url) {
171
- return [
172
- {
173
- url: 'https://github.com/owner/repo/pull/200',
174
- branchName: null,
175
- createdAt: new Date(0),
176
- isDraft: false,
177
- isConflicted: false,
178
- isPassedAllCiJob: true,
179
- isCiStateSuccess: true,
180
- isResolvedAllReviewComments: true,
181
- isBranchOutOfDate: false,
182
- missingRequiredCheckNames: [],
183
- },
184
- ];
185
- }
186
- return [];
187
- },
188
- );
189
205
 
190
206
  await useCase.run({
191
207
  project: projectWithField,
192
- issues: [openTaskIssue, closedTaskIssue, prItem, secondOpenTaskIssue],
208
+ issues: [
209
+ openTaskIssue,
210
+ closedTaskIssue,
211
+ openPrClosingIssue1,
212
+ secondOpenTaskIssue,
213
+ openPrClosingIssue3,
214
+ ],
193
215
  });
194
216
 
195
- expect(mockIssueRepository.findRelatedOpenPRs).toHaveBeenCalledTimes(2);
196
- expect(mockIssueRepository.findRelatedOpenPRs).toHaveBeenCalledWith(
217
+ expect(mockIssueRepository.setDependedIssueUrl).toHaveBeenCalledTimes(2);
218
+ expect(mockIssueRepository.setDependedIssueUrl).toHaveBeenCalledWith(
219
+ 'https://github.com/owner/repo/pull/100',
220
+ projectWithField,
197
221
  openTaskIssue.url,
198
222
  );
199
- expect(mockIssueRepository.findRelatedOpenPRs).toHaveBeenCalledWith(
223
+ expect(mockIssueRepository.setDependedIssueUrl).toHaveBeenCalledWith(
224
+ 'https://github.com/owner/repo/pull/200',
225
+ projectWithField,
200
226
  secondOpenTaskIssue.url,
201
227
  );
228
+ });
229
+
230
+ it('should set dependencies for every open PR that closes the same open task issue', async () => {
231
+ const secondOpenPrClosingIssue1: Issue = {
232
+ ...mock<Issue>(),
233
+ url: 'https://github.com/owner/repo/pull/105',
234
+ isPr: true,
235
+ isClosed: false,
236
+ state: 'OPEN',
237
+ closingIssueReferenceUrls: ['https://github.com/owner/repo/issues/1'],
238
+ };
239
+
240
+ await useCase.run({
241
+ project: projectWithField,
242
+ issues: [openTaskIssue, openPrClosingIssue1, secondOpenPrClosingIssue1],
243
+ });
244
+
202
245
  expect(mockIssueRepository.setDependedIssueUrl).toHaveBeenCalledTimes(2);
203
246
  expect(mockIssueRepository.setDependedIssueUrl).toHaveBeenCalledWith(
204
247
  'https://github.com/owner/repo/pull/100',
@@ -206,9 +249,9 @@ describe('SetDependedIssueUrlForOpenTaskPRsUseCase', () => {
206
249
  openTaskIssue.url,
207
250
  );
208
251
  expect(mockIssueRepository.setDependedIssueUrl).toHaveBeenCalledWith(
209
- 'https://github.com/owner/repo/pull/200',
252
+ 'https://github.com/owner/repo/pull/105',
210
253
  projectWithField,
211
- secondOpenTaskIssue.url,
254
+ openTaskIssue.url,
212
255
  );
213
256
  });
214
257
 
@@ -216,33 +259,23 @@ describe('SetDependedIssueUrlForOpenTaskPRsUseCase', () => {
216
259
  const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
217
260
  const failingPrUrl = 'https://github.com/owner/repo/pull/100';
218
261
  const succeedingPrUrl = 'https://github.com/owner/repo/pull/200';
262
+ const failingPr: Issue = {
263
+ ...mock<Issue>(),
264
+ url: failingPrUrl,
265
+ isPr: true,
266
+ isClosed: false,
267
+ state: 'OPEN',
268
+ closingIssueReferenceUrls: ['https://github.com/owner/repo/issues/1'],
269
+ };
270
+ const succeedingPr: Issue = {
271
+ ...mock<Issue>(),
272
+ url: succeedingPrUrl,
273
+ isPr: true,
274
+ isClosed: false,
275
+ state: 'OPEN',
276
+ closingIssueReferenceUrls: ['https://github.com/owner/repo/issues/1'],
277
+ };
219
278
 
220
- mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
221
- {
222
- url: failingPrUrl,
223
- branchName: null,
224
- createdAt: new Date(0),
225
- isDraft: false,
226
- isConflicted: false,
227
- isPassedAllCiJob: true,
228
- isCiStateSuccess: true,
229
- isResolvedAllReviewComments: true,
230
- isBranchOutOfDate: false,
231
- missingRequiredCheckNames: [],
232
- },
233
- {
234
- url: succeedingPrUrl,
235
- branchName: null,
236
- createdAt: new Date(0),
237
- isDraft: false,
238
- isConflicted: false,
239
- isPassedAllCiJob: true,
240
- isCiStateSuccess: true,
241
- isResolvedAllReviewComments: true,
242
- isBranchOutOfDate: false,
243
- missingRequiredCheckNames: [],
244
- },
245
- ]);
246
279
  mockIssueRepository.setDependedIssueUrl.mockImplementation(
247
280
  async (prUrl) => {
248
281
  if (prUrl === failingPrUrl) {
@@ -254,7 +287,7 @@ describe('SetDependedIssueUrlForOpenTaskPRsUseCase', () => {
254
287
  await expect(
255
288
  useCase.run({
256
289
  project: projectWithField,
257
- issues: [openTaskIssue],
290
+ issues: [openTaskIssue, failingPr, succeedingPr],
258
291
  }),
259
292
  ).resolves.toBeUndefined();
260
293
 
@@ -4,10 +4,7 @@ import { IssueRepository } from './adapter-interfaces/IssueRepository';
4
4
 
5
5
  export class SetDependedIssueUrlForOpenTaskPRsUseCase {
6
6
  constructor(
7
- readonly issueRepository: Pick<
8
- IssueRepository,
9
- 'findRelatedOpenPRs' | 'setDependedIssueUrl'
10
- >,
7
+ readonly issueRepository: Pick<IssueRepository, 'setDependedIssueUrl'>,
11
8
  ) {}
12
9
 
13
10
  run = async (input: { project: Project; issues: Issue[] }): Promise<void> => {
@@ -17,26 +14,54 @@ export class SetDependedIssueUrlForOpenTaskPRsUseCase {
17
14
  );
18
15
  return;
19
16
  }
17
+ const openPrUrlsByClosedIssueUrl = this.buildOpenPrUrlsByClosedIssueUrl(
18
+ input.issues,
19
+ );
20
20
  for (const issue of input.issues) {
21
21
  if (issue.isPr || issue.isClosed) {
22
22
  continue;
23
23
  }
24
- const relatedOpenPRs = await this.issueRepository.findRelatedOpenPRs(
25
- issue.url,
26
- );
27
- for (const pr of relatedOpenPRs) {
24
+ const relatedOpenPrUrls = openPrUrlsByClosedIssueUrl.get(issue.url);
25
+ if (!relatedOpenPrUrls) {
26
+ continue;
27
+ }
28
+ for (const prUrl of relatedOpenPrUrls) {
28
29
  try {
29
30
  await this.issueRepository.setDependedIssueUrl(
30
- pr.url,
31
+ prUrl,
31
32
  input.project,
32
33
  issue.url,
33
34
  );
34
35
  } catch (error) {
35
36
  console.warn(
36
- `Failed to set depended issue URL for PR ${pr.url}, skipping and continuing with remaining PRs: ${error instanceof Error ? error.message : String(error)}`,
37
+ `Failed to set depended issue URL for PR ${prUrl}, skipping and continuing with remaining PRs: ${error instanceof Error ? error.message : String(error)}`,
37
38
  );
38
39
  }
39
40
  }
40
41
  }
41
42
  };
43
+
44
+ private buildOpenPrUrlsByClosedIssueUrl = (
45
+ issues: Issue[],
46
+ ): Map<string, string[]> => {
47
+ const openPrUrlsByClosedIssueUrl = new Map<string, Set<string>>();
48
+ for (const issue of issues) {
49
+ if (!issue.isPr || issue.isClosed) {
50
+ continue;
51
+ }
52
+ for (const closedIssueUrl of issue.closingIssueReferenceUrls) {
53
+ const existing = openPrUrlsByClosedIssueUrl.get(closedIssueUrl);
54
+ if (existing) {
55
+ existing.add(issue.url);
56
+ } else {
57
+ openPrUrlsByClosedIssueUrl.set(closedIssueUrl, new Set([issue.url]));
58
+ }
59
+ }
60
+ }
61
+ const result = new Map<string, string[]>();
62
+ for (const [closedIssueUrl, prUrls] of openPrUrlsByClosedIssueUrl) {
63
+ result.set(closedIssueUrl, Array.from(prUrls));
64
+ }
65
+ return result;
66
+ };
42
67
  }
@@ -72,6 +72,7 @@ const buildIssue = (overrides: Partial<Issue>): Issue => ({
72
72
  isClosed: false,
73
73
  createdAt: new Date('2024-01-01'),
74
74
  author: 'user',
75
+ closingIssueReferenceUrls: [],
75
76
  ...overrides,
76
77
  });
77
78
 
@@ -51,6 +51,7 @@ const createMockIssue = (overrides: Partial<Issue> = {}): Issue => ({
51
51
  isClosed: false,
52
52
  createdAt: new Date(),
53
53
  author: 'testuser',
54
+ closingIssueReferenceUrls: [],
54
55
  ...overrides,
55
56
  });
56
57
 
@@ -82,6 +82,7 @@ const makeIssue = (overrides: Partial<Issue>): Issue => {
82
82
  isClosed: false,
83
83
  createdAt: new Date('2026-06-13T08:18:45.000Z'),
84
84
  author: 'someone',
85
+ closingIssueReferenceUrls: [],
85
86
  ...overrides,
86
87
  };
87
88
  };
@@ -79,6 +79,7 @@ const makeIssue = (overrides: Partial<Issue>): Issue => {
79
79
  isClosed: false,
80
80
  createdAt: new Date('2026-06-13T08:18:45.000Z'),
81
81
  author: 'someone',
82
+ closingIssueReferenceUrls: [],
82
83
  ...overrides,
83
84
  };
84
85
  };
@@ -37,6 +37,7 @@ const makeIssue = (overrides: Partial<Issue> = {}): Issue => {
37
37
  isClosed: false,
38
38
  createdAt: new Date(),
39
39
  author: '',
40
+ closingIssueReferenceUrls: [],
40
41
  ...overrides,
41
42
  };
42
43
  };