github-issue-tower-defence-management 1.87.0 → 1.88.1

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 (59) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +51 -1
  3. package/bin/adapter/entry-points/cli/index.js +35 -0
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/console/consoleDataDelivery.js +155 -0
  6. package/bin/adapter/entry-points/console/consoleDataDelivery.js.map +1 -0
  7. package/bin/adapter/entry-points/console/consoleDoneStore.js +100 -0
  8. package/bin/adapter/entry-points/console/consoleDoneStore.js.map +1 -0
  9. package/bin/adapter/entry-points/console/consoleOperationApi.js +178 -0
  10. package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -0
  11. package/bin/adapter/entry-points/console/consoleReadApi.js +119 -0
  12. package/bin/adapter/entry-points/console/consoleReadApi.js.map +1 -0
  13. package/bin/adapter/entry-points/console/consoleServer.js +147 -3
  14. package/bin/adapter/entry-points/console/consoleServer.js.map +1 -1
  15. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +54 -12
  16. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  17. package/bin/adapter/entry-points/handlers/inTmuxByHumanDataWriter.js +67 -0
  18. package/bin/adapter/entry-points/handlers/inTmuxByHumanDataWriter.js.map +1 -0
  19. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +3 -0
  20. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  21. package/bin/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.js +91 -0
  22. package/bin/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.js.map +1 -0
  23. package/package.json +1 -1
  24. package/src/adapter/entry-points/cli/index.test.ts +94 -0
  25. package/src/adapter/entry-points/cli/index.ts +61 -0
  26. package/src/adapter/entry-points/console/consoleDataDelivery.test.ts +184 -0
  27. package/src/adapter/entry-points/console/consoleDataDelivery.ts +169 -0
  28. package/src/adapter/entry-points/console/consoleDoneStore.test.ts +98 -0
  29. package/src/adapter/entry-points/console/consoleDoneStore.ts +91 -0
  30. package/src/adapter/entry-points/console/consoleOperationApi.test.ts +444 -0
  31. package/src/adapter/entry-points/console/consoleOperationApi.ts +280 -0
  32. package/src/adapter/entry-points/console/consoleReadApi.test.ts +297 -0
  33. package/src/adapter/entry-points/console/consoleReadApi.ts +192 -0
  34. package/src/adapter/entry-points/console/consoleServer.test.ts +269 -0
  35. package/src/adapter/entry-points/console/consoleServer.ts +228 -4
  36. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +26 -0
  37. package/src/adapter/entry-points/handlers/inTmuxByHumanDataWriter.test.ts +266 -0
  38. package/src/adapter/entry-points/handlers/inTmuxByHumanDataWriter.ts +103 -0
  39. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +34 -0
  40. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +3 -0
  41. package/src/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.test.ts +285 -0
  42. package/src/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.ts +182 -0
  43. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  44. package/types/adapter/entry-points/console/consoleDataDelivery.d.ts +23 -0
  45. package/types/adapter/entry-points/console/consoleDataDelivery.d.ts.map +1 -0
  46. package/types/adapter/entry-points/console/consoleDoneStore.d.ts +10 -0
  47. package/types/adapter/entry-points/console/consoleDoneStore.d.ts.map +1 -0
  48. package/types/adapter/entry-points/console/consoleOperationApi.d.ts +18 -0
  49. package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -0
  50. package/types/adapter/entry-points/console/consoleReadApi.d.ts +44 -0
  51. package/types/adapter/entry-points/console/consoleReadApi.d.ts.map +1 -0
  52. package/types/adapter/entry-points/console/consoleServer.d.ts +8 -1
  53. package/types/adapter/entry-points/console/consoleServer.d.ts.map +1 -1
  54. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  55. package/types/adapter/entry-points/handlers/inTmuxByHumanDataWriter.d.ts +16 -0
  56. package/types/adapter/entry-points/handlers/inTmuxByHumanDataWriter.d.ts.map +1 -0
  57. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  58. package/types/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.d.ts +57 -0
  59. package/types/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.d.ts.map +1 -0
