github-issue-tower-defence-management 1.189.2 → 1.191.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 (57) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +2 -0
  3. package/bin/adapter/entry-points/cli/index.js +5 -0
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/cli/projectConfig.js +3 -0
  6. package/bin/adapter/entry-points/cli/projectConfig.js.map +1 -1
  7. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +3 -1
  8. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  9. package/bin/domain/usecases/ConflictedIssueRevertUseCase.js +84 -0
  10. package/bin/domain/usecases/ConflictedIssueRevertUseCase.js.map +1 -0
  11. package/bin/domain/usecases/HandleScheduledEventUseCase.js +5 -1
  12. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  13. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +23 -0
  14. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  15. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +19 -0
  16. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
  17. package/bin/domain/usecases/StartPreparationUseCase.js +9 -5
  18. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  19. package/bin/domain/usecases/awaitingOwnerApprovalMessage.js +7 -0
  20. package/bin/domain/usecases/awaitingOwnerApprovalMessage.js.map +1 -0
  21. package/bin/domain/usecases/isWaitingForOwnerApproval.js +16 -0
  22. package/bin/domain/usecases/isWaitingForOwnerApproval.js.map +1 -0
  23. package/package.json +1 -1
  24. package/src/adapter/entry-points/cli/index.test.ts +2 -0
  25. package/src/adapter/entry-points/cli/index.ts +9 -0
  26. package/src/adapter/entry-points/cli/projectConfig.ts +8 -0
  27. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +7 -0
  28. package/src/domain/usecases/ConflictedIssueRevertUseCase.test.ts +629 -0
  29. package/src/domain/usecases/ConflictedIssueRevertUseCase.ts +138 -0
  30. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +30 -0
  31. package/src/domain/usecases/HandleScheduledEventUseCase.ts +5 -0
  32. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +86 -0
  33. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +52 -0
  34. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +93 -0
  35. package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +47 -1
  36. package/src/domain/usecases/StartPreparationUseCase.test.ts +76 -0
  37. package/src/domain/usecases/StartPreparationUseCase.ts +9 -5
  38. package/src/domain/usecases/awaitingOwnerApprovalMessage.ts +5 -0
  39. package/src/domain/usecases/isWaitingForOwnerApproval.test.ts +91 -0
  40. package/src/domain/usecases/isWaitingForOwnerApproval.ts +17 -0
  41. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  42. package/types/adapter/entry-points/cli/projectConfig.d.ts +1 -0
  43. package/types/adapter/entry-points/cli/projectConfig.d.ts.map +1 -1
  44. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  45. package/types/domain/usecases/ConflictedIssueRevertUseCase.d.ts +14 -0
  46. package/types/domain/usecases/ConflictedIssueRevertUseCase.d.ts.map +1 -0
  47. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +3 -1
  48. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  49. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +1 -0
  50. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  51. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +1 -0
  52. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
  53. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
  54. package/types/domain/usecases/awaitingOwnerApprovalMessage.d.ts +3 -0
  55. package/types/domain/usecases/awaitingOwnerApprovalMessage.d.ts.map +1 -0
  56. package/types/domain/usecases/isWaitingForOwnerApproval.d.ts +2 -0
  57. package/types/domain/usecases/isWaitingForOwnerApproval.d.ts.map +1 -0
