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.
@@ -24,6 +24,8 @@ jobs:
24
24
 
25
25
  - name: Run tests
26
26
  run: npm run test
27
+ env:
28
+ GH_TOKEN: ${{ secrets.GH_TOKEN }}
27
29
 
28
30
  - name: Upload test results
29
31
  uses: actions/upload-artifact@v6
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,3 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ //# sourceMappingURL=ClaudeWindowUsage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ClaudeWindowUsage.js","sourceRoot":"","sources":["../../../src/domain/entities/ClaudeWindowUsage.ts"],"names":[],"mappings":""}
@@ -0,0 +1,3 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ //# sourceMappingURL=ClaudeRepository.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ClaudeRepository.js","sourceRoot":"","sources":["../../../../src/domain/usecases/adapter-interfaces/ClaudeRepository.ts"],"names":[],"mappings":""}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "npm-cli-gh-issue-preparator",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "",
5
5
  "main": "bin/index.js",
6
6
  "scripts": {
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
+ }
@@ -0,0 +1,5 @@
1
+ export type ClaudeWindowUsage = {
2
+ hour: number;
3
+ utilizationPercentage: number;
4
+ resetsAt: Date;
5
+ };
@@ -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 url1 impl',
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(2);
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[1][1]).toBe(mockProject);
114
- expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(2);
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,5 @@
1
+ import { ClaudeWindowUsage } from '../../entities/ClaudeWindowUsage';
2
+
3
+ export interface ClaudeRepository {
4
+ getUsage(): Promise<ClaudeWindowUsage[]>;
5
+ }
@@ -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,6 @@
1
+ export type ClaudeWindowUsage = {
2
+ hour: number;
3
+ utilizationPercentage: number;
4
+ resetsAt: Date;
5
+ };
6
+ //# sourceMappingURL=ClaudeWindowUsage.d.ts.map
@@ -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,5 @@
1
+ import { ClaudeWindowUsage } from '../../entities/ClaudeWindowUsage';
2
+ export interface ClaudeRepository {
3
+ getUsage(): Promise<ClaudeWindowUsage[]>;
4
+ }
5
+ //# sourceMappingURL=ClaudeRepository.d.ts.map
@@ -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"}