github-issue-tower-defence-management 2.6.1 → 2.8.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 (121) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +0 -2
  3. package/bin/adapter/entry-points/cli/index.js +3 -6
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/cli/projectConfig.js +0 -3
  6. package/bin/adapter/entry-points/cli/projectConfig.js.map +1 -1
  7. package/bin/adapter/entry-points/console/consoleAirplaneSnapshotApi.js +347 -0
  8. package/bin/adapter/entry-points/console/consoleAirplaneSnapshotApi.js.map +1 -0
  9. package/bin/adapter/entry-points/console/ui-dist/assets/index-DkMqSpkk.js +88 -0
  10. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  11. package/bin/adapter/entry-points/console/webServer.js +28 -0
  12. package/bin/adapter/entry-points/console/webServer.js.map +1 -1
  13. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +1 -3
  14. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  15. package/bin/domain/usecases/CheckIssueReviewReadinessUseCase.js +4 -2
  16. package/bin/domain/usecases/CheckIssueReviewReadinessUseCase.js.map +1 -1
  17. package/bin/domain/usecases/HandleScheduledEventUseCase.js +1 -6
  18. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  19. package/bin/domain/usecases/IssueRejectionEvaluator.js +0 -23
  20. package/bin/domain/usecases/IssueRejectionEvaluator.js.map +1 -1
  21. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +39 -69
  22. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  23. package/bin/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.js +3 -2
  24. package/bin/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.js.map +1 -1
  25. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +0 -43
  26. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
  27. package/bin/domain/usecases/autoStatusCheckComments.js +1 -11
  28. package/bin/domain/usecases/autoStatusCheckComments.js.map +1 -1
  29. package/bin/domain/usecases/extractNextStepAgentFromComments.js +14 -0
  30. package/bin/domain/usecases/extractNextStepAgentFromComments.js.map +1 -0
  31. package/bin/domain/usecases/triagerAgentName.js +7 -0
  32. package/bin/domain/usecases/triagerAgentName.js.map +1 -0
  33. package/jest.config.js +1 -0
  34. package/package.json +1 -1
  35. package/src/adapter/entry-points/cli/index.test.ts +0 -2
  36. package/src/adapter/entry-points/cli/index.ts +4 -10
  37. package/src/adapter/entry-points/cli/projectConfig.ts +0 -8
  38. package/src/adapter/entry-points/console/consoleAirplaneSnapshotApi.test.ts +658 -0
  39. package/src/adapter/entry-points/console/consoleAirplaneSnapshotApi.ts +482 -0
  40. package/src/adapter/entry-points/console/ui/jest-globals.cjs +26 -0
  41. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleFileDiff.test.tsx +10 -0
  42. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleFileDiff.tsx +23 -16
  43. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleAirplaneModeButton.stories.tsx +56 -0
  44. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleAirplaneModeButton.test.tsx +111 -0
  45. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleAirplaneModeButton.tsx +92 -0
  46. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.stories.tsx +15 -0
  47. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.test.tsx +19 -0
  48. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.tsx +30 -1
  49. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleErrorToast.stories.tsx +10 -0
  50. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.test.tsx +1 -3
  51. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.tsx +8 -7
  52. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useAirplaneMode.test.ts +304 -0
  53. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useAirplaneMode.ts +167 -0
  54. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.test.ts +15 -0
  55. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.ts +6 -1
  56. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleCaches.test.ts +77 -0
  57. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleCaches.ts +48 -3
  58. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleFeaturesConfig.test.ts +51 -0
  59. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleFeaturesConfig.ts +39 -0
  60. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.test.ts +215 -0
  61. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.ts +20 -1
  62. package/src/adapter/entry-points/console/ui/src/features/console/lib/airplaneSnapshot.test.ts +361 -0
  63. package/src/adapter/entry-points/console/ui/src/features/console/lib/airplaneSnapshot.ts +401 -0
  64. package/src/adapter/entry-points/console/ui/src/features/console/lib/resourceCache.ts +4 -0
  65. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +209 -0
  66. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +27 -2
  67. package/src/adapter/entry-points/console/ui-dist/assets/index-DkMqSpkk.js +88 -0
  68. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  69. package/src/adapter/entry-points/console/webServer.test.ts +189 -0
  70. package/src/adapter/entry-points/console/webServer.ts +38 -0
  71. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +0 -7
  72. package/src/domain/usecases/CheckIssueReviewReadinessUseCase.test.ts +26 -4
  73. package/src/domain/usecases/CheckIssueReviewReadinessUseCase.ts +5 -3
  74. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +0 -28
  75. package/src/domain/usecases/HandleScheduledEventUseCase.ts +0 -6
  76. package/src/domain/usecases/IssueRejectionEvaluator.test.ts +0 -119
  77. package/src/domain/usecases/IssueRejectionEvaluator.ts +0 -21
  78. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +185 -327
  79. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +68 -138
  80. package/src/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.test.ts +4 -4
  81. package/src/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.ts +11 -5
  82. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +0 -313
  83. package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +1 -102
  84. package/src/domain/usecases/autoStatusCheckComments.ts +0 -17
  85. package/src/domain/usecases/extractNextStepAgentFromComments.ts +13 -0
  86. package/src/domain/usecases/isHumanComment.test.ts +0 -13
  87. package/src/domain/usecases/triagerAgentName.ts +4 -0
  88. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  89. package/types/adapter/entry-points/cli/projectConfig.d.ts +0 -1
  90. package/types/adapter/entry-points/cli/projectConfig.d.ts.map +1 -1
  91. package/types/adapter/entry-points/console/consoleAirplaneSnapshotApi.d.ts +90 -0
  92. package/types/adapter/entry-points/console/consoleAirplaneSnapshotApi.d.ts.map +1 -0
  93. package/types/adapter/entry-points/console/webServer.d.ts +1 -0
  94. package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
  95. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  96. package/types/domain/usecases/CheckIssueReviewReadinessUseCase.d.ts.map +1 -1
  97. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +1 -3
  98. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  99. package/types/domain/usecases/IssueRejectionEvaluator.d.ts +0 -1
  100. package/types/domain/usecases/IssueRejectionEvaluator.d.ts.map +1 -1
  101. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +0 -1
  102. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  103. package/types/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.d.ts.map +1 -1
  104. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +0 -1
  105. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
  106. package/types/domain/usecases/autoStatusCheckComments.d.ts +0 -4
  107. package/types/domain/usecases/autoStatusCheckComments.d.ts.map +1 -1
  108. package/types/domain/usecases/extractNextStepAgentFromComments.d.ts +5 -0
  109. package/types/domain/usecases/extractNextStepAgentFromComments.d.ts.map +1 -0
  110. package/types/domain/usecases/triagerAgentName.d.ts +3 -0
  111. package/types/domain/usecases/triagerAgentName.d.ts.map +1 -0
  112. package/bin/adapter/entry-points/console/ui-dist/assets/index-CaxYBHiT.js +0 -86
  113. package/src/adapter/entry-points/console/ui-dist/assets/index-CaxYBHiT.js +0 -86
  114. package/src/domain/usecases/TriagerApprovalDispatchUseCase.test.ts +0 -1051
  115. package/src/domain/usecases/TriagerApprovalDispatchUseCase.ts +0 -274
  116. package/src/domain/usecases/awaitingOwnerApprovalMessage.ts +0 -5
  117. package/src/domain/usecases/isPullRequestDeclaredUnnecessary.test.ts +0 -162
  118. package/src/domain/usecases/isPullRequestDeclaredUnnecessary.ts +0 -29
  119. package/src/domain/usecases/isWaitingForOwnerApproval.test.ts +0 -91
  120. package/src/domain/usecases/isWaitingForOwnerApproval.ts +0 -17
  121. package/src/domain/usecases/returnedToAwaitingWorkspaceMessage.ts +0 -5
