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.
- package/CHANGELOG.md +14 -0
- package/bin/adapter/entry-points/console/dashboardComposeService.js +1 -1
- package/bin/adapter/entry-points/console/dashboardComposeService.js.map +1 -1
- package/bin/adapter/entry-points/handlers/tokenStatusWriter.js +1 -0
- package/bin/adapter/entry-points/handlers/tokenStatusWriter.js.map +1 -1
- package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js +6 -1
- package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js.map +1 -1
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +36 -0
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +23 -3
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/dashboard/ComposeDashboardUseCase.js +1 -0
- package/bin/domain/usecases/dashboard/ComposeDashboardUseCase.js.map +1 -1
- package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js +5 -0
- package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/console/dashboardComposeService.test.ts +32 -0
- package/src/adapter/entry-points/console/dashboardComposeService.ts +1 -1
- package/src/adapter/entry-points/handlers/tokenStatusWriter.ts +1 -0
- package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.test.ts +25 -0
- package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.ts +86 -77
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +346 -0
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +64 -0
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +400 -1
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +44 -3
- package/src/domain/usecases/dashboard/ComposeDashboardUseCase.ts +1 -0
- package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.test.ts +17 -0
- package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.ts +8 -1
- package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts.map +1 -1
- package/types/adapter/repositories/ProxyClaudeTokenUsageRepository.d.ts.map +1 -1
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +1 -0
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +1 -0
- package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/dashboard/ComposeDashboardUseCase.d.ts.map +1 -1
- package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts +3 -1
- 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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
37
|
-
sevenDayUtilization
|
|
38
|
-
blocked:
|
|
39
|
-
rejected
|
|
40
|
-
fiveHourRejected:
|
|
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
|
-
|
|
47
|
-
|
|
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 }[]> => {
|