@@ -0,0 +1,138 @@
1
+ import { Issue } from '../entities/Issue';
2
+ import { IssueRepository } from './adapter-interfaces/IssueRepository';
3
+ import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
4
+ import { IssueCommentRepository } from './adapter-interfaces/IssueCommentRepository';
5
+ import {
6
+ AWAITING_WORKSPACE_STATUS_NAME,
7
+ DONE_STATUS_NAME,
8
+ FAILED_PREPARATION_STATUS_NAME,
9
+ ICEBOX_STATUS_NAME,
10
+ IN_TMUX_STATUS_NAME,
11
+ } from '../entities/WorkflowStatus';
12
+
13
+ const EXCLUDED_STATUSES = new Set([
14
+ AWAITING_WORKSPACE_STATUS_NAME,
15
+ DONE_STATUS_NAME,
16
+ ICEBOX_STATUS_NAME,
17
+ FAILED_PREPARATION_STATUS_NAME,
18
+ IN_TMUX_STATUS_NAME,
19
+ ]);
20
+
21
+ export class ConflictedIssueRevertUseCase {
22
+ constructor(
23
+ private readonly projectRepository: Pick<
24
+ ProjectRepository,
25
+ 'findProjectIdByUrl' | 'getProject'
26
+ >,
27
+ private readonly issueRepository: Pick<
28
+ IssueRepository,
29
+ 'getAllIssues' | 'getOpenPullRequests' | 'updateStatus'
30
+ >,
31
+ private readonly issueCommentRepository: Pick<
32
+ IssueCommentRepository,
33
+ 'createComment'
34
+ >,
35
+ ) {}
36
+
37
+ run = async (params: { projectUrl: string }): Promise<void> => {
38
+ const projectId = await this.projectRepository.findProjectIdByUrl(
39
+ params.projectUrl,
40
+ );
41
+ if (!projectId) {
42
+ throw new Error(`Project not found. projectUrl: ${params.projectUrl}`);
43
+ }
44
+ const project = await this.projectRepository.getProject(projectId);
45
+ if (!project) {
46
+ throw new Error(
47
+ `Project not found. projectId: ${projectId} projectUrl: ${params.projectUrl}`,
48
+ );
49
+ }
50
+
51
+ const awaitingWorkspaceStatusOption = project.status.statuses.find(
52
+ (s) => s.name === AWAITING_WORKSPACE_STATUS_NAME,
53
+ );
54
+ if (!awaitingWorkspaceStatusOption) {
55
+ return;
56
+ }
57
+
58
+ const { issues } = await this.issueRepository.getAllIssues(projectId);
59
+
60
+ const targetIssues = issues.filter(
61
+ (issue) =>
62
+ !issue.isPr &&
63
+ (issue.status === null || !EXCLUDED_STATUSES.has(issue.status)),
64
+ );
65
+
66
+ const relatedOpenPrUrlsByIssueUrl =
67
+ this.buildRelatedOpenPrUrlsByIssueUrl(issues);
68
+
69
+ const allPrUrls = Array.from(
70
+ new Set(
71
+ targetIssues.flatMap(
72
+ (issue) => relatedOpenPrUrlsByIssueUrl.get(issue.url) ?? [],
73
+ ),
74
+ ),
75
+ );
76
+
77
+ if (allPrUrls.length === 0) {
78
+ return;
79
+ }
80
+
81
+ const resolvedPrByUrl =
82
+ await this.issueRepository.getOpenPullRequests(allPrUrls);
83
+
84
+ for (const issue of targetIssues) {
85
+ const prUrls = relatedOpenPrUrlsByIssueUrl.get(issue.url) ?? [];
86
+ if (prUrls.length === 0) {
87
+ continue;
88
+ }
89
+
90
+ const relatedPrs = prUrls
91
+ .map((url) => resolvedPrByUrl.get(url) ?? null)
92
+ .filter((pr): pr is NonNullable<typeof pr> => pr !== null);
93
+
94
+ const hasUnknownMergeable = relatedPrs.some(
95
+ (pr) => pr.mergeable === 'UNKNOWN',
96
+ );
97
+ if (hasUnknownMergeable) {
98
+ continue;
99
+ }
100
+
101
+ const hasConflict = relatedPrs.some((pr) => pr.isConflicted);
102
+ if (!hasConflict) {
103
+ continue;
104
+ }
105
+
106
+ await this.issueRepository.updateStatus(
107
+ project,
108
+ issue,
109
+ awaitingWorkspaceStatusOption.id,
110
+ );
111
+ await this.issueCommentRepository.createComment(issue, 'conflict');
112
+ }
113
+ };
114
+
115
+ private buildRelatedOpenPrUrlsByIssueUrl = (
116
+ issues: Issue[],
117
+ ): Map<string, string[]> => {
118
+ const openPrUrlsByIssueUrl = new Map<string, Set<string>>();
119
+ for (const issue of issues) {
120
+ if (!issue.isPr || issue.isClosed) {
121
+ continue;
122
+ }
123
+ for (const referencedIssueUrl of issue.closingIssueReferenceUrls) {
124
+ const existing = openPrUrlsByIssueUrl.get(referencedIssueUrl);
125
+ if (existing) {
126
+ existing.add(issue.url);
127
+ } else {
128
+ openPrUrlsByIssueUrl.set(referencedIssueUrl, new Set([issue.url]));
129
+ }
130
+ }
131
+ }
132
+ const result = new Map<string, string[]>();
133
+ for (const [issueUrl, prUrls] of openPrUrlsByIssueUrl) {
134
+ result.set(issueUrl, Array.from(prUrls));
135
+ }
136
+ return result;
137
+ };
138
+ }
@@ -24,6 +24,7 @@ import { UpdateIssueStatusByLabelUseCase } from './UpdateIssueStatusByLabelUseCa
24
24
  import { IssueNoStatusUpdateUseCase } from './IssueNoStatusUpdateUseCase';
