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.
- package/CHANGELOG.md +14 -0
- package/README.md +16 -1
- package/bin/adapter/entry-points/cli/index.js +36 -0
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/{index-DR_LIP9n.js → index-BooMHl5Y.js} +1 -1
- package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/bin/adapter/entry-points/console/webServer.js +59 -23
- package/bin/adapter/entry-points/console/webServer.js.map +1 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +11 -0
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/ownerCallFileCleaner.js +20 -0
- package/bin/adapter/entry-points/handlers/ownerCallFileCleaner.js.map +1 -0
- package/bin/adapter/entry-points/handlers/ownerCallFileStore.js +80 -0
- package/bin/adapter/entry-points/handlers/ownerCallFileStore.js.map +1 -0
- package/bin/domain/usecases/intmux/OwnerCallFile.js +52 -0
- package/bin/domain/usecases/intmux/OwnerCallFile.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.test.ts +189 -0
- package/src/adapter/entry-points/cli/index.ts +75 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.tsx +1 -4
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.test.tsx +80 -4
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +0 -1
- package/src/adapter/entry-points/console/ui-dist/assets/{index-DR_LIP9n.js → index-BooMHl5Y.js} +1 -1
- package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/src/adapter/entry-points/console/webServer.test.ts +317 -0
- package/src/adapter/entry-points/console/webServer.ts +86 -26
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +35 -0
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +15 -0
- package/src/adapter/entry-points/handlers/ownerCallFileCleaner.test.ts +109 -0
- package/src/adapter/entry-points/handlers/ownerCallFileCleaner.ts +25 -0
- package/src/adapter/entry-points/handlers/ownerCallFileStore.test.ts +286 -0
- package/src/adapter/entry-points/handlers/ownerCallFileStore.ts +145 -0
- package/src/domain/usecases/intmux/OwnerCallFile.test.ts +184 -0
- package/src/domain/usecases/intmux/OwnerCallFile.ts +80 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/console/webServer.d.ts +1 -0
- package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/ownerCallFileCleaner.d.ts +8 -0
- package/types/adapter/entry-points/handlers/ownerCallFileCleaner.d.ts.map +1 -0
- package/types/adapter/entry-points/handlers/ownerCallFileStore.d.ts +21 -0
- package/types/adapter/entry-points/handlers/ownerCallFileStore.d.ts.map +1 -0
- package/types/domain/usecases/intmux/OwnerCallFile.d.ts +18 -0
- 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
|
|
260
|
-
if (
|
|
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,
|
|
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 (
|
|
584
|
-
if (
|
|
585
|
-
|
|
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
|
+
});
|