github-issue-tower-defence-management 2.7.0 → 2.9.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 (80) hide show
  1. package/.github/workflows/umino-project.yml +0 -8
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +1 -1
  4. package/bin/adapter/entry-points/cli/index.js +3 -1
  5. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  6. package/bin/adapter/entry-points/console/consoleAirplaneSnapshotApi.js +347 -0
  7. package/bin/adapter/entry-points/console/consoleAirplaneSnapshotApi.js.map +1 -0
  8. package/bin/adapter/entry-points/console/consoleOperationApi.js +12 -0
  9. package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -1
  10. package/bin/adapter/entry-points/console/ui-dist/assets/index-BSa2JTna.js +88 -0
  11. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  12. package/bin/adapter/entry-points/console/webServer.js +28 -0
  13. package/bin/adapter/entry-points/console/webServer.js.map +1 -1
  14. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +5 -1
  15. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  16. package/bin/adapter/repositories/projectFieldDefinition.js +1 -0
  17. package/bin/adapter/repositories/projectFieldDefinition.js.map +1 -1
  18. package/jest.config.js +1 -0
  19. package/package.json +1 -1
  20. package/src/adapter/entry-points/cli/index.ts +4 -1
  21. package/src/adapter/entry-points/console/consoleAirplaneSnapshotApi.test.ts +658 -0
  22. package/src/adapter/entry-points/console/consoleAirplaneSnapshotApi.ts +482 -0
  23. package/src/adapter/entry-points/console/consoleOperationApi.test.ts +84 -0
  24. package/src/adapter/entry-points/console/consoleOperationApi.ts +17 -0
  25. package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +6 -0
  26. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +5 -1
  27. package/src/adapter/entry-points/console/ui/jest-globals.cjs +26 -0
  28. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleFileDiff.test.tsx +10 -0
  29. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleFileDiff.tsx +23 -16
  30. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleAirplaneModeButton.stories.tsx +56 -0
  31. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleAirplaneModeButton.test.tsx +111 -0
  32. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleAirplaneModeButton.tsx +92 -0
  33. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.stories.tsx +15 -0
  34. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.test.tsx +19 -0
  35. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.tsx +30 -1
  36. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleErrorToast.stories.tsx +10 -0
  37. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleNextActionDateActions.test.tsx +7 -1
  38. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleNextActionDateActions.tsx +14 -0
  39. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.test.tsx +1 -3
  40. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.tsx +8 -7
  41. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useAirplaneMode.test.ts +304 -0
  42. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useAirplaneMode.ts +167 -0
  43. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.test.ts +15 -0
  44. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.ts +6 -1
  45. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleCaches.test.ts +77 -0
  46. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleCaches.ts +48 -3
  47. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleFeaturesConfig.test.ts +51 -0
  48. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleFeaturesConfig.ts +39 -0
  49. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.test.ts +215 -0
  50. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.ts +20 -1
  51. package/src/adapter/entry-points/console/ui/src/features/console/lib/airplaneSnapshot.test.ts +361 -0
  52. package/src/adapter/entry-points/console/ui/src/features/console/lib/airplaneSnapshot.ts +401 -0
  53. package/src/adapter/entry-points/console/ui/src/features/console/lib/resourceCache.ts +4 -0
  54. package/src/adapter/entry-points/console/ui/src/features/console/logic/actionToast.test.ts +22 -0
  55. package/src/adapter/entry-points/console/ui/src/features/console/logic/actionToast.ts +6 -0
  56. package/src/adapter/entry-points/console/ui/src/features/console/logic/operations.ts +5 -1
  57. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +209 -0
  58. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +27 -2
  59. package/src/adapter/entry-points/console/ui-dist/assets/index-BSa2JTna.js +88 -0
  60. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  61. package/src/adapter/entry-points/console/webServer.test.ts +189 -0
  62. package/src/adapter/entry-points/console/webServer.ts +38 -0
  63. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +63 -8
  64. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +7 -1
  65. package/src/adapter/repositories/projectFieldDefinition.test.ts +1 -0
  66. package/src/adapter/repositories/projectFieldDefinition.ts +1 -0
  67. package/src/domain/entities/Project.ts +1 -0
  68. package/src/domain/usecases/ClearPastNextActionDateHourUseCase.test.ts +1 -0
  69. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  70. package/types/adapter/entry-points/console/consoleAirplaneSnapshotApi.d.ts +90 -0
  71. package/types/adapter/entry-points/console/consoleAirplaneSnapshotApi.d.ts.map +1 -0
  72. package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -1
  73. package/types/adapter/entry-points/console/webServer.d.ts +1 -0
  74. package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
  75. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  76. package/types/adapter/repositories/projectFieldDefinition.d.ts.map +1 -1
  77. package/types/domain/entities/Project.d.ts +1 -0
  78. package/types/domain/entities/Project.d.ts.map +1 -1
  79. package/bin/adapter/entry-points/console/ui-dist/assets/index-CaxYBHiT.js +0 -86
  80. package/src/adapter/entry-points/console/ui-dist/assets/index-CaxYBHiT.js +0 -86