25
25
  import { StartPreparationUseCase } from './StartPreparationUseCase';
26
26
  import { RevertOrphanedPreparationUseCase } from './RevertOrphanedPreparationUseCase';
27
+ import { ConflictedIssueRevertUseCase } from './ConflictedIssueRevertUseCase';
27
28
  import { RevertNotReadyReviewQueueIssueUseCase } from './RevertNotReadyReviewQueueIssueUseCase';
28
29
  import { TriagerApprovalDispatchUseCase } from './TriagerApprovalDispatchUseCase';
29
30
  import { AgentDesignationLabelAdoptUseCase } from './AgentDesignationLabelAdoptUseCase';
@@ -127,6 +128,8 @@ describe('HandleScheduledEventUseCase', () => {
127
128
  const mockStartPreparationUseCase = mock<StartPreparationUseCase>();
128
129
  const mockRevertOrphanedPreparationUseCase =
129
130
  mock<RevertOrphanedPreparationUseCase>();
131
+ const mockConflictedIssueRevertUseCase =
132
+ mock<ConflictedIssueRevertUseCase>();
130
133
  const mockRevertNotReadyReviewQueueIssueUseCase =
131
134
  mock<RevertNotReadyReviewQueueIssueUseCase>();
132
135
  const mockTriagerApprovalDispatchUseCase =
@@ -164,6 +167,7 @@ describe('HandleScheduledEventUseCase', () => {
164
167
  mockIssueNoStatusUpdateUseCase,
165
168
  mockStartPreparationUseCase,
166
169
  mockRevertOrphanedPreparationUseCase,
170
+ mockConflictedIssueRevertUseCase,
167
171
  mockRevertNotReadyReviewQueueIssueUseCase,
168
172
  mockTriagerApprovalDispatchUseCase,
169
173
  mockAgentDesignationLabelAdoptUseCase,
@@ -504,6 +508,31 @@ describe('HandleScheduledEventUseCase', () => {
504
508
  );
505
509
  });
506
510
 
511
+ it('should invoke conflictedIssueRevertUseCase on every scheduled run', async () => {
512
+ const input = {
513
+ projectName: 'test-project',
514
+ org: 'test-org',
515
+ projectUrl: 'https://github.com/test-org/test-project',
516
+ manager: 'test-manager',
517
+ workingReport: {
518
+ repo: 'test-repo',
519
+ members: ['member1'],
520
+ spreadsheetUrl: 'https://docs.google.com/spreadsheets/test',
521
+ },
522
+ urlOfStoryView: 'https://github.com/test-org/test-project/issues',
523
+ disabled: false,
524
+ };
525
+
526
+ mockProjectRepository.getProject.mockResolvedValue(mock<Project>());
527
+ await useCase.run(input);
528
+
529
+ expect(mockConflictedIssueRevertUseCase.run).toHaveBeenCalledWith(
530
+ expect.objectContaining({
531
+ projectUrl: 'https://github.com/test-org/test-project',
532
+ }),
533
+ );
534
+ });
535
+
507
536
  it('should invoke revertNotReadyReviewQueueIssueUseCase on every scheduled run', async () => {
508
537
  const input = {
509
538
  projectName: 'test-project',
@@ -1781,6 +1810,7 @@ describe('HandleScheduledEventUseCase', () => {
1781
1810
  mock<IssueNoStatusUpdateUseCase>(),
1782
1811
  mock<StartPreparationUseCase>(),
1783
1812
  mock<RevertOrphanedPreparationUseCase>(),
1813
+ mock<ConflictedIssueRevertUseCase>(),
1784
1814
  mock<RevertNotReadyReviewQueueIssueUseCase>(),
1785
1815
  mock<TriagerApprovalDispatchUseCase>(),
1786
1816
  mock<AgentDesignationLabelAdoptUseCase>(),
@@ -42,6 +42,7 @@ import {
42
42
  } from './DailySecurityScanUseCase';
43
43
  import { QualityCheckAdvanceUseCase } from './QualityCheckAdvanceUseCase';
44
44
  import { ReopenedDoneIssueRevertUseCase } from './ReopenedDoneIssueRevertUseCase';
45
+ import { ConflictedIssueRevertUseCase } from './ConflictedIssueRevertUseCase';
45
46
 
46
47
  export class ProjectNotFoundError extends Error {
47
48
  constructor(message: string) {
@@ -138,6 +139,7 @@ export class HandleScheduledEventUseCase {
138
139
  readonly issueNoStatusUpdateUseCase: IssueNoStatusUpdateUseCase,
139
140
  readonly startPreparationUseCase: StartPreparationUseCase,
140
141
  readonly revertOrphanedPreparationUseCase: RevertOrphanedPreparationUseCase,
142
+ readonly conflictedIssueRevertUseCase: ConflictedIssueRevertUseCase,
141
143
  readonly revertNotReadyReviewQueueIssueUseCase: RevertNotReadyReviewQueueIssueUseCase,
142
144
  readonly triagerApprovalDispatchUseCase: TriagerApprovalDispatchUseCase,
143
145
  readonly agentDesignationLabelAdoptUseCase: AgentDesignationLabelAdoptUseCase,
@@ -436,6 +438,9 @@ ${JSON.stringify(e)}
436
438
  issues,
437
439
  agents: input.agents ?? null,
438
440
  });
441
+ await this.conflictedIssueRevertUseCase.run({
442
+ projectUrl: input.projectUrl,
443
+ });
439
444
  await this.revertNotReadyReviewQueueIssueUseCase.run({
440
445
  projectUrl: input.projectUrl,
441
446
  manager: input.manager,
@@ -4665,4 +4665,90 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
4665
4665
  );
4666
4666
  });
4667
4667
  });
4668
+
4669
+ describe('waitingForOwnerApproval', () => {
4670
+ const issueUrl = 'https://github.com/user/repo/issues/1';
4671
+
4672
+ it('returns to Awaiting Workspace and posts AWAITING_OWNER_APPROVAL message when last report declares waitingForOwnerApproval', async () => {
4673
+ const issue = createMockIssue({ url: issueUrl, status: 'Preparation' });
4674
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
4675
+ mockIssueRepository.get.mockResolvedValue(issue);
4676
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
4677
+ createMockComment({
4678
+ content:
4679
+ 'From: :robot: systems-analyst (model)\n```json\n{"pullRequestRequired": false, "waitingForOwnerApproval": true}\n```',
4680
+ }),
4681
+ ]);
4682
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
4683
+
4684
+ await useCase.run({
4685
+ projectUrl: 'https://github.com/users/user/projects/1',
4686
+ issueUrl,
4687
+ thresholdForAutoReject: 3,
4688
+ workflowBlockerResolvedWebhookUrl: null,
4689
+ allowedIssueAuthors: null,
4690
+ });
4691
+
4692
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
4693
+ expect.objectContaining({ status: 'Awaiting Workspace' }),
4694
+ mockProject,
4695
+ );
4696
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
4697
+ mockProject,
4698
+ expect.objectContaining({ status: 'Awaiting Workspace' }),
4699
+ 'awaiting-workspace-id',
4700
+ );
4701
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
4702
+ expect.objectContaining({ url: issueUrl }),
4703
+ expect.stringContaining('Auto Status Check: AWAITING_OWNER_APPROVAL'),
4704
+ );
4705
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalledWith(
4706
+ mockProject,
4707
+ expect.anything(),
4708
+ 'failed-preparation-id',
4709
+ );
4710
+ });
4711
+
4712
+ it('escalates to Failed Preparation after ownerApprovalTimeoutCycles AWAITING_OWNER_APPROVAL messages', async () => {
4713
+ const issue = createMockIssue({ url: issueUrl, status: 'Preparation' });
4714
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
4715
+ mockIssueRepository.get.mockResolvedValue(issue);
4716
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
4717
+ createMockComment({
4718
+ content:
4719
+ 'From: :robot: systems-analyst (model)\n```json\n{"pullRequestRequired": false, "waitingForOwnerApproval": true}\n```',
4720
+ }),
4721
+ createMockComment({
4722
+ content:
4723
+ 'Auto Status Check: AWAITING_OWNER_APPROVAL\nThe last report declared that this task is waiting for owner approval. Returning to Awaiting Workspace for the next cycle.',
4724
+ }),
4725
+ createMockComment({
4726
+ content:
4727
+ 'Auto Status Check: AWAITING_OWNER_APPROVAL\nThe last report declared that this task is waiting for owner approval. Returning to Awaiting Workspace for the next cycle.',
4728
+ }),
4729
+ ]);
4730
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
4731
+
4732
+ await useCase.run({
4733
+ projectUrl: 'https://github.com/users/user/projects/1',
4734
+ issueUrl,
4735
+ thresholdForAutoReject: 3,
4736
+ workflowBlockerResolvedWebhookUrl: null,
4737
+ allowedIssueAuthors: null,
4738
+ ownerApprovalTimeoutCycles: 2,
4739
+ });
4740
+
4741
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
4742
+ mockProject,
4743
+ expect.anything(),
4744
+ 'failed-preparation-id',
4745
+ );
4746
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
4747
+ expect.anything(),
4748
+ expect.stringContaining(
4749
+ 'Owner approval was not received after 2 cycles. Moving to Failed Preparation.',
4750
+ ),
4751
+ );
4752
+ });
4753
+ });
4668
4754
  });
