npm-cli-gh-issue-preparator 1.0.3 → 1.1.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/test.yml +2 -0
- package/CHANGELOG.md +14 -0
- package/bin/adapter/repositories/OauthAPIClaudeRepository.js +133 -0
- package/bin/adapter/repositories/OauthAPIClaudeRepository.js.map +1 -0
- package/bin/domain/entities/ClaudeWindowUsage.js +3 -0
- package/bin/domain/entities/ClaudeWindowUsage.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/ClaudeRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/ClaudeRepository.js.map +1 -0
- package/package.json +1 -1
- package/renovate.json +4 -6
- package/src/adapter/repositories/GitHubIssueRepository.test.ts +204 -0
- package/src/adapter/repositories/OauthAPIClaudeRepository.test.ts +346 -0
- package/src/adapter/repositories/OauthAPIClaudeRepository.ts +143 -0
- package/src/domain/entities/ClaudeWindowUsage.ts +5 -0
- package/src/domain/usecases/StartPreparationUseCase.test.ts +45 -9
- package/src/domain/usecases/adapter-interfaces/ClaudeRepository.ts +5 -0
- package/types/adapter/repositories/OauthAPIClaudeRepository.d.ts +9 -0
- package/types/adapter/repositories/OauthAPIClaudeRepository.d.ts.map +1 -0
- package/types/domain/entities/ClaudeWindowUsage.d.ts +6 -0
- package/types/domain/entities/ClaudeWindowUsage.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/ClaudeRepository.d.ts +5 -0
- package/types/domain/usecases/adapter-interfaces/ClaudeRepository.d.ts.map +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [1.1.0](https://github.com/HiromiShikata/npm-cli-gh-issue-preparator/compare/v1.0.4...v1.1.0) (2026-01-12)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **core:** add Claude usage repository for OAuth API integration ([57ab791](https://github.com/HiromiShikata/npm-cli-gh-issue-preparator/commit/57ab7914f9574a4e6746183457e643dac47f7261))
|
|
7
|
+
|
|
8
|
+
## [1.0.4](https://github.com/HiromiShikata/npm-cli-gh-issue-preparator/compare/v1.0.3...v1.0.4) (2026-01-10)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **ci:** add GH_TOKEN to test workflow and fix test expectations ([86dbac0](https://github.com/HiromiShikata/npm-cli-gh-issue-preparator/commit/86dbac0bd66e0806531a26338b225ddec22c7826))
|
|
14
|
+
|
|
1
15
|
## [1.0.3](https://github.com/HiromiShikata/npm-cli-gh-issue-preparator/compare/v1.0.2...v1.0.3) (2025-12-14)
|
|
2
16
|
|
|
3
17
|
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.OauthAPIClaudeRepository = void 0;
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
const os = __importStar(require("os"));
|
|
40
|
+
const isCredentialsFile = (value) => {
|
|
41
|
+
if (typeof value !== 'object' || value === null)
|
|
42
|
+
return false;
|
|
43
|
+
return true;
|
|
44
|
+
};
|
|
45
|
+
const isUsageResponse = (value) => {
|
|
46
|
+
if (typeof value !== 'object' || value === null)
|
|
47
|
+
return false;
|
|
48
|
+
return true;
|
|
49
|
+
};
|
|
50
|
+
class OauthAPIClaudeRepository {
|
|
51
|
+
constructor() {
|
|
52
|
+
this.credentialsPath = path.join(os.homedir(), '.claude', '.credentials.json');
|
|
53
|
+
}
|
|
54
|
+
getAccessToken() {
|
|
55
|
+
if (!fs.existsSync(this.credentialsPath)) {
|
|
56
|
+
throw new Error(`Claude credentials file not found at ${this.credentialsPath}. Please login to Claude Code first using: claude login`);
|
|
57
|
+
}
|
|
58
|
+
const fileContent = fs.readFileSync(this.credentialsPath, 'utf-8');
|
|
59
|
+
const credentials = JSON.parse(fileContent);
|
|
60
|
+
if (!isCredentialsFile(credentials)) {
|
|
61
|
+
throw new Error('Invalid credentials file format');
|
|
62
|
+
}
|
|
63
|
+
const accessToken = credentials.claudeAiOauth?.accessToken;
|
|
64
|
+
if (!accessToken) {
|
|
65
|
+
throw new Error('No access token found in credentials file');
|
|
66
|
+
}
|
|
67
|
+
return accessToken;
|
|
68
|
+
}
|
|
69
|
+
async getUsage() {
|
|
70
|
+
const accessToken = this.getAccessToken();
|
|
71
|
+
const response = await fetch('https://api.anthropic.com/api/oauth/usage', {
|
|
72
|
+
method: 'GET',
|
|
73
|
+
headers: {
|
|
74
|
+
Accept: 'application/json, text/plain, */*',
|
|
75
|
+
'Content-Type': 'application/json',
|
|
76
|
+
'User-Agent': 'claude-code/2.0.32',
|
|
77
|
+
Authorization: `Bearer ${accessToken}`,
|
|
78
|
+
'anthropic-beta': 'oauth-2025-04-20',
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
const errorText = await response.text();
|
|
83
|
+
throw new Error(`Claude API error: ${errorText}`);
|
|
84
|
+
}
|
|
85
|
+
const responseData = await response.json();
|
|
86
|
+
if (!isUsageResponse(responseData)) {
|
|
87
|
+
throw new Error('Invalid API response format');
|
|
88
|
+
}
|
|
89
|
+
if (responseData.error) {
|
|
90
|
+
throw new Error(`API error: ${responseData.error}`);
|
|
91
|
+
}
|
|
92
|
+
const usages = [];
|
|
93
|
+
if (responseData.five_hour?.utilization !== undefined) {
|
|
94
|
+
usages.push({
|
|
95
|
+
hour: 5,
|
|
96
|
+
utilizationPercentage: responseData.five_hour.utilization,
|
|
97
|
+
resetsAt: responseData.five_hour.resets_at
|
|
98
|
+
? new Date(responseData.five_hour.resets_at)
|
|
99
|
+
: new Date(),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
if (responseData.seven_day?.utilization !== undefined) {
|
|
103
|
+
usages.push({
|
|
104
|
+
hour: 168,
|
|
105
|
+
utilizationPercentage: responseData.seven_day.utilization,
|
|
106
|
+
resetsAt: responseData.seven_day.resets_at
|
|
107
|
+
? new Date(responseData.seven_day.resets_at)
|
|
108
|
+
: new Date(),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (responseData.seven_day_opus?.utilization !== undefined) {
|
|
112
|
+
usages.push({
|
|
113
|
+
hour: 168,
|
|
114
|
+
utilizationPercentage: responseData.seven_day_opus.utilization,
|
|
115
|
+
resetsAt: responseData.seven_day_opus.resets_at
|
|
116
|
+
? new Date(responseData.seven_day_opus.resets_at)
|
|
117
|
+
: new Date(),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
if (responseData.seven_day_sonnet?.utilization !== undefined) {
|
|
121
|
+
usages.push({
|
|
122
|
+
hour: 168,
|
|
123
|
+
utilizationPercentage: responseData.seven_day_sonnet.utilization,
|
|
124
|
+
resetsAt: responseData.seven_day_sonnet.resets_at
|
|
125
|
+
? new Date(responseData.seven_day_sonnet.resets_at)
|
|
126
|
+
: new Date(),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return usages;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
exports.OauthAPIClaudeRepository = OauthAPIClaudeRepository;
|
|
133
|
+
//# sourceMappingURL=OauthAPIClaudeRepository.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"OauthAPIClaudeRepository.js","sourceRoot":"","sources":["../../../src/adapter/repositories/OauthAPIClaudeRepository.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ClaudeWindowUsage.js","sourceRoot":"","sources":["../../../src/domain/entities/ClaudeWindowUsage.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ClaudeRepository.js","sourceRoot":"","sources":["../../../../src/domain/usecases/adapter-interfaces/ClaudeRepository.ts"],"names":[],"mappings":""}
|
package/package.json
CHANGED
package/renovate.json
CHANGED
|
@@ -11,12 +11,6 @@
|
|
|
11
11
|
"branchConcurrentLimit": 2,
|
|
12
12
|
"ignorePaths": ["**/generated/*", "**/_gen/*"],
|
|
13
13
|
"packageRules": [
|
|
14
|
-
{
|
|
15
|
-
"matchPackagePatterns": ["*"],
|
|
16
|
-
"matchUpdateTypes": ["minor", "patch"],
|
|
17
|
-
"groupName": "all non-major dependencies",
|
|
18
|
-
"groupSlug": "all-minor-patch"
|
|
19
|
-
},
|
|
20
14
|
{
|
|
21
15
|
"matchPackageNames": ["eslint"],
|
|
22
16
|
"allowedVersions": "<9.0.0"
|
|
@@ -32,6 +26,10 @@
|
|
|
32
26
|
{
|
|
33
27
|
"matchPackageNames": ["eslint-plugin-unused-imports"],
|
|
34
28
|
"allowedVersions": "<4.0.0"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"matchPackageNames": ["@google/clasp"],
|
|
32
|
+
"allowedVersions": "3.1.0"
|
|
35
33
|
}
|
|
36
34
|
]
|
|
37
35
|
}
|
|
@@ -270,6 +270,123 @@ describe('GitHubIssueRepository', () => {
|
|
|
270
270
|
expect(issues).toEqual([]);
|
|
271
271
|
});
|
|
272
272
|
|
|
273
|
+
it('should skip items with content but undefined url', async () => {
|
|
274
|
+
mockFetch.mockResolvedValueOnce({
|
|
275
|
+
ok: true,
|
|
276
|
+
json: jest.fn().mockResolvedValue({
|
|
277
|
+
data: {
|
|
278
|
+
organization: {
|
|
279
|
+
projectV2: {
|
|
280
|
+
items: {
|
|
281
|
+
totalCount: 2,
|
|
282
|
+
pageInfo: {
|
|
283
|
+
endCursor: null,
|
|
284
|
+
hasNextPage: false,
|
|
285
|
+
},
|
|
286
|
+
nodes: [
|
|
287
|
+
{
|
|
288
|
+
id: 'item-with-undefined-url',
|
|
289
|
+
content: {
|
|
290
|
+
url: undefined,
|
|
291
|
+
title: 'Item Without URL',
|
|
292
|
+
number: 1,
|
|
293
|
+
labels: {
|
|
294
|
+
nodes: [],
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
fieldValues: {
|
|
298
|
+
nodes: [
|
|
299
|
+
{
|
|
300
|
+
name: 'Done',
|
|
301
|
+
field: {
|
|
302
|
+
name: 'Status',
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
],
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
id: 'valid-issue',
|
|
310
|
+
content: {
|
|
311
|
+
url: 'https://github.com/owner/repo/issues/5',
|
|
312
|
+
title: 'Valid Issue',
|
|
313
|
+
number: 5,
|
|
314
|
+
labels: {
|
|
315
|
+
nodes: [],
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
fieldValues: {
|
|
319
|
+
nodes: [
|
|
320
|
+
{
|
|
321
|
+
name: 'Done',
|
|
322
|
+
field: {
|
|
323
|
+
name: 'Status',
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
],
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
],
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
}),
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const issues = await repository.getAllOpened(mockProject);
|
|
338
|
+
|
|
339
|
+
expect(issues).toHaveLength(1);
|
|
340
|
+
expect(issues[0].id).toBe('valid-issue');
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('should handle items with null labels nodes', async () => {
|
|
344
|
+
mockFetch.mockResolvedValueOnce({
|
|
345
|
+
ok: true,
|
|
346
|
+
json: jest.fn().mockResolvedValue({
|
|
347
|
+
data: {
|
|
348
|
+
organization: {
|
|
349
|
+
projectV2: {
|
|
350
|
+
items: {
|
|
351
|
+
totalCount: 1,
|
|
352
|
+
pageInfo: {
|
|
353
|
+
endCursor: null,
|
|
354
|
+
hasNextPage: false,
|
|
355
|
+
},
|
|
356
|
+
nodes: [
|
|
357
|
+
{
|
|
358
|
+
id: 'issue-no-labels',
|
|
359
|
+
content: {
|
|
360
|
+
url: 'https://github.com/owner/repo/issues/10',
|
|
361
|
+
title: 'Issue Without Labels',
|
|
362
|
+
number: 10,
|
|
363
|
+
labels: null,
|
|
364
|
+
},
|
|
365
|
+
fieldValues: {
|
|
366
|
+
nodes: [
|
|
367
|
+
{
|
|
368
|
+
name: 'Done',
|
|
369
|
+
field: {
|
|
370
|
+
name: 'Status',
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
],
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
],
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
}),
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const issues = await repository.getAllOpened(mockProject);
|
|
385
|
+
|
|
386
|
+
expect(issues).toHaveLength(1);
|
|
387
|
+
expect(issues[0].labels).toEqual([]);
|
|
388
|
+
});
|
|
389
|
+
|
|
273
390
|
it('should handle issue without Status field', async () => {
|
|
274
391
|
mockFetch.mockResolvedValueOnce({
|
|
275
392
|
ok: true,
|
|
@@ -343,6 +460,10 @@ describe('GitHubIssueRepository', () => {
|
|
|
343
460
|
organization: {
|
|
344
461
|
projectV2: {
|
|
345
462
|
fields: {
|
|
463
|
+
pageInfo: {
|
|
464
|
+
hasNextPage: false,
|
|
465
|
+
endCursor: null,
|
|
466
|
+
},
|
|
346
467
|
nodes: [
|
|
347
468
|
{
|
|
348
469
|
id: 'field-1',
|
|
@@ -400,6 +521,10 @@ describe('GitHubIssueRepository', () => {
|
|
|
400
521
|
organization: {
|
|
401
522
|
projectV2: {
|
|
402
523
|
fields: {
|
|
524
|
+
pageInfo: {
|
|
525
|
+
hasNextPage: false,
|
|
526
|
+
endCursor: null,
|
|
527
|
+
},
|
|
403
528
|
nodes: [
|
|
404
529
|
{
|
|
405
530
|
id: 'field-1',
|
|
@@ -453,6 +578,10 @@ describe('GitHubIssueRepository', () => {
|
|
|
453
578
|
organization: {
|
|
454
579
|
projectV2: {
|
|
455
580
|
fields: {
|
|
581
|
+
pageInfo: {
|
|
582
|
+
hasNextPage: false,
|
|
583
|
+
endCursor: null,
|
|
584
|
+
},
|
|
456
585
|
nodes: [
|
|
457
586
|
{
|
|
458
587
|
id: 'field-1',
|
|
@@ -525,6 +654,10 @@ describe('GitHubIssueRepository', () => {
|
|
|
525
654
|
organization: {
|
|
526
655
|
projectV2: {
|
|
527
656
|
fields: {
|
|
657
|
+
pageInfo: {
|
|
658
|
+
hasNextPage: false,
|
|
659
|
+
endCursor: null,
|
|
660
|
+
},
|
|
528
661
|
nodes: [
|
|
529
662
|
{
|
|
530
663
|
id: 'field-1',
|
|
@@ -560,6 +693,10 @@ describe('GitHubIssueRepository', () => {
|
|
|
560
693
|
organization: {
|
|
561
694
|
projectV2: {
|
|
562
695
|
fields: {
|
|
696
|
+
pageInfo: {
|
|
697
|
+
hasNextPage: false,
|
|
698
|
+
endCursor: null,
|
|
699
|
+
},
|
|
563
700
|
nodes: [
|
|
564
701
|
{
|
|
565
702
|
id: 'field-1',
|
|
@@ -600,6 +737,10 @@ describe('GitHubIssueRepository', () => {
|
|
|
600
737
|
organization: {
|
|
601
738
|
projectV2: {
|
|
602
739
|
fields: {
|
|
740
|
+
pageInfo: {
|
|
741
|
+
hasNextPage: false,
|
|
742
|
+
endCursor: null,
|
|
743
|
+
},
|
|
603
744
|
nodes: [
|
|
604
745
|
{
|
|
605
746
|
id: 'field-1',
|
|
@@ -642,6 +783,10 @@ describe('GitHubIssueRepository', () => {
|
|
|
642
783
|
organization: {
|
|
643
784
|
projectV2: {
|
|
644
785
|
fields: {
|
|
786
|
+
pageInfo: {
|
|
787
|
+
hasNextPage: false,
|
|
788
|
+
endCursor: null,
|
|
789
|
+
},
|
|
645
790
|
nodes: [
|
|
646
791
|
{
|
|
647
792
|
id: 'field-1',
|
|
@@ -682,6 +827,10 @@ describe('GitHubIssueRepository', () => {
|
|
|
682
827
|
user: {
|
|
683
828
|
projectV2: {
|
|
684
829
|
fields: {
|
|
830
|
+
pageInfo: {
|
|
831
|
+
hasNextPage: false,
|
|
832
|
+
endCursor: null,
|
|
833
|
+
},
|
|
685
834
|
nodes: [
|
|
686
835
|
{
|
|
687
836
|
id: 'field-1',
|
|
@@ -1032,5 +1181,60 @@ describe('GitHubIssueRepository', () => {
|
|
|
1032
1181
|
|
|
1033
1182
|
expect(result).toBeNull();
|
|
1034
1183
|
});
|
|
1184
|
+
|
|
1185
|
+
it('should handle issue with null labels in get method', async () => {
|
|
1186
|
+
mockFetch.mockResolvedValueOnce({
|
|
1187
|
+
ok: true,
|
|
1188
|
+
json: jest.fn().mockResolvedValue({
|
|
1189
|
+
data: {
|
|
1190
|
+
organization: {
|
|
1191
|
+
projectV2: {
|
|
1192
|
+
items: {
|
|
1193
|
+
totalCount: 1,
|
|
1194
|
+
pageInfo: {
|
|
1195
|
+
endCursor: null,
|
|
1196
|
+
hasNextPage: false,
|
|
1197
|
+
},
|
|
1198
|
+
nodes: [
|
|
1199
|
+
{
|
|
1200
|
+
id: 'issue-null-labels',
|
|
1201
|
+
content: {
|
|
1202
|
+
url: 'https://github.com/owner/repo/issues/1',
|
|
1203
|
+
title: 'Issue With Null Labels',
|
|
1204
|
+
number: 1,
|
|
1205
|
+
labels: null,
|
|
1206
|
+
},
|
|
1207
|
+
fieldValues: {
|
|
1208
|
+
nodes: [
|
|
1209
|
+
{
|
|
1210
|
+
name: 'Done',
|
|
1211
|
+
field: {
|
|
1212
|
+
name: 'Status',
|
|
1213
|
+
},
|
|
1214
|
+
},
|
|
1215
|
+
],
|
|
1216
|
+
},
|
|
1217
|
+
},
|
|
1218
|
+
],
|
|
1219
|
+
},
|
|
1220
|
+
},
|
|
1221
|
+
},
|
|
1222
|
+
},
|
|
1223
|
+
}),
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
const result = await repository.get(
|
|
1227
|
+
'https://github.com/owner/repo/issues/1',
|
|
1228
|
+
mockProject,
|
|
1229
|
+
);
|
|
1230
|
+
|
|
1231
|
+
expect(result).toEqual({
|
|
1232
|
+
id: 'issue-null-labels',
|
|
1233
|
+
url: 'https://github.com/owner/repo/issues/1',
|
|
1234
|
+
title: 'Issue With Null Labels',
|
|
1235
|
+
labels: [],
|
|
1236
|
+
status: 'Done',
|
|
1237
|
+
});
|
|
1238
|
+
});
|
|
1035
1239
|
});
|
|
1036
1240
|
});
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
const mockExistsSync = jest.fn();
|
|
2
|
+
const mockReadFileSync = jest.fn();
|
|
3
|
+
const mockHomedir = jest.fn();
|
|
4
|
+
|
|
5
|
+
jest.mock('fs', () => ({
|
|
6
|
+
existsSync: mockExistsSync,
|
|
7
|
+
readFileSync: mockReadFileSync,
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
jest.mock('os', () => ({
|
|
11
|
+
homedir: mockHomedir,
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
import { OauthAPIClaudeRepository } from './OauthAPIClaudeRepository';
|
|
15
|
+
import * as path from 'path';
|
|
16
|
+
|
|
17
|
+
describe('OauthAPIClaudeRepository', () => {
|
|
18
|
+
let repository: OauthAPIClaudeRepository;
|
|
19
|
+
let mockFetch: jest.Mock;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
mockHomedir.mockReturnValue('/home/testuser');
|
|
23
|
+
mockFetch = jest.fn();
|
|
24
|
+
global.fetch = mockFetch;
|
|
25
|
+
jest.clearAllMocks();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const credentialsPath = path.join(
|
|
29
|
+
'/home/testuser',
|
|
30
|
+
'.claude',
|
|
31
|
+
'.credentials.json',
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
describe('getUsage', () => {
|
|
35
|
+
it('should fetch usage data from Claude API', async () => {
|
|
36
|
+
mockExistsSync.mockReturnValue(true);
|
|
37
|
+
mockReadFileSync.mockReturnValue(
|
|
38
|
+
JSON.stringify({
|
|
39
|
+
claudeAiOauth: {
|
|
40
|
+
accessToken: 'test-access-token',
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
mockFetch.mockResolvedValueOnce({
|
|
46
|
+
ok: true,
|
|
47
|
+
json: jest.fn().mockResolvedValue({
|
|
48
|
+
five_hour: {
|
|
49
|
+
utilization: 25.5,
|
|
50
|
+
resets_at: '2026-01-12T10:00:00Z',
|
|
51
|
+
},
|
|
52
|
+
seven_day: {
|
|
53
|
+
utilization: 50.0,
|
|
54
|
+
resets_at: '2026-01-15T00:00:00Z',
|
|
55
|
+
},
|
|
56
|
+
}),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
repository = new OauthAPIClaudeRepository();
|
|
60
|
+
const usages = await repository.getUsage();
|
|
61
|
+
|
|
62
|
+
expect(usages).toHaveLength(2);
|
|
63
|
+
expect(usages[0]).toEqual({
|
|
64
|
+
hour: 5,
|
|
65
|
+
utilizationPercentage: 25.5,
|
|
66
|
+
resetsAt: new Date('2026-01-12T10:00:00Z'),
|
|
67
|
+
});
|
|
68
|
+
expect(usages[1]).toEqual({
|
|
69
|
+
hour: 168,
|
|
70
|
+
utilizationPercentage: 50.0,
|
|
71
|
+
resetsAt: new Date('2026-01-15T00:00:00Z'),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
75
|
+
'https://api.anthropic.com/api/oauth/usage',
|
|
76
|
+
expect.objectContaining({
|
|
77
|
+
method: 'GET',
|
|
78
|
+
headers: {
|
|
79
|
+
Accept: 'application/json, text/plain, */*',
|
|
80
|
+
'Content-Type': 'application/json',
|
|
81
|
+
'User-Agent': 'claude-code/2.0.32',
|
|
82
|
+
Authorization: 'Bearer test-access-token',
|
|
83
|
+
'anthropic-beta': 'oauth-2025-04-20',
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should include opus and sonnet usage when available', async () => {
|
|
90
|
+
mockExistsSync.mockReturnValue(true);
|
|
91
|
+
mockReadFileSync.mockReturnValue(
|
|
92
|
+
JSON.stringify({
|
|
93
|
+
claudeAiOauth: {
|
|
94
|
+
accessToken: 'test-access-token',
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
mockFetch.mockResolvedValueOnce({
|
|
100
|
+
ok: true,
|
|
101
|
+
json: jest.fn().mockResolvedValue({
|
|
102
|
+
five_hour: {
|
|
103
|
+
utilization: 10.0,
|
|
104
|
+
resets_at: '2026-01-12T10:00:00Z',
|
|
105
|
+
},
|
|
106
|
+
seven_day: {
|
|
107
|
+
utilization: 20.0,
|
|
108
|
+
resets_at: '2026-01-15T00:00:00Z',
|
|
109
|
+
},
|
|
110
|
+
seven_day_opus: {
|
|
111
|
+
utilization: 30.0,
|
|
112
|
+
resets_at: '2026-01-16T00:00:00Z',
|
|
113
|
+
},
|
|
114
|
+
seven_day_sonnet: {
|
|
115
|
+
utilization: 40.0,
|
|
116
|
+
resets_at: '2026-01-17T00:00:00Z',
|
|
117
|
+
},
|
|
118
|
+
}),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
repository = new OauthAPIClaudeRepository();
|
|
122
|
+
const usages = await repository.getUsage();
|
|
123
|
+
|
|
124
|
+
expect(usages).toHaveLength(4);
|
|
125
|
+
expect(usages[2]).toEqual({
|
|
126
|
+
hour: 168,
|
|
127
|
+
utilizationPercentage: 30.0,
|
|
128
|
+
resetsAt: new Date('2026-01-16T00:00:00Z'),
|
|
129
|
+
});
|
|
130
|
+
expect(usages[3]).toEqual({
|
|
131
|
+
hour: 168,
|
|
132
|
+
utilizationPercentage: 40.0,
|
|
133
|
+
resetsAt: new Date('2026-01-17T00:00:00Z'),
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should throw error when credentials file not found', async () => {
|
|
138
|
+
mockExistsSync.mockReturnValue(false);
|
|
139
|
+
|
|
140
|
+
repository = new OauthAPIClaudeRepository();
|
|
141
|
+
|
|
142
|
+
await expect(repository.getUsage()).rejects.toThrow(
|
|
143
|
+
`Claude credentials file not found at ${credentialsPath}`,
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should throw error when access token is missing', async () => {
|
|
148
|
+
mockExistsSync.mockReturnValue(true);
|
|
149
|
+
mockReadFileSync.mockReturnValue(
|
|
150
|
+
JSON.stringify({
|
|
151
|
+
claudeAiOauth: {},
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
repository = new OauthAPIClaudeRepository();
|
|
156
|
+
|
|
157
|
+
await expect(repository.getUsage()).rejects.toThrow(
|
|
158
|
+
'No access token found in credentials file',
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should throw error when credentials file has invalid format', async () => {
|
|
163
|
+
mockExistsSync.mockReturnValue(true);
|
|
164
|
+
mockReadFileSync.mockReturnValue('null');
|
|
165
|
+
|
|
166
|
+
repository = new OauthAPIClaudeRepository();
|
|
167
|
+
|
|
168
|
+
await expect(repository.getUsage()).rejects.toThrow(
|
|
169
|
+
'Invalid credentials file format',
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should throw error when API response is not ok', async () => {
|
|
174
|
+
mockExistsSync.mockReturnValue(true);
|
|
175
|
+
mockReadFileSync.mockReturnValue(
|
|
176
|
+
JSON.stringify({
|
|
177
|
+
claudeAiOauth: {
|
|
178
|
+
accessToken: 'test-access-token',
|
|
179
|
+
},
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
mockFetch.mockResolvedValueOnce({
|
|
184
|
+
ok: false,
|
|
185
|
+
text: jest.fn().mockResolvedValue('API Error'),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
repository = new OauthAPIClaudeRepository();
|
|
189
|
+
|
|
190
|
+
await expect(repository.getUsage()).rejects.toThrow(
|
|
191
|
+
'Claude API error: API Error',
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should throw error when API returns error in response', async () => {
|
|
196
|
+
mockExistsSync.mockReturnValue(true);
|
|
197
|
+
mockReadFileSync.mockReturnValue(
|
|
198
|
+
JSON.stringify({
|
|
199
|
+
claudeAiOauth: {
|
|
200
|
+
accessToken: 'test-access-token',
|
|
201
|
+
},
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
mockFetch.mockResolvedValueOnce({
|
|
206
|
+
ok: true,
|
|
207
|
+
json: jest.fn().mockResolvedValue({
|
|
208
|
+
error: 'Invalid token',
|
|
209
|
+
}),
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
repository = new OauthAPIClaudeRepository();
|
|
213
|
+
|
|
214
|
+
await expect(repository.getUsage()).rejects.toThrow(
|
|
215
|
+
'API error: Invalid token',
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('should throw error when API response format is invalid', async () => {
|
|
220
|
+
mockExistsSync.mockReturnValue(true);
|
|
221
|
+
mockReadFileSync.mockReturnValue(
|
|
222
|
+
JSON.stringify({
|
|
223
|
+
claudeAiOauth: {
|
|
224
|
+
accessToken: 'test-access-token',
|
|
225
|
+
},
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
mockFetch.mockResolvedValueOnce({
|
|
230
|
+
ok: true,
|
|
231
|
+
json: jest.fn().mockResolvedValue(null),
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
repository = new OauthAPIClaudeRepository();
|
|
235
|
+
|
|
236
|
+
await expect(repository.getUsage()).rejects.toThrow(
|
|
237
|
+
'Invalid API response format',
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('should return empty array when no usage data available', async () => {
|
|
242
|
+
mockExistsSync.mockReturnValue(true);
|
|
243
|
+
mockReadFileSync.mockReturnValue(
|
|
244
|
+
JSON.stringify({
|
|
245
|
+
claudeAiOauth: {
|
|
246
|
+
accessToken: 'test-access-token',
|
|
247
|
+
},
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
mockFetch.mockResolvedValueOnce({
|
|
252
|
+
ok: true,
|
|
253
|
+
json: jest.fn().mockResolvedValue({}),
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
repository = new OauthAPIClaudeRepository();
|
|
257
|
+
const usages = await repository.getUsage();
|
|
258
|
+
|
|
259
|
+
expect(usages).toHaveLength(0);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('should handle missing resets_at by using current date', async () => {
|
|
263
|
+
mockExistsSync.mockReturnValue(true);
|
|
264
|
+
mockReadFileSync.mockReturnValue(
|
|
265
|
+
JSON.stringify({
|
|
266
|
+
claudeAiOauth: {
|
|
267
|
+
accessToken: 'test-access-token',
|
|
268
|
+
},
|
|
269
|
+
}),
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const beforeTest = new Date();
|
|
273
|
+
|
|
274
|
+
mockFetch.mockResolvedValueOnce({
|
|
275
|
+
ok: true,
|
|
276
|
+
json: jest.fn().mockResolvedValue({
|
|
277
|
+
five_hour: {
|
|
278
|
+
utilization: 25.5,
|
|
279
|
+
},
|
|
280
|
+
}),
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
repository = new OauthAPIClaudeRepository();
|
|
284
|
+
const usages = await repository.getUsage();
|
|
285
|
+
|
|
286
|
+
const afterTest = new Date();
|
|
287
|
+
|
|
288
|
+
expect(usages).toHaveLength(1);
|
|
289
|
+
expect(usages[0].hour).toBe(5);
|
|
290
|
+
expect(usages[0].utilizationPercentage).toBe(25.5);
|
|
291
|
+
expect(usages[0].resetsAt.getTime()).toBeGreaterThanOrEqual(
|
|
292
|
+
beforeTest.getTime(),
|
|
293
|
+
);
|
|
294
|
+
expect(usages[0].resetsAt.getTime()).toBeLessThanOrEqual(
|
|
295
|
+
afterTest.getTime(),
|
|
296
|
+
);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('should handle missing resets_at for all window types', async () => {
|
|
300
|
+
mockExistsSync.mockReturnValue(true);
|
|
301
|
+
mockReadFileSync.mockReturnValue(
|
|
302
|
+
JSON.stringify({
|
|
303
|
+
claudeAiOauth: {
|
|
304
|
+
accessToken: 'test-access-token',
|
|
305
|
+
},
|
|
306
|
+
}),
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
const beforeTest = new Date();
|
|
310
|
+
|
|
311
|
+
mockFetch.mockResolvedValueOnce({
|
|
312
|
+
ok: true,
|
|
313
|
+
json: jest.fn().mockResolvedValue({
|
|
314
|
+
five_hour: {
|
|
315
|
+
utilization: 10.0,
|
|
316
|
+
},
|
|
317
|
+
seven_day: {
|
|
318
|
+
utilization: 20.0,
|
|
319
|
+
},
|
|
320
|
+
seven_day_opus: {
|
|
321
|
+
utilization: 30.0,
|
|
322
|
+
},
|
|
323
|
+
seven_day_sonnet: {
|
|
324
|
+
utilization: 40.0,
|
|
325
|
+
},
|
|
326
|
+
}),
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
repository = new OauthAPIClaudeRepository();
|
|
330
|
+
const usages = await repository.getUsage();
|
|
331
|
+
|
|
332
|
+
const afterTest = new Date();
|
|
333
|
+
|
|
334
|
+
expect(usages).toHaveLength(4);
|
|
335
|
+
|
|
336
|
+
for (const usage of usages) {
|
|
337
|
+
expect(usage.resetsAt.getTime()).toBeGreaterThanOrEqual(
|
|
338
|
+
beforeTest.getTime(),
|
|
339
|
+
);
|
|
340
|
+
expect(usage.resetsAt.getTime()).toBeLessThanOrEqual(
|
|
341
|
+
afterTest.getTime(),
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { ClaudeRepository } from '../../domain/usecases/adapter-interfaces/ClaudeRepository';
|
|
2
|
+
import { ClaudeWindowUsage } from '../../domain/entities/ClaudeWindowUsage';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import * as os from 'os';
|
|
6
|
+
|
|
7
|
+
type CredentialsFile = {
|
|
8
|
+
claudeAiOauth?: {
|
|
9
|
+
accessToken?: string;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type UsageWindow = {
|
|
14
|
+
utilization?: number;
|
|
15
|
+
resets_at?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type UsageResponse = {
|
|
19
|
+
five_hour?: UsageWindow;
|
|
20
|
+
seven_day?: UsageWindow;
|
|
21
|
+
seven_day_opus?: UsageWindow;
|
|
22
|
+
seven_day_sonnet?: UsageWindow;
|
|
23
|
+
error?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const isCredentialsFile = (value: unknown): value is CredentialsFile => {
|
|
27
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
28
|
+
return true;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const isUsageResponse = (value: unknown): value is UsageResponse => {
|
|
32
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
33
|
+
return true;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export class OauthAPIClaudeRepository implements ClaudeRepository {
|
|
37
|
+
private readonly credentialsPath: string;
|
|
38
|
+
|
|
39
|
+
constructor() {
|
|
40
|
+
this.credentialsPath = path.join(
|
|
41
|
+
os.homedir(),
|
|
42
|
+
'.claude',
|
|
43
|
+
'.credentials.json',
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private getAccessToken(): string {
|
|
48
|
+
if (!fs.existsSync(this.credentialsPath)) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Claude credentials file not found at ${this.credentialsPath}. Please login to Claude Code first using: claude login`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const fileContent = fs.readFileSync(this.credentialsPath, 'utf-8');
|
|
55
|
+
const credentials: unknown = JSON.parse(fileContent);
|
|
56
|
+
|
|
57
|
+
if (!isCredentialsFile(credentials)) {
|
|
58
|
+
throw new Error('Invalid credentials file format');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const accessToken = credentials.claudeAiOauth?.accessToken;
|
|
62
|
+
|
|
63
|
+
if (!accessToken) {
|
|
64
|
+
throw new Error('No access token found in credentials file');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return accessToken;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async getUsage(): Promise<ClaudeWindowUsage[]> {
|
|
71
|
+
const accessToken = this.getAccessToken();
|
|
72
|
+
|
|
73
|
+
const response = await fetch('https://api.anthropic.com/api/oauth/usage', {
|
|
74
|
+
method: 'GET',
|
|
75
|
+
headers: {
|
|
76
|
+
Accept: 'application/json, text/plain, */*',
|
|
77
|
+
'Content-Type': 'application/json',
|
|
78
|
+
'User-Agent': 'claude-code/2.0.32',
|
|
79
|
+
Authorization: `Bearer ${accessToken}`,
|
|
80
|
+
'anthropic-beta': 'oauth-2025-04-20',
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
const errorText = await response.text();
|
|
86
|
+
throw new Error(`Claude API error: ${errorText}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const responseData: unknown = await response.json();
|
|
90
|
+
|
|
91
|
+
if (!isUsageResponse(responseData)) {
|
|
92
|
+
throw new Error('Invalid API response format');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (responseData.error) {
|
|
96
|
+
throw new Error(`API error: ${responseData.error}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const usages: ClaudeWindowUsage[] = [];
|
|
100
|
+
|
|
101
|
+
if (responseData.five_hour?.utilization !== undefined) {
|
|
102
|
+
usages.push({
|
|
103
|
+
hour: 5,
|
|
104
|
+
utilizationPercentage: responseData.five_hour.utilization,
|
|
105
|
+
resetsAt: responseData.five_hour.resets_at
|
|
106
|
+
? new Date(responseData.five_hour.resets_at)
|
|
107
|
+
: new Date(),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (responseData.seven_day?.utilization !== undefined) {
|
|
112
|
+
usages.push({
|
|
113
|
+
hour: 168,
|
|
114
|
+
utilizationPercentage: responseData.seven_day.utilization,
|
|
115
|
+
resetsAt: responseData.seven_day.resets_at
|
|
116
|
+
? new Date(responseData.seven_day.resets_at)
|
|
117
|
+
: new Date(),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (responseData.seven_day_opus?.utilization !== undefined) {
|
|
122
|
+
usages.push({
|
|
123
|
+
hour: 168,
|
|
124
|
+
utilizationPercentage: responseData.seven_day_opus.utilization,
|
|
125
|
+
resetsAt: responseData.seven_day_opus.resets_at
|
|
126
|
+
? new Date(responseData.seven_day_opus.resets_at)
|
|
127
|
+
: new Date(),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (responseData.seven_day_sonnet?.utilization !== undefined) {
|
|
132
|
+
usages.push({
|
|
133
|
+
hour: 168,
|
|
134
|
+
utilizationPercentage: responseData.seven_day_sonnet.utilization,
|
|
135
|
+
resetsAt: responseData.seven_day_sonnet.resets_at
|
|
136
|
+
? new Date(responseData.seven_day_sonnet.resets_at)
|
|
137
|
+
: new Date(),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return usages;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -67,7 +67,7 @@ describe('StartPreparationUseCase', () => {
|
|
|
67
67
|
expect(mockIssueRepository.update.mock.calls[0][1]).toBe(mockProject);
|
|
68
68
|
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
69
69
|
expect(mockLocalCommandRunner.runCommand.mock.calls[0][0]).toBe(
|
|
70
|
-
'aw https://github.com/user/repo
|
|
70
|
+
'aw url1 impl https://github.com/user/repo',
|
|
71
71
|
);
|
|
72
72
|
});
|
|
73
73
|
it('should assign workspace to awaiting issues', async () => {
|
|
@@ -100,18 +100,13 @@ describe('StartPreparationUseCase', () => {
|
|
|
100
100
|
preparationStatus: 'Preparation',
|
|
101
101
|
defaultAgentName: 'agent1',
|
|
102
102
|
});
|
|
103
|
-
expect(mockIssueRepository.update.mock.calls).toHaveLength(
|
|
103
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(1);
|
|
104
104
|
expect(mockIssueRepository.update.mock.calls[0][0]).toMatchObject({
|
|
105
|
-
id: '1',
|
|
106
|
-
status: 'Preparation',
|
|
107
|
-
});
|
|
108
|
-
expect(mockIssueRepository.update.mock.calls[0][1]).toBe(mockProject);
|
|
109
|
-
expect(mockIssueRepository.update.mock.calls[1][0]).toMatchObject({
|
|
110
105
|
id: '2',
|
|
111
106
|
status: 'Preparation',
|
|
112
107
|
});
|
|
113
|
-
expect(mockIssueRepository.update.mock.calls[
|
|
114
|
-
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(
|
|
108
|
+
expect(mockIssueRepository.update.mock.calls[0][1]).toBe(mockProject);
|
|
109
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
115
110
|
});
|
|
116
111
|
it('should not assign workspace if maximum preparing issues reached', async () => {
|
|
117
112
|
const preparationIssues: Issue[] = Array.from({ length: 6 }, (_, i) => ({
|
|
@@ -147,4 +142,45 @@ describe('StartPreparationUseCase', () => {
|
|
|
147
142
|
expect(issue7UpdateCalls).toHaveLength(0);
|
|
148
143
|
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
|
|
149
144
|
});
|
|
145
|
+
it('should handle defensive break when pop returns undefined', async () => {
|
|
146
|
+
const awaitingIssue: Issue = {
|
|
147
|
+
id: '1',
|
|
148
|
+
url: 'url1',
|
|
149
|
+
title: 'Issue 1',
|
|
150
|
+
labels: [],
|
|
151
|
+
status: 'Awaiting Workspace',
|
|
152
|
+
};
|
|
153
|
+
let popCallCount = 0;
|
|
154
|
+
const issuesWithMockedPop: Issue[] = [awaitingIssue, awaitingIssue];
|
|
155
|
+
const mockedPop = jest.fn((): Issue | undefined => {
|
|
156
|
+
popCallCount++;
|
|
157
|
+
if (popCallCount === 1) {
|
|
158
|
+
return awaitingIssue;
|
|
159
|
+
}
|
|
160
|
+
return undefined;
|
|
161
|
+
});
|
|
162
|
+
Object.defineProperty(issuesWithMockedPop, 'pop', { value: mockedPop });
|
|
163
|
+
Object.defineProperty(issuesWithMockedPop, 'filter', {
|
|
164
|
+
value: () => issuesWithMockedPop,
|
|
165
|
+
});
|
|
166
|
+
const allIssues: Issue[] = [];
|
|
167
|
+
Object.defineProperty(allIssues, 'filter', {
|
|
168
|
+
value: jest.fn(() => issuesWithMockedPop),
|
|
169
|
+
});
|
|
170
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
171
|
+
mockIssueRepository.getAllOpened.mockResolvedValueOnce(allIssues);
|
|
172
|
+
mockLocalCommandRunner.runCommand.mockResolvedValue({
|
|
173
|
+
stdout: '',
|
|
174
|
+
stderr: '',
|
|
175
|
+
exitCode: 0,
|
|
176
|
+
});
|
|
177
|
+
await useCase.run({
|
|
178
|
+
projectUrl: 'https://github.com/user/repo',
|
|
179
|
+
awaitingWorkspaceStatus: 'Awaiting Workspace',
|
|
180
|
+
preparationStatus: 'Preparation',
|
|
181
|
+
defaultAgentName: 'agent1',
|
|
182
|
+
});
|
|
183
|
+
expect(mockIssueRepository.update.mock.calls).toHaveLength(1);
|
|
184
|
+
expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
|
|
185
|
+
});
|
|
150
186
|
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ClaudeRepository } from '../../domain/usecases/adapter-interfaces/ClaudeRepository';
|
|
2
|
+
import { ClaudeWindowUsage } from '../../domain/entities/ClaudeWindowUsage';
|
|
3
|
+
export declare class OauthAPIClaudeRepository implements ClaudeRepository {
|
|
4
|
+
private readonly credentialsPath;
|
|
5
|
+
constructor();
|
|
6
|
+
private getAccessToken;
|
|
7
|
+
getUsage(): Promise<ClaudeWindowUsage[]>;
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=OauthAPIClaudeRepository.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"OauthAPIClaudeRepository.d.ts","sourceRoot":"","sources":["../../../src/adapter/repositories/OauthAPIClaudeRepository.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,2DAA2D,CAAC;AAC7F,OAAO,EAAE,iBAAiB,EAAE,MAAM,yCAAyC,CAAC;AAkC5E,qBAAa,wBAAyB,YAAW,gBAAgB;IAC/D,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAS;;IAUzC,OAAO,CAAC,cAAc;IAuBhB,QAAQ,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC;CAyE/C"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ClaudeWindowUsage.d.ts","sourceRoot":"","sources":["../../../src/domain/entities/ClaudeWindowUsage.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,iBAAiB,GAAG;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,qBAAqB,EAAE,MAAM,CAAC;IAC9B,QAAQ,EAAE,IAAI,CAAC;CAChB,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ClaudeRepository.d.ts","sourceRoot":"","sources":["../../../../src/domain/usecases/adapter-interfaces/ClaudeRepository.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kCAAkC,CAAC;AAErE,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAAC;CAC1C"}
|