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
@@ -36,6 +36,10 @@ import {
36
36
  composeDashboardText,
37
37
  dashboardComposeFilesPresent,
38
38
  } from './dashboardComposeService';
39
+ import {
40
+ OWNER_CALL_FILE_DIRECTORY_NAME,
41
+ OWNER_CALL_FILE_EXTENSION,
42
+ } from '../../../domain/usecases/intmux/OwnerCallFile';
39
43
 
40
44
  export const DEFAULT_WEB_PORT = 9980;
41
45
 
@@ -74,6 +78,7 @@ const MIME_TYPES: Record<string, string> = {
74
78
  '.map': 'application/json; charset=utf-8',
75
79
  '.woff': 'font/woff',
76
80
  '.woff2': 'font/woff2',
81
+ '.yaml': 'text/yaml; charset=utf-8',
77
82
  };
78
83
 
79
84
  export const hasDotSegment = (requestPath: string): boolean =>
@@ -84,7 +89,8 @@ export const hasDotSegment = (requestPath: string): boolean =>
84
89
  export const requiresToken = (requestPath: string): boolean =>
85
90
  requestPath.startsWith('/api/') ||
86
91
  requestPath === '/api' ||
87
- requestPath.endsWith('.json');
92
+ requestPath.endsWith('.json') ||
93
+ requestPath.endsWith(OWNER_CALL_FILE_EXTENSION);
88
94
 
89
95
  const SAFE_PJCODE = /^[A-Za-z0-9._-]+$/;
90
96
 
@@ -227,6 +233,12 @@ const FLAT_IN_TMUX_PREFIX = '/in-tmux-by-human/';
227
233
 
228
234
  const FLAT_IN_TMUX_FILE = /^[A-Za-z0-9._-]+\.json$/;
229
235
 
236
+ const NAME_SEGMENT = '[A-Za-z0-9_-][A-Za-z0-9._-]*';
237
+
238
+ const OWNER_CALL_FILE = new RegExp(
239
+ `^${OWNER_CALL_FILE_DIRECTORY_NAME}/${NAME_SEGMENT}/${NAME_SEGMENT}${OWNER_CALL_FILE_EXTENSION.replace('.', '\\.')}$`,
240
+ );
241
+
230
242
  export const DASHBOARD_REQUEST_PATH = '/tdpm.txt';
231
243
 
232
244
  export const IMAGE_PROXY_REQUEST_PATH = '/api/img';
@@ -249,6 +261,10 @@ export const resolveDashboardFilePath = (
249
261
  return resolvedCandidate;
250
262
  };
251
263
 