@@ -20,6 +20,11 @@ import {
20
20
  RETURNED_TO_AWAITING_WORKSPACE_MESSAGE,
21
21
  RETURNED_TO_AWAITING_WORKSPACE_MESSAGE_HEAD,
22
22
  } from './returnedToAwaitingWorkspaceMessage';
23
+ import { isWaitingForOwnerApproval } from './isWaitingForOwnerApproval';
24
+ import {
25
+ AWAITING_OWNER_APPROVAL_MESSAGE,
26
+ AWAITING_OWNER_APPROVAL_MESSAGE_HEAD,
27
+ } from './awaitingOwnerApprovalMessage';
23
28
  import {
24
29
  ConsoleListItem,
25
30
  ConsoleTabName,
@@ -121,6 +126,7 @@ export class NotifyFinishedIssuePreparationUseCase {
121
126
  manager?: string | null;
122
127
  developerAgentName?: string | null;
123
128
  deferPreparation?: boolean | null;
129
+ ownerApprovalTimeoutCycles?: number | null;
124
130
  }): Promise<void> => {
125
131
  const project = await this.projectRepository.getByUrl(params.projectUrl);
126
132
 
@@ -309,6 +315,52 @@ export class NotifyFinishedIssuePreparationUseCase {
309
315
  return;
310
316
  }
311
317
 
318
+ if (
319
+ lastAgentReport !== null &&
320
+ isWaitingForOwnerApproval(lastAgentReport.content)
321
+ ) {
322
+ const ownerApprovalTimeoutCycles =
323
+ params.ownerApprovalTimeoutCycles ?? 12;
324
+ const awaitingOwnerApprovalCount = comments.filter(
325
+ (comment) =>
326
+ isTrustedAuthor(comment.author) &&
327
+ comment.content.startsWith(AWAITING_OWNER_APPROVAL_MESSAGE_HEAD),
328
+ ).length;
329
+ if (awaitingOwnerApprovalCount < ownerApprovalTimeoutCycles) {
330
+ issue.status = AWAITING_WORKSPACE_STATUS_NAME;
331
+ await this.issueRepository.update(issue, project);
332
+ await this.issueRepository.updateStatus(
333
+ project,
334
+ issue,
335
+ awaitingWorkspaceStatusOption.id,
336
+ );
337
+ await this.patchConsoleTab(issue);
338
+ await this.issueCommentRepository.createComment(
339
+ issue,
340
+ AWAITING_OWNER_APPROVAL_MESSAGE,
341
+ );
342
+ return;
343
+ }
344
+ issue.status = FAILED_PREPARATION_STATUS_NAME;
345
+ await this.issueRepository.update(issue, project);
346
+ await this.issueRepository.updateStatus(
347
+ project,
348
+ issue,
349
+ failedPreparationStatusOption.id,
350
+ );
351
+ await this.patchConsoleTab(issue);
352
+ await this.issueCommentRepository.createComment(
353
+ issue,
354
+ `Owner approval was not received after ${ownerApprovalTimeoutCycles} cycles. Moving to Failed Preparation.`,
355
+ );
356
+ await this.sendWorkflowBlockerNotification(
357
+ params.issueUrl,
358
+ params.workflowBlockerResolvedWebhookUrl,
359
+ project,
360
+ );
361
+ return;
362
+ }
363
+
312
364
  const { rejections, approvedPrUrl } = await this.collectRejections(
313
365
  issue,
314
366
  comments,
@@ -2158,4 +2158,97 @@ describe('RevertOrphanedPreparationUseCase', () => {
2158
2158
  'Auto Status Check: REJECTED',
2159
2159
  );
2160
2160
  });
2161
+
2162
+ it('should return orphaned issue to Awaiting Workspace with AWAITING_OWNER_APPROVAL when last report declares waitingForOwnerApproval', async () => {
2163
+ const stuckIssue = createMockIssue({
2164
+ url: 'https://github.com/user/repo/issues/10',
2165
+ status: 'Preparation',
2166
+ });
2167
+ mockIssueRepository.getAllIssues.mockResolvedValue({
2168
+ project: mockProject,
2169
+ issues: [stuckIssue],
2170
+ cacheUsed: false,
2171
+ });
2172
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
2173
+ stdout: '',
2174
+ stderr: '',
2175
+ exitCode: 1,
2176
+ });
2177
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
2178
+ {
2179
+ author: 'agent-bot',
2180
+ content:
2181
+ 'From: :robot: systems-analyst (model)\n```json\n{"pullRequestRequired": false, "waitingForOwnerApproval": true}\n```',
2182
+ createdAt: new Date(),
2183
+ },
2184
+ ]);
2185
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
2186
+
2187
+ await useCase.run({
2188
+ projectUrl: 'https://github.com/user/repo',
2189
+ preparationProcessCheckCommand: 'pgrep -fa "claude-agent.*{URL}"',
2190
+ thresholdForAutoReject: 3,
2191
+ allowedIssueAuthors: ['agent-bot'],
2192
+ });
2193
+
2194
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
2195
+ expect(mockIssueRepository.updateStatus.mock.calls[0][2]).toBe('1');
2196
+ expect(mockIssueCommentRepository.createComment.mock.calls).toHaveLength(1);
2197
+ expect(mockIssueCommentRepository.createComment.mock.calls[0][1]).toContain(
2198
+ 'Auto Status Check: AWAITING_OWNER_APPROVAL',
2199
+ );
2200
+ });
2201
+
2202
+ it('should escalate to Failed Preparation after ownerApprovalTimeoutCycles AWAITING_OWNER_APPROVAL messages in RevertOrphaned', async () => {
2203
+ const stuckIssue = createMockIssue({
2204
+ url: 'https://github.com/user/repo/issues/10',
2205
+ status: 'Preparation',
2206
+ });
2207
+ mockIssueRepository.getAllIssues.mockResolvedValue({
2208
+ project: mockProject,
2209
+ issues: [stuckIssue],
2210
+ cacheUsed: false,
2211
+ });
2212
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
2213
+ stdout: '',
2214
+ stderr: '',
2215
+ exitCode: 1,
2216
+ });
2217
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
2218
+ {
2219
+ author: 'agent-bot',
2220
+ content:
2221
+ 'From: :robot: systems-analyst (model)\n```json\n{"pullRequestRequired": false, "waitingForOwnerApproval": true}\n```',
2222
+ createdAt: new Date(),
2223
+ },
2224
+ {
2225
+ author: 'agent-bot',
2226
+ content:
2227
+ 'Auto Status Check: AWAITING_OWNER_APPROVAL\nThe last report declared that this task is waiting for owner approval. Returning to Awaiting Workspace for the next cycle.',
2228
+ createdAt: new Date(),
2229
+ },
2230
+ {
2231
+ author: 'agent-bot',
2232
+ content:
2233
+ 'Auto Status Check: AWAITING_OWNER_APPROVAL\nThe last report declared that this task is waiting for owner approval. Returning to Awaiting Workspace for the next cycle.',
2234
+ createdAt: new Date(),
2235
+ },
2236
+ ]);
2237
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
2238
+
2239
+ await useCase.run({
2240
+ projectUrl: 'https://github.com/user/repo',
2241
+ preparationProcessCheckCommand: 'pgrep -fa "claude-agent.*{URL}"',
2242
+ thresholdForAutoReject: 3,
2243
+ allowedIssueAuthors: ['agent-bot'],
2244
+ ownerApprovalTimeoutCycles: 2,
2245
+ });
2246
+
2247
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
2248
+ expect(mockIssueRepository.updateStatus.mock.calls[0][2]).toBe('5');
2249
+ expect(mockIssueCommentRepository.createComment.mock.calls).toHaveLength(1);
2250
+ expect(mockIssueCommentRepository.createComment.mock.calls[0][1]).toContain(
2251
+ 'Owner approval was not received after 2 cycles. Moving to Failed Preparation.',
2252
+ );
2253
+ });
2161
2254
  });
