github-issue-tower-defence-management 1.194.1 → 1.195.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 (37) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/bin/adapter/entry-points/console/dashboardComposeService.js +1 -1
  3. package/bin/adapter/entry-points/console/dashboardComposeService.js.map +1 -1
  4. package/bin/adapter/entry-points/handlers/tokenStatusWriter.js +1 -0
  5. package/bin/adapter/entry-points/handlers/tokenStatusWriter.js.map +1 -1
  6. package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js +6 -1
  7. package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js.map +1 -1
  8. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +36 -0
  9. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  10. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +23 -3
  11. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
  12. package/bin/domain/usecases/dashboard/ComposeDashboardUseCase.js +1 -0
  13. package/bin/domain/usecases/dashboard/ComposeDashboardUseCase.js.map +1 -1
  14. package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js +5 -0
  15. package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js.map +1 -1
  16. package/package.json +1 -1
  17. package/src/adapter/entry-points/console/dashboardComposeService.test.ts +32 -0
  18. package/src/adapter/entry-points/console/dashboardComposeService.ts +1 -1
  19. package/src/adapter/entry-points/handlers/tokenStatusWriter.ts +1 -0
  20. package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.test.ts +25 -0
  21. package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.ts +86 -77
  22. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +346 -0
  23. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +64 -0
  24. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +400 -1
  25. package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +44 -3
  26. package/src/domain/usecases/dashboard/ComposeDashboardUseCase.ts +1 -0
  27. package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.test.ts +17 -0
  28. package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.ts +8 -1
  29. package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts.map +1 -1
  30. package/types/adapter/repositories/ProxyClaudeTokenUsageRepository.d.ts.map +1 -1
  31. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +1 -0
  32. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  33. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +1 -0
  34. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
  35. package/types/domain/usecases/dashboard/ComposeDashboardUseCase.d.ts.map +1 -1
  36. package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts +3 -1
  37. package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts.map +1 -1
