github-issue-tower-defence-management 1.152.3 → 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 +7 -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/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/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
|
@@ -3,6 +3,7 @@ import * as fs from 'fs';
|
|
|
3
3
|
import * as os from 'os';
|
|
4
4
|
import * as path from 'path';
|
|
5
5
|
import { mock } from 'jest-mock-extended';
|
|
6
|
+
import { parseAllDocuments } from 'yaml';
|
|
6
7
|
import {
|
|
7
8
|
DEFAULT_WEB_PORT,
|
|
8
9
|
CONSOLE_TOKEN_HEADER,
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
isConsoleAppRoute,
|
|
14
15
|
extractProvidedToken,
|
|
15
16
|
extractCookieToken,
|
|
17
|
+
isOwnerCallFileRequestPath,
|
|
16
18
|
resolveFlatInTmuxFilePath,
|
|
17
19
|
resolveDashboardFilePath,
|
|
18
20
|
startWebServer,
|
|
@@ -23,6 +25,9 @@ import { readDoneProjectItemIds } from './consoleDoneStore';
|
|
|
23
25
|
import { IssueRepository } from '../../../domain/usecases/adapter-interfaces/IssueRepository';
|
|
24
26
|
import { Project } from '../../../domain/entities/Project';
|
|
25
27
|
import { Issue } from '../../../domain/entities/Issue';
|
|
28
|
+
import { ownerCallFileRelativePath } from '../../../domain/usecases/intmux/OwnerCallFile';
|
|
29
|
+
import { toTmuxSessionName } from '../../../domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase';
|
|
30
|
+
import { ownerCallFileAppend } from '../handlers/ownerCallFileStore';
|
|
26
31
|
|
|
27
32
|
describe('webServer pure helpers', () => {
|
|
28
33
|
describe('DEFAULT_WEB_PORT', () => {
|
|
@@ -983,6 +988,46 @@ describe('resolveFlatInTmuxFilePath', () => {
|
|
|
983
988
|
resolveFlatInTmuxFilePath(baseDir, '/in-tmux-by-human/..%2fsecret.json'),
|
|
984
989
|
).toBeNull();
|
|
985
990
|
});
|
|
991
|
+
|
|
992
|
+
it('resolves an owner call file that sits one directory deep', () => {
|
|
993
|
+
expect(
|
|
994
|
+
resolveFlatInTmuxFilePath(
|
|
995
|
+
baseDir,
|
|
996
|
+
'/in-tmux-by-human/call-to-user/umino/secretary.yaml',
|
|
997
|
+
),
|
|
998
|
+
).toBe(
|
|
999
|
+
path.join(
|
|
1000
|
+
path.resolve(baseDir),
|
|
1001
|
+
'call-to-user',
|
|
1002
|
+
'umino',
|
|
1003
|
+
'secretary.yaml',
|
|
1004
|
+
),
|
|
1005
|
+
);
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
it('returns null for a yaml file outside the owner call directory shape', () => {
|
|
1009
|
+
expect(
|
|
1010
|
+
resolveFlatInTmuxFilePath(baseDir, '/in-tmux-by-human/secretary.yaml'),
|
|
1011
|
+
).toBeNull();
|
|
1012
|
+
expect(
|
|
1013
|
+
resolveFlatInTmuxFilePath(
|
|
1014
|
+
baseDir,
|
|
1015
|
+
'/in-tmux-by-human/other-directory/umino/secretary.yaml',
|
|
1016
|
+
),
|
|
1017
|
+
).toBeNull();
|
|
1018
|
+
expect(
|
|
1019
|
+
resolveFlatInTmuxFilePath(
|
|
1020
|
+
baseDir,
|
|
1021
|
+
'/in-tmux-by-human/call-to-user/umino/nested/secretary.yaml',
|
|
1022
|
+
),
|
|
1023
|
+
).toBeNull();
|
|
1024
|
+
expect(
|
|
1025
|
+
resolveFlatInTmuxFilePath(
|
|
1026
|
+
baseDir,
|
|
1027
|
+
'/in-tmux-by-human/call-to-user/umino/secretary.json',
|
|
1028
|
+
),
|
|
1029
|
+
).toBeNull();
|
|
1030
|
+
});
|
|
986
1031
|
});
|
|
987
1032
|
|
|
988
1033
|
describe('resolveDashboardFilePath', () => {
|
|
@@ -1945,3 +1990,275 @@ describe('webServer token cookie redirect', () => {
|
|
|
1945
1990
|
}
|
|
1946
1991
|
});
|
|
1947
1992
|
});
|
|
1993
|
+
|
|
1994
|
+
const toPlainValue = (document: { toJS: () => unknown }): unknown =>
|
|
1995
|
+
document.toJS();
|
|
1996
|
+
|
|
1997
|
+
describe('webServer owner call file route', () => {
|
|
1998
|
+
const testToken = 'owner-call-token-value';
|
|
1999
|
+
const sessionName = toTmuxSessionName(
|
|
2000
|
+
'https://github.com/OWNER/REPO/issues/1',
|
|
2001
|
+
);
|
|
2002
|
+
const ownerCall = {
|
|
2003
|
+
sessionName,
|
|
2004
|
+
calledAt: '2026-08-14T04:22:28Z',
|
|
2005
|
+
body: ' the first line of the call body.\n\na later line of the call body.\n',
|
|
2006
|
+
};
|
|
2007
|
+
|
|
2008
|
+
const requestServer = (
|
|
2009
|
+
server: http.Server,
|
|
2010
|
+
requestPath: string,
|
|
2011
|
+
method = 'GET',
|
|
2012
|
+
): Promise<{
|
|
2013
|
+
statusCode: number;
|
|
2014
|
+
body: string;
|
|
2015
|
+
contentType: string | undefined;
|
|
2016
|
+
}> => {
|
|
2017
|
+
const address = server.address();
|
|
2018
|
+
if (address === null || typeof address === 'string') {
|
|
2019
|
+
throw new Error('server is not listening on a TCP port');
|
|
2020
|
+
}
|
|
2021
|
+
return new Promise((resolve, reject) => {
|
|
2022
|
+
const httpRequest = http.request(
|
|
2023
|
+
{ host: '127.0.0.1', port: address.port, path: requestPath, method },
|
|
2024
|
+
(response) => {
|
|
2025
|
+
const chunks: Uint8Array[] = [];
|
|
2026
|
+
response.on('data', (chunk: Uint8Array) => chunks.push(chunk));
|
|
2027
|
+
response.on('end', () => {
|
|
2028
|
+
resolve({
|
|
2029
|
+
statusCode: response.statusCode ?? 0,
|
|
2030
|
+
body: Buffer.concat(chunks).toString('utf-8'),
|
|
2031
|
+
contentType: response.headers['content-type'],
|
|
2032
|
+
});
|
|
2033
|
+
});
|
|
2034
|
+
},
|
|
2035
|
+
);
|
|
2036
|
+
httpRequest.on('error', reject);
|
|
2037
|
+
httpRequest.end();
|
|
2038
|
+
});
|
|
2039
|
+
};
|
|
2040
|
+
|
|
2041
|
+
const closeServer = (server: http.Server): Promise<void> =>
|
|
2042
|
+
new Promise((resolve, reject) => {
|
|
2043
|
+
server.close((error) => {
|
|
2044
|
+
if (error) {
|
|
2045
|
+
reject(error);
|
|
2046
|
+
return;
|
|
2047
|
+
}
|
|
2048
|
+
resolve();
|
|
2049
|
+
});
|
|
2050
|
+
});
|
|
2051
|
+
|
|
2052
|
+
const startWithOwnerCallFile = async (
|
|
2053
|
+
projectCode: string | null,
|
|
2054
|
+
): Promise<{
|
|
2055
|
+
server: http.Server;
|
|
2056
|
+
tmpDir: string;
|
|
2057
|
+
inTmuxDataDir: string;
|
|
2058
|
+
requestPath: string;
|
|
2059
|
+
}> => {
|
|
2060
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'owner-call-server-'));
|
|
2061
|
+
const inTmuxDataDir = path.join(tmpDir, 'in-tmux-by-human');
|
|
2062
|
+
fs.mkdirSync(inTmuxDataDir, { recursive: true });
|
|
2063
|
+
fs.writeFileSync(path.join(tmpDir, 'secret.yaml'), 'secret: true\n');
|
|
2064
|
+
ownerCallFileAppend({ dataDir: inTmuxDataDir, projectCode, ownerCall });
|
|
2065
|
+
const server = await startWebServer({
|
|
2066
|
+
accessToken: testToken,
|
|
2067
|
+
uiDistDir: path.join(tmpDir, 'ui-dist'),
|
|
2068
|
+
consoleDataOutputDir: null,
|
|
2069
|
+
inTmuxDataDir,
|
|
2070
|
+
dashboardDir: null,
|
|
2071
|
+
dashboardDataDir: null,
|
|
2072
|
+
dashboardProjectNames: [],
|
|
2073
|
+
port: 0,
|
|
2074
|
+
});
|
|
2075
|
+
return {
|
|
2076
|
+
server,
|
|
2077
|
+
tmpDir,
|
|
2078
|
+
inTmuxDataDir,
|
|
2079
|
+
requestPath: `/in-tmux-by-human/${ownerCallFileRelativePath(
|
|
2080
|
+
projectCode,
|
|
2081
|
+
sessionName,
|
|
2082
|
+
)}`,
|
|
2083
|
+
};
|
|
2084
|
+
};
|
|
2085
|
+
|
|
2086
|
+
it('requires a token for the owner call file path', () => {
|
|
2087
|
+
expect(
|
|
2088
|
+
requiresToken(
|
|
2089
|
+
`/in-tmux-by-human/${ownerCallFileRelativePath('umino', sessionName)}`,
|
|
2090
|
+
),
|
|
2091
|
+
).toBe(true);
|
|
2092
|
+
});
|
|
2093
|
+
|
|
2094
|
+
it('recognizes only the owner call file path as deletable', () => {
|
|
2095
|
+
expect(
|
|
2096
|
+
isOwnerCallFileRequestPath(
|
|
2097
|
+
`/in-tmux-by-human/${ownerCallFileRelativePath('umino', sessionName)}`,
|
|
2098
|
+
),
|
|
2099
|
+
).toBe(true);
|
|
2100
|
+
expect(isOwnerCallFileRequestPath('/in-tmux-by-human/index.v4.json')).toBe(
|
|
2101
|
+
false,
|
|
2102
|
+
);
|
|
2103
|
+
expect(
|
|
2104
|
+
isOwnerCallFileRequestPath('/in-tmux-by-human/call-to-user/umino.yaml'),
|
|
2105
|
+
).toBe(false);
|
|
2106
|
+
});
|
|
2107
|
+
|
|
2108
|
+
it('resolves the owner call file the append entry point writes, without a hardcoded path', () => {
|
|
2109
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'owner-call-path-'));
|
|
2110
|
+
try {
|
|
2111
|
+
ownerCallFileAppend({
|
|
2112
|
+
dataDir,
|
|
2113
|
+
projectCode: 'umino',
|
|
2114
|
+
ownerCall,
|
|
2115
|
+
});
|
|
2116
|
+
const relativePath = ownerCallFileRelativePath('umino', sessionName);
|
|
2117
|
+
|
|
2118
|
+
const resolved = resolveFlatInTmuxFilePath(
|
|
2119
|
+
dataDir,
|
|
2120
|
+
`/in-tmux-by-human/${relativePath}`,
|
|
2121
|
+
);
|
|
2122
|
+
|
|
2123
|
+
expect(resolved).not.toBeNull();
|
|
2124
|
+
expect(fs.readFileSync(String(resolved), 'utf-8')).toBe(
|
|
2125
|
+
fs.readFileSync(path.join(dataDir, relativePath), 'utf-8'),
|
|
2126
|
+
);
|
|
2127
|
+
} finally {
|
|
2128
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
2129
|
+
}
|
|
2130
|
+
});
|
|
2131
|
+
|
|
2132
|
+
it('serves, then deletes, the file the append entry point wrote', async () => {
|
|
2133
|
+
const { server, tmpDir, requestPath } =
|
|
2134
|
+
await startWithOwnerCallFile('umino');
|
|
2135
|
+
try {
|
|
2136
|
+
const fetched = await requestServer(
|
|
2137
|
+
server,
|
|
2138
|
+
`${requestPath}?k=${testToken}`,
|
|
2139
|
+
);
|
|
2140
|
+
expect(fetched.statusCode).toBe(200);
|
|
2141
|
+
expect(fetched.contentType).toContain('text/yaml');
|
|
2142
|
+
const documents = parseAllDocuments(fetched.body);
|
|
2143
|
+
expect(documents).toHaveLength(1);
|
|
2144
|
+
expect(documents[0].toJS()).toEqual(ownerCall);
|
|
2145
|
+
|
|
2146
|
+
const deleted = await requestServer(
|
|
2147
|
+
server,
|
|
2148
|
+
`${requestPath}?k=${testToken}`,
|
|
2149
|
+
'DELETE',
|
|
2150
|
+
);
|
|
2151
|
+
expect(deleted.statusCode).toBe(204);
|
|
2152
|
+
|
|
2153
|
+
const refetched = await requestServer(
|
|
2154
|
+
server,
|
|
2155
|
+
`${requestPath}?k=${testToken}`,
|
|
2156
|
+
);
|
|
2157
|
+
expect(refetched.statusCode).toBe(404);
|
|
2158
|
+
|
|
2159
|
+
const deletedAgain = await requestServer(
|
|
2160
|
+
server,
|
|
2161
|
+
`${requestPath}?k=${testToken}`,
|
|
2162
|
+
'DELETE',
|
|
2163
|
+
);
|
|
2164
|
+
expect(deletedAgain.statusCode).toBe(204);
|
|
2165
|
+
} finally {
|
|
2166
|
+
await closeServer(server);
|
|
2167
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
2168
|
+
}
|
|
2169
|
+
});
|
|
2170
|
+
|
|
2171
|
+
it('serves two appended documents oldest first', async () => {
|
|
2172
|
+
const { server, tmpDir, inTmuxDataDir, requestPath } =
|
|
2173
|
+
await startWithOwnerCallFile(null);
|
|
2174
|
+
try {
|
|
2175
|
+
ownerCallFileAppend({
|
|
2176
|
+
dataDir: inTmuxDataDir,
|
|
2177
|
+
projectCode: null,
|
|
2178
|
+
ownerCall: {
|
|
2179
|
+
sessionName,
|
|
2180
|
+
calledAt: '2026-08-14T05:00:00Z',
|
|
2181
|
+
body: 'the newer call\n',
|
|
2182
|
+
},
|
|
2183
|
+
});
|
|
2184
|
+
|
|
2185
|
+
const fetched = await requestServer(
|
|
2186
|
+
server,
|
|
2187
|
+
`${requestPath}?k=${testToken}`,
|
|
2188
|
+
);
|
|
2189
|
+
expect(fetched.statusCode).toBe(200);
|
|
2190
|
+
expect(parseAllDocuments(fetched.body).map(toPlainValue)).toEqual([
|
|
2191
|
+
ownerCall,
|
|
2192
|
+
{
|
|
2193
|
+
sessionName,
|
|
2194
|
+
calledAt: '2026-08-14T05:00:00Z',
|
|
2195
|
+
body: 'the newer call\n',
|
|
2196
|
+
},
|
|
2197
|
+
]);
|
|
2198
|
+
} finally {
|
|
2199
|
+
await closeServer(server);
|
|
2200
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
2201
|
+
}
|
|
2202
|
+
});
|
|
2203
|
+
|
|
2204
|
+
it('rejects a request that carries no access token', async () => {
|
|
2205
|
+
const { server, tmpDir, requestPath } =
|
|
2206
|
+
await startWithOwnerCallFile('umino');
|
|
2207
|
+
try {
|
|
2208
|
+
expect((await requestServer(server, requestPath)).statusCode).toBe(401);
|
|
2209
|
+
expect(
|
|
2210
|
+
(await requestServer(server, requestPath, 'DELETE')).statusCode,
|
|
2211
|
+
).toBe(401);
|
|
2212
|
+
} finally {
|
|
2213
|
+
await closeServer(server);
|
|
2214
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
2215
|
+
}
|
|
2216
|
+
});
|
|
2217
|
+
|
|
2218
|
+
it('rejects a request whose path escapes the data directory and leaves the outside file untouched', async () => {
|
|
2219
|
+
const { server, tmpDir } = await startWithOwnerCallFile('umino');
|
|
2220
|
+
const escapingPath = '/in-tmux-by-human/call-to-user/../../secret.yaml';
|
|
2221
|
+
try {
|
|
2222
|
+
const fetched = await requestServer(
|
|
2223
|
+
server,
|
|
2224
|
+
`${escapingPath}?k=${testToken}`,
|
|
2225
|
+
);
|
|
2226
|
+
expect(fetched.statusCode).toBe(404);
|
|
2227
|
+
expect(fetched.body).not.toContain('secret: true');
|
|
2228
|
+
|
|
2229
|
+
const deleted = await requestServer(
|
|
2230
|
+
server,
|
|
2231
|
+
`${escapingPath}?k=${testToken}`,
|
|
2232
|
+
'DELETE',
|
|
2233
|
+
);
|
|
2234
|
+
expect(deleted.statusCode).toBe(404);
|
|
2235
|
+
expect(fs.existsSync(path.join(tmpDir, 'secret.yaml'))).toBe(true);
|
|
2236
|
+
} finally {
|
|
2237
|
+
await closeServer(server);
|
|
2238
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
2239
|
+
}
|
|
2240
|
+
});
|
|
2241
|
+
|
|
2242
|
+
it('refuses to delete a flat in-tmux json file through the owner call route', async () => {
|
|
2243
|
+
const { server, tmpDir, inTmuxDataDir } =
|
|
2244
|
+
await startWithOwnerCallFile('umino');
|
|
2245
|
+
fs.writeFileSync(
|
|
2246
|
+
path.join(inTmuxDataDir, 'index.v4.json'),
|
|
2247
|
+
'{"version":4,"projects":[]}\n',
|
|
2248
|
+
);
|
|
2249
|
+
try {
|
|
2250
|
+
const deleted = await requestServer(
|
|
2251
|
+
server,
|
|
2252
|
+
`/in-tmux-by-human/index.v4.json?k=${testToken}`,
|
|
2253
|
+
'DELETE',
|
|
2254
|
+
);
|
|
2255
|
+
expect(deleted.statusCode).toBe(404);
|
|
2256
|
+
expect(fs.existsSync(path.join(inTmuxDataDir, 'index.v4.json'))).toBe(
|
|
2257
|
+
true,
|
|
2258
|
+
);
|
|
2259
|
+
} finally {
|
|
2260
|
+
await closeServer(server);
|
|
2261
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
2262
|
+
}
|
|
2263
|
+
});
|
|
2264
|
+
});
|
|
@@ -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,
|