@@ -22,6 +22,11 @@ import {
22
22
  RETURNED_TO_AWAITING_WORKSPACE_MESSAGE,
23
23
  RETURNED_TO_AWAITING_WORKSPACE_MESSAGE_HEAD,
24
24
  } from './returnedToAwaitingWorkspaceMessage';
25
+ import { isWaitingForOwnerApproval } from './isWaitingForOwnerApproval';
26
+ import {
27
+ AWAITING_OWNER_APPROVAL_MESSAGE,
28
+ AWAITING_OWNER_APPROVAL_MESSAGE_HEAD,
29
+ } from './awaitingOwnerApprovalMessage';
25
30
  import { extractNextStepAgent } from './extractNextStepAgent';
26
31
  import { findLastAgentReport } from './findLastAgentReport';
27
32
  import { isAgentReportBody } from './isAgentReportBody';
@@ -35,7 +40,10 @@ import {
35
40
  const ORPHANED_PREPARATION_REJECTION_DETAIL = 'ORPHANED_PREPARATION';
36
41
 
37
42
  type OrphanedPreparationOutcome =
38
- 'advanceToQualityCheck' | 'returnToLabelSelectedAgent' | 'reject';
43
+ | 'advanceToQualityCheck'
44
+ | 'returnToLabelSelectedAgent'
45
+ | 'returnToOwnerApprovalCycle'
46
+ | 'reject';
39
47
 
40
48
  export class RevertOrphanedPreparationUseCase {
41
49
  constructor(
@@ -74,6 +82,7 @@ export class RevertOrphanedPreparationUseCase {
74
82
  labelsAsLlmAgentName?: string[] | null;
75
83
  labelsNotRequiringPullRequest?: string[] | null;
76
84
  allowedIssueAuthors?: string[] | null;
85
+ ownerApprovalTimeoutCycles?: number | null;
77
86
  }): Promise<void> => {
78
87
  const projectId = await this.projectRepository.findProjectIdByUrl(
79
88
  params.projectUrl,
@@ -191,6 +200,40 @@ export class RevertOrphanedPreparationUseCase {
191
200
  }
192
201
  continue;
193
202
  }
203
+ if (outcome === 'returnToOwnerApprovalCycle') {
204
+ const ownerApprovalTimeoutCycles =
205
+ params.ownerApprovalTimeoutCycles ?? 12;
206
+ const awaitingOwnerApprovalCount = comments.filter(
207
+ (comment) =>
208
+ isAuthorAuthorizedForAutoStatusCheck(
209
+ comment.author,
210
+ params.allowedIssueAuthors,
211
+ ) &&
212
+ comment.content.startsWith(AWAITING_OWNER_APPROVAL_MESSAGE_HEAD),
213
+ ).length;
214
+ if (awaitingOwnerApprovalCount < ownerApprovalTimeoutCycles) {
215
+ await this.issueRepository.updateStatus(
216
+ project,
217
+ issue,
218
+ awaitingWorkspaceStatusOption.id,
219
+ );
220
+ await this.issueCommentRepository.createComment(
221
+ issue,
222
+ AWAITING_OWNER_APPROVAL_MESSAGE,
223
+ );
224
+ } else if (failedPreparationStatusOption) {
225
+ await this.issueRepository.updateStatus(
226
+ project,
227
+ issue,
228
+ failedPreparationStatusOption.id,
229
+ );
230
+ await this.issueCommentRepository.createComment(
231
+ issue,
232
+ `Owner approval was not received after ${ownerApprovalTimeoutCycles} cycles. Moving to Failed Preparation.`,
233
+ );
234
+ }
235
+ continue;
236
+ }
194
237
  if (outcome === 'returnToLabelSelectedAgent') {
195
238
  await this.issueRepository.updateStatus(
196
239
  project,
@@ -324,6 +367,9 @@ export class RevertOrphanedPreparationUseCase {
324
367
  ) &&
325
368
  !this.reportBodyHasNextStep(lastReport.content)
326
369
  ) {
370
+ if (isWaitingForOwnerApproval(lastReport.content)) {
371
+ return { outcome: 'returnToOwnerApprovalCycle', comments };
372
+ }
327
373
  const alreadyReturnedToWorkspace = comments.some(
328
374
  (comment) =>
329
375
  isTrustedAuthor(comment.author) &&
@@ -979,6 +979,82 @@ describe('StartPreparationUseCase', () => {
979
979
  ],
980
980
  ]);
981
981
  });
982
+ it('should not treat a cross-repository PR as duplicate when a same-repository PR exists', async () => {
983
+ const awaitingIssues: Issue[] = [
984
+ createMockIssue({
985
+ url: 'https://github.com/HiromiShikata/secretary/issues/4290',
986
+ title: 'Issue 4290',
987
+ nameWithOwner: 'HiromiShikata/secretary',
988
+ number: 4290,
989
+ org: 'HiromiShikata',
990
+ repo: 'secretary',
991
+ labels: ['category:impl'],
992
+ status: 'Awaiting Workspace',
993
+ }),
994
+ ];
995
+ const sameRepoPR: RelatedPullRequest = {
996
+ url: 'https://github.com/HiromiShikata/secretary/pull/4610',
997
+ branchName: 'i4290',
998
+ createdAt: new Date('2026-08-28T13:38:00Z'),
999
+ isDraft: false,
1000
+ isConflicted: false,
1001
+ mergeable: null,
1002
+ isPassedAllCiJob: false,
1003
+ isCiStateSuccess: false,
1004
+ isResolvedAllReviewComments: false,
1005
+ isBranchOutOfDate: false,
1006
+ missingRequiredCheckNames: [],
1007
+ };
1008
+ const crossRepoPR: RelatedPullRequest = {
1009
+ url: 'https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/pull/1813',
1010
+ branchName: 'i4290',
1011
+ createdAt: new Date('2026-08-28T10:55:00Z'),
1012
+ isDraft: false,
1013
+ isConflicted: false,
1014
+ mergeable: null,
1015
+ isPassedAllCiJob: false,
1016
+ isCiStateSuccess: false,
1017
+ isResolvedAllReviewComments: false,
1018
+ isBranchOutOfDate: false,
1019
+ missingRequiredCheckNames: [],
1020
+ };
1021
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
1022
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
1023
+ createMockStoryObjectMap(awaitingIssues),
1024
+ );
1025
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
1026
+ sameRepoPR,
1027
+ crossRepoPR,
1028
+ ]);
1029
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
1030
+ stdout: '',
1031
+ stderr: '',
1032
+ exitCode: 0,
1033
+ });
1034
+ await useCase.run({
1035
+ projectUrl: 'https://github.com/users/user/projects/1',
1036
+ defaultAgentName: 'agent1',
1037
+ defaultLlmModelName: 'claude-opus',
1038
+ fallbackLlmModelName: null,
1039
+ defaultLlmAgentName: null,
1040
+ configFilePath: '/path/to/config.yml',
1041
+ maximumPreparingIssuesCount: null,
1042
+ utilizationPercentageThreshold: 90,
1043
+ allowedIssueAuthors: ['testuser'],
1044
+ manager: 'manager-user',
1045
+ codexHomeCandidates: null,
1046
+ labelsAsLlmAgentName: null,
1047
+ });
1048
+ expect(mockIssueRepository.closePullRequest).not.toHaveBeenCalled();
1049
+ expect(mockIssueRepository.deletePullRequestBranch).not.toHaveBeenCalled();
1050
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
1051
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][1]).toContain(
1052
+ '--branch',
1053
+ );
1054
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][1]).toContain(
1055
+ 'i4290',
1056
+ );
1057
+ });
982
1058
  it('should skip issue after resolving duplicates when adopted canonical PR has null branchName', async () => {
983
1059
  const awaitingIssues: Issue[] = [
984
1060
  createMockIssue({
@@ -554,8 +554,12 @@ export class StartPreparationUseCase {
554
554
  const relatedPRs = await this.issueRepository.findRelatedOpenPRs(
555
555
  issue.url,
556
556
  );
557
- if (relatedPRs.length > 1) {
558
- const sortedPRs = [...relatedPRs].sort(
557
+ const sameRepoRelatedPRs = relatedPRs.filter((pr) => {
558
+ const match = /^https?:\/\/[^/]+\/([^/]+\/[^/]+)\//.exec(pr.url);
559
+ return match === null || match[1] === issue.nameWithOwner;
560
+ });
561
+ if (sameRepoRelatedPRs.length > 1) {
562
+ const sortedPRs = [...sameRepoRelatedPRs].sort(
559
563
  (a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
560
564
  );
561
565
  const canonicalPR = sortedPRs[0];
@@ -585,14 +589,14 @@ export class StartPreparationUseCase {
585
589
  continue;
586
590
  }
587
591
  branchName = canonicalPR.branchName;
588
- } else if (relatedPRs.length === 1) {
589
- if (relatedPRs[0].branchName === null) {
592
+ } else if (sameRepoRelatedPRs.length === 1) {
593
+ if (sameRepoRelatedPRs[0].branchName === null) {
590
594
  console.warn(
591
595
  `Skipping issue ${issue.url}: related open PR has unavailable head branch.`,
592
596
  );
593
597
  continue;
594
598
  }
595
- branchName = relatedPRs[0].branchName;
599
+ branchName = sameRepoRelatedPRs[0].branchName;
596
600
  } else {
597
601
  branchName = `i${issue.number}`;
598
602
  }