@@ -76,6 +76,31 @@ describe('ProxyClaudeTokenUsageRepository', () => {
76
76
  const pastReset = Math.floor(Date.now() / 1000) - 3600;
77
77
  const observedAfterReset = Math.floor(Date.now() / 1000) - 60;
78
78
 
79
+ it('should leave out a token whose subscription the proxy has flagged as disabled', async () => {
80
+ mockLoadTokenEntries.mockReturnValue([
81
+ { name: 'alice', token: 'token-a' },
82
+ { name: 'disabled-one', token: 'token-disabled' },
83
+ ]);
84
+ mockReadRateLimit.mockImplementation((token: string) => ({
85
+ fiveHourUtilization: 0,
86
+ fiveHourReset: futureReset,
87
+ sevenDayUtilization: 0,
88
+ sevenDayReset: futureReset,
89
+ blocked: false,
90
+ rejected: false,
91
+ unifiedRejected: false,
92
+ fiveHourRejected: false,
93
+ sevenDayRejected: false,
94
+ modelWeeklyLimits: {},
95
+ subscriptionDisabled: token === 'token-disabled',
96
+ }));
97
+ const repository = new ProxyClaudeTokenUsageRepository('/tokens.json');
98
+
99
+ const result = await repository.getAvailableTokenUsages();
100
+
101
+ expect(result.map((usage) => usage.token)).toEqual(['token-a']);
102
+ });
103
+
79
104
  it('should map each token to its cached utilization and include name', async () => {
80
105
  mockLoadTokenEntries.mockReturnValue([
81
106
  { name: 'alice', token: 'token-a' },
@@ -27,88 +27,97 @@ export class ProxyClaudeTokenUsageRepository implements ClaudeTokenUsageReposito
27
27
  return [];
28
28
  }
29
29
  const nowEpochSeconds = Date.now() / 1000;
30
- return entries.map(({ name, token, selectionWeight }) => {
31
- const snapshot = readRateLimit(token);
32
- if (snapshot === null) {
30
+ const usages = entries.map(
31
+ ({ name, token, selectionWeight }): ClaudeTokenUsage | null => {
32
+ const snapshot = readRateLimit(token);
33
+ if (snapshot !== null && snapshot.subscriptionDisabled) {
34
+ console.error(
35
+ `Claude subscription access is disabled for token '${name}'; leaving it out of the preparation concurrency allocation.`,
36
+ );
37
+ return null;
38
+ }
39
+ if (snapshot === null) {
40
+ return {
41
+ name,
42
+ token,
43
+ fiveHourUtilization: 0,
44
+ sevenDayUtilization: 0,
45
+ blocked: false,
46
+ rejected: false,
47
+ fiveHourRejected: false,
48
+ modelWeeklyLimits: {},
49
+ blockedUntilEpoch: 0,
50
+ selectionWeight,
51
+ };
52
+ }
53
+ const fiveHourExpired =
54
+ nowEpochSeconds > snapshot.fiveHourReset &&
55
+ snapshot.lastUpdatedEpoch >= snapshot.fiveHourReset;
56
+ const sevenDayExpired =
57
+ nowEpochSeconds > snapshot.sevenDayReset &&
58
+ snapshot.lastUpdatedEpoch >= snapshot.sevenDayReset;
59
+ const fiveHourUtilization = fiveHourExpired
60
+ ? 0
61
+ : snapshot.fiveHourUtilization;
62
+ const sevenDayUtilization = sevenDayExpired
63
+ ? 0
64
+ : snapshot.sevenDayUtilization;
65
+ const fiveHourRejectionActive =
66
+ snapshot.fiveHourRejected && !fiveHourExpired;
67
+ const sevenDayRejectionActive =
68
+ snapshot.sevenDayRejected && !sevenDayExpired;
69
+ const unifiedRejectionActive =
70
+ snapshot.unifiedRejected && !fiveHourExpired;
71
+ const rejected =
72
+ unifiedRejectionActive ||
73
+ fiveHourRejectionActive ||
74
+ sevenDayRejectionActive;
75
+ const modelWeeklyLimits: Record<
76
+ string,
77
+ { rejected: boolean; resetsAt: number }
78
+ > = {};
79
+ for (const [limitType, limit] of Object.entries(
80
+ snapshot.modelWeeklyLimits,
81
+ )) {
82
+ const expired = nowEpochSeconds > limit.resetsAt;
83
+ modelWeeklyLimits[limitType] = {
84
+ rejected: limit.rejected && !expired,
85
+ resetsAt: limit.resetsAt,
86
+ };
87
+ }
88
+ const hasAnySevenDayWeeklyLimit =
89
+ modelWeeklyLimits['seven_day'] !== undefined ||
90
+ modelWeeklyLimits['seven_day_opus'] !== undefined ||
91
+ modelWeeklyLimits['seven_day_sonnet'] !== undefined;
92
+ const needsGenericSevenDayBridge =
93
+ modelWeeklyLimits['seven_day'] === undefined &&
94
+ (!hasAnySevenDayWeeklyLimit || sevenDayRejectionActive);
95
+ if (
96
+ snapshot.sevenDayReset > 0 &&
97
+ !sevenDayExpired &&
98
+ needsGenericSevenDayBridge
99
+ ) {
100
+ modelWeeklyLimits['seven_day'] = {
101
+ rejected: sevenDayRejectionActive,
102
+ resetsAt: snapshot.sevenDayReset,
103
+ };
104
+ }
105
+ const cooldownActive = snapshot.blockedUntilEpoch > nowEpochSeconds;
33
106
  return {
34
107
  name,
35
108
  token,
36
- fiveHourUtilization: 0,
37
- sevenDayUtilization: 0,
38
- blocked: false,
39
- rejected: false,
40
- fiveHourRejected: false,
41
- modelWeeklyLimits: {},
42
- blockedUntilEpoch: 0,
109
+ fiveHourUtilization,
110
+ sevenDayUtilization,
111
+ blocked: snapshot.blocked,
112
+ rejected,
113
+ fiveHourRejected: fiveHourRejectionActive,
114
+ modelWeeklyLimits,
115
+ blockedUntilEpoch: cooldownActive ? snapshot.blockedUntilEpoch : 0,
43
116
  selectionWeight,
44
117
  };
45
- }
46
- const fiveHourExpired =
47
- nowEpochSeconds > snapshot.fiveHourReset &&
48
- snapshot.lastUpdatedEpoch >= snapshot.fiveHourReset;
49
- const sevenDayExpired =
50
- nowEpochSeconds > snapshot.sevenDayReset &&
51
- snapshot.lastUpdatedEpoch >= snapshot.sevenDayReset;
52
- const fiveHourUtilization = fiveHourExpired
53
- ? 0
54
- : snapshot.fiveHourUtilization;
55
- const sevenDayUtilization = sevenDayExpired
56
- ? 0
57
- : snapshot.sevenDayUtilization;
58
- const fiveHourRejectionActive =
59
- snapshot.fiveHourRejected && !fiveHourExpired;
60
- const sevenDayRejectionActive =
61
- snapshot.sevenDayRejected && !sevenDayExpired;
62
- const unifiedRejectionActive =
63
- snapshot.unifiedRejected && !fiveHourExpired;
64
- const rejected =
65
- unifiedRejectionActive ||
66
- fiveHourRejectionActive ||
67
- sevenDayRejectionActive;
68
- const modelWeeklyLimits: Record<
69
- string,
70
- { rejected: boolean; resetsAt: number }
71
- > = {};
72
- for (const [limitType, limit] of Object.entries(
73
- snapshot.modelWeeklyLimits,
74
- )) {
75
- const expired = nowEpochSeconds > limit.resetsAt;
76
- modelWeeklyLimits[limitType] = {
77
- rejected: limit.rejected && !expired,
78
- resetsAt: limit.resetsAt,
79
- };
80
- }
81
- const hasAnySevenDayWeeklyLimit =
82
- modelWeeklyLimits['seven_day'] !== undefined ||
83
- modelWeeklyLimits['seven_day_opus'] !== undefined ||
84
- modelWeeklyLimits['seven_day_sonnet'] !== undefined;
85
- const needsGenericSevenDayBridge =
86
- modelWeeklyLimits['seven_day'] === undefined &&
87
- (!hasAnySevenDayWeeklyLimit || sevenDayRejectionActive);
88
- if (
89
- snapshot.sevenDayReset > 0 &&
90
- !sevenDayExpired &&
91
- needsGenericSevenDayBridge
92
- ) {
93
- modelWeeklyLimits['seven_day'] = {
94
- rejected: sevenDayRejectionActive,
95
- resetsAt: snapshot.sevenDayReset,
96
- };
97
- }
98
- const cooldownActive = snapshot.blockedUntilEpoch > nowEpochSeconds;
99
- return {
100
- name,
101
- token,
102
- fiveHourUtilization,
103
- sevenDayUtilization,
104
- blocked: snapshot.blocked,
105
- rejected,
106
- fiveHourRejected: fiveHourRejectionActive,
107
- modelWeeklyLimits,
108
- blockedUntilEpoch: cooldownActive ? snapshot.blockedUntilEpoch : 0,
109
- selectionWeight,
110
- };
111
- });
118
+ },
119
+ );
120
+ return usages.filter((usage): usage is ClaudeTokenUsage => usage !== null);
112
121
  };
113
122
 
114
123
  getTokenInFlightCounts = async (): Promise<Record<string, number>> => {
@@ -4825,4 +4825,350 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
4825
4825
  );
4826
4826
  });
4827
4827
  });
