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.
- package/CHANGELOG.md +14 -0
- package/README.md +51 -1
- package/bin/adapter/entry-points/cli/index.js +35 -0
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/console/consoleDataDelivery.js +155 -0
- package/bin/adapter/entry-points/console/consoleDataDelivery.js.map +1 -0
- package/bin/adapter/entry-points/console/consoleDoneStore.js +100 -0
- package/bin/adapter/entry-points/console/consoleDoneStore.js.map +1 -0
- package/bin/adapter/entry-points/console/consoleOperationApi.js +178 -0
- package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -0
- package/bin/adapter/entry-points/console/consoleReadApi.js +119 -0
- package/bin/adapter/entry-points/console/consoleReadApi.js.map +1 -0
- package/bin/adapter/entry-points/console/consoleServer.js +147 -3
- package/bin/adapter/entry-points/console/consoleServer.js.map +1 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +54 -12
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/inTmuxByHumanDataWriter.js +67 -0
- package/bin/adapter/entry-points/handlers/inTmuxByHumanDataWriter.js.map +1 -0
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +3 -0
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.js +91 -0
- package/bin/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.test.ts +94 -0
- package/src/adapter/entry-points/cli/index.ts +61 -0
- package/src/adapter/entry-points/console/consoleDataDelivery.test.ts +184 -0
- package/src/adapter/entry-points/console/consoleDataDelivery.ts +169 -0
- package/src/adapter/entry-points/console/consoleDoneStore.test.ts +98 -0
- package/src/adapter/entry-points/console/consoleDoneStore.ts +91 -0
- package/src/adapter/entry-points/console/consoleOperationApi.test.ts +444 -0
- package/src/adapter/entry-points/console/consoleOperationApi.ts +280 -0
- package/src/adapter/entry-points/console/consoleReadApi.test.ts +297 -0
- package/src/adapter/entry-points/console/consoleReadApi.ts +192 -0
- package/src/adapter/entry-points/console/consoleServer.test.ts +269 -0
- package/src/adapter/entry-points/console/consoleServer.ts +228 -4
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +26 -0
- package/src/adapter/entry-points/handlers/inTmuxByHumanDataWriter.test.ts +266 -0
- package/src/adapter/entry-points/handlers/inTmuxByHumanDataWriter.ts +103 -0
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +34 -0
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +3 -0
- package/src/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.test.ts +285 -0
- package/src/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.ts +182 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/console/consoleDataDelivery.d.ts +23 -0
- package/types/adapter/entry-points/console/consoleDataDelivery.d.ts.map +1 -0
- package/types/adapter/entry-points/console/consoleDoneStore.d.ts +10 -0
- package/types/adapter/entry-points/console/consoleDoneStore.d.ts.map +1 -0
- package/types/adapter/entry-points/console/consoleOperationApi.d.ts +18 -0
- package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -0
- package/types/adapter/entry-points/console/consoleReadApi.d.ts +44 -0
- package/types/adapter/entry-points/console/consoleReadApi.d.ts.map +1 -0
- package/types/adapter/entry-points/console/consoleServer.d.ts +8 -1
- package/types/adapter/entry-points/console/consoleServer.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/inTmuxByHumanDataWriter.d.ts +16 -0
- package/types/adapter/entry-points/handlers/inTmuxByHumanDataWriter.d.ts.map +1 -0
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.d.ts +57 -0
- package/types/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.d.ts.map +1 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import {
|
|
5
|
+
buildConsoleDataResponse,
|
|
6
|
+
parseConsoleDataRoute,
|
|
7
|
+
} from './consoleDataDelivery';
|
|
8
|
+
import { recordDoneProjectItemId } from './consoleDoneStore';
|
|
9
|
+
|
|
10
|
+
describe('parseConsoleDataRoute', () => {
|
|
11
|
+
it('parses a list route', () => {
|
|
12
|
+
expect(parseConsoleDataRoute('/projects/umino/prs/list.json')).toEqual({
|
|
13
|
+
kind: 'list',
|
|
14
|
+
pjcode: 'umino',
|
|
15
|
+
tab: 'prs',
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('parses a list route for todo-by-human', () => {
|
|
20
|
+
expect(
|
|
21
|
+
parseConsoleDataRoute('/projects/umino/todo-by-human/list.json'),
|
|
22
|
+
).toEqual({ kind: 'list', pjcode: 'umino', tab: 'todo-by-human' });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('parses a detail route', () => {
|
|
26
|
+
expect(
|
|
27
|
+
parseConsoleDataRoute('/projects/umino/triage/detail/123.json'),
|
|
28
|
+
).toEqual({
|
|
29
|
+
kind: 'detail',
|
|
30
|
+
pjcode: 'umino',
|
|
31
|
+
tab: 'triage',
|
|
32
|
+
key: '123.json',
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('parses an in-tmux route', () => {
|
|
37
|
+
expect(
|
|
38
|
+
parseConsoleDataRoute('/projects/umino/in-tmux-by-human/list.json'),
|
|
39
|
+
).toEqual({
|
|
40
|
+
kind: 'in-tmux',
|
|
41
|
+
pjcode: 'umino',
|
|
42
|
+
relativePath: 'list.json',
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('parses a nested in-tmux route', () => {
|
|
47
|
+
expect(
|
|
48
|
+
parseConsoleDataRoute('/projects/umino/in-tmux-by-human/sub/data.json'),
|
|
49
|
+
).toEqual({
|
|
50
|
+
kind: 'in-tmux',
|
|
51
|
+
pjcode: 'umino',
|
|
52
|
+
relativePath: 'sub/data.json',
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('rejects unknown tabs', () => {
|
|
57
|
+
expect(
|
|
58
|
+
parseConsoleDataRoute('/projects/umino/unknown/list.json'),
|
|
59
|
+
).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('rejects a non-projects prefix', () => {
|
|
63
|
+
expect(parseConsoleDataRoute('/other/umino/prs/list.json')).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('rejects dot segments in pjcode or tab', () => {
|
|
67
|
+
expect(parseConsoleDataRoute('/projects/../prs/list.json')).toBeNull();
|
|
68
|
+
expect(parseConsoleDataRoute('/projects/umino/../list.json')).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('rejects a non-json detail key', () => {
|
|
72
|
+
expect(
|
|
73
|
+
parseConsoleDataRoute('/projects/umino/prs/detail/123.txt'),
|
|
74
|
+
).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('rejects an empty in-tmux relative path', () => {
|
|
78
|
+
expect(
|
|
79
|
+
parseConsoleDataRoute('/projects/umino/in-tmux-by-human'),
|
|
80
|
+
).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('buildConsoleDataResponse', () => {
|
|
85
|
+
let baseDir: string;
|
|
86
|
+
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-data-'));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
afterEach(() => {
|
|
92
|
+
fs.rmSync(baseDir, { recursive: true, force: true });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const writeJson = (relativePath: string, data: unknown): void => {
|
|
96
|
+
const filePath = path.join(baseDir, relativePath);
|
|
97
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
98
|
+
fs.writeFileSync(filePath, JSON.stringify(data));
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
it('returns 404 when the list file is absent', () => {
|
|
102
|
+
const response = buildConsoleDataResponse(baseDir, {
|
|
103
|
+
kind: 'list',
|
|
104
|
+
pjcode: 'umino',
|
|
105
|
+
tab: 'prs',
|
|
106
|
+
});
|
|
107
|
+
expect(response.statusCode).toBe(404);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('serves a list file and applies the done exclusion', () => {
|
|
111
|
+
writeJson('umino/prs/list.json', {
|
|
112
|
+
pjcode: 'umino',
|
|
113
|
+
items: [
|
|
114
|
+
{ projectItemId: 'PVTI_1', title: 'keep' },
|
|
115
|
+
{ projectItemId: 'PVTI_2', title: 'drop' },
|
|
116
|
+
],
|
|
117
|
+
});
|
|
118
|
+
recordDoneProjectItemId(baseDir, 'umino', 'prs', 'PVTI_2');
|
|
119
|
+
const response = buildConsoleDataResponse(baseDir, {
|
|
120
|
+
kind: 'list',
|
|
121
|
+
pjcode: 'umino',
|
|
122
|
+
tab: 'prs',
|
|
123
|
+
});
|
|
124
|
+
expect(response.statusCode).toBe(200);
|
|
125
|
+
const parsed: unknown = JSON.parse(response.body);
|
|
126
|
+
expect(parsed).toEqual({
|
|
127
|
+
pjcode: 'umino',
|
|
128
|
+
items: [{ projectItemId: 'PVTI_1', title: 'keep' }],
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('serves a list file without an items array unchanged', () => {
|
|
133
|
+
writeJson('umino/prs/list.json', { pjcode: 'umino' });
|
|
134
|
+
const response = buildConsoleDataResponse(baseDir, {
|
|
135
|
+
kind: 'list',
|
|
136
|
+
pjcode: 'umino',
|
|
137
|
+
tab: 'prs',
|
|
138
|
+
});
|
|
139
|
+
expect(response.statusCode).toBe(200);
|
|
140
|
+
expect(JSON.parse(response.body)).toEqual({ pjcode: 'umino' });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('serves a detail file without exclusion', () => {
|
|
144
|
+
writeJson('umino/triage/detail/123.json', { number: 123 });
|
|
145
|
+
const response = buildConsoleDataResponse(baseDir, {
|
|
146
|
+
kind: 'detail',
|
|
147
|
+
pjcode: 'umino',
|
|
148
|
+
tab: 'triage',
|
|
149
|
+
key: '123.json',
|
|
150
|
+
});
|
|
151
|
+
expect(response.statusCode).toBe(200);
|
|
152
|
+
expect(JSON.parse(response.body)).toEqual({ number: 123 });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('returns 404 when the detail file is absent', () => {
|
|
156
|
+
const response = buildConsoleDataResponse(baseDir, {
|
|
157
|
+
kind: 'detail',
|
|
158
|
+
pjcode: 'umino',
|
|
159
|
+
tab: 'triage',
|
|
160
|
+
key: '404.json',
|
|
161
|
+
});
|
|
162
|
+
expect(response.statusCode).toBe(404);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('serves an in-tmux file', () => {
|
|
166
|
+
writeJson('umino/in-tmux-by-human/list.json', { items: [] });
|
|
167
|
+
const response = buildConsoleDataResponse(baseDir, {
|
|
168
|
+
kind: 'in-tmux',
|
|
169
|
+
pjcode: 'umino',
|
|
170
|
+
relativePath: 'list.json',
|
|
171
|
+
});
|
|
172
|
+
expect(response.statusCode).toBe(200);
|
|
173
|
+
expect(JSON.parse(response.body)).toEqual({ items: [] });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('returns 404 when the in-tmux file is absent', () => {
|
|
177
|
+
const response = buildConsoleDataResponse(baseDir, {
|
|
178
|
+
kind: 'in-tmux',
|
|
179
|
+
pjcode: 'umino',
|
|
180
|
+
relativePath: 'missing.json',
|
|
181
|
+
});
|
|
182
|
+
expect(response.statusCode).toBe(404);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { readDoneProjectItemIds } from './consoleDoneStore';
|
|
4
|
+
|
|
5
|
+
export const CONSOLE_LIST_TAB_NAMES: string[] = [
|
|
6
|
+
'prs',
|
|
7
|
+
'triage',
|
|
8
|
+
'unread',
|
|
9
|
+
'failed-preparation',
|
|
10
|
+
'todo-by-human',
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export type ConsoleDataRoute =
|
|
14
|
+
| { kind: 'list'; pjcode: string; tab: string }
|
|
15
|
+
| { kind: 'detail'; pjcode: string; tab: string; key: string }
|
|
16
|
+
| { kind: 'in-tmux'; pjcode: string; relativePath: string };
|
|
17
|
+
|
|
18
|
+
const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
|
19
|
+
|
|
20
|
+
const isSafeSegment = (segment: string): boolean =>
|
|
21
|
+
SAFE_SEGMENT.test(segment) && !segment.startsWith('.');
|
|
22
|
+
|
|
23
|
+
export const parseConsoleDataRoute = (
|
|
24
|
+
requestPath: string,
|
|
25
|
+
): ConsoleDataRoute | null => {
|
|
26
|
+
const segments = requestPath
|
|
27
|
+
.split('/')
|
|
28
|
+
.filter((segment) => segment.length > 0);
|
|
29
|
+
if (segments.length < 3 || segments[0] !== 'projects') {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const pjcode = segments[1];
|
|
33
|
+
if (!isSafeSegment(pjcode)) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const tab = segments[2];
|
|
37
|
+
if (!isSafeSegment(tab)) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
if (tab === 'in-tmux-by-human') {
|
|
41
|
+
const rest = segments.slice(3);
|
|
42
|
+
if (rest.length === 0 || rest.some((segment) => !isSafeSegment(segment))) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return { kind: 'in-tmux', pjcode, relativePath: rest.join('/') };
|
|
46
|
+
}
|
|
47
|
+
if (!CONSOLE_LIST_TAB_NAMES.includes(tab)) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
if (segments.length === 4 && segments[3] === 'list.json') {
|
|
51
|
+
return { kind: 'list', pjcode, tab };
|
|
52
|
+
}
|
|
53
|
+
if (segments.length === 5 && segments[3] === 'detail') {
|
|
54
|
+
const key = segments[4];
|
|
55
|
+
if (!isSafeSegment(key) || !key.endsWith('.json')) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return { kind: 'detail', pjcode, tab, key };
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
64
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
65
|
+
|
|
66
|
+
type ReadJsonResult = { found: false } | { found: true; data: unknown };
|
|
67
|
+
|
|
68
|
+
const readJsonFile = (filePath: string): ReadJsonResult => {
|
|
69
|
+
let raw: string;
|
|
70
|
+
try {
|
|
71
|
+
const stat = fs.statSync(filePath);
|
|
72
|
+
if (!stat.isFile()) {
|
|
73
|
+
return { found: false };
|
|
74
|
+
}
|
|
75
|
+
raw = fs.readFileSync(filePath, 'utf-8');
|
|
76
|
+
} catch {
|
|
77
|
+
return { found: false };
|
|
78
|
+
}
|
|
79
|
+
const data: unknown = JSON.parse(raw);
|
|
80
|
+
return { found: true, data };
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const isExcludedItem = (item: unknown, doneSet: Set<string>): boolean => {
|
|
84
|
+
if (!isRecord(item)) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
const projectItemId = item.projectItemId;
|
|
88
|
+
return typeof projectItemId === 'string' && doneSet.has(projectItemId);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const applyDoneExclusion = (
|
|
92
|
+
listData: unknown,
|
|
93
|
+
doneProjectItemIds: string[],
|
|
94
|
+
): unknown => {
|
|
95
|
+
if (!isRecord(listData) || !Array.isArray(listData.items)) {
|
|
96
|
+
return listData;
|
|
97
|
+
}
|
|
98
|
+
const doneSet = new Set(doneProjectItemIds);
|
|
99
|
+
const items = listData.items.filter((item) => !isExcludedItem(item, doneSet));
|
|
100
|
+
return { ...listData, items };
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export type ConsoleDataResponse = {
|
|
104
|
+
statusCode: number;
|
|
105
|
+
contentType: string;
|
|
106
|
+
body: string;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export const buildConsoleDataResponse = (
|
|
110
|
+
consoleDataOutputDir: string,
|
|
111
|
+
route: ConsoleDataRoute,
|
|
112
|
+
): ConsoleDataResponse => {
|
|
113
|
+
if (route.kind === 'list') {
|
|
114
|
+
const filePath = path.join(
|
|
115
|
+
consoleDataOutputDir,
|
|
116
|
+
route.pjcode,
|
|
117
|
+
route.tab,
|
|
118
|
+
'list.json',
|
|
119
|
+
);
|
|
120
|
+
const listResult = readJsonFile(filePath);
|
|
121
|
+
if (!listResult.found) {
|
|
122
|
+
return notFoundJson();
|
|
123
|
+
}
|
|
124
|
+
const doneProjectItemIds = readDoneProjectItemIds(
|
|
125
|
+
consoleDataOutputDir,
|
|
126
|
+
route.pjcode,
|
|
127
|
+
route.tab,
|
|
128
|
+
);
|
|
129
|
+
const filtered = applyDoneExclusion(listResult.data, doneProjectItemIds);
|
|
130
|
+
return okJson(filtered);
|
|
131
|
+
}
|
|
132
|
+
if (route.kind === 'detail') {
|
|
133
|
+
const filePath = path.join(
|
|
134
|
+
consoleDataOutputDir,
|
|
135
|
+
route.pjcode,
|
|
136
|
+
route.tab,
|
|
137
|
+
'detail',
|
|
138
|
+
route.key,
|
|
139
|
+
);
|
|
140
|
+
const detailResult = readJsonFile(filePath);
|
|
141
|
+
if (!detailResult.found) {
|
|
142
|
+
return notFoundJson();
|
|
143
|
+
}
|
|
144
|
+
return okJson(detailResult.data);
|
|
145
|
+
}
|
|
146
|
+
const inTmuxFilePath = path.join(
|
|
147
|
+
consoleDataOutputDir,
|
|
148
|
+
route.pjcode,
|
|
149
|
+
'in-tmux-by-human',
|
|
150
|
+
route.relativePath,
|
|
151
|
+
);
|
|
152
|
+
const inTmuxResult = readJsonFile(inTmuxFilePath);
|
|
153
|
+
if (!inTmuxResult.found) {
|
|
154
|
+
return notFoundJson();
|
|
155
|
+
}
|
|
156
|
+
return okJson(inTmuxResult.data);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const okJson = (data: unknown): ConsoleDataResponse => ({
|
|
160
|
+
statusCode: 200,
|
|
161
|
+
contentType: 'application/json; charset=utf-8',
|
|
162
|
+
body: JSON.stringify(data),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const notFoundJson = (): ConsoleDataResponse => ({
|
|
166
|
+
statusCode: 404,
|
|
167
|
+
contentType: 'text/plain; charset=utf-8',
|
|
168
|
+
body: 'Not Found',
|
|
169
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import {
|
|
5
|
+
CONSOLE_DONE_FILE_NAME,
|
|
6
|
+
CONSOLE_DONE_TAB_NAMES,
|
|
7
|
+
doneFilePathForTab,
|
|
8
|
+
readDoneProjectItemIds,
|
|
9
|
+
recordDoneProjectItemId,
|
|
10
|
+
recordDoneProjectItemIdAcrossTabs,
|
|
11
|
+
} from './consoleDoneStore';
|
|
12
|
+
|
|
13
|
+
describe('consoleDoneStore', () => {
|
|
14
|
+
let baseDir: string;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-done-'));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
fs.rmSync(baseDir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('doneFilePathForTab', () => {
|
|
25
|
+
it('places the done file under pjcode/tab', () => {
|
|
26
|
+
expect(doneFilePathForTab(baseDir, 'umino', 'prs')).toBe(
|
|
27
|
+
path.join(baseDir, 'umino', 'prs', CONSOLE_DONE_FILE_NAME),
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('readDoneProjectItemIds', () => {
|
|
33
|
+
it('returns an empty array when the file is absent', () => {
|
|
34
|
+
expect(readDoneProjectItemIds(baseDir, 'umino', 'prs')).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns the persisted project item ids', () => {
|
|
38
|
+
recordDoneProjectItemId(baseDir, 'umino', 'prs', 'PVTI_1');
|
|
39
|
+
recordDoneProjectItemId(baseDir, 'umino', 'prs', 'PVTI_2');
|
|
40
|
+
expect(readDoneProjectItemIds(baseDir, 'umino', 'prs')).toEqual([
|
|
41
|
+
'PVTI_1',
|
|
42
|
+
'PVTI_2',
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns an empty array for malformed json', () => {
|
|
47
|
+
const filePath = doneFilePathForTab(baseDir, 'umino', 'prs');
|
|
48
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
49
|
+
fs.writeFileSync(filePath, '{ not json');
|
|
50
|
+
expect(() => readDoneProjectItemIds(baseDir, 'umino', 'prs')).toThrow();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('returns an empty array when projectItemIds is not an array', () => {
|
|
54
|
+
const filePath = doneFilePathForTab(baseDir, 'umino', 'prs');
|
|
55
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
56
|
+
fs.writeFileSync(filePath, JSON.stringify({ projectItemIds: 'x' }));
|
|
57
|
+
expect(readDoneProjectItemIds(baseDir, 'umino', 'prs')).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('filters out non-string entries', () => {
|
|
61
|
+
const filePath = doneFilePathForTab(baseDir, 'umino', 'prs');
|
|
62
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
63
|
+
fs.writeFileSync(
|
|
64
|
+
filePath,
|
|
65
|
+
JSON.stringify({ projectItemIds: ['PVTI_1', 1, null, ''] }),
|
|
66
|
+
);
|
|
67
|
+
expect(readDoneProjectItemIds(baseDir, 'umino', 'prs')).toEqual([
|
|
68
|
+
'PVTI_1',
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('recordDoneProjectItemId', () => {
|
|
74
|
+
it('does not record an empty id', () => {
|
|
75
|
+
recordDoneProjectItemId(baseDir, 'umino', 'prs', '');
|
|
76
|
+
expect(readDoneProjectItemIds(baseDir, 'umino', 'prs')).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('does not duplicate an already recorded id', () => {
|
|
80
|
+
recordDoneProjectItemId(baseDir, 'umino', 'prs', 'PVTI_1');
|
|
81
|
+
recordDoneProjectItemId(baseDir, 'umino', 'prs', 'PVTI_1');
|
|
82
|
+
expect(readDoneProjectItemIds(baseDir, 'umino', 'prs')).toEqual([
|
|
83
|
+
'PVTI_1',
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('recordDoneProjectItemIdAcrossTabs', () => {
|
|
89
|
+
it('records the id into every tab done file', () => {
|
|
90
|
+
recordDoneProjectItemIdAcrossTabs(baseDir, 'umino', 'PVTI_99');
|
|
91
|
+
for (const tab of CONSOLE_DONE_TAB_NAMES) {
|
|
92
|
+
expect(readDoneProjectItemIds(baseDir, 'umino', tab)).toEqual([
|
|
93
|
+
'PVTI_99',
|
|
94
|
+
]);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
export const CONSOLE_DONE_FILE_NAME = '.done.json';
|
|
5
|
+
|
|
6
|
+
export type ConsoleDoneRecord = {
|
|
7
|
+
projectItemIds: string[];
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const isValidProjectItemId = (value: unknown): value is string =>
|
|
11
|
+
typeof value === 'string' && value.length > 0;
|
|
12
|
+
|
|
13
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
14
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
15
|
+
|
|
16
|
+
const parseDoneRecord = (raw: string): ConsoleDoneRecord => {
|
|
17
|
+
const parsed: unknown = JSON.parse(raw);
|
|
18
|
+
if (!isRecord(parsed)) {
|
|
19
|
+
return { projectItemIds: [] };
|
|
20
|
+
}
|
|
21
|
+
const rawProjectItemIds = parsed.projectItemIds;
|
|
22
|
+
if (!Array.isArray(rawProjectItemIds)) {
|
|
23
|
+
return { projectItemIds: [] };
|
|
24
|
+
}
|
|
25
|
+
const projectItemIds = rawProjectItemIds.filter(isValidProjectItemId);
|
|
26
|
+
return { projectItemIds };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const doneFilePathForTab = (
|
|
30
|
+
consoleDataOutputDir: string,
|
|
31
|
+
pjcode: string,
|
|
32
|
+
tab: string,
|
|
33
|
+
): string =>
|
|
34
|
+
path.join(consoleDataOutputDir, pjcode, tab, CONSOLE_DONE_FILE_NAME);
|
|
35
|
+
|
|
36
|
+
export const readDoneProjectItemIds = (
|
|
37
|
+
consoleDataOutputDir: string,
|
|
38
|
+
pjcode: string,
|
|
39
|
+
tab: string,
|
|
40
|
+
): string[] => {
|
|
41
|
+
const filePath = doneFilePathForTab(consoleDataOutputDir, pjcode, tab);
|
|
42
|
+
let raw: string;
|
|
43
|
+
try {
|
|
44
|
+
raw = fs.readFileSync(filePath, 'utf-8');
|
|
45
|
+
} catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
return parseDoneRecord(raw).projectItemIds;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const recordDoneProjectItemId = (
|
|
52
|
+
consoleDataOutputDir: string,
|
|
53
|
+
pjcode: string,
|
|
54
|
+
tab: string,
|
|
55
|
+
projectItemId: string,
|
|
56
|
+
): void => {
|
|
57
|
+
if (!isValidProjectItemId(projectItemId)) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const filePath = doneFilePathForTab(consoleDataOutputDir, pjcode, tab);
|
|
61
|
+
const existing = readDoneProjectItemIds(consoleDataOutputDir, pjcode, tab);
|
|
62
|
+
if (existing.includes(projectItemId)) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const updated: ConsoleDoneRecord = {
|
|
66
|
+
projectItemIds: [...existing, projectItemId],
|
|
67
|
+
};
|
|
68
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
69
|
+
const tmpPath = `${filePath}.tmp`;
|
|
70
|
+
fs.writeFileSync(tmpPath, JSON.stringify(updated));
|
|
71
|
+
fs.renameSync(tmpPath, filePath);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const CONSOLE_DONE_TAB_NAMES: string[] = [
|
|
75
|
+
'prs',
|
|
76
|
+
'triage',
|
|
77
|
+
'unread',
|
|
78
|
+
'failed-preparation',
|
|
79
|
+
'todo-by-human',
|
|
80
|
+
'in-tmux-by-human',
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
export const recordDoneProjectItemIdAcrossTabs = (
|
|
84
|
+
consoleDataOutputDir: string,
|
|
85
|
+
pjcode: string,
|
|
86
|
+
projectItemId: string,
|
|
87
|
+
): void => {
|
|
88
|
+
for (const tab of CONSOLE_DONE_TAB_NAMES) {
|
|
89
|
+
recordDoneProjectItemId(consoleDataOutputDir, pjcode, tab, projectItemId);
|
|
90
|
+
}
|
|
91
|
+
};
|