@@ -0,0 +1,482 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import type { IssueRepository } from '../../../domain/usecases/adapter-interfaces/IssueRepository';
4
+ import { CONSOLE_LIST_TAB_NAMES } from './consoleTabNames';
5
+ import {
6
+ IssueTitleStateCache,
7
+ PullRequestStatusCache,
8
+ handleComments,
9
+ handleIssueTitle,
10
+ handleItemBody,
11
+ handlePrCommits,
12
+ handlePrFiles,
13
+ handlePullRequestStatus,
14
+ handleRelatedPrs,
15
+ deriveMergeableStatus,
16
+ } from './consoleReadApi';
17
+
18
+ export type AirplaneFilesItem = {
19
+ path: string;
20
+ additions: number;
21
+ deletions: number;
22
+ status: string;
23
+ patch: string | null;
24
+ rawUrl: string | null;
25
+ };
26
+
27
+ export type AirplaneCommitItem = {
28
+ sha: string;
29
+ message: string;
30
+ author: string;
31
+ authoredAt: string;
32
+ };
33
+
34
+ export type AirplaneCommentItem = {
35
+ author: string;
36
+ body: string;
37
+ createdAt: string;
38
+ };
39
+
40
+ export type AirplanePrStatusItem = {
41
+ found: boolean;
42
+ isConflicted: boolean;
43
+ mergeableStatus: string;
44
+ isPassedAllCiJob: boolean;
45
+ isCiStateSuccess: boolean;
46
+ isBranchOutOfDate: boolean;
47
+ missingRequiredCheckNames: string[];
48
+ };
49
+
50
+ export type AirplaneStateItem = {
51
+ state: string;
52
+ merged: boolean;
53
+ isPullRequest: boolean;
54
+ title: string;
55
+ };
56
+
57
+ export type AirplaneRelatedPrItem = {
58
+ url: string;
59
+ branchName: string | null;
60
+ createdAt: string;
61
+ isDraft: boolean;
62
+ isConflicted: boolean;
63
+ mergeableStatus: string;
64
+ isPassedAllCiJob: boolean;
65
+ isCiStateSuccess: boolean;
66
+ isResolvedAllReviewComments: boolean;
67
+ isBranchOutOfDate: boolean;
68
+ missingRequiredCheckNames: string[];
69
+ summary: {
70
+ title: string;
71
+ body: string;
72
+ additions: number;
73
+ deletions: number;
74
+ changedFiles: number;
75
+ } | null;
76
+ };
77
+
78
+ export type AirplaneItemData = {
79
+ body: string;
80
+ comments: AirplaneCommentItem[];
81
+ state: AirplaneStateItem;
82
+ files: AirplaneFilesItem[] | null;
83
+ commits: AirplaneCommitItem[] | null;
84
+ prStatus: AirplanePrStatusItem | null;
85
+ relatedPrs: AirplaneRelatedPrItem[] | null;
86
+ };
87
+
88
+ export type AirplaneTabData = Record<string, unknown>;
89
+
90
+ export type AirplaneSnapshotPayload = {
91
+ capturedAt: string;
92
+ tabs: Record<string, AirplaneTabData>;
93
+ items: Record<string, AirplaneItemData>;
94
+ failures: string[];
95
+ };
96
+
97
+ export type AirplaneSyncEvent =
98
+ | { type: 'progress'; fetched: number; total: number }
99
+ | { type: 'done'; snapshot: AirplaneSnapshotPayload }
100
+ | { type: 'error'; message: string };
101
+
102
+ const RAW_CONTENT_SIZE_LIMIT = 512 * 1024;
103
+
104
+ const fetchRawFileContent = async (
105
+ rawUrl: string,
106
+ ghToken: string,
107
+ ): Promise<{ patch: string | null; dataUrl: string | null }> => {
108
+ try {
109
+ const response = await fetch(rawUrl, {
110
+ headers: { Authorization: `token ${ghToken}` },
111
+ });
112
+ if (!response.ok) {
113
+ return { patch: null, dataUrl: null };
114
+ }
115
+ const contentType = response.headers.get('content-type') ?? '';
116
+ const arrayBuffer = await response.arrayBuffer();
117
+ if (arrayBuffer.byteLength > RAW_CONTENT_SIZE_LIMIT) {
118
+ return { patch: null, dataUrl: null };
119
+ }
120
+ const buffer = Buffer.from(arrayBuffer);
121
+ if (contentType.startsWith('image/')) {
122
+ const base64 = buffer.toString('base64');
123
+ return {
124
+ patch: null,
125
+ dataUrl: `data:${contentType.split(';')[0]};base64,${base64}`,
126
+ };
127
+ }
128
+ if (
129
+ contentType.startsWith('text/') ||
130
+ contentType.includes('json') ||
131
+ contentType.includes('xml') ||
132
+ !buffer.includes(0)
133
+ ) {
134
+ return { patch: buffer.toString('utf-8'), dataUrl: null };
135
+ }
136
+ return { patch: null, dataUrl: null };
137
+ } catch {
138
+ return { patch: null, dataUrl: null };
139
+ }
140
+ };
141
+
142
+ const enrichFilesWithRawContent = async (
143
+ files: AirplaneFilesItem[],
144
+ ghToken: string,
145
+ ): Promise<AirplaneFilesItem[]> =>
146
+ Promise.all(
147
+ files.map(async (file) => {
148
+ if (file.patch !== null || file.rawUrl === null) {
149
+ return file;
150
+ }
151
+ const result = await fetchRawFileContent(file.rawUrl, ghToken);
152
+ if (result.patch !== null) {
153
+ return { ...file, patch: result.patch };
154
+ }
155
+ if (result.dataUrl !== null) {
156
+ return { ...file, rawUrl: result.dataUrl };
157
+ }
158
+ return file;
159
+ }),
160
+ );
161
+
162
+ export type AirplaneSyncResponseWriter = {
163
+ writeHead(statusCode: number, headers: Record<string, string>): void;
164
+ write(data: string): void;
165
+ end(): void;
166
+ };
167
+
168
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
169
+ value !== null && typeof value === 'object' && !Array.isArray(value);
170
+
171
+ const getString = (value: unknown): string =>
172
+ typeof value === 'string' ? value : '';
173
+
174
+ const getNumber = (value: unknown): number =>
175
+ typeof value === 'number' ? value : 0;
176
+
177
+ const getBoolean = (value: unknown): boolean => value === true;
178
+
179
+ const parseStringArray = (value: unknown): string[] =>
180
+ Array.isArray(value)
181
+ ? value.filter((entry): entry is string => typeof entry === 'string')
182
+ : [];
183
+
184
+ const normalizePrFiles = (body: unknown): AirplaneFilesItem[] | null => {
185
+ if (!isRecord(body)) {
186
+ return null;
187
+ }
188
+ if (body.files === null) {
189
+ return null;
190
+ }
191
+ if (!Array.isArray(body.files)) {
192
+ return [];
193
+ }
194
+ return body.files.filter(isRecord).map((file) => ({
195
+ path: getString(file.path) || getString(file.filename),
196
+ additions: getNumber(file.additions),
197
+ deletions: getNumber(file.deletions),
198
+ status: getString(file.status),
199
+ patch: typeof file.patch === 'string' ? file.patch : null,
200
+ rawUrl: typeof file.rawUrl === 'string' ? file.rawUrl : null,
201
+ }));
202
+ };
203
+
204
+ const normalizePrCommits = (body: unknown): AirplaneCommitItem[] => {
205
+ if (!isRecord(body) || !Array.isArray(body.commits)) {
206
+ return [];
207
+ }
208
+ return body.commits.filter(isRecord).map((commit) => ({
209
+ sha: getString(commit.sha),
210
+ message: getString(commit.message),
211
+ author: getString(commit.author),
212
+ authoredAt: getString(commit.authoredAt),
213
+ }));
214
+ };
215
+
216
+ const normalizePrStatus = (body: unknown): AirplanePrStatusItem => {
217
+ if (!isRecord(body)) {
218
+ return {
219
+ found: false,
220
+ isConflicted: false,
221
+ mergeableStatus: 'UNKNOWN',
222
+ isPassedAllCiJob: false,
223
+ isCiStateSuccess: false,
224
+ isBranchOutOfDate: false,
225
+ missingRequiredCheckNames: [],
226
+ };
227
+ }
228
+ const found = getBoolean(body.found);
229
+ const status = isRecord(body.status) ? body.status : {};
230
+ return {
231
+ found,
232
+ isConflicted: getBoolean(status.isConflicted),
233
+ mergeableStatus: getString(status.mergeableStatus) || 'UNKNOWN',
234
+ isPassedAllCiJob: getBoolean(status.isPassedAllCiJob),
235
+ isCiStateSuccess: getBoolean(status.isCiStateSuccess),
236
+ isBranchOutOfDate: getBoolean(status.isBranchOutOfDate),
237
+ missingRequiredCheckNames: parseStringArray(
238
+ status.missingRequiredCheckNames,
239
+ ),
240
+ };
241
+ };
242
+
243
+ const normalizeComments = (body: unknown): AirplaneCommentItem[] => {
244
+ if (!isRecord(body) || !Array.isArray(body.comments)) {
245
+ return [];
246
+ }
247
+ return body.comments.filter(isRecord).map((comment) => ({
248
+ author: getString(comment.author),
249
+ body: getString(comment.body),
250
+ createdAt: getString(comment.createdAt),
251
+ }));
252
+ };
253
+
254
+ const normalizeState = (body: unknown): AirplaneStateItem => {
255
+ if (!isRecord(body)) {
256
+ return { state: 'open', merged: false, isPullRequest: false, title: '' };
257
+ }
258
+ return {
259
+ state: getString(body.state) || 'open',
260
+ merged: getBoolean(body.merged),
261
+ isPullRequest: getBoolean(body.isPullRequest),
262
+ title: getString(body.title),
263
+ };
264
+ };
265
+
266
+ const normalizeRelatedPrs = (body: unknown): AirplaneRelatedPrItem[] => {
267
+ if (!isRecord(body) || !Array.isArray(body.relatedPullRequests)) {
268
+ return [];
269
+ }
270
+ return body.relatedPullRequests.filter(isRecord).map((pr) => {
271
+ const summary = isRecord(pr.summary)
272
+ ? {
273
+ title: getString(pr.summary.title),
274
+ body: getString(pr.summary.body),
275
+ additions: getNumber(pr.summary.additions),
276
+ deletions: getNumber(pr.summary.deletions),
277
+ changedFiles: getNumber(pr.summary.changedFiles),
278
+ }
279
+ : null;
280
+ return {
281
+ url: getString(pr.url),
282
+ branchName: typeof pr.branchName === 'string' ? pr.branchName : null,
283
+ createdAt: getString(pr.createdAt),
284
+ isDraft: getBoolean(pr.isDraft),
285
+ isConflicted: getBoolean(pr.isConflicted),
286
+ mergeableStatus:
287
+ getString(pr.mergeableStatus) ||
288
+ deriveMergeableStatus(
289
+ typeof pr.mergeable === 'string' ? pr.mergeable : null,
290
+ ),
291
+ isPassedAllCiJob: getBoolean(pr.isPassedAllCiJob),
292
+ isCiStateSuccess: getBoolean(pr.isCiStateSuccess),
293
+ isResolvedAllReviewComments: getBoolean(pr.isResolvedAllReviewComments),
294
+ isBranchOutOfDate: getBoolean(pr.isBranchOutOfDate),
295
+ missingRequiredCheckNames: parseStringArray(pr.missingRequiredCheckNames),
296
+ summary,
297
+ };
298
+ });
299
+ };
300
+
301
+ const discoverPjcodes = (consoleDataOutputDir: string): string[] => {
302
+ try {
303
+ const entries = fs.readdirSync(consoleDataOutputDir, {
304
+ withFileTypes: true,
305
+ });
306
+ return entries
307
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
308
+ .map((entry) => entry.name);
309
+ } catch {
310
+ return [];
311
+ }
312
+ };
313
+
314
+ const readTabListJson = (
315
+ consoleDataOutputDir: string,
316
+ pjcode: string,
317
+ tab: string,
318
+ ): unknown => {
319
+ const filePath = path.join(consoleDataOutputDir, pjcode, tab, 'list.json');
320
+ try {
321
+ const stat = fs.statSync(filePath);
322
+ if (!stat.isFile()) {
323
+ return null;
324
+ }
325
+ const raw = fs.readFileSync(filePath, 'utf-8');
326
+ return JSON.parse(raw);
327
+ } catch {
328
+ return null;
329
+ }
330
+ };
331
+
332
+ type UniqueItem = { url: string; isPr: boolean };
333
+
334
+ const collectUniqueItems = (
335
+ tabData: Record<string, AirplaneTabData>,
336
+ ): UniqueItem[] => {
337
+ const seen = new Set<string>();
338
+ const items: UniqueItem[] = [];
339
+ for (const pjTabs of Object.values(tabData)) {
340
+ for (const tabPayload of Object.values(pjTabs)) {
341
+ if (!isRecord(tabPayload) || !Array.isArray(tabPayload.items)) {
342
+ continue;
343
+ }
344
+ for (const item of tabPayload.items) {
345
+ if (!isRecord(item) || typeof item.url !== 'string') {
346
+ continue;
347
+ }
348
+ if (seen.has(item.url)) {
349
+ continue;
350
+ }
351
+ seen.add(item.url);
352
+ items.push({ url: item.url, isPr: getBoolean(item.isPr) });
353
+ }
354
+ }
355
+ }
356
+ return items;
357
+ };
358
+
359
+ const runWithConcurrency = async (
360
+ tasks: Array<() => Promise<void>>,
361
+ limit: number,
362
+ ): Promise<void> => {
363
+ let index = 0;
364
+ const workers = Array.from(
365
+ { length: Math.min(limit, tasks.length) },
366
+ async () => {
367
+ while (index < tasks.length) {
368
+ const current = tasks[index];
369
+ index += 1;
370
+ await current();
371
+ }
372
+ },
373
+ );
374
+ await Promise.all(workers);
375
+ };
376
+
377
+ const SYNC_CONCURRENCY_LIMIT = 5;
378
+
379
+ const writeSseEvent = (
380
+ response: AirplaneSyncResponseWriter,
381
+ event: AirplaneSyncEvent,
382
+ ): void => {
383
+ response.write(`data: ${JSON.stringify(event)}\n\n`);
384
+ };
385
+
386
+ export const handleAirplaneSync = async (
387
+ response: AirplaneSyncResponseWriter,
388
+ consoleDataOutputDir: string,
389
+ issueRepository: IssueRepository,
390
+ issueTitleStateCache: IssueTitleStateCache,
391
+ pullRequestStatusCache: PullRequestStatusCache,
392
+ ghToken: string | null = null,
393
+ ): Promise<void> => {
394
+ response.writeHead(200, {
395
+ 'Content-Type': 'text/event-stream; charset=utf-8',
396
+ 'Cache-Control': 'no-store',
397
+ Connection: 'keep-alive',
398
+ });
399
+
400
+ const pjcodes = discoverPjcodes(consoleDataOutputDir);
401
+ const tabData: Record<string, AirplaneTabData> = {};
402
+ for (const pjcode of pjcodes) {
403
+ const pjTabs: AirplaneTabData = {};
404
+ for (const tab of CONSOLE_LIST_TAB_NAMES) {
405
+ const payload = readTabListJson(consoleDataOutputDir, pjcode, tab);
406
+ if (payload !== null) {
407
+ pjTabs[tab] = payload;
408
+ }
409
+ }
410
+ tabData[pjcode] = pjTabs;
411
+ }
412
+
413
+ const uniqueItems = collectUniqueItems(tabData);
414
+ const total = uniqueItems.length;
415
+ let fetched = 0;
416
+ const failures: string[] = [];
417
+ const items: Record<string, AirplaneItemData> = {};
418
+
419
+ writeSseEvent(response, { type: 'progress', fetched: 0, total });
420
+
421
+ const tasks = uniqueItems.map((item) => async (): Promise<void> => {
422
+ const { url, isPr } = item;
423
+ try {
424
+ const [bodyResult, commentsResult, stateResult] = await Promise.all([
425
+ handleItemBody(issueRepository, url),
426
+ handleComments(issueRepository, url),
427
+ handleIssueTitle(issueRepository, issueTitleStateCache, url),
428
+ ]);
429
+
430
+ let files: AirplaneFilesItem[] | null = null;
431
+ let commits: AirplaneCommitItem[] | null = null;
432
+ let prStatus: AirplanePrStatusItem | null = null;
433
+ let relatedPrs: AirplaneRelatedPrItem[] | null = null;
434
+
435
+ if (isPr) {
436
+ const [filesResult, commitsResult, prStatusResult] = await Promise.all([
437
+ handlePrFiles(issueRepository, url),
438
+ handlePrCommits(issueRepository, url),
439
+ handlePullRequestStatus(issueRepository, pullRequestStatusCache, url),
440
+ ]);
441
+ files = normalizePrFiles(filesResult.body);
442
+ if (files !== null && ghToken !== null) {
443
+ files = await enrichFilesWithRawContent(files, ghToken);
444
+ }
445
+ commits = normalizePrCommits(commitsResult.body);
446
+ prStatus = normalizePrStatus(prStatusResult.body);
447
+ } else {
448
+ const relatedPrsResult = await handleRelatedPrs(issueRepository, url);
449
+ relatedPrs = normalizeRelatedPrs(relatedPrsResult.body);
450
+ }
451
+
452
+ items[url] = {
453
+ body:
454
+ isRecord(bodyResult.body) && typeof bodyResult.body.body === 'string'
455
+ ? bodyResult.body.body
456
+ : '',
457
+ comments: normalizeComments(commentsResult.body),
458
+ state: normalizeState(stateResult.body),
459
+ files,
460
+ commits,
461
+ prStatus,
462
+ relatedPrs,
463
+ };
464
+ } catch {
465
+ failures.push(url);
466
+ }
467
+ fetched += 1;
468
+ writeSseEvent(response, { type: 'progress', fetched, total });
469
+ });
470
+
471
+ await runWithConcurrency(tasks, SYNC_CONCURRENCY_LIMIT);
472
+
473
+ const snapshot: AirplaneSnapshotPayload = {
474
+ capturedAt: new Date().toISOString(),
475
+ tabs: tabData,
476
+ items,
477
+ failures,
478
+ };
479
+
480
+ writeSseEvent(response, { type: 'done', snapshot });
481
+ response.end();
482
+ };
@@ -758,6 +758,90 @@ describe('consoleOperationApi', () => {
758
758
  expectRecordedAcrossTabs('PVTI_g');
759
759
  });
