npm-cli-gh-issue-preparator 1.0.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 (91) hide show
  1. package/.env.example +0 -0
  2. package/.eslintrc.cjs +65 -0
  3. package/.github/CODEOWNERS +2 -0
  4. package/.github/workflows/commit-lint.yml +52 -0
  5. package/.github/workflows/configs/commitlint.config.js +27 -0
  6. package/.github/workflows/create-pr.yml +66 -0
  7. package/.github/workflows/empty-format-test-job.yml +28 -0
  8. package/.github/workflows/format.yml +25 -0
  9. package/.github/workflows/publish.yml +47 -0
  10. package/.github/workflows/test.yml +38 -0
  11. package/.github/workflows/umino-project.yml +191 -0
  12. package/.prettierignore +22 -0
  13. package/.prettierrc +5 -0
  14. package/CHANGELOG.md +27 -0
  15. package/CONTRIBUTING.md +107 -0
  16. package/README.md +49 -0
  17. package/bin/adapter/entry-points/cli/index.js +72 -0
  18. package/bin/adapter/entry-points/cli/index.js.map +1 -0
  19. package/bin/adapter/repositories/GitHubIssueRepository.js +340 -0
  20. package/bin/adapter/repositories/GitHubIssueRepository.js.map +1 -0
  21. package/bin/adapter/repositories/GitHubProjectRepository.js +123 -0
  22. package/bin/adapter/repositories/GitHubProjectRepository.js.map +1 -0
  23. package/bin/adapter/repositories/NodeLocalCommandRunner.js +34 -0
  24. package/bin/adapter/repositories/NodeLocalCommandRunner.js.map +1 -0
  25. package/bin/domain/entities/Issue.js +3 -0
  26. package/bin/domain/entities/Issue.js.map +1 -0
  27. package/bin/domain/entities/Project.js +3 -0
  28. package/bin/domain/entities/Project.js.map +1 -0
  29. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +37 -0
  30. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -0
  31. package/bin/domain/usecases/StartPreparationUseCase.js +31 -0
  32. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -0
  33. package/bin/domain/usecases/adapter-interfaces/IssueRepository.js +3 -0
  34. package/bin/domain/usecases/adapter-interfaces/IssueRepository.js.map +1 -0
  35. package/bin/domain/usecases/adapter-interfaces/LocalCommandRunner.js +3 -0
  36. package/bin/domain/usecases/adapter-interfaces/LocalCommandRunner.js.map +1 -0
  37. package/bin/domain/usecases/adapter-interfaces/ProjectRepository.js +3 -0
  38. package/bin/domain/usecases/adapter-interfaces/ProjectRepository.js.map +1 -0
  39. package/bin/index.js +6 -0
  40. package/bin/index.js.map +1 -0
  41. package/commitlint.config.js +6 -0
  42. package/jest.config.js +33 -0
  43. package/package.json +75 -0
  44. package/renovate.json +37 -0
  45. package/src/adapter/entry-points/cli/index.integration.test.ts +143 -0
  46. package/src/adapter/entry-points/cli/index.test.ts +165 -0
  47. package/src/adapter/entry-points/cli/index.ts +110 -0
  48. package/src/adapter/repositories/GitHubIssueRepository.integration.test.ts +50 -0
  49. package/src/adapter/repositories/GitHubIssueRepository.test.ts +996 -0
  50. package/src/adapter/repositories/GitHubIssueRepository.ts +470 -0
  51. package/src/adapter/repositories/GitHubProjectRepository.test.ts +252 -0
  52. package/src/adapter/repositories/GitHubProjectRepository.ts +162 -0
  53. package/src/adapter/repositories/NodeLocalCommandRunner.test.ts +80 -0
  54. package/src/adapter/repositories/NodeLocalCommandRunner.ts +37 -0
  55. package/src/domain/entities/Issue.ts +7 -0
  56. package/src/domain/entities/Project.ts +7 -0
  57. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +109 -0
  58. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +48 -0
  59. package/src/domain/usecases/StartPreparationUseCase.test.ts +150 -0
  60. package/src/domain/usecases/StartPreparationUseCase.ts +48 -0
  61. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +8 -0
  62. package/src/domain/usecases/adapter-interfaces/LocalCommandRunner.ts +7 -0
  63. package/src/domain/usecases/adapter-interfaces/ProjectRepository.ts +5 -0
  64. package/src/index.test.ts +7 -0
  65. package/src/index.ts +3 -0
  66. package/tsconfig.build.json +11 -0
  67. package/tsconfig.json +16 -0
  68. package/types/adapter/entry-points/cli/index.d.ts +5 -0
  69. package/types/adapter/entry-points/cli/index.d.ts.map +1 -0
  70. package/types/adapter/repositories/GitHubIssueRepository.d.ts +14 -0
  71. package/types/adapter/repositories/GitHubIssueRepository.d.ts.map +1 -0
  72. package/types/adapter/repositories/GitHubProjectRepository.d.ts +9 -0
  73. package/types/adapter/repositories/GitHubProjectRepository.d.ts.map +1 -0
  74. package/types/adapter/repositories/NodeLocalCommandRunner.d.ts +9 -0
  75. package/types/adapter/repositories/NodeLocalCommandRunner.d.ts.map +1 -0
  76. package/types/domain/entities/Issue.d.ts +8 -0
  77. package/types/domain/entities/Issue.d.ts.map +1 -0
  78. package/types/domain/entities/Project.d.ts +8 -0
  79. package/types/domain/entities/Project.d.ts.map +1 -0
  80. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +20 -0
  81. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -0
  82. package/types/domain/usecases/StartPreparationUseCase.d.ts +17 -0
  83. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -0
  84. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +8 -0
  85. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -0
  86. package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts +8 -0
  87. package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts.map +1 -0
  88. package/types/domain/usecases/adapter-interfaces/ProjectRepository.d.ts +5 -0
  89. package/types/domain/usecases/adapter-interfaces/ProjectRepository.d.ts.map +1 -0
  90. package/types/index.d.ts +3 -0
  91. package/types/index.d.ts.map +1 -0
