github-issue-tower-defence-management 2.7.0 → 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.
- package/CHANGELOG.md +7 -0
- package/bin/adapter/entry-points/cli/index.js +3 -1
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/console/consoleAirplaneSnapshotApi.js +347 -0
- package/bin/adapter/entry-points/console/consoleAirplaneSnapshotApi.js.map +1 -0
- package/bin/adapter/entry-points/console/ui-dist/assets/index-DkMqSpkk.js +88 -0
- package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/bin/adapter/entry-points/console/webServer.js +28 -0
- package/bin/adapter/entry-points/console/webServer.js.map +1 -1
- package/jest.config.js +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.ts +4 -1
- package/src/adapter/entry-points/console/consoleAirplaneSnapshotApi.test.ts +658 -0
- package/src/adapter/entry-points/console/consoleAirplaneSnapshotApi.ts +482 -0
- package/src/adapter/entry-points/console/ui/jest-globals.cjs +26 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleFileDiff.test.tsx +10 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleFileDiff.tsx +23 -16
- package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleAirplaneModeButton.stories.tsx +56 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleAirplaneModeButton.test.tsx +111 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleAirplaneModeButton.tsx +92 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.stories.tsx +15 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.test.tsx +19 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.tsx +30 -1
- package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleErrorToast.stories.tsx +10 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.test.tsx +1 -3
- package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.tsx +8 -7
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useAirplaneMode.test.ts +304 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useAirplaneMode.ts +167 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.test.ts +15 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.ts +6 -1
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleCaches.test.ts +77 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleCaches.ts +48 -3
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleFeaturesConfig.test.ts +51 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleFeaturesConfig.ts +39 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.test.ts +215 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.ts +20 -1
- package/src/adapter/entry-points/console/ui/src/features/console/lib/airplaneSnapshot.test.ts +361 -0
- package/src/adapter/entry-points/console/ui/src/features/console/lib/airplaneSnapshot.ts +401 -0
- package/src/adapter/entry-points/console/ui/src/features/console/lib/resourceCache.ts +4 -0
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +209 -0
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +27 -2
- package/src/adapter/entry-points/console/ui-dist/assets/index-DkMqSpkk.js +88 -0
- package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/src/adapter/entry-points/console/webServer.test.ts +189 -0
- package/src/adapter/entry-points/console/webServer.ts +38 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/console/consoleAirplaneSnapshotApi.d.ts +90 -0
- package/types/adapter/entry-points/console/consoleAirplaneSnapshotApi.d.ts.map +1 -0
- package/types/adapter/entry-points/console/webServer.d.ts +1 -0
- package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/index-CaxYBHiT.js +0 -86
- 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
|
+
};
|
|
@@ -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
|
|
@@ -147,22 +147,29 @@ export const ConsoleFileDiff = ({
|
|
|
147
147
|
useState<ConsoleInlineCommentTarget | null>(null);
|
|
148
148
|
|
|
149
149
|
if (patch === null || patch === '') {
|
|
150
|
-
if (
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
150
|
+
if (path !== undefined && rawUrl !== undefined && rawUrl !== null) {
|
|
151
|
+
if (rawUrl.startsWith('data:image/')) {
|
|
152
|
+
return (
|
|
153
|
+
<div className="console-file-diff-image">
|
|
154
|
+
<img
|
|
155
|
+
className="console-file-diff-image-content"
|
|
156
|
+
src={rawUrl}
|
|
157
|
+
alt={path}
|
|
158
|
+
/>
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (isImageFilePath(path) && buildImageProxyUrl !== undefined) {
|
|
163
|
+
return (
|
|
164
|
+
<div className="console-file-diff-image">
|
|
165
|
+
<img
|
|
166
|
+
className="console-file-diff-image-content"
|
|
167
|
+
src={buildImageProxyUrl(rawUrl)}
|
|
168
|
+
alt={path}
|
|
169
|
+
/>
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
166
173
|
}
|
|
167
174
|
return (
|
|
168
175
|
<p className="console-file-diff-empty">(no diff / binary or too large)</p>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { ConsoleAirplaneModeButton } from './ConsoleAirplaneModeButton';
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof ConsoleAirplaneModeButton> = {
|
|
5
|
+
title: 'Console/ConsoleAirplaneModeButton',
|
|
6
|
+
component: ConsoleAirplaneModeButton,
|
|
7
|
+
args: {
|
|
8
|
+
status: 'off',
|
|
9
|
+
progress: null,
|
|
10
|
+
capturedAt: null,
|
|
11
|
+
failures: [],
|
|
12
|
+
onStartSync: () => {},
|
|
13
|
+
onTurnOff: () => {},
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default meta;
|
|
18
|
+
|
|
19
|
+
type Story = StoryObj<typeof ConsoleAirplaneModeButton>;
|
|
20
|
+
|
|
21
|
+
export const Off: Story = {
|
|
22
|
+
args: {
|
|
23
|
+
status: 'off',
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const SyncingNoProgress: Story = {
|
|
28
|
+
args: {
|
|
29
|
+
status: 'syncing',
|
|
30
|
+
progress: { fetched: 0, total: 0 },
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const SyncingWithProgress: Story = {
|
|
35
|
+
args: {
|
|
36
|
+
status: 'syncing',
|
|
37
|
+
progress: { fetched: 127, total: 557 },
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const On: Story = {
|
|
42
|
+
args: {
|
|
43
|
+
status: 'on',
|
|
44
|
+
capturedAt: '2026-08-18T11:30:00Z',
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const SyncError: Story = {
|
|
49
|
+
args: {
|
|
50
|
+
status: 'error',
|
|
51
|
+
failures: [
|
|
52
|
+
'https://github.com/owner/repo/issues/99',
|
|
53
|
+
'https://github.com/owner/repo/pull/100',
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
};
|