github-issue-tower-defence-management 1.39.0 → 1.41.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 (58) hide show
  1. package/.github/workflows/create-pr.yml +12 -5
  2. package/.github/workflows/umino-project.yml +5 -4
  3. package/CHANGELOG.md +26 -0
  4. package/README.md +21 -9
  5. package/bin/adapter/entry-points/cli/index.js +45 -10
  6. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  7. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +32 -8
  8. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  9. package/bin/adapter/repositories/NodeLocalCommandRunner.js +3 -3
  10. package/bin/adapter/repositories/NodeLocalCommandRunner.js.map +1 -1
  11. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +446 -11
  12. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  13. package/bin/domain/usecases/HandleScheduledEventUseCase.js +5 -2
  14. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  15. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +111 -16
  16. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  17. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +7 -2
  18. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
  19. package/bin/domain/usecases/StartPreparationUseCase.js +107 -72
  20. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/adapter/entry-points/cli/index.test.ts +26 -13
  23. package/src/adapter/entry-points/cli/index.ts +74 -13
  24. package/src/adapter/repositories/NodeLocalCommandRunner.test.ts +12 -12
  25. package/src/adapter/repositories/NodeLocalCommandRunner.ts +7 -4
  26. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +3 -0
  27. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +689 -12
  28. package/src/adapter/repositories/issue/RestIssueRepository.test.ts +3 -0
  29. package/src/domain/entities/Issue.ts +1 -0
  30. package/src/domain/usecases/GetStoryObjectMapUseCase.test.ts +1 -0
  31. package/src/domain/usecases/HandleScheduledEventUseCase.ts +11 -3
  32. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +983 -167
  33. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +177 -26
  34. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +22 -9
  35. package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +8 -3
  36. package/src/domain/usecases/StartPreparationUseCase.test.ts +1696 -290
  37. package/src/domain/usecases/StartPreparationUseCase.ts +171 -126
  38. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +4 -4
  39. package/src/domain/usecases/adapter-interfaces/LocalCommandRunner.ts +4 -1
  40. package/types/adapter/entry-points/cli/index.d.ts +4 -1
  41. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  42. package/types/adapter/repositories/NodeLocalCommandRunner.d.ts +1 -1
  43. package/types/adapter/repositories/NodeLocalCommandRunner.d.ts.map +1 -1
  44. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +7 -6
  45. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  46. package/types/domain/entities/Issue.d.ts +1 -0
  47. package/types/domain/entities/Issue.d.ts.map +1 -1
  48. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +5 -1
  49. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  50. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +5 -1
  51. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  52. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
  53. package/types/domain/usecases/StartPreparationUseCase.d.ts +10 -18
  54. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
  55. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +3 -3
  56. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
  57. package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts +1 -1
  58. package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts.map +1 -1
@@ -1,4 +1,7 @@
1
- import { IssueRepository } from './adapter-interfaces/IssueRepository';
1
+ import {
2
+ IssueRepository,
3
+ RelatedPullRequest,
4
+ } from './adapter-interfaces/IssueRepository';
2
5
  import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
3
6
  import { IssueCommentRepository } from './adapter-interfaces/IssueCommentRepository';
4
7
  import { WebhookRepository } from './adapter-interfaces/WebhookRepository';
@@ -23,6 +26,7 @@ export class IllegalIssueStatusError extends Error {
23
26
  }
24
27
  type RejectedReasonType =
25
28
  | 'NO_REPORT_FROM_AGENT_BOT'
29
+ | 'REPORT_HAS_NEXT_STEP'
26
30
  | 'PULL_REQUEST_NOT_FOUND'
27
31
  | 'MULTIPLE_PULL_REQUESTS_FOUND'
28
32
  | 'PULL_REQUEST_CONFLICTED'
@@ -38,7 +42,12 @@ export class NotifyFinishedIssuePreparationUseCase {
38
42
  >,
39
43
  private readonly issueRepository: Pick<
40
44
  IssueRepository,
41
- 'get' | 'update' | 'findRelatedOpenPRs' | 'getStoryObjectMap'
45
+ | 'get'
46
+ | 'update'
47
+ | 'updateNextActionDate'
48
+ | 'findRelatedOpenPRs'
49
+ | 'getStoryObjectMap'
50
+ | 'getOpenPullRequest'
42
51
  >,
43
52
  private readonly issueCommentRepository: Pick<
44
53
  IssueCommentRepository,
@@ -84,9 +93,61 @@ export class NotifyFinishedIssuePreparationUseCase {
84
93
  params.preparationStatus,
85
94
  );
86
95
  }