@@ -0,0 +1,996 @@
1
+ import { GitHubIssueRepository } from './GitHubIssueRepository';
2
+ import { Project } from '../../domain/entities/Project';
3
+ import { Issue } from '../../domain/entities/Issue';
4
+
5
+ describe('GitHubIssueRepository', () => {
6
+ let repository: GitHubIssueRepository;
7
+ let mockFetch: jest.Mock;
8
+
9
+ const mockProject: Project = {
10
+ id: '123',
11
+ url: 'https://github.com/orgs/test-org/projects/123',
12
+ name: 'Test Project',
13
+ statuses: ['Awaiting Workspace', 'Preparation', 'Done'],
14
+ customFieldNames: ['Status', 'workspace'],
15
+ };
16
+
17
+ const mockUserProject: Project = {
18
+ id: 'user-123',
19
+ url: 'https://github.com/users/testuser/projects/456',
20
+ name: 'User Project',
21
+ statuses: ['Todo', 'Done'],
22
+ customFieldNames: ['Status'],
23
+ };
24
+
25
+ beforeEach(() => {
26
+ repository = new GitHubIssueRepository('test-token');
27
+ mockFetch = jest.fn();
28
+ global.fetch = mockFetch;
29
+ jest.clearAllMocks();
30
+ });
31
+
32
+ describe('getAllOpened', () => {
33
+ it('should fetch all opened issues from project', async () => {
34
+ mockFetch.mockResolvedValueOnce({
35
+ ok: true,
36
+ json: jest.fn().mockResolvedValue({
37
+ data: {
38
+ organization: {
39
+ projectV2: {
40
+ items: {
41
+ nodes: [
42
+ {
43
+ id: 'issue-1',
44
+ content: {
45
+ url: 'https://github.com/owner/repo/issues/1',
46
+ title: 'Test Issue 1',
47
+ number: 1,
48
+ labels: {
49
+ nodes: [{ name: 'bug' }, { name: 'category:impl' }],
50
+ },
51
+ },
52
+ fieldValues: {
53
+ nodes: [
54
+ {
55
+ name: 'Awaiting Workspace',
56
+ field: {
57
+ name: 'Status',
58
+ },
59
+ },
60
+ ],
61
+ },
62
+ },
63
+ {
64
+ id: 'issue-2',
65
+ content: {
66
+ url: 'https://github.com/owner/repo/issues/2',
67
+ title: 'Test Issue 2',
68
+ number: 2,
69
+ labels: {
70
+ nodes: [{ name: 'enhancement' }],
71
+ },
72
+ },
73
+ fieldValues: {
74
+ nodes: [
75
+ {
76
+ name: 'Preparation',
77
+ field: {
78
+ name: 'Status',
79
+ },
80
+ },
81
+ ],
82
+ },
83
+ },
84
+ ],
85
+ },
86
+ },
87
+ },
88
+ },
89
+ }),
90
+ });
91
+
92
+ const issues = await repository.getAllOpened(mockProject);
93
+
94
+ expect(issues).toHaveLength(2);
95
+ expect(issues[0]).toEqual({
96
+ id: 'issue-1',
97
+ url: 'https://github.com/owner/repo/issues/1',
98
+ title: 'Test Issue 1',
99
+ labels: ['bug', 'category:impl'],
100
+ status: 'Awaiting Workspace',
101
+ });
102
+ expect(issues[1]).toEqual({
103
+ id: 'issue-2',
104
+ url: 'https://github.com/owner/repo/issues/2',
105
+ title: 'Test Issue 2',
106
+ labels: ['enhancement'],
107
+ status: 'Preparation',
108
+ });
109
+ });
110
+
111
+ it('should throw error when response is not ok', async () => {
112
+ mockFetch.mockResolvedValueOnce({
113
+ ok: false,
114
+ text: jest.fn().mockResolvedValue('API Error'),
115
+ });
116
+
117
+ await expect(repository.getAllOpened(mockProject)).rejects.toThrow(
118
+ 'GitHub API error',
119
+ );
120
+ });
121
+
122
+ it('should throw error when response format is invalid', async () => {
123
+ mockFetch.mockResolvedValueOnce({
124
+ ok: true,
125
+ json: jest.fn().mockResolvedValue(null),
126
+ });
127
+
128
+ await expect(repository.getAllOpened(mockProject)).rejects.toThrow(
129
+ 'Invalid API response format',
130
+ );
131
+ });
132
+
133
+ it('should handle invalid project URL', async () => {
134
+ const invalidProject: Project = {
135
+ ...mockProject,
136
+ url: 'https://invalid-url',
137
+ };
138
+
139
+ await expect(repository.getAllOpened(invalidProject)).rejects.toThrow(
140
+ 'Invalid GitHub project URL',
141
+ );
142
+ });
143
+
144
+ it('should fetch issues from user project', async () => {
145
+ mockFetch.mockResolvedValueOnce({
146
+ ok: true,
147
+ json: jest.fn().mockResolvedValue({
148
+ data: {
149
+ user: {
150
+ projectV2: {
151
+ items: {
152
+ nodes: [
153
+ {
154
+ id: 'user-issue-1',
155
+ content: {
156
+ url: 'https://github.com/owner/repo/issues/10',
157
+ title: 'User Issue',
158
+ number: 10,
159
+ labels: {
160
+ nodes: [{ name: 'enhancement' }],
161
+ },
162
+ },
163
+ fieldValues: {
164
+ nodes: [
165
+ {
166
+ name: 'Todo',
167
+ field: {
168
+ name: 'Status',
169
+ },
170
+ },
171
+ ],
172
+ },
173
+ },
174
+ ],
175
+ },
176
+ },
177
+ },
178
+ },
179
+ }),
180
+ });
181
+
182
+ const issues = await repository.getAllOpened(mockUserProject);
183
+
184
+ expect(issues).toHaveLength(1);
185
+ expect(issues[0]).toEqual({
186
+ id: 'user-issue-1',
187
+ url: 'https://github.com/owner/repo/issues/10',
188
+ title: 'User Issue',
189
+ labels: ['enhancement'],
190
+ status: 'Todo',
191
+ });
192
+ });
193
+
194
+ it('should skip items without content', async () => {
195
+ mockFetch.mockResolvedValueOnce({
196
+ ok: true,
197
+ json: jest.fn().mockResolvedValue({
198
+ data: {
199
+ organization: {
200
+ projectV2: {
201
+ items: {
202
+ nodes: [
203
+ {
204
+ id: 'item-without-content',
205
+ content: null,
206
+ },
207
+ {
208
+ id: 'valid-issue',
209
+ content: {
210
+ url: 'https://github.com/owner/repo/issues/5',
211
+ title: 'Valid Issue',
212
+ number: 5,
213
+ labels: {
214
+ nodes: [],
215
+ },
216
+ },
217
+ fieldValues: {
218
+ nodes: [
219
+ {
220
+ name: 'Done',
221
+ field: {
222
+ name: 'Status',
223
+ },
224
+ },
225
+ ],
226
+ },
227
+ },
228
+ ],
229
+ },
230
+ },
231
+ },
232
+ },
233
+ }),
234
+ });
235
+
236
+ const issues = await repository.getAllOpened(mockProject);
237
+
238
+ expect(issues).toHaveLength(1);
239
+ expect(issues[0].id).toBe('valid-issue');
240
+ });
241
+
242
+ it('should return empty array when both organization and user projects are null', async () => {
243
+ mockFetch.mockResolvedValueOnce({
244
+ ok: true,
245
+ json: jest.fn().mockResolvedValue({
246
+ data: {
247
+ organization: null,
248
+ user: null,
249
+ },
250
+ }),
251
+ });
252
+
253
+ const issues = await repository.getAllOpened(mockProject);
254
+
255
+ expect(issues).toEqual([]);
256
+ });
257
+
258
+ it('should handle issue without Status field', async () => {
259
+ mockFetch.mockResolvedValueOnce({
260
+ ok: true,
261
+ json: jest.fn().mockResolvedValue({
262
+ data: {
263
+ organization: {
264
+ projectV2: {
265
+ items: {
266
+ nodes: [
267
+ {
268
+ id: 'issue-no-status',
269
+ content: {
270
+ url: 'https://github.com/owner/repo/issues/99',
271
+ title: 'Issue Without Status',
272
+ number: 99,
273
+ labels: {
274
+ nodes: [{ name: 'bug' }],
275
+ },
276
+ },
277
+ fieldValues: {
278
+ nodes: [
279
+ {
280
+ name: 'Some Value',
281
+ field: {
282
+ name: 'OtherField',
283
+ },
284
+ },
285
+ ],
286
+ },
287
+ },
288
+ ],
289
+ },
290
+ },
291
+ },
292
+ },
293
+ }),
294
+ });
295
+
296
+ const issues = await repository.getAllOpened(mockProject);
297
+
298
+ expect(issues).toHaveLength(1);
299
+ expect(issues[0]).toEqual({
300
+ id: 'issue-no-status',
301
+ url: 'https://github.com/owner/repo/issues/99',
302
+ title: 'Issue Without Status',
303
+ labels: ['bug'],
304
+ status: '',
305
+ });
306
+ });
307
+ });
308
+
309
+ describe('update', () => {
310
+ it('should update issue status', async () => {
311
+ const issue: Issue = {
312
+ id: 'issue-1',
313
+ url: 'https://github.com/owner/repo/issues/1',
314
+ title: 'Test Issue',
315
+ labels: ['bug'],
316
+ status: 'Preparation',
317
+ };
318
+
319
+ mockFetch.mockResolvedValueOnce({
320
+ ok: true,
321
+ json: jest.fn().mockResolvedValue({
322
+ data: {
323
+ organization: {
324
+ projectV2: {
325
+ fields: {
326
+ nodes: [
327
+ {
328
+ id: 'field-1',
329
+ name: 'Status',
330
+ options: [{ id: 'status-1', name: 'Preparation' }],
331
+ },
332
+ ],
333
+ },
334
+ },
335
+ },
336
+ },
337
+ }),
338
+ });
339
+
340
+ mockFetch.mockResolvedValueOnce({
341
+ ok: true,
342
+ json: jest.fn().mockResolvedValue({
343
+ data: {
344
+ updateProjectV2ItemFieldValue: {
345
+ projectV2Item: {
346
+ id: 'issue-1',
347
+ },
348
+ },
349
+ },
350
+ }),
351
+ });
352
+
353
+ await repository.update(issue, mockProject);
354
+
355
+ expect(mockFetch).toHaveBeenCalledWith(
356
+ 'https://api.github.com/graphql',
357
+ expect.objectContaining({
358
+ method: 'POST',
359
+ headers: {
360
+ Authorization: 'Bearer test-token',
361
+ 'Content-Type': 'application/json',
362
+ },
363
+ }),
364
+ );
365
+ });
366
+
367
+ it('should update issue status to Done', async () => {
368
+ const issue: Issue = {
369
+ id: 'issue-1',
370
+ url: 'https://github.com/owner/repo/issues/1',
371
+ title: 'Test Issue',
372
+ labels: ['bug'],
373
+ status: 'Done',
374
+ };
375
+
376
+ mockFetch.mockResolvedValueOnce({
377
+ ok: true,
378
+ json: jest.fn().mockResolvedValue({
379
+ data: {
380
+ organization: {
381
+ projectV2: {
382
+ fields: {
383
+ nodes: [
384
+ {
385
+ id: 'field-1',
386
+ name: 'Status',
387
+ options: [{ id: 'status-2', name: 'Done' }],
388
+ },
389
+ ],
390
+ },
391
+ },
392
+ },
393
+ },
394
+ }),
395
+ });
396
+
397
+ mockFetch.mockResolvedValueOnce({
398
+ ok: true,
399
+ json: jest.fn().mockResolvedValue({
400
+ data: {
401
+ updateProjectV2ItemFieldValue: {
402
+ projectV2Item: {
403
+ id: 'issue-1',
404
+ },
405
+ },
406
+ },
407
+ }),
408
+ });
409
+
410
+ await repository.update(issue, mockProject);
411
+
412
+ expect(mockFetch).toHaveBeenCalledWith(
413
+ 'https://api.github.com/graphql',
414
+ expect.objectContaining({
415
+ method: 'POST',
416
+ }),
417
+ );
418
+ });
419
+
420
+ it('should throw error when status option not found', async () => {
421
+ const issue: Issue = {
422
+ id: 'issue-1',
423
+ url: 'https://github.com/owner/repo/issues/1',
424
+ title: 'Test Issue',
425
+ labels: ['bug'],
426
+ status: 'NonExistentStatus',
427
+ };
428
+
429
+ mockFetch.mockResolvedValueOnce({
430
+ ok: true,
431
+ json: jest.fn().mockResolvedValue({
432
+ data: {
433
+ organization: {
434
+ projectV2: {
435
+ fields: {
436
+ nodes: [
437
+ {
438
+ id: 'field-1',
439
+ name: 'Status',
440
+ options: [{ id: 'status-1', name: 'Preparation' }],
441
+ },
442
+ ],
443
+ },
444
+ },
445
+ },
446
+ },
447
+ }),
448
+ });
449
+
450
+ await expect(repository.update(issue, mockProject)).rejects.toThrow(
451
+ 'Status option not found for status: NonExistentStatus',
452
+ );
453
+ });
454
+
455
+ it('should throw error when getStatusOptionId response is not ok', async () => {
456
+ const issue: Issue = {
457
+ id: 'issue-1',
458
+ url: 'https://github.com/owner/repo/issues/1',
459
+ title: 'Test Issue',
460
+ labels: ['bug'],
461
+ status: 'Preparation',
462
+ };
463
+
464
+ mockFetch.mockResolvedValueOnce({
465
+ ok: false,
466
+ });
467
+
468
+ await expect(repository.update(issue, mockProject)).rejects.toThrow(
469
+ 'Status option not found',
470
+ );
471
+ });
472
+
473
+ it('should throw error when getStatusOptionId response is invalid', async () => {
474
+ const issue: Issue = {
475
+ id: 'issue-1',
476
+ url: 'https://github.com/owner/repo/issues/1',
477
+ title: 'Test Issue',
478
+ labels: ['bug'],
479
+ status: 'Preparation',
480
+ };
481
+
482
+ mockFetch.mockResolvedValueOnce({
483
+ ok: true,
484
+ json: jest.fn().mockResolvedValue(null),
485
+ });
486
+
487
+ await expect(repository.update(issue, mockProject)).rejects.toThrow(
488
+ 'Status option not found',
489
+ );
490
+ });
491
+
492
+ it('should throw error when status field not found', async () => {
493
+ const issue: Issue = {
494
+ id: 'issue-1',
495
+ url: 'https://github.com/owner/repo/issues/1',
496
+ title: 'Test Issue',
497
+ labels: ['bug'],
498
+ status: 'Preparation',
499
+ };
500
+
501
+ mockFetch.mockResolvedValueOnce({
502
+ ok: true,
503
+ json: jest.fn().mockResolvedValue({
504
+ data: {
505
+ organization: {
506
+ projectV2: {
507
+ fields: {
508
+ nodes: [
509
+ {
510
+ id: 'field-1',
511
+ name: 'NotStatus',
512
+ options: [{ id: 'status-1', name: 'Preparation' }],
513
+ },
514
+ ],
515
+ },
516
+ },
517
+ },
518
+ },
519
+ }),
520
+ });
521
+
522
+ await expect(repository.update(issue, mockProject)).rejects.toThrow(
523
+ 'Status option not found',
524
+ );
525
+ });
526
+
527
+ it('should throw error when update mutation fails', async () => {
528
+ const issue: Issue = {
529
+ id: 'issue-1',
530
+ url: 'https://github.com/owner/repo/issues/1',
531
+ title: 'Test Issue',
532
+ labels: ['bug'],
533
+ status: 'Preparation',
534
+ };
535
+
536
+ mockFetch.mockResolvedValueOnce({
537
+ ok: true,
538
+ json: jest.fn().mockResolvedValue({
539
+ data: {
540
+ organization: {
541
+ projectV2: {
542
+ fields: {
543
+ nodes: [
544
+ {
545
+ id: 'field-1',
546
+ name: 'Status',
547
+ options: [{ id: 'status-1', name: 'Preparation' }],
548
+ },
549
+ ],
550
+ },
551
+ },
552
+ },
553
+ },
554
+ }),
555
+ });
556
+
557
+ mockFetch.mockResolvedValueOnce({
558
+ ok: false,
559
+ json: jest.fn().mockResolvedValue({ error: 'API Error' }),
560
+ });
561
+
562
+ await expect(repository.update(issue, mockProject)).rejects.toThrow(
563
+ 'GitHub API error',
564
+ );
565
+ });
566
+
567
+ it('should throw error when update response has GraphQL errors', async () => {
568
+ const issue: Issue = {
569
+ id: 'issue-1',
570
+ url: 'https://github.com/owner/repo/issues/1',
571
+ title: 'Test Issue',
572
+ labels: ['bug'],
573
+ status: 'Preparation',
574
+ };
575
+
576
+ mockFetch.mockResolvedValueOnce({
577
+ ok: true,
578
+ json: jest.fn().mockResolvedValue({
579
+ data: {
580
+ organization: {
581
+ projectV2: {
582
+ fields: {
583
+ nodes: [
584
+ {
585
+ id: 'field-1',
586
+ name: 'Status',
587
+ options: [{ id: 'status-1', name: 'Preparation' }],
588
+ },
589
+ ],
590
+ },
591
+ },
592
+ },
593
+ },
594
+ }),
595
+ });
596
+
597
+ mockFetch.mockResolvedValueOnce({
598
+ ok: true,
599
+ json: jest.fn().mockResolvedValue({
600
+ errors: [{ message: 'GraphQL Error' }],
601
+ }),
602
+ });
603
+
604
+ await expect(repository.update(issue, mockProject)).rejects.toThrow(
605
+ 'GraphQL errors',
606
+ );
607
+ });
608
+
609
+ it('should throw error when update response format is invalid', async () => {
610
+ const issue: Issue = {
611
+ id: 'issue-1',
612
+ url: 'https://github.com/owner/repo/issues/1',
613
+ title: 'Test Issue',
614
+ labels: ['bug'],
615
+ status: 'Preparation',
616
+ };
617
+
618
+ mockFetch.mockResolvedValueOnce({
619
+ ok: true,
620
+ json: jest.fn().mockResolvedValue({
621
+ data: {
622
+ organization: {
623
+ projectV2: {
624
+ fields: {
625
+ nodes: [
626
+ {
627
+ id: 'field-1',
628
+ name: 'Status',
629
+ options: [{ id: 'status-1', name: 'Preparation' }],
630
+ },
631
+ ],
632
+ },
633
+ },
634
+ },
635
+ },
636
+ }),
637
+ });
638
+
639
+ mockFetch.mockResolvedValueOnce({
640
+ ok: true,
641
+ json: jest.fn().mockResolvedValue(null),
642
+ });
643
+
644
+ await expect(repository.update(issue, mockProject)).rejects.toThrow(
645
+ 'Invalid API response format',
646
+ );
647
+ });
648
+
649
+ it('should update issue status for user project', async () => {
650
+ const issue: Issue = {
651
+ id: 'issue-1',
652
+ url: 'https://github.com/owner/repo/issues/1',
653
+ title: 'Test Issue',
654
+ labels: ['bug'],
655
+ status: 'Todo',
656
+ };
657
+
658
+ mockFetch.mockResolvedValueOnce({
659
+ ok: true,
660
+ json: jest.fn().mockResolvedValue({
661
+ data: {
662
+ user: {
663
+ projectV2: {
664
+ fields: {
665
+ nodes: [
666
+ {
667
+ id: 'field-1',
668
+ name: 'Status',
669
+ options: [{ id: 'status-1', name: 'Todo' }],
670
+ },
671
+ ],
672
+ },
673
+ },
674
+ },
675
+ },
676
+ }),
677
+ });
678
+
679
+ mockFetch.mockResolvedValueOnce({
680
+ ok: true,
681
+ json: jest.fn().mockResolvedValue({
682
+ data: {
683
+ updateProjectV2ItemFieldValue: {
684
+ projectV2Item: {
685
+ id: 'issue-1',
686
+ },
687
+ },
688
+ },
689
+ }),
690
+ });
691
+
692
+ await repository.update(issue, mockUserProject);
693
+
694
+ expect(mockFetch).toHaveBeenCalled();
695
+ });
696
+
697
+ it('should throw error when both organization and user projects are null in getStatusOptionId', async () => {
698
+ const issue: Issue = {
699
+ id: 'issue-1',
700
+ url: 'https://github.com/owner/repo/issues/1',
701
+ title: 'Test Issue',
702
+ labels: ['bug'],
703
+ status: 'Preparation',
704
+ };
705
+
706
+ mockFetch.mockResolvedValueOnce({
707
+ ok: true,
708
+ json: jest.fn().mockResolvedValue({
709
+ data: {
710
+ organization: null,
711
+ user: null,
712
+ },
713
+ }),
714
+ });
715
+
716
+ await expect(repository.update(issue, mockProject)).rejects.toThrow(
717
+ 'Status option not found',
718
+ );
719
+ });
720
+ });
721
+
722
+ describe('get', () => {
723
+ it('should return issue when found', async () => {
724
+ mockFetch.mockResolvedValueOnce({
725
+ ok: true,
726
+ json: jest.fn().mockResolvedValue({
727
+ data: {
728
+ organization: {
729
+ projectV2: {
730
+ items: {
731
+ nodes: [
732
+ {
733
+ id: 'issue-1',
734
+ content: {
735
+ url: 'https://github.com/owner/repo/issues/1',
736
+ title: 'Test Issue',
737
+ number: 1,
738
+ labels: {
739
+ nodes: [{ name: 'bug' }],
740
+ },
741
+ },
742
+ fieldValues: {
743
+ nodes: [
744
+ {
745
+ name: 'Preparation',
746
+ field: {
747
+ name: 'Status',
748
+ },
749
+ },
750
+ ],
751
+ },
752
+ },
753
+ ],
754
+ },
755
+ },
756
+ },
757
+ },
758
+ }),
759
+ });
760
+
761
+ const result = await repository.get(
762
+ 'https://github.com/owner/repo/issues/1',
763
+ mockProject,
764
+ );
765
+
766
+ expect(result).toEqual({
767
+ id: 'issue-1',
768
+ url: 'https://github.com/owner/repo/issues/1',
769
+ title: 'Test Issue',
770
+ labels: ['bug'],
771
+ status: 'Preparation',
772
+ });
773
+ });
774
+
775
+ it('should return null when response is not ok', async () => {
776
+ mockFetch.mockResolvedValueOnce({
777
+ ok: false,
778
+ });
779
+
780
+ const result = await repository.get(
781
+ 'https://github.com/owner/repo/issues/1',
782
+ mockProject,
783
+ );
784
+ expect(result).toBeNull();
785
+ });
786
+
787
+ it('should return null when response format is invalid', async () => {
788
+ mockFetch.mockResolvedValueOnce({
789
+ ok: true,
790
+ json: jest.fn().mockResolvedValue(null),
791
+ });
792
+
793
+ const result = await repository.get(
794
+ 'https://github.com/owner/repo/issues/1',
795
+ mockProject,
796
+ );
797
+ expect(result).toBeNull();
798
+ });
799
+
800
+ it('should return null when issue not found', async () => {
801
+ mockFetch.mockResolvedValueOnce({
802
+ ok: true,
803
+ json: jest.fn().mockResolvedValue({
804
+ data: {
805
+ organization: {
806
+ projectV2: {
807
+ items: {
808
+ nodes: [],
809
+ },
810
+ },
811
+ },
812
+ },
813
+ }),
814
+ });
815
+
816
+ const result = await repository.get(
817
+ 'https://github.com/owner/repo/issues/999',
818
+ mockProject,
819
+ );
820
+ expect(result).toBeNull();
821
+ });
822
+
823
+ it('should return issue from user project when found', async () => {
824
+ mockFetch.mockResolvedValueOnce({
825
+ ok: true,
826
+ json: jest.fn().mockResolvedValue({
827
+ data: {
828
+ user: {
829
+ projectV2: {
830
+ items: {
831
+ nodes: [
832
+ {
833
+ id: 'issue-2',
834
+ content: {
835
+ url: 'https://github.com/owner/repo/issues/2',
836
+ title: 'User Issue',
837
+ number: 2,
838
+ labels: {
839
+ nodes: [{ name: 'feature' }],
840
+ },
841
+ },
842
+ fieldValues: {
843
+ nodes: [
844
+ {
845
+ name: 'Todo',
846
+ field: {
847
+ name: 'Status',
848
+ },
849
+ },
850
+ ],
851
+ },
852
+ },
853
+ ],
854
+ },
855
+ },
856
+ },
857
+ },
858
+ }),
859
+ });
860
+
861
+ const result = await repository.get(
862
+ 'https://github.com/owner/repo/issues/2',
863
+ mockUserProject,
864
+ );
865
+
866
+ expect(result).toEqual({
867
+ id: 'issue-2',
868
+ url: 'https://github.com/owner/repo/issues/2',
869
+ title: 'User Issue',
870
+ labels: ['feature'],
871
+ status: 'Todo',
872
+ });
873
+ });
874
+
875
+ it('should return issue with empty status when status field not found', async () => {
876
+ mockFetch.mockResolvedValueOnce({
877
+ ok: true,
878
+ json: jest.fn().mockResolvedValue({
879
+ data: {
880
+ organization: {
881
+ projectV2: {
882
+ items: {
883
+ nodes: [
884
+ {
885
+ id: 'issue-3',
886
+ content: {
887
+ url: 'https://github.com/owner/repo/issues/3',
888
+ title: 'Issue Without Status',
889
+ number: 3,
890
+ labels: {
891
+ nodes: [],
892
+ },
893
+ },
894
+ fieldValues: {
895
+ nodes: [
896
+ {
897
+ text: 'Some text',
898
+ field: {
899
+ name: 'Description',
900
+ },
901
+ },
902
+ ],
903
+ },
904
+ },
905
+ ],
906
+ },
907
+ },
908
+ },
909
+ },
910
+ }),
911
+ });
912
+
913
+ const result = await repository.get(
914
+ 'https://github.com/owner/repo/issues/3',
915
+ mockProject,
916
+ );
917
+
918
+ expect(result).toEqual({
919
+ id: 'issue-3',
920
+ url: 'https://github.com/owner/repo/issues/3',
921
+ title: 'Issue Without Status',
922
+ labels: [],
923
+ status: '',
924
+ });
925
+ });
926
+
927
+ it('should skip items without content in get method', async () => {
928
+ mockFetch.mockResolvedValueOnce({
929
+ ok: true,
930
+ json: jest.fn().mockResolvedValue({
931
+ data: {
932
+ organization: {
933
+ projectV2: {
934
+ items: {
935
+ nodes: [
936
+ {
937
+ id: 'item-without-content',
938
+ content: null,
939
+ },
940
+ {
941
+ id: 'target-issue',
942
+ content: {
943
+ url: 'https://github.com/owner/repo/issues/4',
944
+ title: 'Target Issue',
945
+ number: 4,
946
+ labels: {
947
+ nodes: [],
948
+ },
949
+ },
950
+ fieldValues: {
951
+ nodes: [
952
+ {
953
+ name: 'Done',
954
+ field: {
955
+ name: 'Status',
956
+ },
957
+ },
958
+ ],
959
+ },
960
+ },
961
+ ],
962
+ },
963
+ },
964
+ },
965
+ },
966
+ }),
967
+ });
968
+
969
+ const result = await repository.get(
970
+ 'https://github.com/owner/repo/issues/4',
971
+ mockProject,
972
+ );
973
+
974
+ expect(result?.id).toBe('target-issue');
975
+ });
976
+
977
+ it('should return null when both organization and user projects are null', async () => {
978
+ mockFetch.mockResolvedValueOnce({
979
+ ok: true,
980
+ json: jest.fn().mockResolvedValue({
981
+ data: {
982
+ organization: null,
983
+ user: null,
984
+ },
985
+ }),
986
+ });
987
+
988
+ const result = await repository.get(
989
+ 'https://github.com/owner/repo/issues/1',
990
+ mockProject,
991
+ );
992
+
993
+ expect(result).toBeNull();
994
+ });
995
+ });
996
+ });