@@ -1,6 +1,27 @@
1
1
  import * as http from 'http';
2
2
  import * as fs from 'fs';
3
3
  import * as path from 'path';
4
+ import { IssueRepository } from '../../../domain/usecases/adapter-interfaces/IssueRepository';
5
+ import { Project } from '../../../domain/entities/Project';
6
+ import {
7
+ buildConsoleDataResponse,
8
+ parseConsoleDataRoute,
9
+ } from './consoleDataDelivery';
10
+ import {
11
+ IssueTitleStateCache,
12
+ handleComments,
13
+ handleIssueTitle,
14
+ handleItemBody,
15
+ handlePrCommits,
16
+ handlePrFiles,
17
+ handleRelatedPrs,
18
+ } from './consoleReadApi';
19
+ import {
20
+ ConsoleOperationContext,
21
+ handleIntmux,
22
+ handleReview,
23
+ handleTriage,
24
+ } from './consoleOperationApi';
4
25
 
5
26
  export const DEFAULT_CONSOLE_PORT = 9981;
6
27
 
@@ -108,6 +129,10 @@ export type ConsoleServerOptions = {
108
129
  accessToken: string;
109
130
  uiDistDir: string;
110
131
  consoleDataOutputDir: string | null;
132
+ pjcode?: string | null;
133
+ issueRepository?: IssueRepository | null;
134
+ project?: Project | null;
135
+ issueTitleStateCache?: IssueTitleStateCache | null;
111
136
  };
112
137
 