760
760
 
761
+ it('snoozes for one hour via updateNextActionHour', async () => {
762
+ const projectWithHour: Project = {
763
+ ...project,
764
+ nextActionHour: {
765
+ name: 'Next Action Hour',
766
+ fieldId: 'nahField',
767
+ options: [],
768
+ },
769
+ };
770
+ const beforeHour = new Date().getUTCHours();
771
+ const response = await handleTriage(contextForProject(projectWithHour), {
772
+ pjcode: 'acme',
773
+ action: 'snooze_1hour',
774
+ issueUrl: 'https://github.com/o/r/issues/1',
775
+ projectItemId: 'PVTI_1h',
776
+ });
777
+ expect(response.statusCode).toBe(200);
778
+ expect(issueRepository.updateNextActionHour).toHaveBeenCalledTimes(1);
779
+ const call = issueRepository.updateNextActionHour.mock.calls[0];
780
+ expect(call[0]).toMatchObject({
781
+ nextActionHour: { fieldId: 'nahField' },
782
+ });
783
+ expect(call[2]).toBeGreaterThanOrEqual(beforeHour + 1);
784
+ expect(call[2]).toBeLessThanOrEqual(beforeHour + 2);
785
+ expectRecordedAcrossTabs('PVTI_1h');
786
+ });
787
+
788
+ it('snoozes for three hours via updateNextActionHour', async () => {
789
+ const projectWithHour: Project = {
790
+ ...project,
791
+ nextActionHour: {
792
+ name: 'Next Action Hour',
793
+ fieldId: 'nahField',
794
+ options: [],
795
+ },
796
+ };
797
+ const beforeHour = new Date().getUTCHours();
798
+ const response = await handleTriage(contextForProject(projectWithHour), {
799
+ pjcode: 'acme',
800
+ action: 'snooze_3hours',
801
+ issueUrl: 'https://github.com/o/r/issues/1',
802
+ projectItemId: 'PVTI_3h',
803
+ });
804
+ expect(response.statusCode).toBe(200);
805
+ expect(issueRepository.updateNextActionHour).toHaveBeenCalledTimes(1);
806
+ const call = issueRepository.updateNextActionHour.mock.calls[0];
807
+ expect(call[0]).toMatchObject({
808
+ nextActionHour: { fieldId: 'nahField' },
809
+ });
810
+ expect(call[2]).toBeGreaterThanOrEqual(beforeHour + 3);
811
+ expect(call[2]).toBeLessThanOrEqual(beforeHour + 4);
812
+ expectRecordedAcrossTabs('PVTI_3h');
813
+ });
814
+
815
+ it('returns 400 when snooze_1hour is used on a project without nextActionHour', async () => {
816
+ const projectWithoutHour: Project = { ...project, nextActionHour: null };
817
+ const response = await handleTriage(
818
+ contextForProject(projectWithoutHour),
819
+ {
820
+ pjcode: 'acme',
821
+ action: 'snooze_1hour',
822
+ issueUrl: 'https://github.com/o/r/issues/1',
823
+ projectItemId: 'PVTI_nah',
824
+ },
825
+ );
826
+ expect(response.statusCode).toBe(400);
827
+ expect(issueRepository.updateNextActionHour).not.toHaveBeenCalled();
828
+ });
829
+
830
+ it('returns 400 when snooze_3hours is used on a project without nextActionHour', async () => {
831
+ const projectWithoutHour: Project = { ...project, nextActionHour: null };
832
+ const response = await handleTriage(
833
+ contextForProject(projectWithoutHour),
834
+ {
835
+ pjcode: 'acme',
836
+ action: 'snooze_3hours',
837
+ issueUrl: 'https://github.com/o/r/issues/1',
838
+ projectItemId: 'PVTI_nah3',
839
+ },
840
+ );
841
+ expect(response.statusCode).toBe(400);
842
+ expect(issueRepository.updateNextActionHour).not.toHaveBeenCalled();
843
+ });
844
+
761
845
  it('closes an issue as completed via the triage close action', async () => {
762
846
  const response = await handleTriage(context, {
763
847
  pjcode: 'acme',
@@ -540,6 +540,23 @@ export const handleTriage = async (
540
540
  return ok();
541
541
  }
542
542
 
543
+ if (action === 'snooze_1hour' || action === 'snooze_3hours') {
544
+ if (project.nextActionHour === null) {
545
+ return badRequest('project does not have a nextActionHour field');
546
+ }
547
+ const hoursToAdd = action === 'snooze_1hour' ? 1 : 3;
548
+ const targetHour = new Date().getUTCHours() + hoursToAdd;
549
+ await context
550
+ .resolveIssueRepository(issueUrl)
551
+ .updateNextActionHour(
552
+ { ...project, nextActionHour: project.nextActionHour },
553
+ projectItemReference(issueUrl, projectItemId),
554
+ targetHour,
555
+ );
556
+ recordDone(context, pjcode, projectItemId);
557
+ return ok();
558
+ }
559
+
543
560
  if (action === 'snooze_1day' || action === 'snooze_1week') {
544
561
  const days = action === 'snooze_1day' ? 1 : 7;
545
562
  const target = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
@@ -119,6 +119,12 @@ test('renders the Workflow Blocker tab leftmost and shows its detail operations'
119
119
  await expect(
120
120
  page.locator('.console-op-button', { hasText: 'Close as not planned' }),
121
121
  ).toBeVisible();
122
+ await expect(
123
+ page.locator('.console-op-button', { hasText: '+1 hour' }),
124
+ ).toBeVisible();
125
+ await expect(
126
+ page.locator('.console-op-button', { hasText: '+3 hours' }),
127
+ ).toBeVisible();
122
128
  await expect(
123
129
  page.locator('.console-op-button', { hasText: '+1 day' }),
124
130
  ).toBeVisible();
@@ -347,7 +347,11 @@ const buildE2eProject = (): Project => ({
347
347
  })),
348
348
  },
349
349
  nextActionDate: { name: 'Next Action Date', fieldId: 'PVTF_nad' },
350
- nextActionHour: { name: 'Next Action Hour', fieldId: 'PVTF_nah' },
350
+ nextActionHour: {
351
+ name: 'Next Action Hour',
352
+ fieldId: 'PVTF_nah',
353
+ options: [],
354
+ },
351
355
  story: {
352
356
  name: 'Story',
353
357
  fieldId: 'PVTSSF_story',
@@ -0,0 +1,26 @@
1
+ // Plain CommonJS - runs before test framework so we can control load order.
2
+ // jsdom does not expose these Node 18+ globals; wire them up here.
3
+ const { TextDecoder, TextEncoder } = require('util');
4
+ global.TextDecoder = TextDecoder;
5
+ global.TextEncoder = TextEncoder;
6
+
7
+ const { ReadableStream, TextDecoderStream, TransformStream } = require('stream/web');
8
+ global.ReadableStream = ReadableStream;
9
+ global.TextDecoderStream = TextDecoderStream;
10
+ global.TransformStream = TransformStream;
11
+
12
+ // Minimal Response implementation sufficient for Cache API usage in tests.
13
+ // airplaneSnapshot.ts only calls: new Response(body, init), .json(), .text()
14
+ class MockResponse {
15
+ constructor(body, _init) {
16
+ this._body = typeof body === 'string' ? body : '';
17
+ }
18
+ async json() {
19
+ return JSON.parse(this._body);
20
+ }
21
+ async text() {
22
+ return this._body;
23
+ }
24
+ }
25
+
26
+ global.Response = MockResponse;
@@ -40,6 +40,16 @@ describe('ConsoleFileDiff', () => {
40
40
  expect(queryByText('(no diff / binary or too large)')).toBeNull();
41
41
  });
42
42
 
43
+ it('renders an image directly from a data URL without the proxy when rawUrl is a data URI', () => {
44
+ const dataUrl =
45
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
46
+ const { getByAltText, queryByText } = render(
47
+ <ConsoleFileDiff patch={null} path="assets/logo.png" rawUrl={dataUrl} />,
48
+ );
49
+ expect(getByAltText('assets/logo.png')).toHaveAttribute('src', dataUrl);
50
+ expect(queryByText('(no diff / binary or too large)')).toBeNull();
51
+ });
52
+
43
53
  it('keeps the placeholder for a binary file that is not an image', () => {
44
54
  const { getByText } = render(
45
55
  <ConsoleFileDiff