github-issue-tower-defence-management 1.152.2 → 1.153.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 (44) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +16 -1
  3. package/bin/adapter/entry-points/cli/index.js +36 -0
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/console/ui-dist/assets/{index-DR_LIP9n.js → index-BooMHl5Y.js} +1 -1
  6. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  7. package/bin/adapter/entry-points/console/webServer.js +59 -23
  8. package/bin/adapter/entry-points/console/webServer.js.map +1 -1
  9. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +11 -0
  10. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  11. package/bin/adapter/entry-points/handlers/ownerCallFileCleaner.js +20 -0
  12. package/bin/adapter/entry-points/handlers/ownerCallFileCleaner.js.map +1 -0
  13. package/bin/adapter/entry-points/handlers/ownerCallFileStore.js +80 -0
  14. package/bin/adapter/entry-points/handlers/ownerCallFileStore.js.map +1 -0
  15. package/bin/domain/usecases/intmux/OwnerCallFile.js +52 -0
  16. package/bin/domain/usecases/intmux/OwnerCallFile.js.map +1 -0
  17. package/package.json +1 -1
  18. package/src/adapter/entry-points/cli/index.test.ts +189 -0
  19. package/src/adapter/entry-points/cli/index.ts +75 -0
  20. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.tsx +1 -4
  21. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.test.tsx +80 -4
  22. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +0 -1
  23. package/src/adapter/entry-points/console/ui-dist/assets/{index-DR_LIP9n.js → index-BooMHl5Y.js} +1 -1
  24. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  25. package/src/adapter/entry-points/console/webServer.test.ts +317 -0
  26. package/src/adapter/entry-points/console/webServer.ts +86 -26
  27. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +35 -0
  28. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +15 -0
  29. package/src/adapter/entry-points/handlers/ownerCallFileCleaner.test.ts +109 -0
  30. package/src/adapter/entry-points/handlers/ownerCallFileCleaner.ts +25 -0
  31. package/src/adapter/entry-points/handlers/ownerCallFileStore.test.ts +286 -0
  32. package/src/adapter/entry-points/handlers/ownerCallFileStore.ts +145 -0
  33. package/src/domain/usecases/intmux/OwnerCallFile.test.ts +184 -0
  34. package/src/domain/usecases/intmux/OwnerCallFile.ts +80 -0
  35. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  36. package/types/adapter/entry-points/console/webServer.d.ts +1 -0
  37. package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
  38. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  39. package/types/adapter/entry-points/handlers/ownerCallFileCleaner.d.ts +8 -0
  40. package/types/adapter/entry-points/handlers/ownerCallFileCleaner.d.ts.map +1 -0
  41. package/types/adapter/entry-points/handlers/ownerCallFileStore.d.ts +21 -0
  42. package/types/adapter/entry-points/handlers/ownerCallFileStore.d.ts.map +1 -0
  43. package/types/domain/usecases/intmux/OwnerCallFile.d.ts +18 -0
  44. package/types/domain/usecases/intmux/OwnerCallFile.d.ts.map +1 -0
@@ -1,4 +1,5 @@
1
1
  import * as fs from 'fs';
2
+ import * as os from 'os';
2
3
  import * as path from 'path';
3
4
  import YAML from 'yaml';
4
5
 
@@ -21,6 +22,8 @@ import {
21
22
  import { StartPreparationUseCase } from '../../../domain/usecases/StartPreparationUseCase';
22
23
  import { NotifyFinishedIssuePreparationUseCase } from '../../../domain/usecases/NotifyFinishedIssuePreparationUseCase';
23
24
  import { CheckIssueReviewReadinessUseCase } from '../../../domain/usecases/CheckIssueReviewReadinessUseCase';
25
+ import { ownerCallFileRelativePath } from '../../../domain/usecases/intmux/OwnerCallFile';
26
+ import { toTmuxSessionName } from '../../../domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase';
24
27
 
25
28
  jest.mock('../../../domain/usecases/StartPreparationUseCase');
26
29
  jest.mock('../../../domain/usecases/NotifyFinishedIssuePreparationUseCase');
@@ -2371,6 +2374,192 @@ mysteryKey: 'value'
2371
2374
  });