96
+
97
+ if (issue.dependedIssueUrls.length === 0) {
98
+ try {
99
+ const storyObjectMap =
100
+ await this.issueRepository.getStoryObjectMap(project);
101
+ for (const storyObject of storyObjectMap.values()) {
102
+ const towerDefenceIssue = storyObject.issues.find(
103
+ (i) => i.url === issue.url,
104
+ );
105
+ if (towerDefenceIssue) {
106
+ issue.dependedIssueUrls = towerDefenceIssue.dependedIssueUrls;
107
+ break;
108
+ }
109
+ }
110
+ } catch (error) {
111
+ console.warn(
112
+ 'Failed to enrich dependedIssueUrls from story object map:',
113
+ error,
114
+ );
115
+ }
116
+ }
117
+
118
+ if (issue.dependedIssueUrls.length > 0) {
119
+ issue.status = params.awaitingWorkspaceStatus;
120
+ await this.issueRepository.update(issue, project);
121
+ await this.issueCommentRepository.createComment(
122
+ issue,
123
+ `Issue has dependent issue URLs: ${issue.dependedIssueUrls.join(', ')}`,
124
+ );
125
+ return;
126
+ }
127
+
128
+ if (issue.nextActionDate !== null || issue.nextActionHour !== null) {
129
+ issue.status = params.awaitingWorkspaceStatus;
130
+ await this.issueRepository.update(issue, project);
131
+ await this.issueCommentRepository.createComment(
132
+ issue,
133
+ `Issue has next action date or hour set: nextActionDate=${issue.nextActionDate?.toISOString() ?? 'null'}, nextActionHour=${issue.nextActionHour ?? 'null'}`,
134
+ );
135
+ return;
136
+ }
137
+
87
138
  const comments =
88
139
  await this.issueCommentRepository.getCommentsFromIssue(issue);
89
140
 
141
+ const { rejections, approvedPrUrl } = await this.collectRejections(
142
+ issue,
143
+ comments,
144
+ );
145
+
146
+ const rejectionStatusMessage =
147
+ rejections.length > 0
148
+ ? `Auto Status Check: REJECTED\n${rejections.map((r) => `- ${r.detail}`).join('\n')}`
149
+ : 'Auto Status Check: APPROVED';
150
+
90
151
  const lastTargetComments = comments.slice(
91
152
  -params.thresholdForAutoReject * 2,
92
153
  );
@@ -95,15 +156,38 @@ export class NotifyFinishedIssuePreparationUseCase {
95
156
  comment.content.startsWith('Auto Status Check: REJECTED'),
96
157
  ).length >= params.thresholdForAutoReject &&
97
158
  !lastTargetComments.some((comment) =>
98
- comment.content.toLowerCase().startsWith('retry'),
159
+ comment.content
160
+ .toLowerCase()
161
+ .includes('failed to pass the check automatically'),
99
162
  )