4828
+
4829
+ describe('non-developer agent CI failure reassignment', () => {
4830
+ const makeProjectWithDeveloper = (developerName = 'developer') =>
4831
+ createMockProject({
4832
+ dependedIssueUrlSeparatedByComma: {
4833
+ name: 'Depended Issue URL',
4834
+ fieldId: 'depended-field-id',
4835
+ },
4836
+ agent: {
4837
+ name: 'Agent',
4838
+ fieldId: 'agent-field-id',
4839
+ options: [
4840
+ {
4841
+ id: 'opt-developer',
4842
+ name: developerName,
4843
+ color: 'GRAY',
4844
+ description: '',
4845
+ },
4846
+ ],
4847
+ },
4848
+ });
4849
+
4850
+ it('should return issue to Awaiting Workspace and reassign to developer when chore agent has exactly one linked PR with failing CI', async () => {
4851
+ const issue = createMockIssue({
4852
+ url: 'https://github.com/user/repo/issues/1',
4853
+ status: 'Preparation',
4854
+ agent: 'chore',
4855
+ });
4856
+ const projectWithDeveloper = makeProjectWithDeveloper();
4857
+ mockProjectRepository.getByUrl.mockResolvedValue(projectWithDeveloper);
4858
+ mockIssueRepository.get.mockResolvedValue(issue);
4859
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
4860
+ createMockComment({ content: 'From: :robot: Test report' }),
4861
+ ]);
4862
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
4863
+ {
4864
+ url: 'https://github.com/user/repo/pull/99',
4865
+ isConflicted: false,
4866
+ isPassedAllCiJob: false,
4867
+ isCiStateSuccess: false,
4868
+ isResolvedAllReviewComments: true,
4869
+ isBranchOutOfDate: false,
4870
+ missingRequiredCheckNames: [],
4871
+ },
4872
+ ]);
4873
+
4874
+ await useCase.run({
4875
+ projectUrl: 'https://github.com/users/user/projects/1',
4876
+ issueUrl: 'https://github.com/user/repo/issues/1',
4877
+ thresholdForAutoReject: 3,
4878
+ workflowBlockerResolvedWebhookUrl: null,
4879
+ allowedIssueAuthors: null,
4880
+ });
4881
+
4882
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
4883
+ expect.objectContaining({ status: 'Awaiting Workspace' }),
4884
+ projectWithDeveloper,
4885
+ );
4886
+ expect(mockIssueRepository.setIssueAgentField).toHaveBeenCalledWith(
4887
+ 'https://github.com/user/repo/issues/1',
4888
+ projectWithDeveloper,
4889
+ 'opt-developer',
4890
+ );
4891
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
4892
+ expect.objectContaining({
4893
+ url: 'https://github.com/user/repo/issues/1',
4894
+ }),
4895
+ expect.stringContaining('ANY_CI_JOB_FAILED_OR_IN_PROGRESS'),
4896
+ );
4897
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
4898
+ expect.objectContaining({
4899
+ url: 'https://github.com/user/repo/issues/1',
4900
+ }),
4901
+ expect.stringContaining('https://github.com/user/repo/pull/99'),
4902
+ );
4903
+ });
4904
+
4905
+ it('should advance to Awaiting Quality Check when chore agent has exactly one linked PR where all CI passes', async () => {
4906
+ const issue = createMockIssue({
4907
+ url: 'https://github.com/user/repo/issues/1',
4908
+ status: 'Preparation',
4909
+ agent: 'chore',
4910
+ });
4911
+ const projectWithDeveloper = makeProjectWithDeveloper();
4912
+ mockProjectRepository.getByUrl.mockResolvedValue(projectWithDeveloper);
4913
+ mockIssueRepository.get.mockResolvedValue(issue);
4914
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
4915
+ createMockComment({ content: 'From: :robot: Test report' }),
4916
+ ]);
4917
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
4918
+ {
4919
+ url: 'https://github.com/user/repo/pull/99',
4920
+ isConflicted: false,
4921
+ isPassedAllCiJob: true,
4922
+ isCiStateSuccess: true,
4923
+ isResolvedAllReviewComments: true,
4924
+ isBranchOutOfDate: false,
4925
+ missingRequiredCheckNames: [],
4926
+ },
4927
+ ]);
4928
+
4929
+ await useCase.run({
4930
+ projectUrl: 'https://github.com/users/user/projects/1',
4931
+ issueUrl: 'https://github.com/user/repo/issues/1',
4932
+ thresholdForAutoReject: 3,
4933
+ workflowBlockerResolvedWebhookUrl: null,
4934
+ allowedIssueAuthors: null,
4935
+ });
4936
+
4937
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
4938
+ expect.objectContaining({ status: 'Awaiting Quality Check' }),
4939
+ projectWithDeveloper,
4940
+ );
4941
+ expect(mockIssueRepository.setIssueAgentField).not.toHaveBeenCalled();
4942
+ });
4943
+
4944
+ it('should not trigger reassignment when chore agent has no linked PRs', async () => {
4945
+ const issue = createMockIssue({
4946
+ url: 'https://github.com/user/repo/issues/1',
4947
+ status: 'Preparation',
4948
+ agent: 'chore',
4949
+ });
4950
+ const projectWithDeveloper = makeProjectWithDeveloper();
4951
+ mockProjectRepository.getByUrl.mockResolvedValue(projectWithDeveloper);
4952
+ mockIssueRepository.get.mockResolvedValue(issue);
4953
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
4954
+ createMockComment({ content: 'From: :robot: Test report' }),
4955
+ ]);
4956
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
4957
+
4958
+ await useCase.run({
4959
+ projectUrl: 'https://github.com/users/user/projects/1',
4960
+ issueUrl: 'https://github.com/user/repo/issues/1',
4961
+ thresholdForAutoReject: 3,
4962
+ workflowBlockerResolvedWebhookUrl: null,
4963
+ allowedIssueAuthors: null,
4964
+ });
4965
+
4966
+ expect(mockIssueRepository.setIssueAgentField).not.toHaveBeenCalled();
4967
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
4968
+ expect.objectContaining({ status: 'Awaiting Quality Check' }),
4969
+ projectWithDeveloper,
4970
+ );
4971
+ });
4972
+
4973
+ it('should not trigger the new path when developer agent has a failing CI PR', async () => {
4974
+ const issue = createMockIssue({
4975
+ url: 'https://github.com/user/repo/issues/1',
4976
+ status: 'Preparation',
4977
+ agent: 'developer',
4978
+ });
4979
+ const projectWithDeveloper = makeProjectWithDeveloper();
4980
+ mockProjectRepository.getByUrl.mockResolvedValue(projectWithDeveloper);
4981
+ mockIssueRepository.get.mockResolvedValue(issue);
4982
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
4983
+ createMockComment({ content: 'From: :robot: Test report' }),
4984
+ ]);
4985
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
4986
+ {
4987
+ url: 'https://github.com/user/repo/pull/99',
4988
+ isConflicted: false,
4989
+ isPassedAllCiJob: false,
4990
+ isCiStateSuccess: false,
4991
+ isResolvedAllReviewComments: true,
4992
+ isBranchOutOfDate: false,
4993
+ missingRequiredCheckNames: [],
4994
+ },
4995
+ ]);
4996
+
4997
+ await useCase.run({
4998
+ projectUrl: 'https://github.com/users/user/projects/1',
4999
+ issueUrl: 'https://github.com/user/repo/issues/1',
5000
+ thresholdForAutoReject: 3,
5001
+ workflowBlockerResolvedWebhookUrl: null,
5002
+ allowedIssueAuthors: null,
5003
+ });
5004
+
5005
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
5006
+ expect.objectContaining({ status: 'Awaiting Workspace' }),
5007
+ projectWithDeveloper,
5008
+ );
5009
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
5010
+ expect.objectContaining({
5011
+ url: 'https://github.com/user/repo/issues/1',
5012
+ }),
5013
+ expect.stringContaining('ANY_CI_JOB_FAILED_OR_IN_PROGRESS'),
5014
+ );
5015
+ });
5016
+
5017
+ it('should reassign to the configured developerAgentName when chore agent has a failing CI PR', async () => {
5018
+ const issue = createMockIssue({
5019
+ url: 'https://github.com/user/repo/issues/1',
5020
+ status: 'Preparation',
5021
+ agent: 'chore',
5022
+ });
5023
+ const projectWithCustomDeveloper =
5024
+ makeProjectWithDeveloper('custom-developer');
5025
+ mockProjectRepository.getByUrl.mockResolvedValue(
5026
+ projectWithCustomDeveloper,
5027
+ );
5028
+ mockIssueRepository.get.mockResolvedValue(issue);
5029
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
5030
+ createMockComment({ content: 'From: :robot: Test report' }),
5031
+ ]);
5032
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
5033
+ {
5034
+ url: 'https://github.com/user/repo/pull/99',
5035
+ isConflicted: false,
5036
+ isPassedAllCiJob: false,
5037
+ isCiStateSuccess: false,
5038
+ isResolvedAllReviewComments: true,
5039
+ isBranchOutOfDate: false,
5040
+ missingRequiredCheckNames: [],
5041
+ },
5042
+ ]);
5043
+
5044
+ await useCase.run({
5045
+ projectUrl: 'https://github.com/users/user/projects/1',
5046
+ issueUrl: 'https://github.com/user/repo/issues/1',
5047
+ thresholdForAutoReject: 3,
5048
+ workflowBlockerResolvedWebhookUrl: null,
5049
+ allowedIssueAuthors: null,
5050
+ developerAgentName: 'custom-developer',
5051
+ });
5052
+
5053
+ expect(mockIssueRepository.setIssueAgentField).toHaveBeenCalledWith(
5054
+ 'https://github.com/user/repo/issues/1',
5055
+ projectWithCustomDeveloper,
5056
+ 'opt-developer',
5057
+ );
5058
+ });
5059
+
5060
+ it('should not trigger the new path when agent is null', async () => {
5061
+ const issue = createMockIssue({
5062
+ url: 'https://github.com/user/repo/issues/1',
5063
+ status: 'Preparation',
5064
+ agent: null,
5065
+ });
5066
+ const projectWithDeveloper = makeProjectWithDeveloper();
5067
+ mockProjectRepository.getByUrl.mockResolvedValue(projectWithDeveloper);
5068
+ mockIssueRepository.get.mockResolvedValue(issue);
5069
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
5070
+ createMockComment({ content: 'From: :robot: Test report' }),
5071
+ ]);
5072
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
5073
+ {
5074
+ url: 'https://github.com/user/repo/pull/99',
5075
+ isConflicted: false,
5076
+ isPassedAllCiJob: false,
5077
+ isCiStateSuccess: false,
5078
+ isResolvedAllReviewComments: true,
5079
+ isBranchOutOfDate: false,
5080
+ missingRequiredCheckNames: [],
5081
+ },
5082
+ ]);
5083
+
5084
+ await useCase.run({
5085
+ projectUrl: 'https://github.com/users/user/projects/1',
5086
+ issueUrl: 'https://github.com/user/repo/issues/1',
5087
+ thresholdForAutoReject: 3,
5088
+ workflowBlockerResolvedWebhookUrl: null,
5089
+ allowedIssueAuthors: null,
5090
+ });
5091
+
5092
+ expect(mockIssueRepository.setIssueAgentField).not.toHaveBeenCalled();
5093
+ });
5094
+
5095
+ it('should not trigger the new path when agent is pr-reviewer', async () => {
5096
+ const issue = createMockIssue({
5097
+ url: 'https://github.com/user/repo/issues/1',
5098
+ status: 'Preparation',
5099
+ agent: 'pr-reviewer',
5100
+ });
5101
+ const projectWithDeveloper = makeProjectWithDeveloper();
5102
+ mockProjectRepository.getByUrl.mockResolvedValue(projectWithDeveloper);
5103
+ mockIssueRepository.get.mockResolvedValue(issue);
5104
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
5105
+ createMockComment({ content: 'From: :robot: Test report' }),
5106
+ ]);
5107
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
5108
+ {
5109
+ url: 'https://github.com/user/repo/pull/99',
5110
+ isConflicted: false,
5111
+ isPassedAllCiJob: false,
5112
+ isCiStateSuccess: false,
5113
+ isResolvedAllReviewComments: true,
5114
+ isBranchOutOfDate: false,
5115
+ missingRequiredCheckNames: [],
5116
+ },
5117
+ ]);
5118
+
5119
+ await useCase.run({
5120
+ projectUrl: 'https://github.com/users/user/projects/1',
5121
+ issueUrl: 'https://github.com/user/repo/issues/1',
5122
+ thresholdForAutoReject: 3,
5123
+ workflowBlockerResolvedWebhookUrl: null,
5124
+ allowedIssueAuthors: null,
5125
+ });
5126
+
5127
+ expect(mockIssueRepository.setIssueAgentField).not.toHaveBeenCalled();
5128
+ });
5129
+
5130
+ it('should not trigger the new path when chore agent has two failing CI linked PRs', async () => {
5131
+ const issue = createMockIssue({
5132
+ url: 'https://github.com/user/repo/issues/1',
5133
+ status: 'Preparation',
5134
+ agent: 'chore',
5135
+ });
5136
+ const projectWithDeveloper = makeProjectWithDeveloper();
5137
+ mockProjectRepository.getByUrl.mockResolvedValue(projectWithDeveloper);
5138
+ mockIssueRepository.get.mockResolvedValue(issue);
5139
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
5140
+ createMockComment({ content: 'From: :robot: Test report' }),
5141
+ ]);
5142
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
5143
+ {
5144
+ url: 'https://github.com/user/repo/pull/99',
5145
+ isConflicted: false,
5146
+ isPassedAllCiJob: false,
5147
+ isCiStateSuccess: false,
5148
+ isResolvedAllReviewComments: true,
5149
+ isBranchOutOfDate: false,
5150
+ missingRequiredCheckNames: [],
5151
+ },
5152
+ {
5153
+ url: 'https://github.com/user/repo/pull/100',
5154
+ isConflicted: false,
5155
+ isPassedAllCiJob: false,
5156
+ isCiStateSuccess: false,
5157
+ isResolvedAllReviewComments: true,
5158
+ isBranchOutOfDate: false,
5159
+ missingRequiredCheckNames: [],
5160
+ },
5161
+ ]);
5162
+
5163
+ await useCase.run({
5164
+ projectUrl: 'https://github.com/users/user/projects/1',
5165
+ issueUrl: 'https://github.com/user/repo/issues/1',
5166
+ thresholdForAutoReject: 3,
5167
+ workflowBlockerResolvedWebhookUrl: null,
5168
+ allowedIssueAuthors: null,
5169
+ });
5170
+
5171
+ expect(mockIssueRepository.setIssueAgentField).not.toHaveBeenCalled();
5172
+ });
5173
+ });
4828
5174
  });