113
138
  const sendNotFound = (response: http.ServerResponse): void => {
@@ -134,11 +159,189 @@ const serveBootstrapIndex = (response: http.ServerResponse): void => {
134
159
  response.end(PLACEHOLDER_INDEX_HTML);
135
160
  };
136
161
 
137
- export const handleConsoleRequest = (
162
+ const sendJson = (
163
+ response: http.ServerResponse,
164
+ statusCode: number,
165
+ body: unknown,
166
+ ): void => {
167
+ response.writeHead(statusCode, {
168
+ 'Content-Type': 'application/json; charset=utf-8',
169
+ 'Cache-Control': 'no-store',
170
+ });
171
+ response.end(JSON.stringify(body));
172
+ };
173
+
174
+ const sendDataResponse = (
175
+ response: http.ServerResponse,
176
+ statusCode: number,
177
+ contentType: string,
178
+ body: string,
179
+ ): void => {
180
+ response.writeHead(statusCode, {
181
+ 'Content-Type': contentType,
182
+ 'Cache-Control': 'no-store',
183
+ });
184
+ response.end(body);
185
+ };
186
+
187
+ const readRequestBody = (request: http.IncomingMessage): Promise<string> =>
188
+ new Promise((resolve, reject) => {
189
+ const chunks: Uint8Array[] = [];
190
+ request.on('data', (chunk: Uint8Array) => chunks.push(chunk));
191
+ request.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
192
+ request.on('error', reject);
193
+ });
194
+
195
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
196
+ value !== null && typeof value === 'object' && !Array.isArray(value);
197
+
198
+ const parseRequestBody = (raw: string): Record<string, unknown> | null => {
199
+ if (raw.length === 0) {
200
+ return {};
201
+ }
202
+ let parsed: unknown;
203
+ try {
204
+ parsed = JSON.parse(raw);
205
+ } catch {
206
+ return null;
207
+ }
208
+ if (!isRecord(parsed)) {
209
+ return null;
210
+ }
211
+ return parsed;
212
+ };
213
+
214
+ const handleReadApi = async (
215
+ options: ConsoleServerOptions,
216
+ requestPath: string,
217
+ searchParams: URLSearchParams,
218
+ ): Promise<{ statusCode: number; body: unknown } | null> => {
219
+ const issueRepository = options.issueRepository ?? null;
220
+ if (issueRepository === null) {
221
+ return null;
222
+ }
223
+ const cache = options.issueTitleStateCache ?? null;
224
+ const url = searchParams.get('url');
225
+ switch (requestPath) {
226
+ case '/api/itembody':
227
+ return handleItemBody(issueRepository, url);
228
+ case '/api/comments':
229
+ return handleComments(issueRepository, url);
230
+ case '/api/prfiles':
231
+ return handlePrFiles(issueRepository, url);
232
+ case '/api/prcommits':
233
+ return handlePrCommits(issueRepository, url);
234
+ case '/api/relatedprs':
235
+ return handleRelatedPrs(issueRepository, url);
236
+ case '/api/issuetitle':
237
+ if (cache === null) {
238
+ return null;
239
+ }
240
+ return handleIssueTitle(issueRepository, cache, url);
241
+ default:
242
+ return null;
243
+ }
244
+ };
245
+
246
+ const handleOperationApi = async (
247
+ options: ConsoleServerOptions,
248
+ requestPath: string,
249
+ body: Record<string, unknown>,
250
+ ): Promise<{ statusCode: number; body: unknown } | null> => {
251
+ const issueRepository = options.issueRepository ?? null;
252
+ const project = options.project ?? null;
253
+ if (issueRepository === null || project === null) {
254
+ return null;
255
+ }
256
+ const context: ConsoleOperationContext = {
257
+ issueRepository,
258
+ project,
259
+ consoleDataOutputDir: options.consoleDataOutputDir,
260
+ pjcode: options.pjcode ?? null,
261
+ };
262
+ switch (requestPath) {
263
+ case '/api/review':
264
+ return handleReview(context, body);
265
+ case '/api/triage':
266
+ return handleTriage(context, body);
267
+ case '/api/intmux':
268
+ return handleIntmux(context, body);
269
+ default:
270
+ return null;
271
+ }
272
+ };
273
+
274
+ const handleTokenedRequest = async (
138
275
  options: ConsoleServerOptions,
139
276
  request: http.IncomingMessage,
140
277
  response: http.ServerResponse,
141
- ): void => {
278
+ requestPath: string,
279
+ searchParams: URLSearchParams,
280
+ ): Promise<void> => {
281
+ const method = (request.method ?? 'GET').toUpperCase();
282
+
283
+ if (requestPath.startsWith('/api/')) {
284
+ if (method === 'GET') {
285
+ const readResult = await handleReadApi(
286
+ options,
287
+ requestPath,
288
+ searchParams,
289
+ );
290
+ if (readResult === null) {
291
+ sendNotFound(response);
292
+ return;
293
+ }
294
+ sendJson(response, readResult.statusCode, readResult.body);
295
+ return;
296
+ }
297
+ if (method === 'POST') {
298
+ const raw = await readRequestBody(request);
299
+ const parsedBody = parseRequestBody(raw);
300
+ if (parsedBody === null) {
301
+ sendJson(response, 400, { error: 'invalid JSON body' });
302
+ return;
303
+ }
304
+ const operationResult = await handleOperationApi(
305
+ options,
306
+ requestPath,
307
+ parsedBody,
308
+ );
309
+ if (operationResult === null) {
310
+ sendNotFound(response);
311
+ return;
312
+ }
313
+ sendJson(response, operationResult.statusCode, operationResult.body);
314
+ return;
315
+ }
316
+ sendNotFound(response);
317
+ return;
318
+ }
319
+
320
+ if (method === 'GET') {
321
+ const dataRoute = parseConsoleDataRoute(requestPath);
322
+ if (dataRoute !== null && options.consoleDataOutputDir !== null) {
323
+ const dataResponse = buildConsoleDataResponse(
324
+ options.consoleDataOutputDir,
325
+ dataRoute,
326
+ );
327
+ sendDataResponse(
328
+ response,
329
+ dataResponse.statusCode,
330
+ dataResponse.contentType,
331
+ dataResponse.body,
332
+ );
333
+ return;
334
+ }
335
+ }
336
+
337
+ sendNotFound(response);
338
+ };
339
+
340
+ export const handleConsoleRequest = async (
341
+ options: ConsoleServerOptions,
342
+ request: http.IncomingMessage,
343
+ response: http.ServerResponse,
344
+ ): Promise<void> => {
142
345
  const requestUrl = new URL(request.url ?? '/', 'http://localhost');
143
346
  const requestPath = requestUrl.pathname;
144
347
 
@@ -156,7 +359,13 @@ export const handleConsoleRequest = (
156
359
  sendUnauthorized(response);
157
360
  return;
158
361
  }
159
- sendNotFound(response);
362
+ await handleTokenedRequest(
363
+ options,
364
+ request,
365
+ response,
366
+ requestPath,
367
+ requestUrl.searchParams,
368
+ );
160
369
  return;
161
370
  }
162
371
 
@@ -196,11 +405,26 @@ export const handleConsoleRequest = (
196
405
  response.end(staticContent);
197
406
  };
198
407
 
408
+ const sendInternalServerError = (response: http.ServerResponse): void => {
409
+ if (response.headersSent) {
410
+ response.end();
411
+ return;
412
+ }
413
+ response.writeHead(500, {
414
+ 'Content-Type': 'text/plain; charset=utf-8',
415
+ 'Cache-Control': 'no-store',
416
+ });
417
+ response.end('Internal Server Error');
418
+ };
419
+
199
420
  export const createConsoleServer = (
200
421
  options: ConsoleServerOptions,
201
422
  ): http.Server =>
202
423
  http.createServer((request, response) => {
203
- handleConsoleRequest(options, request, response);
424
+ handleConsoleRequest(options, request, response).catch((error) => {
425
+ console.error('console request failed', error);
426
+ sendInternalServerError(response);
427
+ });
204
428
  });
205
429
 
206
430
  export type StartConsoleServerOptions = ConsoleServerOptions & {
@@ -3,6 +3,7 @@ import TYPIA from 'typia';
3
3
  import fs from 'fs';
4
4
  import { writeSituationFile } from './situationFileWriter';
5
5
  import { writeConsoleLists } from './consoleListsWriter';
6
+ import { writeInTmuxByHumanData } from './inTmuxByHumanDataWriter';
6
7
  import { writeRotationOrderFile } from './rotationOrderFileWriter';
7
8
  import {
8
9
  fetchProjectReadme,
@@ -68,6 +69,10 @@ export class HandleScheduledEventUseCaseHandler {
68
69
  type inputType = Parameters<HandleScheduledEventUseCase['run']>[0] & {
69
70
  claudeCodeOauthTokenListJsonPath?: string;
70
71
  consoleDataOutputDir?: string;
72
+ inTmuxDataOutputDir?: string;
73
+ inTmuxConsoleBaseUrl?: string;
74
+ inTmuxConsoleToken?: string;
75
+ inTmuxProjectOrder?: string[];
71
76
  credentials: {
72
77
  manager: {
73
78
  github: {
@@ -383,6 +388,27 @@ export class HandleScheduledEventUseCaseHandler {
383
388
  }`,
384
389
  );
385
390
  }
391
+
392
+ try {
393
+ writeInTmuxByHumanData({
394
+ inTmuxDataOutputDir: mergedInput.inTmuxDataOutputDir ?? null,
395
+ inTmuxConsoleBaseUrl: mergedInput.inTmuxConsoleBaseUrl ?? null,
396
+ inTmuxConsoleToken: mergedInput.inTmuxConsoleToken ?? null,
397
+ inTmuxProjectOrder: mergedInput.inTmuxProjectOrder ?? null,
398
+ pjcode: input.projectName,
399
+ assigneeLogin: input.manager,
400
+ org: input.org,
401
+ repo: input.workingReport.repo,
402
+ project: result.project,
403
+ issues: result.issues,
404
+ });
405
+ } catch (error) {
406
+ console.error(
407
+ `Failed to write in-tmux-by-human data: ${
408
+ error instanceof Error ? error.message : String(error)
409
+ }`,
410
+ );
411
+ }
386
412
  }
387
413
  return result;
388
414
  };
@@ -0,0 +1,266 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { Issue } from '../../../domain/entities/Issue';
5
+ import { FieldOption, Project } from '../../../domain/entities/Project';
6
+ import { writeInTmuxByHumanData } from './inTmuxByHumanDataWriter';
7
+
8
+ const ASSIGNEE = 'owner-login';
9
+ const CONSOLE_BASE_URL = 'https://console.example.test';
10
+ const CONSOLE_TOKEN = 'test-token-value';
11
+
12
+ const option = (
13
+ id: string,
14
+ name: string,
15
+ color: FieldOption['color'],
16
+ ): FieldOption => ({ id, name, color, description: '' });
17
+
18
+ const project: Project = {
19
+ id: 'project-node-id',
20
+ url: 'https://github.com/orgs/demo/projects/1',
21
+ databaseId: 1,
22
+ name: 'demo',
23
+ status: {
24
+ name: 'Status',
25
+ fieldId: 'status-field',
26
+ statuses: [option('st-tmux', 'In Tmux by human', 'RED')],
27
+ },
28
+ nextActionDate: null,
29
+ nextActionHour: null,
30
+ story: {
31
+ name: 'story',
32
+ fieldId: 'story-field',
33
+ databaseId: 2,
34
+ stories: [option('s1', 'Story Alpha', 'BLUE')],
35
+ workflowManagementStory: { id: 'wm', name: 'workflow management' },
36
+ },
37
+ remainingEstimationMinutes: null,
38
+ dependedIssueUrlSeparatedByComma: null,
39
+ completionDate50PercentConfidence: null,
40
+ };
41
+
42
+ const makeIssue = (overrides: Partial<Issue>): Issue => ({
43
+ nameWithOwner: 'demo/repo',
44
+ number: 1,
45
+ title: 'Issue 1',
46
+ state: 'OPEN',
47
+ status: 'In Tmux by human',
48
+ story: 'Story Alpha',
49
+ nextActionDate: null,
50
+ nextActionHour: null,
51
+ estimationMinutes: null,
52
+ dependedIssueUrls: [],
53
+ completionDate50PercentConfidence: null,
54
+ url: 'https://github.com/demo/repo/issues/1',
55
+ assignees: [ASSIGNEE],
56
+ labels: [],
57
+ org: 'demo',
58
+ repo: 'repo',
59
+ body: 'should never be written',
60
+ itemId: 'item-1',
61
+ isPr: false,
62
+ isInProgress: false,
63
+ isClosed: false,
64
+ createdAt: new Date('2026-06-13T08:18:45.000Z'),
65
+ author: 'someone',
66
+ ...overrides,
67
+ });
68
+
69
+ const baseParams = (outDir: string) => ({
70
+ inTmuxDataOutputDir: outDir,
71
+ inTmuxConsoleBaseUrl: CONSOLE_BASE_URL,
72
+ inTmuxConsoleToken: CONSOLE_TOKEN,
73
+ inTmuxProjectOrder: ['demo'],
74
+ pjcode: 'demo',
75
+ assigneeLogin: ASSIGNEE,
76
+ org: 'demo-org',
77
+ repo: 'demo-repo',
78
+ project,
79
+ issues: [makeIssue({})],
80
+ });
81
+
82
+ const readJson = (filePath: string): unknown =>
83
+ JSON.parse(fs.readFileSync(filePath, 'utf8'));
84
+
85
+ describe('writeInTmuxByHumanData', () => {
86
+ let outDir: string;
87
+
88
+ beforeEach(() => {
89
+ outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'intmux-out-'));
90
+ });
91
+
92
+ afterEach(() => {
93
+ fs.rmSync(outDir, { recursive: true, force: true });
94
+ });
95
+
96
+ const file = (name: string): string => path.join(outDir, name);
97
+
98
+ it('writes the four per-project files and the four index files', () => {
99
+ writeInTmuxByHumanData(baseParams(outDir));
100
+ for (const name of [
101
+ 'demo.json',
102
+ 'demo.v2.json',
103
+ 'demo.v3.json',
104
+ 'demo.v4.json',
105
+ 'index.json',
106
+ 'index.v2.json',
107
+ 'index.v3.json',
108
+ 'index.v4.json',
109
+ ]) {
110
+ expect(fs.existsSync(file(name))).toBe(true);
111
+ }
112
+ });
113
+
114
+ it('writes pretty-printed JSON ending with a trailing newline', () => {
115
+ writeInTmuxByHumanData(baseParams(outDir));
116
+ const raw = fs.readFileSync(file('demo.json'), 'utf8');
117
+ expect(raw.endsWith('\n')).toBe(true);
118
+ expect(raw).toContain('\n ');
119
+ });
120
+
121
+ it('writes the v1 shape with a flat url string array', () => {
122
+ writeInTmuxByHumanData(baseParams(outDir));
123
+ expect(readJson(file('demo.json'))).toEqual([
124
+ {
125
+ story: 'Story Alpha',
126
+ urls: ['https://github.com/demo/repo/issues/1'],
127
+ },
128
+ ]);
129
+ });
130
+
131
+ it('writes the v2 shape with url and title objects', () => {
132
+ writeInTmuxByHumanData(baseParams(outDir));
133
+ expect(readJson(file('demo.v2.json'))).toEqual([
134
+ {
135
+ story: 'Story Alpha',
136
+ urls: [
137
+ { url: 'https://github.com/demo/repo/issues/1', title: 'Issue 1' },
138
+ ],
139
+ },
140
+ ]);
141
+ });
142
+
143
+ it('writes the v3 document with overviewUrl and a token-free console url', () => {
144
+ writeInTmuxByHumanData(baseParams(outDir));
145
+ expect(readJson(file('demo.v3.json'))).toEqual({
146
+ version: 3,
147
+ overviewUrl: 'https://github.com/orgs/demo/projects/1',
148
+ tdpmConsoleUrl: 'https://console.example.test/projects/demo/prs',
149
+ groups: [
150
+ {
151
+ story: 'Story Alpha',
152
+ urls: [
153
+ { url: 'https://github.com/demo/repo/issues/1', title: 'Issue 1' },
154
+ ],
155
+ },
156
+ ],
157
+ });
158
+ });
159
+
160
+ it('writes the v4 document with the token-bearing console url and derived new issue url', () => {
161
+ writeInTmuxByHumanData(baseParams(outDir));
162
+ expect(readJson(file('demo.v4.json'))).toEqual({
163
+ version: 4,
164
+ overviewUrl: 'https://github.com/orgs/demo/projects/1',
165
+ tdpmConsoleUrl:
166
+ 'https://console.example.test/projects/demo/prs?k=test-token-value',
167
+ newIssueUrl:
168
+ 'https://github.com/demo-org/demo-repo/issues/new?assignees=owner-login',
169
+ groups: [
170
+ {
171
+ story: 'Story Alpha',
172
+ sessions: [
173
+ {
174
+ name: 'https://github.com/demo/repo/issues/1',
175
+ description: 'Issue 1',
176
+ },
177
+ ],
178
+ },
179
+ ],
180
+ });
181
+ });
182
+
183
+ it('writes index files listing configured projects whose per-project file exists', () => {
184
+ fs.writeFileSync(file('xmile.json'), '[]\n');
185
+ writeInTmuxByHumanData({
186
+ ...baseParams(outDir),
187
+ inTmuxProjectOrder: ['demo', 'xmile', 'xcare'],
188
+ });
189
+ expect(readJson(file('index.json'))).toEqual({
190
+ projects: ['demo', 'xmile'],
191
+ });
192
+ expect(readJson(file('index.v2.json'))).toEqual({
193
+ version: 2,
194
+ projects: ['demo', 'xmile'],
195
+ });
196
+ expect(readJson(file('index.v3.json'))).toEqual({
197
+ version: 3,
198
+ projects: ['demo', 'xmile'],
199
+ });
200
+ });
201
+
202
+ it('builds the v4 index path from the output dir basename and the token', () => {
203
+ writeInTmuxByHumanData(baseParams(outDir));
204
+ const basename = path.basename(outDir);
205
+ expect(readJson(file('index.v4.json'))).toEqual({
206
+ version: 4,
207
+ projects: [
208
+ { name: 'demo', path: `/${basename}/demo.v4.json?k=test-token-value` },
209
+ ],
210
+ });
211
+ });
212
+
213
+ it('skips the v4 file and the v4 index when the token is unset but still writes v1, v2, v3 and v1-v3 index', () => {
214
+ writeInTmuxByHumanData({
215
+ ...baseParams(outDir),
216
+ inTmuxConsoleToken: undefined,
217
+ });
218
+ expect(fs.existsSync(file('demo.json'))).toBe(true);
219
+ expect(fs.existsSync(file('demo.v2.json'))).toBe(true);
220
+ expect(fs.existsSync(file('demo.v3.json'))).toBe(true);
221
+ expect(fs.existsSync(file('demo.v4.json'))).toBe(false);
222
+ expect(fs.existsSync(file('index.json'))).toBe(true);
223
+ expect(fs.existsSync(file('index.v3.json'))).toBe(true);
224
+ expect(fs.existsSync(file('index.v4.json'))).toBe(false);
225
+ });
226
+
227
+ it('skips the v3 and v4 files when the console base url is unset', () => {
228
+ writeInTmuxByHumanData({
229
+ ...baseParams(outDir),
230
+ inTmuxConsoleBaseUrl: undefined,
231
+ });
232
+ expect(fs.existsSync(file('demo.json'))).toBe(true);
233
+ expect(fs.existsSync(file('demo.v2.json'))).toBe(true);
234
+ expect(fs.existsSync(file('demo.v3.json'))).toBe(false);
235
+ expect(fs.existsSync(file('demo.v4.json'))).toBe(false);
236
+ });
237
+
238
+ it('skips the index files when the project order is empty', () => {
239
+ writeInTmuxByHumanData({ ...baseParams(outDir), inTmuxProjectOrder: [] });
240
+ expect(fs.existsSync(file('demo.json'))).toBe(true);
241
+ expect(fs.existsSync(file('index.json'))).toBe(false);
242
+ });
243
+
244
+ it('does not leave a temp file behind after writing', () => {
245
+ writeInTmuxByHumanData(baseParams(outDir));
246
+ expect(fs.existsSync(`${file('demo.json')}.tmp`)).toBe(false);
247
+ });
248
+
249
+ it('is a no-op when the output dir is unset', () => {
250
+ writeInTmuxByHumanData({
251
+ ...baseParams(outDir),
252
+ inTmuxDataOutputDir: undefined,
253
+ });
254
+ expect(fs.readdirSync(outDir)).toHaveLength(0);
255
+ });
256
+
257
+ it('is a no-op when pjcode is unset', () => {
258
+ writeInTmuxByHumanData({ ...baseParams(outDir), pjcode: '' });
259
+ expect(fs.readdirSync(outDir)).toHaveLength(0);
260
+ });
261
+
262
+ it('is a no-op when the assignee login is unset', () => {
263
+ writeInTmuxByHumanData({ ...baseParams(outDir), assigneeLogin: null });
264
+ expect(fs.readdirSync(outDir)).toHaveLength(0);
265
+ });
266
+ });
@@ -0,0 +1,103 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import type { Issue } from '../../../domain/entities/Issue';
4
+ import type { Project } from '../../../domain/entities/Project';
5
+ import {
6
+ GenerateInTmuxByHumanDataUseCase,
7
+ InTmuxByHumanData,
8
+ } from '../../../domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase';
9
+
10
+ export type InTmuxByHumanDataWriterParams = {
11
+ inTmuxDataOutputDir: string | null | undefined;
12
+ inTmuxConsoleBaseUrl: string | null | undefined;
13
+ inTmuxConsoleToken: string | null | undefined;
14
+ inTmuxProjectOrder: string[] | null | undefined;
15
+ pjcode: string | null | undefined;
16
+ assigneeLogin: string | null | undefined;
17
+ org: string;
18
+ repo: string;
19
+ project: Project;
20
+ issues: Issue[];
21
+ };
22
+
23
+ const writeJsonAtomic = (filePath: string, data: unknown): void => {
24
+ const dir = path.dirname(filePath);
25
+ fs.mkdirSync(dir, { recursive: true });
26
+ const tmpPath = `${filePath}.tmp`;
27
+ fs.writeFileSync(tmpPath, `${JSON.stringify(data, null, 2)}\n`);
28
+ fs.renameSync(tmpPath, filePath);
29
+ };
30
+
31
+ export const writeInTmuxByHumanData = (
32
+ params: InTmuxByHumanDataWriterParams,
33
+ ): void => {
34
+ const {
35
+ inTmuxDataOutputDir,
36
+ inTmuxConsoleBaseUrl,
37
+ inTmuxConsoleToken,
38
+ inTmuxProjectOrder,
39
+ pjcode,
40
+ assigneeLogin,
41
+ org,
42
+ repo,
43
+ project,
44
+ issues,
45
+ } = params;
46
+ if (!inTmuxDataOutputDir || !pjcode || !assigneeLogin) {
47
+ return;
48
+ }
49
+
50
+ const data: InTmuxByHumanData = new GenerateInTmuxByHumanDataUseCase().run({
51
+ project,
52
+ issues,
53
+ pjcode,
54
+ assigneeLogin,
55
+ org,
56
+ repo,
57
+ consoleBaseUrl: inTmuxConsoleBaseUrl ?? null,
58
+ consoleToken: inTmuxConsoleToken ?? null,
59
+ });
60
+
61
+ writeJsonAtomic(path.join(inTmuxDataOutputDir, `${pjcode}.json`), data.v1);
62
+ writeJsonAtomic(path.join(inTmuxDataOutputDir, `${pjcode}.v2.json`), data.v2);
63
+ if (data.v3) {
64
+ writeJsonAtomic(
65
+ path.join(inTmuxDataOutputDir, `${pjcode}.v3.json`),
66
+ data.v3,
67
+ );
68
+ }
69
+ if (data.v4) {
70
+ writeJsonAtomic(
71
+ path.join(inTmuxDataOutputDir, `${pjcode}.v4.json`),
72
+ data.v4,
73
+ );
74
+ }
75
+
76
+ if (!inTmuxProjectOrder || inTmuxProjectOrder.length === 0) {
77
+ return;
78
+ }
79
+ const presentProjects = inTmuxProjectOrder.filter((name) =>
80
+ fs.existsSync(path.join(inTmuxDataOutputDir, `${name}.json`)),
81
+ );
82
+ writeJsonAtomic(path.join(inTmuxDataOutputDir, 'index.json'), {
83
+ projects: presentProjects,
84
+ });
85
+ writeJsonAtomic(path.join(inTmuxDataOutputDir, 'index.v2.json'), {
86
+ version: 2,
87
+ projects: presentProjects,
88
+ });
89
+ writeJsonAtomic(path.join(inTmuxDataOutputDir, 'index.v3.json'), {
90
+ version: 3,
91
+ projects: presentProjects,
92
+ });
93
+ if (inTmuxConsoleToken) {
94
+ const outputDirBasename = path.basename(inTmuxDataOutputDir);
95
+ writeJsonAtomic(path.join(inTmuxDataOutputDir, 'index.v4.json'), {
96
+ version: 4,
97
+ projects: presentProjects.map((name) => ({
98
+ name,
99
+ path: `/${outputDirBasename}/${name}.v4.json?k=${inTmuxConsoleToken}`,
100
+ })),
101
+ });
102
+ }
103
+ };