100
163
  ) {
101
164
  issue.status = params.awaitingQualityCheckStatus;
102
165
  await this.issueRepository.update(issue, project);
166
+ const escalationStatusLine =
167
+ rejections.length > 0
168
+ ? rejectionStatusMessage
169
+ : 'Auto Status Check: APPROVED (escalated due to prior failures)';
170
+ if (rejections.length === 0 && approvedPrUrl !== null) {
171
+ await this.setPrNextActionDate(approvedPrUrl, project);
172
+ }
103
173
  await this.issueCommentRepository.createComment(
104
174
  issue,
105
- `Failed to pass the check autimatically for ${params.thresholdForAutoReject} times`,
175
+ `${escalationStatusLine}\n\nFailed to pass the check automatically for ${params.thresholdForAutoReject} times`,
176
+ );
177
+ await this.sendWorkflowBlockerNotification(
178
+ params.issueUrl,
179
+ params.workflowBlockerResolvedWebhookUrl,
180
+ project,
106
181
  );
182
+ return;
183
+ }
184
+
185
+ if (rejections.length <= 0) {
186
+ issue.status = params.awaitingQualityCheckStatus;
187
+ await this.issueRepository.update(issue, project);
188
+ if (approvedPrUrl !== null) {
189
+ await this.setPrNextActionDate(approvedPrUrl, project);
190
+ }
107
191
  await this.sendWorkflowBlockerNotification(
108
192
  params.issueUrl,
109
193
  params.workflowBlockerResolvedWebhookUrl,
@@ -112,34 +196,63 @@ export class NotifyFinishedIssuePreparationUseCase {
112
196
  return;
113
197
  }
114
198
 
199
+ issue.status = params.awaitingWorkspaceStatus;
200
+ await this.issueRepository.update(issue, project);
201
+
202
+ await this.issueCommentRepository.createComment(
203
+ issue,
204
+ rejectionStatusMessage,
205
+ );
206
+ };
207
+
208
+ private collectRejections = async (
209
+ issue: { url: string; labels: string[]; isPr: boolean },
210
+ comments: { content: string }[],
211
+ ): Promise<{
212
+ rejections: { type: RejectedReasonType; detail: string }[];
213
+ approvedPrUrl: string | null;
214
+ }> => {
115
215
  const rejections: { type: RejectedReasonType; detail: string }[] = [];
216
+ let approvedPrUrl: string | null = null;
116
217
  const lastComment = comments[comments.length - 1];
117
218
  if (!lastComment || !lastComment.content.startsWith('From:')) {
118
219
  rejections.push({
119
220
  type: 'NO_REPORT_FROM_AGENT_BOT',
120
221
  detail: 'NO_REPORT_FROM_AGENT_BOT',
121
222
  });
223
+ } else if (this.reportBodyHasNextStep(lastComment.content)) {
224
+ rejections.push({
225
+ type: 'REPORT_HAS_NEXT_STEP',
226
+ detail: 'REPORT_HAS_NEXT_STEP',
227
+ });
122
228
  }
123
229
 
124
230
  const categoryLabels = issue.labels.filter((label) =>
125
231
  label.startsWith('category:'),
126
232
  );
127
- if (categoryLabels.length <= 0 || categoryLabels.includes('category:e2e')) {
128
- const relatedOpenPrs = await this.issueRepository.findRelatedOpenPRs(
129
- issue.url,
130
- );
131
- if (relatedOpenPrs.length <= 0) {
233
+ const hasLlmAgentLabel = issue.labels.some(
234
+ (l) => l === 'llm-agent' || l.startsWith('llm-agent:'),
235
+ );
236
+ if (
237
+ !hasLlmAgentLabel &&
238
+ (categoryLabels.length <= 0 || categoryLabels.includes('category:e2e'))
239
+ ) {
240
+ const prsToCheck = issue.isPr
241
+ ? await this.resolveOpenPrsForPrItem(issue.url)
242
+ : await this.issueRepository.findRelatedOpenPRs(issue.url);
243
+
244
+ if (prsToCheck.length <= 0) {
132
245
  rejections.push({
133
246
  type: 'PULL_REQUEST_NOT_FOUND',
134
247
  detail: 'PULL_REQUEST_NOT_FOUND',
135
248
  });
136
- } else if (relatedOpenPrs.length > 1) {
249
+ } else if (prsToCheck.length > 1) {
137
250
  rejections.push({
138
251
  type: 'MULTIPLE_PULL_REQUESTS_FOUND',
139
- detail: `MULTIPLE_PULL_REQUESTS_FOUND: ${relatedOpenPrs.map((pr) => pr.url).join(', ')}`,
252
+ detail: `MULTIPLE_PULL_REQUESTS_FOUND: ${prsToCheck.map((pr) => pr.url).join(', ')}`,
140
253
  });
141
254
  } else {
142
- const pr = relatedOpenPrs[0];
255
+ const pr = prsToCheck[0];
143
256
  if (pr.isConflicted) {
144
257
  rejections.push({
145
258
  type: 'PULL_REQUEST_CONFLICTED',
@@ -170,26 +283,64 @@ export class NotifyFinishedIssuePreparationUseCase {
170
283
  detail: `ANY_REVIEW_COMMENT_NOT_RESOLVED: ${pr.url}`,
171
284
  });
172
285
  }
286
+ if (
287
+ !pr.isConflicted &&
288
+ pr.isPassedAllCiJob &&
289
+ pr.isResolvedAllReviewComments
290
+ ) {
291
+ approvedPrUrl = pr.url;
292
+ }
173
293
  }
174
294
  }
175
295
 
176
- if (rejections.length <= 0) {
177
- issue.status = params.awaitingQualityCheckStatus;
178
- await this.issueRepository.update(issue, project);
179
- await this.sendWorkflowBlockerNotification(
180
- params.issueUrl,
181
- params.workflowBlockerResolvedWebhookUrl,
182
- project,
183
- );
184
- return;
296
+ return { rejections, approvedPrUrl };
297
+ };
298
+
299
+ private resolveOpenPrsForPrItem = async (
300
+ prUrl: string,
301
+ ): Promise<RelatedPullRequest[]> => {
302
+ const pr = await this.issueRepository.getOpenPullRequest(prUrl);
303
+ if (pr === null) {
304
+ return [];
185
305
  }
306
+ return [pr];
307
+ };
186
308
 
187
- issue.status = params.awaitingWorkspaceStatus;
188
- await this.issueRepository.update(issue, project);
309
+ private reportBodyHasNextStep = (body: string): boolean => {
310
+ const reportMatch = body.match(/```json\n([\s\S]*?)\n```/);
311
+ if (!reportMatch || reportMatch.length < 2) {
312
+ return false;
313
+ }
314
+ let reportJson: unknown;
315
+ try {
316
+ reportJson = JSON.parse(reportMatch[1]);
317
+ } catch (error) {
318
+ console.warn(
319
+ 'Invalid JSON in report body while checking nextStep:',
320
+ error,
321
+ );
322
+ return false;
323
+ }
324
+ if (typeof reportJson !== 'object' || reportJson === null) {
325
+ return false;
326
+ }
327
+ if (!('nextStep' in reportJson)) {
328
+ return false;
329
+ }
330
+ const nextStepValue = Reflect.get(reportJson, 'nextStep');
331
+ return nextStepValue !== null && nextStepValue !== undefined;
332
+ };
189
333
 
190
- await this.issueCommentRepository.createComment(
191
- issue,
192
- `Auto Status Check: REJECTED\n${rejections.map((r) => `- ${r.detail}`).join('\n')}`,
334
+ private setPrNextActionDate = async (
335
+ prUrl: string,
336
+ project: Parameters<IssueRepository['get']>[1],
337
+ ): Promise<void> => {
338
+ const nextActionDate = new Date();
339
+ nextActionDate.setMonth(nextActionDate.getMonth() + 1);
340
+ await this.issueRepository.updateNextActionDate(
341
+ prUrl,
342
+ project,
343
+ nextActionDate,
193
344
  );
194
345
  };
195
346
 
@@ -30,6 +30,7 @@ const createMockIssue = (overrides: Partial<Issue> = {}): Issue => ({
30
30
  isInProgress: false,
31
31
  isClosed: false,
32
32
  createdAt: new Date(),
33
+ author: '',
33
34
  ...overrides,
34
35
  });
35
36
 
@@ -136,9 +137,13 @@ describe('RevertOrphanedPreparationUseCase', () => {
136
137
  expect(mockIssueRepository.createComment.mock.calls).toHaveLength(1);
137
138
  expect(mockIssueRepository.createComment.mock.calls[0][0]).toBe(stuckIssue);
138
139
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
139
- expect(mockLocalCommandRunner.runCommand.mock.calls[0][0]).toBe(
140
- 'pgrep -fa "claude-agent.*https://github.com/user/repo/issues/10"',
141
- );
140
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][0]).toBe('sh');
141
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][1]).toEqual([
142
+ '-c',
143
+ 'pgrep -fa "claude-agent.*$1"',
144
+ '--',
145
+ 'https://github.com/user/repo/issues/10',
146
+ ]);
142
147
  });
143
148
 
144
149
  it('should leave in-flight Preparation issue untouched when check command exits zero', async () => {
@@ -196,9 +201,13 @@ describe('RevertOrphanedPreparationUseCase', () => {
196
201
  });
197
202
 
198
203
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
199
- expect(mockLocalCommandRunner.runCommand.mock.calls[0][0]).toBe(
200
- 'check https://github.com/user/repo/issues/10',
201
- );
204
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][0]).toBe('sh');
205
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][1]).toEqual([
206
+ '-c',
207
+ 'check $1',
208
+ '--',
209
+ 'https://github.com/user/repo/issues/10',
210
+ ]);
202
211
  expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