2372
2375
  });
2373
2376
 
2377
+ describe('ownerCallFileAppend and ownerCallFileDelete', () => {
2378
+ const issueUrl = 'https://github.com/OWNER/REPO/issues/1';
2379
+ const otherIssueUrl = 'https://github.com/OWNER/REPO/issues/2';
2380
+ const sessionName = toTmuxSessionName(issueUrl);
2381
+ let ownerCallDataDir = '';
2382
+ let bodyFilePath = '';
2383
+
2384
+ beforeEach(() => {
2385
+ ownerCallDataDir = fs.mkdtempSync(
2386
+ path.join(os.tmpdir(), 'owner-call-cli-'),
2387
+ );
2388
+ bodyFilePath = path.join(ownerCallDataDir, 'body.txt');
2389
+ fs.writeFileSync(bodyFilePath, ' the call body\n\nsecond paragraph\n');
2390
+ });
2391
+
2392
+ afterEach(() => {
2393
+ fs.rmSync(ownerCallDataDir, { recursive: true, force: true });
2394
+ });
2395
+
2396
+ // The in-tmux-by-human data the scheduled run writes into the same
2397
+ // directory serveWeb serves: one file per project, each listing the
2398
+ // sessions of that project by their raw issue url.
2399
+ type InTmuxByHumanProjectData = {
2400
+ projectCode: string;
2401
+ sessionIssueUrls: string[];
2402
+ };
2403
+
2404
+ const writeInTmuxByHumanData = (
2405
+ projects: InTmuxByHumanProjectData[],
2406
+ ): void => {
2407
+ for (const project of projects) {
2408
+ fs.writeFileSync(
2409
+ path.join(ownerCallDataDir, `${project.projectCode}.v4.json`),
2410
+ `${JSON.stringify({
2411
+ version: 4,
2412
+ overviewUrl: 'https://github.com/orgs/OWNER/projects/1',
2413
+ tdpmConsoleUrl: 'http://localhost/projects/code',
2414
+ newIssueUrl: 'https://github.com/OWNER/REPO/issues/new',
2415
+ groups: [
2416
+ {
2417
+ story: 'a story',
2418
+ sessions: project.sessionIssueUrls.map((url) => ({
2419
+ name: url,
2420
+ description: 'an issue title',
2421
+ })),
2422
+ },
2423
+ ],
2424
+ })}\n`,
2425
+ );
2426
+ }
2427
+ };
2428
+
2429
+ const projectCodeListing = (
2430
+ projects: InTmuxByHumanProjectData[],
2431
+ url: string,
2432
+ ): string | null =>
2433
+ projects.find((project) => project.sessionIssueUrls.includes(url))
2434
+ ?.projectCode ?? null;
2435
+
2436
+ const appendedFilePath = (projectCode: string | null): string =>
2437
+ path.join(
2438
+ ownerCallDataDir,
2439
+ ownerCallFileRelativePath(projectCode, sessionName),
2440
+ );
2441
+
2442
+ const runAppend = (calledAt: string): Promise<unknown> =>
2443
+ program.parseAsync([
2444
+ 'node',
2445
+ 'test',
2446
+ 'ownerCallFileAppend',
2447
+ '--session',
2448
+ sessionName,
2449
+ '--calledAt',
2450
+ calledAt,
2451
+ '--body-file',
2452
+ bodyFilePath,
2453
+ '--inTmuxDataDir',
2454
+ ownerCallDataDir,
2455
+ ]);
2456
+
2457
+ it('appends the call under the directory of the project whose session list holds the session', async () => {
2458
+ const projects: InTmuxByHumanProjectData[] = [
2459
+ { projectCode: 'other', sessionIssueUrls: [otherIssueUrl] },
2460
+ { projectCode: 'umino', sessionIssueUrls: [issueUrl] },
2461
+ ];
2462
+ writeInTmuxByHumanData(projects);
2463
+ const stdoutSpy = jest
2464
+ .spyOn(process.stdout, 'write')
2465
+ .mockImplementation(() => true);
2466
+
2467
+ await runAppend('2026-08-14T04:22:28Z');
2468
+
2469
+ const documents = YAML.parseAllDocuments(
2470
+ fs.readFileSync(
2471
+ appendedFilePath(projectCodeListing(projects, issueUrl)),
2472
+ 'utf-8',
2473
+ ),
2474
+ );
2475
+ expect(documents).toHaveLength(1);
2476
+ expect(documents[0].toJS()).toEqual({
2477
+ sessionName,
2478
+ calledAt: '2026-08-14T04:22:28Z',
2479
+ body: ' the call body\n\nsecond paragraph\n',
2480
+ });
2481
+ expect(stdoutSpy).not.toHaveBeenCalled();
2482
+
2483
+ stdoutSpy.mockRestore();
2484
+ });
2485
+
2486
+ it('appends the call under NA when no project lists the session', async () => {
2487
+ const projects: InTmuxByHumanProjectData[] = [
2488
+ { projectCode: 'umino', sessionIssueUrls: [otherIssueUrl] },
2489
+ ];
2490
+ writeInTmuxByHumanData(projects);
2491
+
2492
+ await runAppend('2026-08-14T04:22:28Z');
2493
+
2494
+ expect(projectCodeListing(projects, issueUrl)).toBeNull();
2495
+ expect(fs.existsSync(appendedFilePath(null))).toBe(true);
2496
+ });
2497
+
2498
+ it('rejects a calledAt that is not a UTC ISO-8601 timestamp with second precision', async () => {
2499
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
2500
+ const processExitSpy = jest
2501
+ .spyOn(process, 'exit')
2502
+ .mockImplementation(() => {
2503
+ throw new Error('process.exit called');
2504
+ });
2505
+
2506
+ await expect(runAppend('2026-08-14 04:22:28')).rejects.toThrow(
2507
+ 'process.exit called',
2508
+ );
2509
+ expect(processExitSpy).toHaveBeenCalledWith(1);
2510
+ expect(fs.existsSync(appendedFilePath(null))).toBe(false);
2511
+
2512
+ consoleErrorSpy.mockRestore();
2513
+ processExitSpy.mockRestore();
2514
+ });
2515
+
2516
+ const runDelete = (): Promise<unknown> =>
2517
+ program.parseAsync([
2518
+ 'node',
2519
+ 'test',
2520
+ 'ownerCallFileDelete',
2521
+ '--session',
2522
+ sessionName,
2523
+ '--inTmuxDataDir',
2524
+ ownerCallDataDir,
2525
+ ]);
2526
+
2527
+ it('deletes the file of the session whichever project directory holds it', async () => {
2528
+ const projects: InTmuxByHumanProjectData[] = [
2529
+ { projectCode: 'umino', sessionIssueUrls: [issueUrl] },
2530
+ ];
2531
+ writeInTmuxByHumanData(projects);
2532
+ await runAppend('2026-08-14T04:22:28Z');
2533
+ const writtenFilePath = appendedFilePath(
2534
+ projectCodeListing(projects, issueUrl),
2535
+ );
2536
+ expect(fs.existsSync(writtenFilePath)).toBe(true);
2537
+
2538
+ await runDelete();
2539
+
2540
+ expect(fs.existsSync(writtenFilePath)).toBe(false);
2541
+ });
2542
+
2543
+ it('deletes the file left behind under the project the session has since left', async () => {
2544
+ writeInTmuxByHumanData([
2545
+ { projectCode: 'umino', sessionIssueUrls: [issueUrl] },
2546
+ ]);
2547
+ await runAppend('2026-08-14T04:22:28Z');
2548
+ writeInTmuxByHumanData([
2549
+ { projectCode: 'umino', sessionIssueUrls: [] },
2550
+ { projectCode: 'other', sessionIssueUrls: [issueUrl] },
2551
+ ]);
2552
+
2553
+ await runDelete();
2554
+
2555
+ expect(fs.existsSync(appendedFilePath('umino'))).toBe(false);
2556
+ });
2557
+
2558
+ it('succeeds when the file to delete is already absent', async () => {
2559
+ await expect(runDelete()).resolves.toBeDefined();
2560
+ });
2561
+ });
2562
+
2374
2563
  describe('runCliProgram', () => {
2375
2564
  const mockCheckUseCaseRejectingWith = (error: Error): void => {
2376
2565
  const MockedCheckUseCase = jest.mocked(CheckIssueReviewReadinessUseCase);
@@ -63,6 +63,12 @@ import {
63
63
  loadLiveSessionOauthTokenSelectionSettings,
64
64
  resolveFleetConfigFilePath,
65
65
  } from './fleetConfig';
66
+ import { isOwnerCallCalledAtValid } from '../../../domain/usecases/intmux/OwnerCallFile';
67
+ import {
68
+ ownerCallFileAppend,
69
+ ownerCallFileDeleteInEveryProject,
70
+ ownerCallProjectCodeInInTmuxByHumanData,
71
+ } from '../handlers/ownerCallFileStore';
66
72
 
67
73
  type StartDaemonOptions = {
68
74
  projectUrl?: string;
@@ -102,6 +108,18 @@ type ServeWebOptions = {
102
108
  dashboardProjectNames?: string;
103
109
  };
104
110
 
111
+ type OwnerCallFileAppendOptions = {
112
+ session: string;
113
+ calledAt: string;
114
+ bodyFile: string;
115
+ inTmuxDataDir?: string;
116
+ };
117
+
118
+ type OwnerCallFileDeleteOptions = {
119
+ session: string;
120
+ inTmuxDataDir?: string;
121
+ };
122
+
105
123
  const DEFAULT_IN_TMUX_DATA_DIR =
106
124
  '/home/hiromi/0_workspaces/workspace1/jsonpub/in-tmux-by-human';
107
125
 
@@ -1149,6 +1167,63 @@ program
1149
1167
  }
1150
1168
  });
1151
1169
 
1170
+ program
1171
+ .command('ownerCallFileAppend')
1172
+ .description(
1173
+ 'Append one owner call as a YAML document to the per-session owner call file. The project the session belongs to is resolved from the in-tmux-by-human data in the data directory, and a session no project lists is written under NA. The file is created when it does not exist, and every later call is appended after the existing documents so the oldest call stays first. Nothing is written to stdout on success.',
1174
+ )
1175
+ .requiredOption('--session <name>', 'tmux session name that raised the call')
1176
+ .requiredOption(
1177
+ '--calledAt <timestamp>',
1178
+ 'Time the call was raised, as UTC ISO-8601 with second precision and a trailing Z',
1179
+ )
1180
+ .requiredOption(
1181
+ '--body-file <path>',
1182
+ 'Path to the file holding the call body; the body is read from a file because it is multi-line and can be long',
1183
+ )
1184
+ .option(
1185
+ '--inTmuxDataDir <path>',
1186
+ `Directory the owner call files are written under, the same directory serveWeb serves them from (default: ${DEFAULT_IN_TMUX_DATA_DIR})`,
1187
+ )
1188
+ .action((options: OwnerCallFileAppendOptions) => {
1189
+ if (!isOwnerCallCalledAtValid(options.calledAt)) {
1190
+ console.error(
1191
+ '--calledAt must be a UTC ISO-8601 timestamp with second precision and a trailing Z, for example 2026-08-14T04:22:28Z',
1192
+ );
1193
+ return process.exit(1);
1194
+ }
1195
+ const dataDir = options.inTmuxDataDir ?? DEFAULT_IN_TMUX_DATA_DIR;
1196
+ ownerCallFileAppend({
1197
+ dataDir,
1198
+ projectCode: ownerCallProjectCodeInInTmuxByHumanData(
1199
+ dataDir,
1200
+ options.session,
1201
+ ),
1202
+ ownerCall: {
1203
+ sessionName: options.session,
1204
+ calledAt: options.calledAt,
1205
+ body: fs.readFileSync(options.bodyFile, 'utf-8'),
1206
+ },
1207
+ });
1208
+ });
1209
+
1210
+ program
1211
+ .command('ownerCallFileDelete')
1212
+ .description(
1213
+ 'Delete the owner call file of one session, whichever project directory holds it, so a session that moved between projects after its call was appended leaves nothing behind. It succeeds when the file is already absent, so a reset that runs twice is not an error. Nothing is written to stdout on success.',
1214
+ )
1215
+ .requiredOption('--session <name>', 'tmux session name that raised the call')
1216
+ .option(
1217
+ '--inTmuxDataDir <path>',
1218
+ `Directory the owner call files are written under, the same directory serveWeb serves them from (default: ${DEFAULT_IN_TMUX_DATA_DIR})`,
1219
+ )
1220
+ .action((options: OwnerCallFileDeleteOptions) => {
1221
+ ownerCallFileDeleteInEveryProject({
1222
+ dataDir: options.inTmuxDataDir ?? DEFAULT_IN_TMUX_DATA_DIR,
1223
+ sessionName: options.session,
1224
+ });
1225
+ });
1226
+
1152
1227
  export const reportFatalErrorAndExit = (error: unknown): void => {
1153
1228
  console.error(error);
1154
1229
  process.exit(1);
@@ -51,7 +51,6 @@ export type ConsoleItemDetailProps = {
51
51
  bodyIsLoading: boolean;
52
52
  bodyError: string | null;
53
53
  comments: ConsoleComment[];
54
- hasPostedComment?: boolean;
55
54
  commentsAreLoading: boolean;
56
55
  commentsError: string | null;
57
56
  files: ConsoleChangedFile[];
@@ -83,7 +82,6 @@ export const ConsoleItemDetail = ({
83
82
  bodyIsLoading,
84
83
  bodyError,
85
84
  comments,
86
- hasPostedComment = false,
87
85
  commentsAreLoading,
88
86
  commentsError,
89
87
  files,
@@ -277,10 +275,9 @@ export const ConsoleItemDetail = ({
277
275
  )}
278
276
 
279
277
  <ConsolePanel
280
- key={hasPostedComment ? 'comments-with-posted' : 'comments'}
281
278
  title="Comments"
282
279
  count={commentsCount}
283
- defaultCollapsed={item.isPr && !hasPostedComment}
280
+ defaultCollapsed={item.isPr}
284
281
  >
285
282
  <ConsoleCommentList
286
283
  comments={comments}
@@ -83,6 +83,20 @@ const buildOperations = (): ConsoleOperationsApi => ({
83
83
  addInlineReviewComment: jest.fn(async () => {}),
84
84
  });
85
85
 
86
+ const findCommentsPanelToggle = (container: HTMLElement): HTMLElement => {
87
+ const toggle = Array.from(
88
+ container.querySelectorAll('.console-panel-toggle'),
89
+ ).find((element) =>
90
+ element
91
+ .querySelector('.console-panel-title')
92
+ ?.textContent?.startsWith('Comments'),
93
+ );
94
+ if (toggle === undefined) {
95
+ throw new Error('Comments panel toggle is not rendered');
96
+ }
97
+ return toggle as HTMLElement;
98
+ };
99
+
86
100
  describe('ConsoleItemDetailContainer', () => {
87
101
  it('queues the review action and commits it through the operations api for a PR item', async () => {
88
102
  const operations = buildOperations();
@@ -182,7 +196,7 @@ describe('ConsoleItemDetailContainer', () => {
182
196
  ).toBe(0);
183
197
  });
184
198
 
185
- it('opens the comments panel of a pull request item so the posted comment is on screen', async () => {
199
+ it('leaves the collapsed comments panel of a pull request item collapsed after a comment is posted', async () => {
186
200
  const operations = buildOperations();
187
201
  operations.addComment = jest.fn(async (_item, body) => ({
188
202
  author: 'HiromiShikata',
@@ -205,16 +219,78 @@ describe('ConsoleItemDetailContainer', () => {
205
219
  />,
206
220
  );
207
221
 
222
+ await waitFor(() => {
223
+ expect(findCommentsPanelToggle(container).textContent).toContain(
224
+ 'Comments (0)',
225
+ );
226
+ });
227
+ expect(
228
+ findCommentsPanelToggle(container).getAttribute('aria-expanded'),
229
+ ).toBe('false');
230
+
208
231
  fireEvent.change(getByPlaceholderText('Leave a comment…'), {
209
232
  target: { value: 'Posted from the pull request item.' },
210
233
  });
211
234
  fireEvent.click(getByText('Comment'));
212
235
 
213
236
  await waitFor(() => {
214
- expect(
215
- container.querySelector('.console-comment-list')?.textContent,
216
- ).toContain('Posted from the pull request item.');
237
+ expect(findCommentsPanelToggle(container).textContent).toContain(
238
+ 'Comments (1)',
239
+ );
240
+ });
241
+ expect(
242
+ findCommentsPanelToggle(container).getAttribute('aria-expanded'),
243
+ ).toBe('false');
244
+ expect(container.querySelector('.console-comment-list')).toBeNull();
245
+ });
246
+
247
+ it('keeps a reader-collapsed comments panel collapsed after a comment is posted on an issue item', async () => {
248
+ const operations = buildOperations();
249
+ operations.addComment = jest.fn(async (_item, body) => ({
250
+ author: 'HiromiShikata',
251
+ body,
252
+ createdAt: '2026-06-19T11:58:00.000Z',
253
+ }));
254
+ const { container, getByPlaceholderText, getByText } = render(
255
+ <ConsoleItemDetailContainer
256
+ tab="unread"
257
+ item={issueItem}
258
+ caches={buildCaches()}
259
+ operations={operations}
260
+ statusOptions={consoleStatusOptionsFixture}
261
+ storyOptions={consoleStoryOptionsFixture}
262
+ storyColors={consoleStoryColorsFixture}
263
+ storyName="TDPM Console port"
264
+ overlayStatus={null}
265
+ now={Date.parse('2026-06-19T12:00:00.000Z')}
266
+ onQueueAction={jest.fn()}
267
+ />,
268
+ );
269
+
270
+ await waitFor(() => {
271
+ expect(findCommentsPanelToggle(container).textContent).toContain(
272
+ 'Comments (0)',
273
+ );
274
+ });
275
+ fireEvent.click(findCommentsPanelToggle(container));
276
+ expect(
277
+ findCommentsPanelToggle(container).getAttribute('aria-expanded'),
278
+ ).toBe('false');
279
+
280
+ fireEvent.change(getByPlaceholderText('Leave a comment…'), {
281
+ target: { value: 'Posted from the issue item.' },
217
282
  });
283
+ fireEvent.click(getByText('Comment'));
284
+
285
+ await waitFor(() => {
286
+ expect(findCommentsPanelToggle(container).textContent).toContain(
287
+ 'Comments (1)',
288
+ );
289
+ });
290
+ expect(
291
+ findCommentsPanelToggle(container).getAttribute('aria-expanded'),
292
+ ).toBe('false');
293
+ expect(container.querySelector('.console-comment-list')).toBeNull();
218
294
  });
219
295
 
220
296
  it('shows Approve for an issue item from the generated related open pull request urls before the related pull requests are fetched', () => {
@@ -171,7 +171,6 @@ export const ConsoleItemDetailContainer = ({
171
171
  bodyIsLoading={detail.bodyIsLoading}
172
172
  bodyError={detail.bodyError}
173
173
  comments={mergePostedComments(detail.comments, postedComments)}
174
- hasPostedComment={postedComments.length > 0}
175
174
  commentsAreLoading={detail.commentsAreLoading}
176
175
  commentsError={detail.commentsError}
177
176
  files={detail.files}