github-issue-tower-defence-management 1.194.2 → 1.196.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/.github/workflows/codeql.yml +2 -2
- package/CHANGELOG.md +19 -0
- package/README.md +2 -2
- package/bin/adapter/entry-points/cli/fleetConfig.js +2 -0
- package/bin/adapter/entry-points/cli/fleetConfig.js.map +1 -1
- package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js +1 -1
- package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js.map +1 -1
- package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js +17 -2
- package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.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/OauthTokenSelectUseCase.js +18 -10
- package/bin/domain/usecases/OauthTokenSelectUseCase.js.map +1 -1
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +23 -3
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
- package/package.json +2 -2
- package/src/adapter/entry-points/cli/fleetConfig.test.ts +33 -1
- package/src/adapter/entry-points/cli/fleetConfig.ts +16 -0
- package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.test.ts +78 -0
- package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.ts +2 -0
- package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.test.ts +137 -17
- package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.ts +30 -2
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +346 -0
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +64 -0
- package/src/domain/usecases/OauthTokenSelectUseCase.test.ts +99 -0
- package/src/domain/usecases/OauthTokenSelectUseCase.ts +30 -5
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +400 -1
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +44 -3
- package/types/adapter/entry-points/cli/fleetConfig.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/OauthTokenSelectHandler.d.ts.map +1 -1
- package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts +3 -0
- package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.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/OauthTokenSelectUseCase.d.ts +7 -1
- package/types/domain/usecases/OauthTokenSelectUseCase.d.ts.map +1 -1
- package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +1 -0
- package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
|
@@ -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 }[]> => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
CL_SCRIPT_OAUTH_TOKEN_SELECTION_THRESHOLDS,
|
|
2
3
|
OauthTokenCandidate,
|
|
3
4
|
OauthTokenSelectUseCase,
|
|
4
5
|
OauthTokenWindowSnapshot,
|
|
@@ -550,3 +551,101 @@ describe('sevenDayUrgencyFactor', () => {
|
|
|
550
551
|
expect(almostReset).toBe(SEVEN_DAY_WINDOW_HOURS);
|
|
551
552
|
});
|
|
552
553
|
});
|
|
554
|
+
|
|
555
|
+
describe('OauthTokenSelectUseCase with CL script thresholds', () => {
|
|
556
|
+
const useCase = new OauthTokenSelectUseCase();
|
|
557
|
+
|
|
558
|
+
it('excludes a token whose 5h window is less than 60% free when using CL script thresholds', () => {
|
|
559
|
+
const result = useCase.run(
|
|
560
|
+
[
|
|
561
|
+
candidate('busy5h', snapshot({ fiveHourUtilization: 0.41 })),
|
|
562
|
+
candidate('ok', snapshot({ fiveHourUtilization: 0.4 })),
|
|
563
|
+
],
|
|
564
|
+
NOW,
|
|
565
|
+
Math.random,
|
|
566
|
+
CL_SCRIPT_OAUTH_TOKEN_SELECTION_THRESHOLDS,
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
expect(result.selected?.name).toBe('ok');
|
|
570
|
+
const busy = result.metrics.find((m) => m.name === 'busy5h');
|
|
571
|
+
expect(busy?.eligible).toBe(false);
|
|
572
|
+
expect(busy?.exclusionReason).toContain('5h window');
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it('treats exactly 40% used 5h utilization as eligible under CL script thresholds (boundary)', () => {
|
|
576
|
+
const result = useCase.run(
|
|
577
|
+
[candidate('boundary', snapshot({ fiveHourUtilization: 0.4 }))],
|
|
578
|
+
NOW,
|
|
579
|
+
Math.random,
|
|
580
|
+
CL_SCRIPT_OAUTH_TOKEN_SELECTION_THRESHOLDS,
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
expect(result.selected?.name).toBe('boundary');
|
|
584
|
+
const boundary = result.metrics.find((m) => m.name === 'boundary');
|
|
585
|
+
expect(boundary?.fiveHourFreeRatio).toBeCloseTo(0.6, 9);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it('excludes a token whose 7d window is less than 14% free when using CL script thresholds', () => {
|
|
589
|
+
const result = useCase.run(
|
|
590
|
+
[
|
|
591
|
+
candidate('busy7d', snapshot({ sevenDayUtilization: 0.87 })),
|
|
592
|
+
candidate('ok', snapshot({ sevenDayUtilization: 0.86 })),
|
|
593
|
+
],
|
|
594
|
+
NOW,
|
|
595
|
+
Math.random,
|
|
596
|
+
CL_SCRIPT_OAUTH_TOKEN_SELECTION_THRESHOLDS,
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
expect(result.selected?.name).toBe('ok');
|
|
600
|
+
const busy = result.metrics.find((m) => m.name === 'busy7d');
|
|
601
|
+
expect(busy?.eligible).toBe(false);
|
|
602
|
+
expect(busy?.exclusionReason).toContain('7d window');
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it('treats exactly 86% used 7d utilization as eligible under CL script thresholds (boundary)', () => {
|
|
606
|
+
const result = useCase.run(
|
|
607
|
+
[candidate('boundary', snapshot({ sevenDayUtilization: 0.86 }))],
|
|
608
|
+
NOW,
|
|
609
|
+
Math.random,
|
|
610
|
+
CL_SCRIPT_OAUTH_TOKEN_SELECTION_THRESHOLDS,
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
expect(result.selected?.name).toBe('boundary');
|
|
614
|
+
const boundary = result.metrics.find((m) => m.name === 'boundary');
|
|
615
|
+
expect(boundary?.sevenDayFreeRatio).toBeCloseTo(0.14, 9);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it('returns null when only a token with 59% 5h free is available under CL script thresholds', () => {
|
|
619
|
+
const result = useCase.run(
|
|
620
|
+
[candidate('marginal', snapshot({ fiveHourUtilization: 0.41 }))],
|
|
621
|
+
NOW,
|
|
622
|
+
Math.random,
|
|
623
|
+
CL_SCRIPT_OAUTH_TOKEN_SELECTION_THRESHOLDS,
|
|
624
|
+
);
|
|
625
|
+
|
|
626
|
+
expect(result.selected).toBeNull();
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it('returns null when only a token with 13% 7d free is available under CL script thresholds', () => {
|
|
630
|
+
const result = useCase.run(
|
|
631
|
+
[candidate('marginal', snapshot({ sevenDayUtilization: 0.87 }))],
|
|
632
|
+
NOW,
|
|
633
|
+
Math.random,
|
|
634
|
+
CL_SCRIPT_OAUTH_TOKEN_SELECTION_THRESHOLDS,
|
|
635
|
+
);
|
|
636
|
+
|
|
637
|
+
expect(result.selected).toBeNull();
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('includes the correct threshold percentages in the exclusion reason message', () => {
|
|
641
|
+
const result = useCase.run(
|
|
642
|
+
[candidate('low5h', snapshot({ fiveHourUtilization: 0.5 }))],
|
|
643
|
+
NOW,
|
|
644
|
+
Math.random,
|
|
645
|
+
CL_SCRIPT_OAUTH_TOKEN_SELECTION_THRESHOLDS,
|
|
646
|
+
);
|
|
647
|
+
|
|
648
|
+
const low5h = result.metrics.find((m) => m.name === 'low5h');
|
|
649
|
+
expect(low5h?.exclusionReason).toContain('60%');
|
|
650
|
+
});
|
|
651
|
+
});
|
|
@@ -39,12 +39,29 @@ export type OauthTokenSelectResult = {
|
|
|
39
39
|
metrics: OauthTokenCandidateMetrics[];
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
+
export type OauthTokenSelectionThresholds = {
|
|
43
|
+
fiveHourMinFreeRatio: number;
|
|
44
|
+
sevenDayMinFreeRatio: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
42
47
|
const SECONDS_PER_DAY = 86400;
|
|
43
48
|
const SEVEN_DAYS_IN_SECONDS = 7 * SECONDS_PER_DAY;
|
|
44
49
|
|
|
45
50
|
export const FIVE_HOUR_MIN_FREE_RATIO = 0.25;
|
|
46
51
|
export const SEVEN_DAY_MIN_FREE_RATIO = 0.03;
|
|
47
52
|
|
|
53
|
+
export const DEFAULT_OAUTH_TOKEN_SELECTION_THRESHOLDS: OauthTokenSelectionThresholds =
|
|
54
|
+
{
|
|
55
|
+
fiveHourMinFreeRatio: FIVE_HOUR_MIN_FREE_RATIO,
|
|
56
|
+
sevenDayMinFreeRatio: SEVEN_DAY_MIN_FREE_RATIO,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const CL_SCRIPT_OAUTH_TOKEN_SELECTION_THRESHOLDS: OauthTokenSelectionThresholds =
|
|
60
|
+
{
|
|
61
|
+
fiveHourMinFreeRatio: 0.6,
|
|
62
|
+
sevenDayMinFreeRatio: 0.14,
|
|
63
|
+
};
|
|
64
|
+
|
|
48
65
|
export const SEVEN_DAY_WINDOW_HOURS = 168;
|
|
49
66
|
export const MIN_HOURS_TO_RESET = 1;
|
|
50
67
|
const SECONDS_PER_HOUR = 3600;
|
|
@@ -91,9 +108,14 @@ export class OauthTokenSelectUseCase {
|
|
|
91
108
|
candidates: OauthTokenCandidate[],
|
|
92
109
|
nowEpochSeconds: number,
|
|
93
110
|
random: SelectionRandom = Math.random,
|
|
111
|
+
thresholds: OauthTokenSelectionThresholds = DEFAULT_OAUTH_TOKEN_SELECTION_THRESHOLDS,
|
|
94
112
|
): OauthTokenSelectResult => {
|
|
95
113
|
const evaluated = candidates.map((candidate) => {
|
|
96
|
-
const evaluatedMetric = this.evaluate(
|
|
114
|
+
const evaluatedMetric = this.evaluate(
|
|
115
|
+
candidate,
|
|
116
|
+
nowEpochSeconds,
|
|
117
|
+
thresholds,
|
|
118
|
+
);
|
|
97
119
|
return {
|
|
98
120
|
candidate,
|
|
99
121
|
metric: {
|
|
@@ -136,6 +158,7 @@ export class OauthTokenSelectUseCase {
|
|
|
136
158
|
private evaluate = (
|
|
137
159
|
candidate: OauthTokenCandidate,
|
|
138
160
|
nowEpochSeconds: number,
|
|
161
|
+
thresholds: OauthTokenSelectionThresholds,
|
|
139
162
|
): Omit<OauthTokenCandidateMetrics, 'drawWeight'> => {
|
|
140
163
|
const fiveHourFreeRatio = this.fiveHourFreeRatio(
|
|
141
164
|
candidate.snapshot,
|
|
@@ -156,6 +179,7 @@ export class OauthTokenSelectUseCase {
|
|
|
156
179
|
candidate.fableRejected,
|
|
157
180
|
fiveHourFreeRatio,
|
|
158
181
|
sevenDayFreeRatio,
|
|
182
|
+
thresholds,
|
|
159
183
|
);
|
|
160
184
|
|
|
161
185
|
return {
|
|
@@ -174,6 +198,7 @@ export class OauthTokenSelectUseCase {
|
|
|
174
198
|
fableRejected: boolean,
|
|
175
199
|
fiveHourFreeRatio: number,
|
|
176
200
|
sevenDayFreeRatio: number,
|
|
201
|
+
thresholds: OauthTokenSelectionThresholds,
|
|
177
202
|
): string | null => {
|
|
178
203
|
if (subscriptionDisabled) {
|
|
179
204
|
return 'organization has disabled Claude subscription access for Claude Code';
|
|
@@ -184,11 +209,11 @@ export class OauthTokenSelectUseCase {
|
|
|
184
209
|
if (fableRejected) {
|
|
185
210
|
return 'fable weekly limit exhausted (a fable request was rejected with HTTP 429)';
|
|
186
211
|
}
|
|
187
|
-
if (fiveHourFreeRatio <
|
|
188
|
-
return `5h window only ${this.toPercent(fiveHourFreeRatio)}% free (requires >= ${this.toPercent(
|
|
212
|
+
if (fiveHourFreeRatio < thresholds.fiveHourMinFreeRatio) {
|
|
213
|
+
return `5h window only ${this.toPercent(fiveHourFreeRatio)}% free (requires >= ${this.toPercent(thresholds.fiveHourMinFreeRatio)}%)`;
|
|
189
214
|
}
|
|
190
|
-
if (sevenDayFreeRatio <
|
|
191
|
-
return `7d window only ${this.toPercent(sevenDayFreeRatio)}% free (requires >= ${this.toPercent(
|
|
215
|
+
if (sevenDayFreeRatio < thresholds.sevenDayMinFreeRatio) {
|
|
216
|
+
return `7d window only ${this.toPercent(sevenDayFreeRatio)}% free (requires >= ${this.toPercent(thresholds.sevenDayMinFreeRatio)}%)`;
|
|
192
217
|
}
|
|
193
218
|
return null;
|
|
194
219
|
};
|