@@ -373,6 +373,44 @@ export class NotifyFinishedIssuePreparationUseCase {
373
373
  return;
374
374
  }
375
375
 
376
+ const ciFailingPrUrl = await this.resolveLinkedPrWithCiFailure(
377
+ issue,
378
+ params.developerAgentName ?? null,
379
+ );
380
+ if (ciFailingPrUrl !== null) {
381
+ const effectiveDeveloperAgentName =
382
+ params.developerAgentName ?? 'developer';
383
+ const agentOptionId = await this.ensureAgentOptionAndGetId(
384
+ project,
385
+ effectiveDeveloperAgentName,
386
+ );
387
+ if (agentOptionId !== null) {
388
+ await this.issueRepository.setIssueAgentField(
389
+ params.issueUrl,
390
+ project,
391
+ agentOptionId,
392
+ );
393
+ }
394
+ issue.status = AWAITING_WORKSPACE_STATUS_NAME;
395
+ await this.issueRepository.update(issue, project);
396
+ await this.issueRepository.updateStatus(
397
+ project,
398
+ issue,
399
+ awaitingWorkspaceStatusOption.id,
400
+ );
401
+ await this.patchConsoleTab(issue);
402
+ await this.setDependedIssueUrlForAllOpenPRs(
403
+ issue,
404
+ params.issueUrl,
405
+ project,
406
+ );
407
+ await this.issueCommentRepository.createComment(
408
+ issue,
409
+ `Auto Status Check: REJECTED\n- ANY_CI_JOB_FAILED_OR_IN_PROGRESS: ${ciFailingPrUrl}`,
410
+ );
411
+ return;
412
+ }
413
+
376
414
  const { rejections, approvedPrUrl } = await this.collectRejections(
377
415
  issue,
378
416
  comments,
@@ -710,6 +748,32 @@ export class NotifyFinishedIssuePreparationUseCase {
710
748
  }
711
749
  };
712
750
 
751
+ private resolveLinkedPrWithCiFailure = async (
752
+ issue: { url: string; agent: string | null; isPr: boolean },
753
+ developerAgentName: string | null,
754
+ ): Promise<string | null> => {
755
+ const effectiveDeveloperName = developerAgentName ?? 'developer';
756
+ if (
757
+ issue.agent === null ||
758
+ issue.agent === effectiveDeveloperName ||
759
+ issue.agent === 'pr-reviewer'
760
+ ) {
761
+ return null;
762
+ }
763
+ let openPrs: { url: string; isPassedAllCiJob: boolean }[];
764
+ if (issue.isPr) {
765
+ const pr = await this.issueRepository.getOpenPullRequest(issue.url);
766
+ openPrs = pr === null ? [] : [pr];
767
+ } else {
768
+ openPrs = await this.issueRepository.findRelatedOpenPRs(issue.url);
769
+ }
770
+ if (openPrs.length !== 1) {
771
+ return null;
772
+ }
773
+ const pr = openPrs[0];
774
+ return !pr.isPassedAllCiJob ? pr.url : null;
775
+ };
776
+
713
777
  private resolveOpenPrsForPrItem = async (
714
778
  prUrl: string,
715
779
  ): Promise<{ url: string }[]> => {