203
212
  });
204
213
 
@@ -295,8 +304,12 @@ describe('RevertOrphanedPreparationUseCase', () => {
295
304
  });
296
305
 
297
306
  expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
298
- expect(mockLocalCommandRunner.runCommand.mock.calls[0][0]).toBe(
299
- 'pgrep -fa "claude-agent.*https://github.com/org/project/issues/99"',
300
- );
307
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][0]).toBe('sh');
308
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][1]).toEqual([
309
+ '-c',
310
+ 'pgrep -fa "claude-agent.*$1"',
311
+ '--',
312
+ 'https://github.com/org/project/issues/99',
313
+ ]);
301
314
  });
302
315
  });
@@ -51,11 +51,16 @@ export class RevertOrphanedPreparationUseCase {
51
51
  }
52
52
 
53
53
  for (const issue of preparationIssues) {
54
- const command = params.preparationProcessCheckCommand.replace(
54
+ const commandTemplate = params.preparationProcessCheckCommand.replace(
55
55
  '{URL}',
56
- issue.url,
56
+ '$1',
57
57
  );
58
- const { exitCode } = await this.localCommandRunner.runCommand(command);
58
+ const { exitCode } = await this.localCommandRunner.runCommand('sh', [
59
+ '-c',
60
+ commandTemplate,
61
+ '--',
62
+ issue.url,
63
+ ]);
59
64
  if (exitCode !== 0) {
60
65
  await this.issueRepository.updateStatus(
61
66
  project,