@@ -0,0 +1,658 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { mock } from 'jest-mock-extended';
5
+ import type { IssueRepository } from '../../../domain/usecases/adapter-interfaces/IssueRepository';
6
+ import { IssueTitleStateCache, PullRequestStatusCache } from './consoleReadApi';
7
+ import {
8
+ handleAirplaneSync,
9
+ type AirplaneSyncEvent,
10
+ type AirplaneSyncResponseWriter,
11
+ } from './consoleAirplaneSnapshotApi';
12
+
13
+ type DoneEvent = Extract<AirplaneSyncEvent, { type: 'done' }>;
14
+ type ProgressEvent = Extract<AirplaneSyncEvent, { type: 'progress' }>;
15
+
16
+ const isDoneEvent = (e: unknown): e is DoneEvent =>
17
+ e !== null && typeof e === 'object' && 'type' in e && e.type === 'done';
18
+
19
+ const isProgressEvent = (e: unknown): e is ProgressEvent =>
20
+ e !== null && typeof e === 'object' && 'type' in e && e.type === 'progress';
21
+
22
+ const collectSseEvents = (chunks: string[]): unknown[] => {
23
+ const raw = chunks.join('');
24
+ return raw
25
+ .split('\n\n')
26
+ .filter((block) => block.startsWith('data: '))
27
+ .map((block) => {
28
+ const event: unknown = JSON.parse(block.slice('data: '.length));
29
+ return event;
30
+ });
31
+ };
32
+
33
+ const makeResponse = (): {
34
+ response: AirplaneSyncResponseWriter;
35
+ chunks: string[];
36
+ ended: () => boolean;
37
+ } => {
38
+ const chunks: string[] = [];
39
+ let isEnded = false;
40
+ const response: AirplaneSyncResponseWriter = {
41
+ writeHead: jest.fn(),
42
+ write: (chunk: string) => {
43
+ chunks.push(chunk);
44
+ },
45
+ end: () => {
46
+ isEnded = true;
47
+ },
48
+ };
49
+ return { response, chunks, ended: () => isEnded };
50
+ };
51
+
52
+ const writeListJson = (
53
+ dir: string,
54
+ pjcode: string,
55
+ tab: string,
56
+ data: unknown,
57
+ ): void => {
58
+ const tabDir = path.join(dir, pjcode, tab);
59
+ fs.mkdirSync(tabDir, { recursive: true });
60
+ fs.writeFileSync(path.join(tabDir, 'list.json'), JSON.stringify(data));
61
+ };
62
+
63
+ describe('handleAirplaneSync', () => {
64
+ let tmpDir: string;
65
+
66
+ beforeEach(() => {
67
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'airplane-test-'));
68
+ });
69
+
70
+ afterEach(() => {
71
+ fs.rmSync(tmpDir, { recursive: true, force: true });
72
+ });
73
+
74
+ it('sends progress=0 immediately and done event with no items when data dir is empty', async () => {
75
+ const issueRepository = mock<IssueRepository>();
76
+ const { response, chunks, ended } = makeResponse();
77
+
78
+ await handleAirplaneSync(
79
+ response,
80
+ tmpDir,
81
+ issueRepository,
82
+ new IssueTitleStateCache(),
83
+ new PullRequestStatusCache(),
84
+ );
85
+
86
+ const events = collectSseEvents(chunks);
87
+ expect(events[0]).toEqual({ type: 'progress', fetched: 0, total: 0 });
88
+ const doneEvent = events.find(isDoneEvent);
89
+ expect(doneEvent).toBeDefined();
90
+ expect(doneEvent?.snapshot.failures).toEqual([]);
91
+ expect(doneEvent?.snapshot.items).toEqual({});
92
+ expect(ended()).toBe(true);
93
+ });
94
+
95
+ it('fetches body, comments, state and files/commits/prStatus for a PR item', async () => {
96
+ const issueRepository = mock<IssueRepository>();
97
+ const prUrl = 'https://github.com/owner/repo/pull/1';
98
+
99
+ writeListJson(tmpDir, 'acme', 'prs', {
100
+ generatedAt: '2026-01-01T00:00:00Z',
101
+ items: [
102
+ {
103
+ url: prUrl,
104
+ isPr: true,
105
+ number: 1,
106
+ projectItemId: 'PVTI_1',
107
+ },
108
+ ],
109
+ });
110
+
111
+ issueRepository.getIssueOrPullRequestBody.mockResolvedValue('PR body');
112
+ issueRepository.getIssueOrPullRequestComments.mockResolvedValue([
113
+ {
114
+ author: 'alice',
115
+ body: 'nice pr',
116
+ createdAt: new Date('2026-01-02T00:00:00Z'),
117
+ },
118
+ ]);
119
+ issueRepository.getIssueOrPullRequestState.mockResolvedValue({
120
+ state: 'open',
121
+ merged: false,
122
+ isPullRequest: true,
123
+ title: 'Fix the bug',
124
+ });
125
+ issueRepository.getPullRequestDetail.mockResolvedValue({
126
+ title: 'Fix the bug',
127
+ state: 'OPEN',
128
+ merged: false,
129
+ isDraft: false,
130
+ additions: 3,
131
+ deletions: 1,
132
+ changedFiles: 1,
133
+ headRefName: 'feature/fix',
134
+ baseRefName: 'main',
135
+ author: 'alice',
136
+ files: [
137
+ {
138
+ filename: 'src/foo.ts',
139
+ status: 'modified',
140
+ additions: 3,
141
+ deletions: 1,
142
+ patch: '@@ -1 +1 @@\n-old\n+new',
143
+ rawUrl: null,
144
+ },
145
+ ],
146
+ });
147
+ issueRepository.getPullRequestCommits.mockResolvedValue([
148
+ {
149
+ sha: 'abc123',
150
+ message: 'chore: update',
151
+ author: 'alice',
152
+ authoredAt: new Date('2026-01-01T10:00:00Z'),
153
+ },
154
+ ]);
155
+ issueRepository.getOpenPullRequestCiStatus.mockResolvedValue({
156
+ url: 'https://github.com/owner/repo/pull/1',
157
+ isConflicted: false,
158
+ mergeable: 'MERGEABLE',
159
+ isPassedAllCiJob: true,
160
+ isCiStateSuccess: true,
161
+ isBranchOutOfDate: false,
162
+ missingRequiredCheckNames: [],
163
+ });
164
+
165
+ const { response, chunks } = makeResponse();
166
+ await handleAirplaneSync(
167
+ response,
168
+ tmpDir,
169
+ issueRepository,
170
+ new IssueTitleStateCache(),
171
+ new PullRequestStatusCache(),
172
+ );
173
+
174
+ const events = collectSseEvents(chunks);
175
+ const progressEvents = events.filter(isProgressEvent);
176
+ expect(progressEvents[0]).toEqual({
177
+ type: 'progress',
178
+ fetched: 0,
179
+ total: 1,
180
+ });
181
+ expect(progressEvents[progressEvents.length - 1]).toEqual({
182
+ type: 'progress',
183
+ fetched: 1,
184
+ total: 1,
185
+ });
186
+
187
+ const doneEvent = events.find(isDoneEvent);
188
+ expect(doneEvent).toBeDefined();
189
+ expect(doneEvent?.snapshot.failures).toEqual([]);
190
+
191
+ const itemData = doneEvent?.snapshot.items[prUrl];
192
+ expect(itemData).toBeDefined();
193
+ expect(itemData?.body).toBe('PR body');
194
+ expect(itemData?.comments).toEqual([
195
+ {
196
+ author: 'alice',
197
+ body: 'nice pr',
198
+ createdAt: '2026-01-02T00:00:00.000Z',
199
+ },
200
+ ]);
201
+ expect(itemData?.state).toEqual({
202
+ state: 'open',
203
+ merged: false,
204
+ isPullRequest: true,
205
+ title: 'Fix the bug',
206
+ });
207
+ expect(itemData?.files).toEqual([
208
+ {
209
+ path: 'src/foo.ts',
210
+ additions: 3,
211
+ deletions: 1,
212
+ status: 'modified',
213
+ patch: '@@ -1 +1 @@\n-old\n+new',
214
+ rawUrl: null,
215
+ },
216
+ ]);
217
+ expect(itemData?.commits).toEqual([
218
+ {
219
+ sha: 'abc123',
220
+ message: 'chore: update',
221
+ author: 'alice',
222
+ authoredAt: '2026-01-01T10:00:00.000Z',
223
+ },
224
+ ]);
225
+ expect(itemData?.prStatus?.isPassedAllCiJob).toBe(true);
226
+ expect(itemData?.prStatus?.mergeableStatus).toBe('MERGEABLE');
227
+ expect(itemData?.relatedPrs).toBeNull();
228
+ });
229
+
230
+ it('fetches relatedPrs for a non-PR issue', async () => {
231
+ const issueRepository = mock<IssueRepository>();
232
+ const issueUrl = 'https://github.com/owner/repo/issues/5';
233
+
234
+ writeListJson(tmpDir, 'acme', 'todo-by-human', {
235
+ generatedAt: '2026-01-01T00:00:00Z',
236
+ items: [
237
+ {
238
+ url: issueUrl,
239
+ isPr: false,
240
+ number: 5,
241
+ projectItemId: 'PVTI_5',
242
+ },
243
+ ],
244
+ });
245
+
246
+ issueRepository.getIssueOrPullRequestBody.mockResolvedValue('Issue body');
247
+ issueRepository.getIssueOrPullRequestComments.mockResolvedValue([]);
248
+ issueRepository.getIssueOrPullRequestState.mockResolvedValue({
249
+ state: 'open',
250
+ merged: false,
251
+ isPullRequest: false,
252
+ title: 'Fix something',
253
+ });
254
+ issueRepository.findRelatedOpenPRs.mockResolvedValue([]);
255
+
256
+ const { response, chunks } = makeResponse();
257
+ await handleAirplaneSync(
258
+ response,
259
+ tmpDir,
260
+ issueRepository,
261
+ new IssueTitleStateCache(),
262
+ new PullRequestStatusCache(),
263
+ );
264
+
265
+ const events = collectSseEvents(chunks);
266
+ const doneEvent = events.find(isDoneEvent);
267
+ const itemData = doneEvent?.snapshot.items[issueUrl];
268
+ expect(itemData?.files).toBeNull();
269
+ expect(itemData?.commits).toBeNull();
270
+ expect(itemData?.prStatus).toBeNull();
271
+ expect(itemData?.relatedPrs).toEqual([]);
272
+ });
273
+
274
+ it('deduplicates items that appear in multiple tabs', async () => {
275
+ const issueRepository = mock<IssueRepository>();
276
+ const sharedUrl = 'https://github.com/owner/repo/pull/7';
277
+
278
+ const itemEntry = {
279
+ url: sharedUrl,
280
+ isPr: true,
281
+ number: 7,
282
+ projectItemId: 'PVTI_7',
283
+ };
284
+ writeListJson(tmpDir, 'acme', 'prs', {
285
+ generatedAt: '2026-01-01T00:00:00Z',
286
+ items: [itemEntry],
287
+ });
288
+ writeListJson(tmpDir, 'acme', 'workflow-blocker', {
289
+ generatedAt: '2026-01-01T00:00:00Z',
290
+ items: [itemEntry],
291
+ });
292
+
293
+ issueRepository.getIssueOrPullRequestBody.mockResolvedValue('body');
294
+ issueRepository.getIssueOrPullRequestComments.mockResolvedValue([]);
295
+ issueRepository.getIssueOrPullRequestState.mockResolvedValue({
296
+ state: 'open',
297
+ merged: false,
298
+ isPullRequest: true,
299
+ title: 'PR title',
300
+ });
301
+ issueRepository.getPullRequestDetail.mockResolvedValue(null);
302
+ issueRepository.getPullRequestCommits.mockResolvedValue([]);
303
+ issueRepository.getOpenPullRequestCiStatus.mockResolvedValue(null);
304
+
305
+ const { response, chunks } = makeResponse();
306
+ await handleAirplaneSync(
307
+ response,
308
+ tmpDir,
309
+ issueRepository,
310
+ new IssueTitleStateCache(),
311
+ new PullRequestStatusCache(),
312
+ );
313
+
314
+ const events = collectSseEvents(chunks);
315
+ const progressEvents = events.filter(isProgressEvent);
316
+ expect(progressEvents[0]?.total).toBe(1);
317
+
318
+ expect(issueRepository.getIssueOrPullRequestBody).toHaveBeenCalledTimes(1);
319
+ });
320
+
321
+ it('records failures and still sends done event when an item fetch fails', async () => {
322
+ const issueRepository = mock<IssueRepository>();
323
+ const failingUrl = 'https://github.com/owner/repo/issues/99';
324
+
325
+ writeListJson(tmpDir, 'acme', 'todo-by-human', {
326
+ generatedAt: '2026-01-01T00:00:00Z',
327
+ items: [
328
+ {
329
+ url: failingUrl,
330
+ isPr: false,
331
+ number: 99,
332
+ projectItemId: 'PVTI_99',
333
+ },
334
+ ],
335
+ });
336
+
337
+ issueRepository.getIssueOrPullRequestBody.mockRejectedValue(
338
+ new Error('network failure'),
339
+ );
340
+
341
+ const { response, chunks } = makeResponse();
342
+ await handleAirplaneSync(
343
+ response,
344
+ tmpDir,
345
+ issueRepository,
346
+ new IssueTitleStateCache(),
347
+ new PullRequestStatusCache(),
348
+ );
349
+
350
+ const events = collectSseEvents(chunks);
351
+ const doneEvent = events.find(isDoneEvent);
352
+ expect(doneEvent).toBeDefined();
353
+ expect(doneEvent?.snapshot.failures).toContain(failingUrl);
354
+ expect(doneEvent?.snapshot.items[failingUrl]).toBeUndefined();
355
+ });
356
+
357
+ it('does not report airplane mode on while a fetch is outstanding', async () => {
358
+ const issueRepository = mock<IssueRepository>();
359
+ const url1 = 'https://github.com/owner/repo/issues/1';
360
+ const url2 = 'https://github.com/owner/repo/issues/2';
361
+
362
+ writeListJson(tmpDir, 'acme', 'todo-by-human', {
363
+ generatedAt: '2026-01-01T00:00:00Z',
364
+ items: [
365
+ { url: url1, isPr: false, number: 1, projectItemId: 'PVTI_1' },
366
+ { url: url2, isPr: false, number: 2, projectItemId: 'PVTI_2' },
367
+ ],
368
+ });
369
+
370
+ let resolveFirst: () => void = () => {};
371
+ const firstFetchStarted = new Promise<void>((res) => {
372
+ resolveFirst = res;
373
+ });
374
+
375
+ issueRepository.getIssueOrPullRequestBody.mockImplementation(async (u) => {
376
+ if (u === url1) {
377
+ resolveFirst();
378
+ await new Promise((r) => setTimeout(r, 50));
379
+ return 'body1';
380
+ }
381
+ return 'body2';
382
+ });
383
+ issueRepository.getIssueOrPullRequestComments.mockResolvedValue([]);
384
+ issueRepository.getIssueOrPullRequestState.mockResolvedValue({
385
+ state: 'open',
386
+ merged: false,
387
+ isPullRequest: false,
388
+ title: 'x',
389
+ });
390
+ issueRepository.findRelatedOpenPRs.mockResolvedValue([]);
391
+
392
+ const { response, chunks } = makeResponse();
393
+
394
+ const syncDone = handleAirplaneSync(
395
+ response,
396
+ tmpDir,
397
+ issueRepository,
398
+ new IssueTitleStateCache(),
399
+ new PullRequestStatusCache(),
400
+ );
401
+
402
+ await firstFetchStarted;
403
+
404
+ const eventsBeforeDone = collectSseEvents(chunks);
405
+ const hasDoneBeforeAllFetched = eventsBeforeDone.some(isDoneEvent);
406
+ expect(hasDoneBeforeAllFetched).toBe(false);
407
+
408
+ await syncDone;
409
+
410
+ const eventsAfterDone = collectSseEvents(chunks);
411
+ const doneEvent = eventsAfterDone.find(isDoneEvent);
412
+ expect(doneEvent).toBeDefined();
413
+ });
414
+
415
+ it('fetches raw text content for a PR file whose patch is null and stores it as patch', async () => {
416
+ const issueRepository = mock<IssueRepository>();
417
+ const prUrl = 'https://github.com/owner/repo/pull/10';
418
+ const rawFileUrl =
419
+ 'https://raw.githubusercontent.com/owner/repo/sha/src/large.ts';
420
+
421
+ writeListJson(tmpDir, 'acme', 'prs', {
422
+ generatedAt: '2026-01-01T00:00:00Z',
423
+ items: [{ url: prUrl, isPr: true, number: 10, projectItemId: 'PVTI_10' }],
424
+ });
425
+
426
+ issueRepository.getIssueOrPullRequestBody.mockResolvedValue('body');
427
+ issueRepository.getIssueOrPullRequestComments.mockResolvedValue([]);
428
+ issueRepository.getIssueOrPullRequestState.mockResolvedValue({
429
+ state: 'open',
430
+ merged: false,
431
+ isPullRequest: true,
432
+ title: 'Large file PR',
433
+ });
434
+ issueRepository.getPullRequestDetail.mockResolvedValue({
435
+ title: 'Large file PR',
436
+ state: 'OPEN',
437
+ merged: false,
438
+ isDraft: false,
439
+ additions: 10,
440
+ deletions: 0,
441
+ changedFiles: 1,
442
+ headRefName: 'feat',
443
+ baseRefName: 'main',
444
+ author: 'alice',
445
+ files: [
446
+ {
447
+ filename: 'src/large.ts',
448
+ status: 'modified',
449
+ additions: 10,
450
+ deletions: 0,
451
+ patch: null,
452
+ rawUrl: rawFileUrl,
453
+ },
454
+ ],
455
+ });
456
+ issueRepository.getPullRequestCommits.mockResolvedValue([]);
457
+ issueRepository.getOpenPullRequestCiStatus.mockResolvedValue(null);
458
+
459
+ const rawText = 'export const x = 1;\n';
460
+ const rawBuf = Buffer.from(rawText);
461
+ const rawArrayBuffer = rawBuf.buffer.slice(
462
+ rawBuf.byteOffset,
463
+ rawBuf.byteOffset + rawBuf.byteLength,
464
+ );
465
+ const globalFetch = global.fetch;
466
+ global.fetch = jest.fn().mockResolvedValue({
467
+ ok: true,
468
+ headers: { get: () => 'text/plain; charset=utf-8' },
469
+ arrayBuffer: async () => rawArrayBuffer,
470
+ });
471
+
472
+ const { response, chunks } = makeResponse();
473
+ await handleAirplaneSync(
474
+ response,
475
+ tmpDir,
476
+ issueRepository,
477
+ new IssueTitleStateCache(),
478
+ new PullRequestStatusCache(),
479
+ 'gh-token',
480
+ );
481
+
482
+ global.fetch = globalFetch;
483
+
484
+ const events = collectSseEvents(chunks);
485
+ const doneEvent = events.find(isDoneEvent);
486
+ const file = doneEvent?.snapshot.items[prUrl]?.files?.[0];
487
+ expect(file?.patch).toBe(rawText);
488
+ expect(file?.rawUrl).toBe(rawFileUrl);
489
+ });
490
+
491
+ it('fetches raw image content for a PR file whose patch is null and stores it as a data URL', async () => {
492
+ const issueRepository = mock<IssueRepository>();
493
+ const prUrl = 'https://github.com/owner/repo/pull/11';
494
+ const rawFileUrl =
495
+ 'https://raw.githubusercontent.com/owner/repo/sha/assets/logo.png';
496
+
497
+ writeListJson(tmpDir, 'acme', 'prs', {
498
+ generatedAt: '2026-01-01T00:00:00Z',
499
+ items: [{ url: prUrl, isPr: true, number: 11, projectItemId: 'PVTI_11' }],
500
+ });
501
+
502
+ issueRepository.getIssueOrPullRequestBody.mockResolvedValue('body');
503
+ issueRepository.getIssueOrPullRequestComments.mockResolvedValue([]);
504
+ issueRepository.getIssueOrPullRequestState.mockResolvedValue({
505
+ state: 'open',
506
+ merged: false,
507
+ isPullRequest: true,
508
+ title: 'Logo update',
509
+ });
510
+ issueRepository.getPullRequestDetail.mockResolvedValue({
511
+ title: 'Logo update',
512
+ state: 'OPEN',
513
+ merged: false,
514
+ isDraft: false,
515
+ additions: 0,
516
+ deletions: 0,
517
+ changedFiles: 1,
518
+ headRefName: 'feat',
519
+ baseRefName: 'main',
520
+ author: 'alice',
521
+ files: [
522
+ {
523
+ filename: 'assets/logo.png',
524
+ status: 'modified',
525
+ additions: 0,
526
+ deletions: 0,
527
+ patch: null,
528
+ rawUrl: rawFileUrl,
529
+ },
530
+ ],
531
+ });
532
+ issueRepository.getPullRequestCommits.mockResolvedValue([]);
533
+ issueRepository.getOpenPullRequestCiStatus.mockResolvedValue(null);
534
+
535
+ const fakeImageBytes = Buffer.from([137, 80, 78, 71]);
536
+ const globalFetch = global.fetch;
537
+ global.fetch = jest.fn().mockResolvedValue({
538
+ ok: true,
539
+ headers: { get: () => 'image/png' },
540
+ arrayBuffer: async () => fakeImageBytes.buffer,
541
+ });
542
+
543
+ const { response, chunks } = makeResponse();
544
+ await handleAirplaneSync(
545
+ response,
546
+ tmpDir,
547
+ issueRepository,
548
+ new IssueTitleStateCache(),
549
+ new PullRequestStatusCache(),
550
+ 'gh-token',
551
+ );
552
+
553
+ global.fetch = globalFetch;
554
+
555
+ const events = collectSseEvents(chunks);
556
+ const doneEvent = events.find(isDoneEvent);
557
+ const file = doneEvent?.snapshot.items[prUrl]?.files?.[0];
558
+ expect(file?.patch).toBeNull();
559
+ expect(file?.rawUrl).toMatch(/^data:image\/png;base64,/);
560
+ });
561
+
562
+ it('leaves files unchanged when ghToken is null even if rawUrl is present', async () => {
563
+ const issueRepository = mock<IssueRepository>();
564
+ const prUrl = 'https://github.com/owner/repo/pull/12';
565
+ const rawFileUrl =
566
+ 'https://raw.githubusercontent.com/owner/repo/sha/src/big.ts';
567
+
568
+ writeListJson(tmpDir, 'acme', 'prs', {
569
+ generatedAt: '2026-01-01T00:00:00Z',
570
+ items: [{ url: prUrl, isPr: true, number: 12, projectItemId: 'PVTI_12' }],
571
+ });
572
+
573
+ issueRepository.getIssueOrPullRequestBody.mockResolvedValue('body');
574
+ issueRepository.getIssueOrPullRequestComments.mockResolvedValue([]);
575
+ issueRepository.getIssueOrPullRequestState.mockResolvedValue({
576
+ state: 'open',
577
+ merged: false,
578
+ isPullRequest: true,
579
+ title: 'No token',
580
+ });
581
+ issueRepository.getPullRequestDetail.mockResolvedValue({
582
+ title: 'No token',
583
+ state: 'OPEN',
584
+ merged: false,
585
+ isDraft: false,
586
+ additions: 0,
587
+ deletions: 0,
588
+ changedFiles: 1,
589
+ headRefName: 'feat',
590
+ baseRefName: 'main',
591
+ author: 'alice',
592
+ files: [
593
+ {
594
+ filename: 'src/big.ts',
595
+ status: 'modified',
596
+ additions: 0,
597
+ deletions: 0,
598
+ patch: null,
599
+ rawUrl: rawFileUrl,
600
+ },
601
+ ],
602
+ });
603
+ issueRepository.getPullRequestCommits.mockResolvedValue([]);
604
+ issueRepository.getOpenPullRequestCiStatus.mockResolvedValue(null);
605
+
606
+ const fetchSpy = jest.fn();
607
+ const globalFetch = global.fetch;
608
+ global.fetch = fetchSpy;
609
+
610
+ const { response, chunks } = makeResponse();
611
+ await handleAirplaneSync(
612
+ response,
613
+ tmpDir,
614
+ issueRepository,
615
+ new IssueTitleStateCache(),
616
+ new PullRequestStatusCache(),
617
+ null,
618
+ );
619
+
620
+ global.fetch = globalFetch;
621
+
622
+ expect(fetchSpy).not.toHaveBeenCalled();
623
+ const events = collectSseEvents(chunks);
624
+ const doneEvent = events.find(isDoneEvent);
625
+ const file = doneEvent?.snapshot.items[prUrl]?.files?.[0];
626
+ expect(file?.patch).toBeNull();
627
+ expect(file?.rawUrl).toBe(rawFileUrl);
628
+ });
629
+
630
+ it('includes tab data for all discovered pjcodes in the snapshot', async () => {
631
+ const issueRepository = mock<IssueRepository>();
632
+
633
+ writeListJson(tmpDir, 'proj1', 'prs', {
634
+ generatedAt: '2026-01-01T00:00:00Z',
635
+ items: [],
636
+ });
637
+ writeListJson(tmpDir, 'proj2', 'todo-by-human', {
638
+ generatedAt: '2026-01-01T00:00:00Z',
639
+ items: [],
640
+ });
641
+
642
+ const { response, chunks } = makeResponse();
643
+ await handleAirplaneSync(
644
+ response,
645
+ tmpDir,
646
+ issueRepository,
647
+ new IssueTitleStateCache(),
648
+ new PullRequestStatusCache(),
649
+ );
650
+
651
+ const events = collectSseEvents(chunks);
652
+ const doneEvent = events.find(isDoneEvent);
653
+ expect(doneEvent?.snapshot.tabs['proj1']).toBeDefined();
654
+ expect(doneEvent?.snapshot.tabs['proj2']).toBeDefined();
655
+ expect(doneEvent?.snapshot.tabs['proj1']?.['prs']).toBeDefined();
656
+ expect(doneEvent?.snapshot.tabs['proj2']?.['todo-by-human']).toBeDefined();
657
+ });
658
+ });