github-issue-tower-defence-management 1.136.0 → 1.137.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 +19 -0
- package/README.md +7 -0
- package/bin/adapter/entry-points/cli/index.js +24 -0
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +1 -0
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.js +2 -2
- package/bin/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.js.map +1 -1
- package/bin/adapter/repositories/NodeTmuxSessionRepository.js +51 -6
- package/bin/adapter/repositories/NodeTmuxSessionRepository.js.map +1 -1
- package/bin/adapter/repositories/clSessionScopeUnitNameFromCgroupContent.js +10 -0
- package/bin/adapter/repositories/clSessionScopeUnitNameFromCgroupContent.js.map +1 -0
- package/bin/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.js +1 -1
- package/bin/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.js.map +1 -1
- package/bin/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.js +6 -1
- package/bin/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.test.ts +118 -1
- package/src/adapter/entry-points/cli/index.ts +38 -0
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +1 -0
- package/src/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.test.ts +37 -0
- package/src/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.ts +4 -0
- package/src/adapter/entry-points/handlers/staleTmuxSessionCleaner.test.ts +2 -2
- package/src/adapter/repositories/NodeTmuxSessionRepository.test.ts +78 -18
- package/src/adapter/repositories/NodeTmuxSessionRepository.ts +27 -6
- package/src/adapter/repositories/clSessionScopeUnitNameFromCgroupContent.test.ts +34 -0
- package/src/adapter/repositories/clSessionScopeUnitNameFromCgroupContent.ts +8 -0
- package/src/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.test.ts +25 -15
- package/src/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.ts +1 -1
- package/src/domain/usecases/adapter-interfaces/TmuxSessionRepository.ts +1 -0
- package/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.test.ts +132 -8
- package/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.ts +13 -1
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.d.ts +2 -0
- package/types/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.d.ts.map +1 -1
- package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts +4 -2
- package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts.map +1 -1
- package/types/adapter/repositories/clSessionScopeUnitNameFromCgroupContent.d.ts +2 -0
- package/types/adapter/repositories/clSessionScopeUnitNameFromCgroupContent.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts +1 -0
- package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts.map +1 -1
- package/types/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.d.ts +3 -1
- package/types/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.d.ts.map +1 -1
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import YAML from 'yaml';
|
|
4
|
+
|
|
5
|
+
jest.mock('fs', () => {
|
|
6
|
+
const actualFs: typeof fs = jest.requireActual('fs');
|
|
7
|
+
return {
|
|
8
|
+
...actualFs,
|
|
9
|
+
readFileSync: jest.fn(actualFs.readFileSync),
|
|
10
|
+
};
|
|
11
|
+
});
|
|
4
12
|
import {
|
|
5
13
|
program,
|
|
6
14
|
loadConfigFile,
|
|
@@ -53,8 +61,14 @@ jest.mock('../../repositories/issue/ApiV3CheerioRestIssueRepository', () => ({
|
|
|
53
61
|
getCachedProject: jest.fn().mockResolvedValue(null),
|
|
54
62
|
})),
|
|
55
63
|
}));
|
|
64
|
+
const mockRunCommand = jest.fn<
|
|
65
|
+
Promise<{ stdout: string; stderr: string; exitCode: number }>,
|
|
66
|
+
[string, string[]]
|
|
67
|
+
>();
|
|
56
68
|
jest.mock('../../repositories/NodeLocalCommandRunner', () => ({
|
|
57
|
-
NodeLocalCommandRunner: jest.fn().mockImplementation(() => ({
|
|
69
|
+
NodeLocalCommandRunner: jest.fn().mockImplementation(() => ({
|
|
70
|
+
runCommand: mockRunCommand,
|
|
71
|
+
})),
|
|
58
72
|
}));
|
|
59
73
|
jest.mock('../../repositories/OauthAPIClaudeRepository', () => ({
|
|
60
74
|
OauthAPIClaudeRepository: jest.fn().mockImplementation(() => ({
|
|
@@ -151,6 +165,7 @@ describe('CLI', () => {
|
|
|
151
165
|
beforeEach(() => {
|
|
152
166
|
jest.clearAllMocks();
|
|
153
167
|
mockFetchReturningReadme(null);
|
|
168
|
+
mockRunCommand.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
|
|
154
169
|
process.env = { ...originalEnv, GH_TOKEN: 'test-token' };
|
|
155
170
|
writeConfig(defaultConfig);
|
|
156
171
|
});
|
|
@@ -2142,4 +2157,106 @@ mysteryKey: 'value'
|
|
|
2142
2157
|
logSpy.mockRestore();
|
|
2143
2158
|
});
|
|
2144
2159
|
});
|
|
2160
|
+
|
|
2161
|
+
describe('killTmuxSession', () => {
|
|
2162
|
+
it('reaches the real repository command sequence to kill a named session via --session', async () => {
|
|
2163
|
+
await program.parseAsync([
|
|
2164
|
+
'node',
|
|
2165
|
+
'test',
|
|
2166
|
+
'killTmuxSession',
|
|
2167
|
+
'--session',
|
|
2168
|
+
'https_//github_com/owner/repo/issues/9',
|
|
2169
|
+
]);
|
|
2170
|
+
|
|
2171
|
+
const expectedScopeUnitName =
|
|
2172
|
+
'cl-https---github-com-owner-repo-issues-9.scope';
|
|
2173
|
+
expect(mockRunCommand.mock.calls).toEqual([
|
|
2174
|
+
['systemctl', ['--user', 'reset-failed', expectedScopeUnitName]],
|
|
2175
|
+
['systemctl', ['--user', 'stop', expectedScopeUnitName]],
|
|
2176
|
+
['systemctl', ['--user', 'reset-failed', expectedScopeUnitName]],
|
|
2177
|
+
[
|
|
2178
|
+
'tmux',
|
|
2179
|
+
['kill-session', '-t', '=https_//github_com/owner/repo/issues/9'],
|
|
2180
|
+
],
|
|
2181
|
+
]);
|
|
2182
|
+
});
|
|
2183
|
+
|
|
2184
|
+
it('reaches the real repository command sequence to stop its own scope via --self, without calling tmux', async () => {
|
|
2185
|
+
const actualFs = jest.requireActual<typeof fs>('fs');
|
|
2186
|
+
const readFileSyncMock = jest.mocked(fs.readFileSync);
|
|
2187
|
+
readFileSyncMock.mockImplementation(
|
|
2188
|
+
(
|
|
2189
|
+
filePath: Parameters<typeof fs.readFileSync>[0],
|
|
2190
|
+
options: Parameters<typeof fs.readFileSync>[1],
|
|
2191
|
+
): ReturnType<typeof fs.readFileSync> => {
|
|
2192
|
+
if (filePath === '/proc/self/cgroup') {
|
|
2193
|
+
return '0::/user.slice/user-1000.slice/user@1000.service/app.slice/cl-current-session.scope\n';
|
|
2194
|
+
}
|
|
2195
|
+
return actualFs.readFileSync(filePath, options);
|
|
2196
|
+
},
|
|
2197
|
+
);
|
|
2198
|
+
|
|
2199
|
+
await program.parseAsync(['node', 'test', 'killTmuxSession', '--self']);
|
|
2200
|
+
|
|
2201
|
+
expect(mockRunCommand.mock.calls).toEqual([
|
|
2202
|
+
['systemctl', ['--user', 'reset-failed', 'cl-current-session.scope']],
|
|
2203
|
+
['systemctl', ['--user', 'stop', 'cl-current-session.scope']],
|
|
2204
|
+
['systemctl', ['--user', 'reset-failed', 'cl-current-session.scope']],
|
|
2205
|
+
]);
|
|
2206
|
+
expect(mockRunCommand.mock.calls.some((call) => call[0] === 'tmux')).toBe(
|
|
2207
|
+
false,
|
|
2208
|
+
);
|
|
2209
|
+
|
|
2210
|
+
readFileSyncMock.mockImplementation(actualFs.readFileSync);
|
|
2211
|
+
});
|
|
2212
|
+
|
|
2213
|
+
it('exits with an error when neither --session nor --self is provided', async () => {
|
|
2214
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
2215
|
+
const processExitSpy = jest
|
|
2216
|
+
.spyOn(process, 'exit')
|
|
2217
|
+
.mockImplementation(() => {
|
|
2218
|
+
throw new Error('process.exit called');
|
|
2219
|
+
});
|
|
2220
|
+
|
|
2221
|
+
await expect(
|
|
2222
|
+
program.parseAsync(['node', 'test', 'killTmuxSession']),
|
|
2223
|
+
).rejects.toThrow('process.exit called');
|
|
2224
|
+
|
|
2225
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
2226
|
+
'Either --session <name> or --self is required',
|
|
2227
|
+
);
|
|
2228
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
2229
|
+
|
|
2230
|
+
consoleErrorSpy.mockRestore();
|
|
2231
|
+
processExitSpy.mockRestore();
|
|
2232
|
+
});
|
|
2233
|
+
|
|
2234
|
+
it('exits with an error when both --session and --self are provided', async () => {
|
|
2235
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
2236
|
+
const processExitSpy = jest
|
|
2237
|
+
.spyOn(process, 'exit')
|
|
2238
|
+
.mockImplementation(() => {
|
|
2239
|
+
throw new Error('process.exit called');
|
|
2240
|
+
});
|
|
2241
|
+
|
|
2242
|
+
await expect(
|
|
2243
|
+
program.parseAsync([
|
|
2244
|
+
'node',
|
|
2245
|
+
'test',
|
|
2246
|
+
'killTmuxSession',
|
|
2247
|
+
'--session',
|
|
2248
|
+
'some_session',
|
|
2249
|
+
'--self',
|
|
2250
|
+
]),
|
|
2251
|
+
).rejects.toThrow('process.exit called');
|
|
2252
|
+
|
|
2253
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
2254
|
+
'--session and --self cannot be used together',
|
|
2255
|
+
);
|
|
2256
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
2257
|
+
|
|
2258
|
+
consoleErrorSpy.mockRestore();
|
|
2259
|
+
processExitSpy.mockRestore();
|
|
2260
|
+
});
|
|
2261
|
+
});
|
|
2145
2262
|
});
|
|
@@ -29,6 +29,7 @@ import { LocalStorageCacheRepository } from '../../repositories/LocalStorageCach
|
|
|
29
29
|
import { SystemDateRepository } from '../../repositories/SystemDateRepository';
|
|
30
30
|
import { BaseGitHubRepository } from '../../repositories/BaseGitHubRepository';
|
|
31
31
|
import { NodeLocalCommandRunner } from '../../repositories/NodeLocalCommandRunner';
|
|
32
|
+
import { NodeTmuxSessionRepository } from '../../repositories/NodeTmuxSessionRepository';
|
|
32
33
|
import { GitHubIssueCommentRepository } from '../../repositories/GitHubIssueCommentRepository';
|
|
33
34
|
import { FetchWebhookRepository } from '../../repositories/FetchWebhookRepository';
|
|
34
35
|
import { RevertOrphanedPreparationUseCase } from '../../../domain/usecases/RevertOrphanedPreparationUseCase';
|
|
@@ -124,6 +125,11 @@ type CountInTmuxByHumanSessionsPerTokenOptions = {
|
|
|
124
125
|
tokenListJsonPath?: string;
|
|
125
126
|
};
|
|
126
127
|
|
|
128
|
+
type KillTmuxSessionOptions = {
|
|
129
|
+
session?: string;
|
|
130
|
+
self?: boolean;
|
|
131
|
+
};
|
|
132
|
+
|
|
127
133
|
const buildGithubRepositoryParams = (
|
|
128
134
|
localStorageRepository: LocalStorageRepository,
|
|
129
135
|
token: string,
|
|
@@ -1028,6 +1034,38 @@ program
|
|
|
1028
1034
|
}
|
|
1029
1035
|
});
|
|
1030
1036
|
|
|
1037
|
+
program
|
|
1038
|
+
.command('killTmuxSession')
|
|
1039
|
+
.description(
|
|
1040
|
+
'Cleanly kill a tmux session by running tmux kill-session and stopping its cl-*.scope systemd --user unit. Use --session <name> to kill another named session, or --self to terminate the current session from inside it.',
|
|
1041
|
+
)
|
|
1042
|
+
.option('--session <name>', 'Name of the tmux session to kill')
|
|
1043
|
+
.option(
|
|
1044
|
+
'--self',
|
|
1045
|
+
'Terminate the current session by stopping its own cl-*.scope systemd user unit, derived from /proc/self/cgroup',
|
|
1046
|
+
)
|
|
1047
|
+
.action(async (options: KillTmuxSessionOptions) => {
|
|
1048
|
+
if (!options.session && !options.self) {
|
|
1049
|
+
console.error('Either --session <name> or --self is required');
|
|
1050
|
+
process.exit(1);
|
|
1051
|
+
}
|
|
1052
|
+
if (options.session && options.self) {
|
|
1053
|
+
console.error('--session and --self cannot be used together');
|
|
1054
|
+
process.exit(1);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const localCommandRunner = new NodeLocalCommandRunner();
|
|
1058
|
+
const tmuxSessionRepository = new NodeTmuxSessionRepository(
|
|
1059
|
+
localCommandRunner,
|
|
1060
|
+
);
|
|
1061
|
+
|
|
1062
|
+
if (options.self) {
|
|
1063
|
+
await tmuxSessionRepository.killOwnSession();
|
|
1064
|
+
} else if (options.session) {
|
|
1065
|
+
await tmuxSessionRepository.killSession(options.session);
|
|
1066
|
+
}
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1031
1069
|
/* istanbul ignore next */
|
|
1032
1070
|
if (process.argv && require.main === module) {
|
|
1033
1071
|
program.parse(process.argv);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Issue } from '../../../domain/entities/Issue';
|
|
2
|
+
import { IssueRepository } from '../../../domain/usecases/adapter-interfaces/IssueRepository';
|
|
2
3
|
import { LocalCommandRunner } from '../../../domain/usecases/adapter-interfaces/LocalCommandRunner';
|
|
3
4
|
import { reconcileInTmuxByHumanSessions } from './inTmuxByHumanSessionReconciler';
|
|
4
5
|
|
|
@@ -39,6 +40,16 @@ const createMockRunner = (): Mocked<LocalCommandRunner> => ({
|
|
|
39
40
|
runCommand: jest.fn(),
|
|
40
41
|
});
|
|
41
42
|
|
|
43
|
+
const createIssueStateRepository = (
|
|
44
|
+
state = 'open',
|
|
45
|
+
): Pick<IssueRepository, 'getIssueOrPullRequestState'> => ({
|
|
46
|
+
getIssueOrPullRequestState: async () => ({
|
|
47
|
+
state,
|
|
48
|
+
merged: false,
|
|
49
|
+
isPullRequest: false,
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
|
|
42
53
|
describe('reconcileInTmuxByHumanSessions', () => {
|
|
43
54
|
it('does nothing when no launcher command is configured', async () => {
|
|
44
55
|
const runner = createMockRunner();
|
|
@@ -48,6 +59,7 @@ describe('reconcileInTmuxByHumanSessions', () => {
|
|
|
48
59
|
assigneeLogin: ASSIGNEE,
|
|
49
60
|
issues: [makeIssue()],
|
|
50
61
|
localCommandRunner: runner,
|
|
62
|
+
issueStateRepository: createIssueStateRepository(),
|
|
51
63
|
now: NOW,
|
|
52
64
|
});
|
|
53
65
|
|
|
@@ -71,6 +83,7 @@ describe('reconcileInTmuxByHumanSessions', () => {
|
|
|
71
83
|
assigneeLogin: ASSIGNEE,
|
|
72
84
|
issues: [makeIssue()],
|
|
73
85
|
localCommandRunner: runner,
|
|
86
|
+
issueStateRepository: createIssueStateRepository(),
|
|
74
87
|
now: NOW,
|
|
75
88
|
});
|
|
76
89
|
|
|
@@ -118,6 +131,30 @@ describe('reconcileInTmuxByHumanSessions', () => {
|
|
|
118
131
|
assigneeLogin: ASSIGNEE,
|
|
119
132
|
issues: [makeIssue()],
|
|
120
133
|
localCommandRunner: runner,
|
|
134
|
+
issueStateRepository: createIssueStateRepository(),
|
|
135
|
+
now: NOW,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const newSessionCalls = runner.runCommand.mock.calls.filter(
|
|
139
|
+
(call) => call[0] === 'tmux' && call[1][0] === 'new-session',
|
|
140
|
+
);
|
|
141
|
+
expect(newSessionCalls).toHaveLength(0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('does not launch when the live GitHub state of the issue is closed', async () => {
|
|
145
|
+
const runner = createMockRunner();
|
|
146
|
+
runner.runCommand.mockImplementation(async () => ({
|
|
147
|
+
stdout: '',
|
|
148
|
+
stderr: '',
|
|
149
|
+
exitCode: 0,
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
await reconcileInTmuxByHumanSessions({
|
|
153
|
+
inTmuxLauncherCommand: 'cl',
|
|
154
|
+
assigneeLogin: ASSIGNEE,
|
|
155
|
+
issues: [makeIssue()],
|
|
156
|
+
localCommandRunner: runner,
|
|
157
|
+
issueStateRepository: createIssueStateRepository('closed'),
|
|
121
158
|
now: NOW,
|
|
122
159
|
});
|
|
123
160
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Issue } from '../../../domain/entities/Issue';
|
|
2
|
+
import { IssueRepository } from '../../../domain/usecases/adapter-interfaces/IssueRepository';
|
|
2
3
|
import { LocalCommandRunner } from '../../../domain/usecases/adapter-interfaces/LocalCommandRunner';
|
|
3
4
|
import { InTmuxByHumanSessionReconcileUseCase } from '../../../domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase';
|
|
4
5
|
import { NodeTmuxSessionRepository } from '../../repositories/NodeTmuxSessionRepository';
|
|
@@ -8,6 +9,7 @@ export type ReconcileInTmuxByHumanSessionsParams = {
|
|
|
8
9
|
assigneeLogin: string;
|
|
9
10
|
issues: Issue[];
|
|
10
11
|
localCommandRunner: LocalCommandRunner;
|
|
12
|
+
issueStateRepository: Pick<IssueRepository, 'getIssueOrPullRequestState'>;
|
|
11
13
|
now: Date;
|
|
12
14
|
};
|
|
13
15
|
|
|
@@ -19,6 +21,7 @@ export const reconcileInTmuxByHumanSessions = async (
|
|
|
19
21
|
assigneeLogin,
|
|
20
22
|
issues,
|
|
21
23
|
localCommandRunner,
|
|
24
|
+
issueStateRepository,
|
|
22
25
|
now,
|
|
23
26
|
} = params;
|
|
24
27
|
if (!inTmuxLauncherCommand || !assigneeLogin) {
|
|
@@ -26,6 +29,7 @@ export const reconcileInTmuxByHumanSessions = async (
|
|
|
26
29
|
}
|
|
27
30
|
const useCase = new InTmuxByHumanSessionReconcileUseCase(
|
|
28
31
|
new NodeTmuxSessionRepository(localCommandRunner),
|
|
32
|
+
issueStateRepository,
|
|
29
33
|
);
|
|
30
34
|
await useCase.run({
|
|
31
35
|
issues,
|
|
@@ -102,7 +102,7 @@ describe('cleanStaleTmuxSessions', () => {
|
|
|
102
102
|
expect(killCall?.[1]).toEqual([
|
|
103
103
|
'kill-session',
|
|
104
104
|
'-t',
|
|
105
|
-
'https_//github_com/demo/repo/issues/1',
|
|
105
|
+
'=https_//github_com/demo/repo/issues/1',
|
|
106
106
|
]);
|
|
107
107
|
});
|
|
108
108
|
|
|
@@ -160,6 +160,6 @@ describe('cleanStaleTmuxSessions', () => {
|
|
|
160
160
|
(call) => call[0] === 'tmux' && call[1][0] === 'kill-session',
|
|
161
161
|
);
|
|
162
162
|
expect(killCalls).toHaveLength(1);
|
|
163
|
-
expect(killCalls[0][1]).toEqual(['kill-session', '-t', 'idle_no_task']);
|
|
163
|
+
expect(killCalls[0][1]).toEqual(['kill-session', '-t', '=idle_no_task']);
|
|
164
164
|
});
|
|
165
165
|
});
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
1
4
|
import { LocalCommandRunner } from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
|
|
2
5
|
import { NodeTmuxSessionRepository } from './NodeTmuxSessionRepository';
|
|
3
6
|
|
|
@@ -91,7 +94,7 @@ describe('NodeTmuxSessionRepository', () => {
|
|
|
91
94
|
});
|
|
92
95
|
|
|
93
96
|
describe('killSession', () => {
|
|
94
|
-
it('kills the tmux session by name', async () => {
|
|
97
|
+
it('kills the tmux session by exact name', async () => {
|
|
95
98
|
const runner = createMockRunner();
|
|
96
99
|
runner.runCommand.mockResolvedValue({
|
|
97
100
|
stdout: '',
|
|
@@ -102,20 +105,22 @@ describe('NodeTmuxSessionRepository', () => {
|
|
|
102
105
|
|
|
103
106
|
await repository.killSession('no_task_session');
|
|
104
107
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
'
|
|
108
|
+
const tmuxCall = runner.runCommand.mock.calls.find(
|
|
109
|
+
(call) => call[0] === 'tmux',
|
|
110
|
+
);
|
|
111
|
+
expect(tmuxCall).toEqual([
|
|
112
|
+
'tmux',
|
|
113
|
+
['kill-session', '-t', '=no_task_session'],
|
|
110
114
|
]);
|
|
111
115
|
});
|
|
112
116
|
|
|
113
117
|
it('throws when tmux exits non-zero', async () => {
|
|
114
118
|
const runner = createMockRunner();
|
|
115
|
-
runner.runCommand.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
+
runner.runCommand.mockImplementation(async (program: string) => {
|
|
120
|
+
if (program === 'tmux') {
|
|
121
|
+
return { stdout: '', stderr: "can't find session", exitCode: 1 };
|
|
122
|
+
}
|
|
123
|
+
return { stdout: '', stderr: '', exitCode: 0 };
|
|
119
124
|
});
|
|
120
125
|
const repository = new NodeTmuxSessionRepository(runner);
|
|
121
126
|
|
|
@@ -124,7 +129,7 @@ describe('NodeTmuxSessionRepository', () => {
|
|
|
124
129
|
);
|
|
125
130
|
});
|
|
126
131
|
|
|
127
|
-
it('stops the systemd user scope for the session
|
|
132
|
+
it('stops the systemd user scope for the session before killing it, wrapping the stop with reset-failed', async () => {
|
|
128
133
|
const runner = createMockRunner();
|
|
129
134
|
runner.runCommand.mockResolvedValue({
|
|
130
135
|
stdout: '',
|
|
@@ -137,16 +142,14 @@ describe('NodeTmuxSessionRepository', () => {
|
|
|
137
142
|
|
|
138
143
|
const expectedScopeUnitName =
|
|
139
144
|
'cl-https---github-com-owner-repo-issues-9.scope';
|
|
140
|
-
|
|
141
|
-
expect(calls[0]).toEqual([
|
|
142
|
-
'tmux',
|
|
143
|
-
['kill-session', '-t', 'https_//github_com/owner/repo/issues/9'],
|
|
144
|
-
]);
|
|
145
|
-
const systemctlCalls = calls.filter((call) => call[0] === 'systemctl');
|
|
146
|
-
expect(systemctlCalls).toEqual([
|
|
145
|
+
expect(runner.runCommand.mock.calls).toEqual([
|
|
147
146
|
['systemctl', ['--user', 'reset-failed', expectedScopeUnitName]],
|
|
148
147
|
['systemctl', ['--user', 'stop', expectedScopeUnitName]],
|
|
149
148
|
['systemctl', ['--user', 'reset-failed', expectedScopeUnitName]],
|
|
149
|
+
[
|
|
150
|
+
'tmux',
|
|
151
|
+
['kill-session', '-t', '=https_//github_com/owner/repo/issues/9'],
|
|
152
|
+
],
|
|
150
153
|
]);
|
|
151
154
|
});
|
|
152
155
|
|
|
@@ -180,6 +183,63 @@ describe('NodeTmuxSessionRepository', () => {
|
|
|
180
183
|
});
|
|
181
184
|
});
|
|
182
185
|
|
|
186
|
+
describe('killOwnSession', () => {
|
|
187
|
+
let procDirectory: string;
|
|
188
|
+
|
|
189
|
+
beforeEach(() => {
|
|
190
|
+
procDirectory = fs.mkdtempSync(
|
|
191
|
+
path.join(os.tmpdir(), 'node-tmux-session-repository-proc-'),
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
afterEach(() => {
|
|
196
|
+
fs.rmSync(procDirectory, { recursive: true, force: true });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const writeCgroupContent = (content: string): void => {
|
|
200
|
+
const selfDirectory = path.join(procDirectory, 'self');
|
|
201
|
+
fs.mkdirSync(selfDirectory, { recursive: true });
|
|
202
|
+
fs.writeFileSync(path.join(selfDirectory, 'cgroup'), content);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
it('stops only the current session scope derived from /proc/self/cgroup, without calling tmux', async () => {
|
|
206
|
+
writeCgroupContent(
|
|
207
|
+
'0::/user.slice/user-1000.slice/user@1000.service/app.slice/cl-leader-session.scope\n',
|
|
208
|
+
);
|
|
209
|
+
const runner = createMockRunner();
|
|
210
|
+
runner.runCommand.mockResolvedValue({
|
|
211
|
+
stdout: '',
|
|
212
|
+
stderr: '',
|
|
213
|
+
exitCode: 0,
|
|
214
|
+
});
|
|
215
|
+
const repository = new NodeTmuxSessionRepository(runner, procDirectory);
|
|
216
|
+
|
|
217
|
+
await repository.killOwnSession();
|
|
218
|
+
|
|
219
|
+
expect(runner.runCommand.mock.calls).toEqual([
|
|
220
|
+
['systemctl', ['--user', 'reset-failed', 'cl-leader-session.scope']],
|
|
221
|
+
['systemctl', ['--user', 'stop', 'cl-leader-session.scope']],
|
|
222
|
+
['systemctl', ['--user', 'reset-failed', 'cl-leader-session.scope']],
|
|
223
|
+
]);
|
|
224
|
+
expect(
|
|
225
|
+
runner.runCommand.mock.calls.some((call) => call[0] === 'tmux'),
|
|
226
|
+
).toBe(false);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('throws when no cl-*.scope unit can be found in /proc/self/cgroup', async () => {
|
|
230
|
+
writeCgroupContent(
|
|
231
|
+
'0::/user.slice/user-1000.slice/user@1000.service/app.slice/vte-spawn-abc.scope\n',
|
|
232
|
+
);
|
|
233
|
+
const runner = createMockRunner();
|
|
234
|
+
const repository = new NodeTmuxSessionRepository(runner, procDirectory);
|
|
235
|
+
|
|
236
|
+
await expect(repository.killOwnSession()).rejects.toThrow(
|
|
237
|
+
'Failed to determine the current cl-*.scope systemd user unit from /proc/self/cgroup',
|
|
238
|
+
);
|
|
239
|
+
expect(runner.runCommand.mock.calls).toHaveLength(0);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
183
243
|
describe('listInteractiveProcessCommandLines', () => {
|
|
184
244
|
it('parses process command lines from ps output', async () => {
|
|
185
245
|
const runner = createMockRunner();
|
|
@@ -1,10 +1,16 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
1
3
|
import { LocalCommandRunner } from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
|
|
2
4
|
import { TmuxSessionRepository } from '../../domain/usecases/adapter-interfaces/TmuxSessionRepository';
|
|
3
5
|
import { LiveTmuxSession } from '../../domain/entities/LiveTmuxSession';
|
|
4
6
|
import { clSessionScopeUnitName } from './clSessionScopeUnitName';
|
|
7
|
+
import { clSessionScopeUnitNameFromCgroupContent } from './clSessionScopeUnitNameFromCgroupContent';
|
|
5
8
|
|
|
6
9
|
export class NodeTmuxSessionRepository implements TmuxSessionRepository {
|
|
7
|
-
constructor(
|
|
10
|
+
constructor(
|
|
11
|
+
private readonly localCommandRunner: LocalCommandRunner,
|
|
12
|
+
private readonly procDirectory: string = '/proc',
|
|
13
|
+
) {}
|
|
8
14
|
|
|
9
15
|
listLiveSessionNames = async (): Promise<string[]> => {
|
|
10
16
|
const { stdout, exitCode } = await this.localCommandRunner.runCommand(
|
|
@@ -75,9 +81,11 @@ export class NodeTmuxSessionRepository implements TmuxSessionRepository {
|
|
|
75
81
|
};
|
|
76
82
|
|
|
77
83
|
killSession = async (sessionName: string): Promise<void> => {
|
|
84
|
+
const scopeUnitName = clSessionScopeUnitName(sessionName);
|
|
85
|
+
await this.stopScopeUnit(scopeUnitName);
|
|
78
86
|
const { stderr, exitCode } = await this.localCommandRunner.runCommand(
|
|
79
87
|
'tmux',
|
|
80
|
-
['kill-session', '-t', sessionName],
|
|
88
|
+
['kill-session', '-t', `=${sessionName}`],
|
|
81
89
|
);
|
|
82
90
|
if (exitCode !== 0) {
|
|
83
91
|
throw new Error(
|
|
@@ -86,11 +94,24 @@ export class NodeTmuxSessionRepository implements TmuxSessionRepository {
|
|
|
86
94
|
}`,
|
|
87
95
|
);
|
|
88
96
|
}
|
|
89
|
-
await this.stopClSessionScope(sessionName);
|
|
90
97
|
};
|
|
91
98
|
|
|
92
|
-
|
|
93
|
-
const
|
|
99
|
+
killOwnSession = async (): Promise<void> => {
|
|
100
|
+
const cgroupContent = fs.readFileSync(
|
|
101
|
+
path.join(this.procDirectory, 'self', 'cgroup'),
|
|
102
|
+
'utf8',
|
|
103
|
+
);
|
|
104
|
+
const scopeUnitName =
|
|
105
|
+
clSessionScopeUnitNameFromCgroupContent(cgroupContent);
|
|
106
|
+
if (scopeUnitName === null) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
'Failed to determine the current cl-*.scope systemd user unit from /proc/self/cgroup',
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
await this.stopScopeUnit(scopeUnitName);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
private stopScopeUnit = async (scopeUnitName: string): Promise<void> => {
|
|
94
115
|
await this.localCommandRunner.runCommand('systemctl', [
|
|
95
116
|
'--user',
|
|
96
117
|
'reset-failed',
|
|
@@ -107,7 +128,7 @@ export class NodeTmuxSessionRepository implements TmuxSessionRepository {
|
|
|
107
128
|
]);
|
|
108
129
|
if (exitCode !== 0) {
|
|
109
130
|
console.error(
|
|
110
|
-
`Failed to stop systemd user scope "${scopeUnitName}"
|
|
131
|
+
`Failed to stop systemd user scope "${scopeUnitName}": exit code ${exitCode}${
|
|
111
132
|
stderr ? `: ${stderr}` : ''
|
|
112
133
|
}`,
|
|
113
134
|
);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { clSessionScopeUnitNameFromCgroupContent } from './clSessionScopeUnitNameFromCgroupContent';
|
|
2
|
+
|
|
3
|
+
describe('clSessionScopeUnitNameFromCgroupContent', () => {
|
|
4
|
+
it('extracts the cl-*.scope unit from a cgroup v2 single-hierarchy line', () => {
|
|
5
|
+
const cgroupContent =
|
|
6
|
+
'0::/user.slice/user-1000.slice/user@1000.service/app.slice/cl-https---github-com-owner-repo-issues-9.scope\n';
|
|
7
|
+
|
|
8
|
+
const result = clSessionScopeUnitNameFromCgroupContent(cgroupContent);
|
|
9
|
+
|
|
10
|
+
expect(result).toBe('cl-https---github-com-owner-repo-issues-9.scope');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('extracts the cl-*.scope unit from a multi-hierarchy cgroup v1 file', () => {
|
|
14
|
+
const cgroupContent = [
|
|
15
|
+
'12:pids:/user.slice/user-1000.slice/user@1000.service/app.slice/cl-leader-session.scope',
|
|
16
|
+
'11:cpu,cpuacct:/user.slice/user-1000.slice/user@1000.service/app.slice/cl-leader-session.scope',
|
|
17
|
+
'0::/user.slice/user-1000.slice/user@1000.service/app.slice/cl-leader-session.scope',
|
|
18
|
+
'',
|
|
19
|
+
].join('\n');
|
|
20
|
+
|
|
21
|
+
const result = clSessionScopeUnitNameFromCgroupContent(cgroupContent);
|
|
22
|
+
|
|
23
|
+
expect(result).toBe('cl-leader-session.scope');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('returns null when no cl-*.scope unit is present', () => {
|
|
27
|
+
const cgroupContent =
|
|
28
|
+
'0::/user.slice/user-1000.slice/user@1000.service/app.slice/vte-spawn-abc.scope\n';
|
|
29
|
+
|
|
30
|
+
const result = clSessionScopeUnitNameFromCgroupContent(cgroupContent);
|
|
31
|
+
|
|
32
|
+
expect(result).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
const CL_SESSION_SCOPE_UNIT_PATTERN = /cl-[A-Za-z0-9._-]+\.scope/;
|
|
2
|
+
|
|
3
|
+
export const clSessionScopeUnitNameFromCgroupContent = (
|
|
4
|
+
cgroupContent: string,
|
|
5
|
+
): string | null => {
|
|
6
|
+
const match = cgroupContent.match(CL_SESSION_SCOPE_UNIT_PATTERN);
|
|
7
|
+
return match ? match[0] : null;
|
|
8
|
+
};
|
|
@@ -123,26 +123,36 @@ describe('SetWorkflowManagementIssueToStoryUseCase', () => {
|
|
|
123
123
|
expect(mockIssueRepository.removeLabel).not.toHaveBeenCalled();
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
-
it('should
|
|
127
|
-
|
|
126
|
+
it('should map story label to matching story when cacheUsed is true', async () => {
|
|
127
|
+
const issue: Issue = {
|
|
128
|
+
...mock<Issue>(),
|
|
129
|
+
labels: ['story:high-priority'],
|
|
130
|
+
story: null,
|
|
131
|
+
state: 'OPEN',
|
|
132
|
+
nextActionDate: null,
|
|
133
|
+
nextActionHour: null,
|
|
134
|
+
isPr: false,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const promise = useCase.run({
|
|
128
138
|
targetDates: [targetDate],
|
|
129
139
|
project: basicProject,
|
|
130
|
-
issues: [
|
|
131
|
-
{
|
|
132
|
-
...mock<Issue>(),
|
|
133
|
-
labels: ['story:high-priority'],
|
|
134
|
-
story: null,
|
|
135
|
-
state: 'OPEN',
|
|
136
|
-
nextActionDate: null,
|
|
137
|
-
nextActionHour: null,
|
|
138
|
-
isPr: false,
|
|
139
|
-
},
|
|
140
|
-
],
|
|
140
|
+
issues: [issue],
|
|
141
141
|
cacheUsed: true,
|
|
142
142
|
});
|
|
143
|
+
await jest.runAllTimersAsync();
|
|
144
|
+
await promise;
|
|
143
145
|
|
|
144
|
-
expect(mockIssueRepository.updateStory).
|
|
145
|
-
|
|
146
|
+
expect(mockIssueRepository.updateStory.mock.calls).toEqual([
|
|
147
|
+
[
|
|
148
|
+
{ ...basicProject, story: basicProject.story },
|
|
149
|
+
issue,
|
|
150
|
+
'highPriorityId',
|
|
151
|
+
],
|
|
152
|
+
]);
|
|
153
|
+
expect(mockIssueRepository.removeLabel.mock.calls).toEqual([
|
|
154
|
+
[issue, 'story:high-priority'],
|
|
155
|
+
]);
|
|
146
156
|
});
|
|
147
157
|
|
|
148
158
|
it('should map story label to matching story on non-minute-0 target date', async () => {
|