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.
Files changed (38) hide show
  1. package/.github/workflows/codeql.yml +2 -2
  2. package/CHANGELOG.md +19 -0
  3. package/README.md +2 -2
  4. package/bin/adapter/entry-points/cli/fleetConfig.js +2 -0
  5. package/bin/adapter/entry-points/cli/fleetConfig.js.map +1 -1
  6. package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js +1 -1
  7. package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js.map +1 -1
  8. package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js +17 -2
  9. package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js.map +1 -1
  10. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +36 -0
  11. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  12. package/bin/domain/usecases/OauthTokenSelectUseCase.js +18 -10
  13. package/bin/domain/usecases/OauthTokenSelectUseCase.js.map +1 -1
  14. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +23 -3
  15. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
  16. package/package.json +2 -2
  17. package/src/adapter/entry-points/cli/fleetConfig.test.ts +33 -1
  18. package/src/adapter/entry-points/cli/fleetConfig.ts +16 -0
  19. package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.test.ts +78 -0
  20. package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.ts +2 -0
  21. package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.test.ts +137 -17
  22. package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.ts +30 -2
  23. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +346 -0
  24. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +64 -0
  25. package/src/domain/usecases/OauthTokenSelectUseCase.test.ts +99 -0
  26. package/src/domain/usecases/OauthTokenSelectUseCase.ts +30 -5
  27. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +400 -1
  28. package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +44 -3
  29. package/types/adapter/entry-points/cli/fleetConfig.d.ts.map +1 -1
  30. package/types/adapter/entry-points/handlers/OauthTokenSelectHandler.d.ts.map +1 -1
  31. package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts +3 -0
  32. package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts.map +1 -1
  33. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +1 -0
  34. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  35. package/types/domain/usecases/OauthTokenSelectUseCase.d.ts +7 -1
  36. package/types/domain/usecases/OauthTokenSelectUseCase.d.ts.map +1 -1
  37. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +1 -0
  38. 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(candidate, nowEpochSeconds);
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 < FIVE_HOUR_MIN_FREE_RATIO) {
188
- return `5h window only ${this.toPercent(fiveHourFreeRatio)}% free (requires >= ${this.toPercent(FIVE_HOUR_MIN_FREE_RATIO)}%)`;
212
+ if (fiveHourFreeRatio < thresholds.fiveHourMinFreeRatio) {
213
+ return `5h window only ${this.toPercent(fiveHourFreeRatio)}% free (requires >= ${this.toPercent(thresholds.fiveHourMinFreeRatio)}%)`;
189
214
  }
190
- if (sevenDayFreeRatio < SEVEN_DAY_MIN_FREE_RATIO) {
191
- return `7d window only ${this.toPercent(sevenDayFreeRatio)}% free (requires >= ${this.toPercent(SEVEN_DAY_MIN_FREE_RATIO)}%)`;
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
  };