264
+ export const isOwnerCallFileRequestPath = (requestPath: string): boolean =>
265
+ requestPath.startsWith(FLAT_IN_TMUX_PREFIX) &&
266
+ OWNER_CALL_FILE.test(requestPath.slice(FLAT_IN_TMUX_PREFIX.length));
267
+
252
268
  export const resolveFlatInTmuxFilePath = (
253
269
  inTmuxDataDir: string,
254
270
  requestPath: string,
@@ -256,11 +272,16 @@ export const resolveFlatInTmuxFilePath = (
256
272
  if (!requestPath.startsWith(FLAT_IN_TMUX_PREFIX)) {
257
273
  return null;
258
274
  }
259
- const fileName = requestPath.slice(FLAT_IN_TMUX_PREFIX.length);
260
- if (fileName.length === 0 || !FLAT_IN_TMUX_FILE.test(fileName)) {
275
+ const relativePath = requestPath.slice(FLAT_IN_TMUX_PREFIX.length);
276
+ if (
277
+ relativePath.length === 0 ||
278
+ !(
279
+ FLAT_IN_TMUX_FILE.test(relativePath) || OWNER_CALL_FILE.test(relativePath)
280
+ )
281
+ ) {
261
282
  return null;
262
283
  }
263
- const candidate = path.join(inTmuxDataDir, fileName);
284
+ const candidate = path.join(inTmuxDataDir, relativePath);
264
285
  const resolvedRoot = path.resolve(inTmuxDataDir);
265
286
  const resolvedCandidate = path.resolve(candidate);
266
287
  if (!resolvedCandidate.startsWith(resolvedRoot + path.sep)) {
@@ -530,6 +551,56 @@ const handleOperationApi = async (
530
551
  }
531
552
  };
532
553
 
554
+ const serveFlatInTmuxFile = (
555
+ options: WebServerOptions,
556
+ response: http.ServerResponse,
557
+ requestPath: string,
558
+ ): void => {
559
+ if (options.inTmuxDataDir === null) {
560
+ sendNotFound(response);
561
+ return;
562
+ }
563
+ const filePath = resolveFlatInTmuxFilePath(
564
+ options.inTmuxDataDir,
565
+ requestPath,
566
+ );
567
+ const content = filePath === null ? null : readStaticFile(filePath);
568
+ if (filePath === null || content === null) {
569
+ sendNotFound(response);
570
+ return;
571
+ }
572
+ response.writeHead(200, {
573
+ 'Content-Type': contentTypeForPath(filePath),
574
+ 'Cache-Control': 'no-store',
575
+ 'Content-Length': String(content.length),
576
+ });
577
+ response.end(content);
578
+ };
579
+
580
+ const removeOwnerCallFile = (
581
+ options: WebServerOptions,
582
+ response: http.ServerResponse,
583
+ requestPath: string,
584
+ ): void => {
585
+ if (options.inTmuxDataDir === null) {
586
+ sendNotFound(response);
587
+ return;
588
+ }
589
+ const filePath = resolveFlatInTmuxFilePath(
590
+ options.inTmuxDataDir,
591
+ requestPath,
592
+ );
593
+ if (filePath === null) {
594
+ sendNotFound(response);
595
+ return;
596
+ }
597
+ fs.rmSync(filePath, { force: true });
598
+ response.writeHead(204, {
599
+ 'Cache-Control': 'no-store',
600
+ });
601
+ response.end();
602
+ };
603
+
533
604
  const handleTokenedRequest = async (
534
605
  options: WebServerOptions,
535
606
  request: http.IncomingMessage,
@@ -580,31 +651,20 @@ const handleTokenedRequest = async (
580
651
  return;
581
652
  }
582
653
 
583
- if (method === 'GET') {
584
- if (requestPath.startsWith(FLAT_IN_TMUX_PREFIX)) {
585
- if (options.inTmuxDataDir === null) {
586
- sendNotFound(response);
587
- return;
588
- }
589
- const flatFilePath = resolveFlatInTmuxFilePath(
590
- options.inTmuxDataDir,
591
- requestPath,
592
- );
593
- const flatContent =
594
- flatFilePath === null ? null : readStaticFile(flatFilePath);
595
- if (flatContent === null) {
596
- sendNotFound(response);
597
- return;
598
- }
599
- response.writeHead(200, {
600
- 'Content-Type': 'application/json; charset=utf-8',
601
- 'Cache-Control': 'no-store',
602
- 'Content-Length': String(flatContent.length),
603
- });
604
- response.end(flatContent);
654
+ if (requestPath.startsWith(FLAT_IN_TMUX_PREFIX)) {
655
+ if (method === 'GET') {
656
+ serveFlatInTmuxFile(options, response, requestPath);
605
657
  return;
606
658
  }
659
+ if (method === 'DELETE' && isOwnerCallFileRequestPath(requestPath)) {
660
+ removeOwnerCallFile(options, response, requestPath);
661
+ return;
662
+ }
663
+ sendNotFound(response);
664
+ return;
665
+ }
607
666
 
667
+ if (method === 'GET') {
608
668
  const dataRoute = parseConsoleDataRoute(requestPath);
609
669
  if (dataRoute !== null && options.consoleDataOutputDir !== null) {
610
670
  const dataResponse = buildConsoleDataResponse(
@@ -129,6 +129,9 @@ jest.mock('./rotationOrderFileWriter', () => ({
129
129
  jest.mock('./inTmuxByHumanDataWriter', () => ({
130
130
  writeInTmuxByHumanData: jest.fn(),
131
131
  }));
132
+ jest.mock('./ownerCallFileCleaner', () => ({
133
+ cleanClosedIssueOwnerCallFiles: jest.fn(),
134
+ }));
132
135
  jest.mock('./consoleListsWriter', () => {
133
136
  const actual = jest.requireActual<typeof import('./consoleListsWriter')>(
134
137
  './consoleListsWriter',
@@ -139,6 +142,7 @@ jest.mock('./consoleListsWriter', () => {
139
142
  import { HandleScheduledEventUseCaseHandler } from './HandleScheduledEventUseCaseHandler';
140
143
  import { writeSituationFile } from './situationFileWriter';
141
144
  import { writeInTmuxByHumanData } from './inTmuxByHumanDataWriter';
145
+ import { cleanClosedIssueOwnerCallFiles } from './ownerCallFileCleaner';
142
146
  import { writeConsoleLists } from './consoleListsWriter';
143
147
  import { GraphqlProjectRepository } from '../../repositories/GraphqlProjectRepository';
144
148
  import { ApiV3IssueRepository } from '../../repositories/issue/ApiV3IssueRepository';
@@ -756,4 +760,35 @@ defaultAgentName: readme-agent
756
760
  expect(getInTmuxProjectOrderArg()).toBeNull();
757
761
  });
758
762
  });
763
+
764
+ describe('owner call file cleanup of closed issues', () => {
765
+ it('should hand the project issues to the cleanup with the configured project code and data directory', async () => {
766
+ jest.mocked(fs.readFileSync).mockReturnValue(
767
+ YAML.stringify({
768
+ ...validConfig,
769
+ inTmuxDataOutputDir: '/data/in-tmux-by-human',
770
+ }),
771
+ );
772
+ const closedIssue = {
773
+ url: 'https://github.com/TestOrg/test-repo/issues/9',
774
+ isClosed: true,
775
+ };
776
+ mockRun.mockResolvedValueOnce({
777
+ project: { id: 'PVT_kwHOtest123' },
778
+ issues: [closedIssue],
779
+ cacheUsed: false,
780
+ targetDateTimes: [],
781
+ rotationOrder: null,
782
+ });
783
+
784
+ const handler = new HandleScheduledEventUseCaseHandler();
785
+ await handler.handle('config.yml', false);
786
+
787
+ expect(jest.mocked(cleanClosedIssueOwnerCallFiles)).toHaveBeenCalledWith({
788
+ inTmuxDataOutputDir: '/data/in-tmux-by-human',
789
+ pjcode: validConfig.projectName,
790
+ issues: [closedIssue],
791
+ });
792
+ });
793
+ });
759
794
  });
@@ -11,6 +11,7 @@ import { writeDashboardRow } from './dashboardRowWriter';
11
11
  import { writeMachineStatus } from './machineStatusWriter';
12
12
  import { writeTokenStatus } from './tokenStatusWriter';
13
13
  import { writeInTmuxByHumanData } from './inTmuxByHumanDataWriter';
14
+ import { cleanClosedIssueOwnerCallFiles } from './ownerCallFileCleaner';
14
15
  import { reconcileInTmuxByHumanSessions } from './inTmuxByHumanSessionReconciler';
15
16
  import { handleTokenExhaustionHandover } from './tokenExhaustionHandover';
16
17
  import { cleanStaleTmuxSessions } from './staleTmuxSessionCleaner';
@@ -682,6 +683,20 @@ export class HandleScheduledEventUseCaseHandler {
682
683
  );
683
684
  }
684
685
 
686
+ try {
687
+ cleanClosedIssueOwnerCallFiles({
688
+ inTmuxDataOutputDir: mergedInput.inTmuxDataOutputDir ?? null,
689
+ pjcode: input.projectName,
690
+ issues: result.issues,
691
+ });
692
+ } catch (error) {
693
+ console.error(
694
+ `Failed to clean owner call files of closed issues: ${
695
+ error instanceof Error ? error.message : String(error)
696
+ }`,
697
+ );
698
+ }
699
+
685
700
  try {
686
701
  await handleTokenExhaustionHandover({
687
702
  enabled: mergedInput.tokenExhaustionHandoverEnabled ?? false,
@@ -0,0 +1,109 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { Issue } from '../../../domain/entities/Issue';
5
+ import { toTmuxSessionName } from '../../../domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase';
6
+ import { cleanClosedIssueOwnerCallFiles } from './ownerCallFileCleaner';
7
+ import { ownerCallFileAppend, ownerCallFilePath } from './ownerCallFileStore';
8
+
9
+ const makeIssue = (overrides: Partial<Issue> = {}): Issue => ({
10
+ nameWithOwner: 'demo/repo',
11
+ number: 1,
12
+ title: 'Issue 1',
13
+ state: 'OPEN',
14
+ status: 'In Tmux by human',
15
+ story: null,
16
+ nextActionDate: null,
17
+ nextActionHour: null,
18
+ estimationMinutes: null,
19
+ dependedIssueUrls: [],
20
+ completionDate50PercentConfidence: null,
21
+ url: 'https://github.com/demo/repo/issues/1',
22
+ assignees: ['owner-login'],
23
+ labels: [],
24
+ org: 'demo',
25
+ repo: 'repo',
26
+ body: '',
27
+ itemId: 'item-1',
28
+ isPr: false,
29
+ isInProgress: false,
30
+ isClosed: false,
31
+ createdAt: new Date('2026-08-01T00:00:00.000Z'),
32
+ author: '',
33
+ closingIssueReferenceUrls: [],
34
+ ...overrides,
35
+ });
36
+
37
+ describe('cleanClosedIssueOwnerCallFiles', () => {
38
+ let dataDir = '';
39
+
40
+ beforeEach(() => {
41
+ dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'owner-call-cleaner-'));
42
+ });
43
+
44
+ afterEach(() => {
45
+ fs.rmSync(dataDir, { recursive: true, force: true });
46
+ });
47
+
48
+ const appendCallFor = (issue: Issue): string => {
49
+ const sessionName = toTmuxSessionName(issue.url);
50
+ ownerCallFileAppend({
51
+ dataDir,
52
+ projectCode: 'umino',
53
+ ownerCall: {
54
+ sessionName,
55
+ calledAt: '2026-08-14T04:22:28Z',
56
+ body: 'an unanswered call\n',
57
+ },
58
+ });
59
+ return ownerCallFilePath(dataDir, 'umino', sessionName);
60
+ };
61
+
62
+ it('deletes the owner call file of a session whose issue is closed', () => {
63
+ const closedIssue = makeIssue({
64
+ url: 'https://github.com/demo/repo/issues/7',
65
+ state: 'CLOSED',
66
+ isClosed: true,
67
+ });
68
+ const filePath = appendCallFor(closedIssue);
69
+
70
+ cleanClosedIssueOwnerCallFiles({
71
+ inTmuxDataOutputDir: dataDir,
72
+ pjcode: 'umino',
73
+ issues: [closedIssue],
74
+ });
75
+
76
+ expect(fs.existsSync(filePath)).toBe(false);
77
+ });
78
+
79
+ it('keeps the owner call file of a session whose issue is still open', () => {
80
+ const openIssue = makeIssue();
81
+ const filePath = appendCallFor(openIssue);
82
+
83
+ cleanClosedIssueOwnerCallFiles({
84
+ inTmuxDataOutputDir: dataDir,
85
+ pjcode: 'umino',
86
+ issues: [openIssue],
87
+ });
88
+
89
+ expect(fs.existsSync(filePath)).toBe(true);
90
+ });
91
+
92
+ it('does nothing when the data directory or the project code is not configured', () => {
93
+ const closedIssue = makeIssue({ isClosed: true, state: 'CLOSED' });
94
+ const filePath = appendCallFor(closedIssue);
95
+
96
+ cleanClosedIssueOwnerCallFiles({
97
+ inTmuxDataOutputDir: null,
98
+ pjcode: 'umino',
99
+ issues: [closedIssue],
100
+ });
101
+ cleanClosedIssueOwnerCallFiles({
102
+ inTmuxDataOutputDir: dataDir,
103
+ pjcode: null,
104
+ issues: [closedIssue],
105
+ });
106
+
107
+ expect(fs.existsSync(filePath)).toBe(true);
108
+ });
109
+ });
@@ -0,0 +1,25 @@
1
+ import { Issue } from '../../../domain/entities/Issue';
2
+ import { toTmuxSessionName } from '../../../domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase';
3
+ import { ownerCallFileDelete } from './ownerCallFileStore';
4
+
5
+ export type CleanClosedIssueOwnerCallFilesParams = {
6
+ inTmuxDataOutputDir: string | null | undefined;
7
+ pjcode: string | null | undefined;
8
+ issues: Issue[];
9
+ };
10
+
11
+ export const cleanClosedIssueOwnerCallFiles = (
12
+ params: CleanClosedIssueOwnerCallFilesParams,
13
+ ): void => {
14
+ const { inTmuxDataOutputDir, pjcode, issues } = params;
15
+ if (!inTmuxDataOutputDir || !pjcode) {
16
+ return;
17
+ }
18
+ for (const issue of issues.filter((candidate) => candidate.isClosed)) {
19
+ ownerCallFileDelete({
20
+ dataDir: inTmuxDataOutputDir,
21
+ projectCode: pjcode,
22
+ sessionName: toTmuxSessionName(issue.url),
23
+ });
24
+ }
25
+ };
@@ -0,0 +1,286 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { parseAllDocuments } from 'yaml';
5
+ import {
6
+ ownerCallFileAppend,
7
+ ownerCallFileDelete,
8
+ ownerCallFileDeleteInEveryProject,
9
+ ownerCallFilePath,
10
+ ownerCallProjectCodeInInTmuxByHumanData,
11
+ } from './ownerCallFileStore';
12
+ import { ownerCallFileRelativePath } from '../../../domain/usecases/intmux/OwnerCallFile';
13
+ import { toTmuxSessionName } from '../../../domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase';
14
+
15
+ const toPlainValue = (document: { toJS: () => unknown }): unknown =>
16
+ document.toJS();
17
+
18
+ describe('ownerCallFileStore', () => {
19
+ let dataDir = '';
20
+
21
+ beforeEach(() => {
22
+ dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'owner-call-store-'));
23
+ });
24
+
25
+ afterEach(() => {
26
+ fs.rmSync(dataDir, { recursive: true, force: true });
27
+ });
28
+
29
+ const sessionName = 'https_//github_com/OWNER/REPO/issues/1';
30
+
31
+ it('creates the file and its project directory on the first append', () => {
32
+ ownerCallFileAppend({
33
+ dataDir,
34
+ projectCode: 'umino',
35
+ ownerCall: {
36
+ sessionName,
37
+ calledAt: '2026-08-14T04:22:28Z',
38
+ body: 'the only call\n',
39
+ },
40
+ });
41
+
42
+ const filePath = path.join(
43
+ dataDir,
44
+ ownerCallFileRelativePath('umino', sessionName),
45
+ );
46
+ expect(fs.existsSync(filePath)).toBe(true);
47
+ const documents = parseAllDocuments(fs.readFileSync(filePath, 'utf-8'));
48
+ expect(documents).toHaveLength(1);
49
+ expect(documents[0].toJS()).toEqual({
50
+ sessionName,
51
+ calledAt: '2026-08-14T04:22:28Z',
52
+ body: 'the only call\n',
53
+ });
54
+ });
55
+
56
+ it('appends a second document after the first one, oldest first', () => {
57
+ ownerCallFileAppend({
58
+ dataDir,
59
+ projectCode: 'umino',
60
+ ownerCall: {
61
+ sessionName,
62
+ calledAt: '2026-08-14T04:22:28Z',
63
+ body: 'the older call\n',
64
+ },
65
+ });
66
+ ownerCallFileAppend({
67
+ dataDir,
68
+ projectCode: 'umino',
69
+ ownerCall: {
70
+ sessionName,
71
+ calledAt: '2026-08-14T05:00:00Z',
72
+ body: 'the newer call\n',
73
+ },
74
+ });
75
+
76
+ const documents = parseAllDocuments(
77
+ fs.readFileSync(
78
+ ownerCallFilePath(dataDir, 'umino', sessionName),
79
+ 'utf-8',
80
+ ),
81
+ );
82
+ expect(documents.map(toPlainValue)).toEqual([
83
+ {
84
+ sessionName,
85
+ calledAt: '2026-08-14T04:22:28Z',
86
+ body: 'the older call\n',
87
+ },
88
+ {
89
+ sessionName,
90
+ calledAt: '2026-08-14T05:00:00Z',
91
+ body: 'the newer call\n',
92
+ },
93
+ ]);
94
+ });
95
+
96
+ it('writes the file of a session that belongs to no project under NA', () => {
97
+ ownerCallFileAppend({
98
+ dataDir,
99
+ projectCode: null,
100
+ ownerCall: {
101
+ sessionName: 'secretary',
102
+ calledAt: '2026-08-14T04:22:28Z',
103
+ body: 'a call from a long running session\n',
104
+ },
105
+ });
106
+
107
+ expect(
108
+ fs.existsSync(
109
+ path.join(dataDir, ownerCallFileRelativePath(null, 'secretary')),
110
+ ),
111
+ ).toBe(true);
112
+ });
113
+
114
+ it('removes the file of the named project', () => {
115
+ ownerCallFileAppend({
116
+ dataDir,
117
+ projectCode: 'umino',
118
+ ownerCall: {
119
+ sessionName,
120
+ calledAt: '2026-08-14T04:22:28Z',
121
+ body: 'the only call\n',
122
+ },
123
+ });
124
+
125
+ ownerCallFileDelete({ dataDir, projectCode: 'umino', sessionName });
126
+
127
+ expect(
128
+ fs.existsSync(ownerCallFilePath(dataDir, 'umino', sessionName)),
129
+ ).toBe(false);
130
+ });
131
+
132
+ it('succeeds when the file to delete is already absent', () => {
133
+ expect(() =>
134
+ ownerCallFileDelete({ dataDir, projectCode: 'umino', sessionName }),
135
+ ).not.toThrow();
136
+ });
137
+
138
+ it('removes the file of a session whose project code the caller does not know', () => {
139
+ ownerCallFileAppend({
140
+ dataDir,
141
+ projectCode: 'umino',
142
+ ownerCall: {
143
+ sessionName,
144
+ calledAt: '2026-08-14T04:22:28Z',
145
+ body: 'the only call\n',
146
+ },
147
+ });
148
+ ownerCallFileAppend({
149
+ dataDir,
150
+ projectCode: 'other',
151
+ ownerCall: {
152
+ sessionName: 'other_session',
153
+ calledAt: '2026-08-14T04:22:28Z',
154
+ body: 'a call of another session\n',
155
+ },
156
+ });
157
+
158
+ ownerCallFileDeleteInEveryProject({ dataDir, sessionName });
159
+
160
+ expect(
161
+ fs.existsSync(ownerCallFilePath(dataDir, 'umino', sessionName)),
162
+ ).toBe(false);
163
+ expect(
164
+ fs.existsSync(ownerCallFilePath(dataDir, 'other', 'other_session')),
165
+ ).toBe(true);
166
+ });
167
+
168
+ it('succeeds when no owner call directory exists at all', () => {
169
+ expect(() =>
170
+ ownerCallFileDeleteInEveryProject({ dataDir, sessionName }),
171
+ ).not.toThrow();
172
+ });
173
+ });
174
+
175
+ describe('ownerCallProjectCodeInInTmuxByHumanData', () => {
176
+ const issueUrl = 'https://github.com/OWNER/REPO/issues/1';
177
+ const otherIssueUrl = 'https://github.com/OWNER/REPO/issues/2';
178
+ let dataDir = '';
179
+
180
+ beforeEach(() => {
181
+ dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'owner-call-project-'));
182
+ });
183
+
184
+ afterEach(() => {
185
+ fs.rmSync(dataDir, { recursive: true, force: true });
186
+ });
187
+
188
+ const writeProjectData = (projectCode: string, urls: string[]): void => {
189
+ fs.writeFileSync(
190
+ path.join(dataDir, `${projectCode}.v4.json`),
191
+ `${JSON.stringify({
192
+ version: 4,
193
+ overviewUrl: 'https://github.com/orgs/OWNER/projects/1',
194
+ tdpmConsoleUrl: 'http://localhost/projects/code',
195
+ newIssueUrl: 'https://github.com/OWNER/REPO/issues/new',
196
+ groups: [
197
+ {
198
+ story: 'a story',
199
+ sessions: urls.map((url) => ({ name: url, description: 'title' })),
200
+ },
201
+ ],
202
+ })}\n`,
203
+ );
204
+ };
205
+
206
+ it('gives the code of the project whose session list holds the session', () => {
207
+ writeProjectData('other', [otherIssueUrl]);
208
+ writeProjectData('umino', [issueUrl]);
209
+
210
+ expect(
211
+ ownerCallProjectCodeInInTmuxByHumanData(
212
+ dataDir,
213
+ toTmuxSessionName(issueUrl),
214
+ ),
215
+ ).toBe('umino');
216
+ });
217
+
218
+ it('gives null when no project lists the session', () => {
219
+ writeProjectData('umino', [otherIssueUrl]);
220
+
221
+ expect(
222
+ ownerCallProjectCodeInInTmuxByHumanData(
223
+ dataDir,
224
+ toTmuxSessionName(issueUrl),
225
+ ),
226
+ ).toBeNull();
227
+ });
228
+
229
+ it('gives null when the directory holds no in-tmux-by-human data at all', () => {
230
+ expect(
231
+ ownerCallProjectCodeInInTmuxByHumanData(
232
+ dataDir,
233
+ toTmuxSessionName(issueUrl),
234
+ ),
235
+ ).toBeNull();
236
+ });
237
+
238
+ it('gives null when the directory itself is absent', () => {
239
+ expect(
240
+ ownerCallProjectCodeInInTmuxByHumanData(
241
+ path.join(dataDir, 'absent'),
242
+ toTmuxSessionName(issueUrl),
243
+ ),
244
+ ).toBeNull();
245
+ });
246
+
247
+ it('reads neither the project index nor the older versions of the project data', () => {
248
+ fs.writeFileSync(
249
+ path.join(dataDir, 'index.v4.json'),
250
+ `${JSON.stringify({
251
+ version: 4,
252
+ projects: [{ name: 'umino', path: '/in-tmux-by-human/umino.v4.json' }],
253
+ })}\n`,
254
+ );
255
+ fs.writeFileSync(
256
+ path.join(dataDir, 'umino.v3.json'),
257
+ `${JSON.stringify({
258
+ version: 3,
259
+ groups: [{ story: 'a story', urls: [{ url: issueUrl, title: 't' }] }],
260
+ })}\n`,
261
+ );
262
+
263
+ expect(
264
+ ownerCallProjectCodeInInTmuxByHumanData(
265
+ dataDir,
266
+ toTmuxSessionName(issueUrl),
267
+ ),
268
+ ).toBeNull();
269
+ });
270
+
271
+ it('skips a project data file that cannot be read as the expected shape', () => {
272
+ fs.writeFileSync(path.join(dataDir, 'broken.v4.json'), 'not json at all\n');
273
+ fs.writeFileSync(
274
+ path.join(dataDir, 'shapeless.v4.json'),
275
+ `${JSON.stringify({ version: 4, groups: 'not a list' })}\n`,
276
+ );
277
+ writeProjectData('umino', [issueUrl]);
278
+
279
+ expect(
280
+ ownerCallProjectCodeInInTmuxByHumanData(
281
+ dataDir,
282
+ toTmuxSessionName(issueUrl),
283
+ ),
284
+ ).toBe('umino');
285
+ });
286
+ });