github-issue-tower-defence-management 1.32.0 → 1.34.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 +20 -0
- package/README.md +92 -6
- package/bin/adapter/entry-points/cli/index.js +422 -5
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +67 -33
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/repositories/FetchWebhookRepository.js +10 -0
- package/bin/adapter/repositories/FetchWebhookRepository.js.map +1 -0
- package/bin/adapter/repositories/GitHubIssueCommentRepository.js +190 -0
- package/bin/adapter/repositories/GitHubIssueCommentRepository.js.map +1 -0
- package/bin/adapter/repositories/OauthAPIClaudeRepository.js +225 -0
- package/bin/adapter/repositories/OauthAPIClaudeRepository.js.map +1 -0
- package/bin/domain/usecases/HandleScheduledEventUseCase.js +17 -1
- package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +73 -17
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/adapter-interfaces/WebhookRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/WebhookRepository.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.test.ts +1315 -15
- package/src/adapter/entry-points/cli/index.ts +648 -5
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +14 -0
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +17 -2
- package/src/adapter/repositories/FetchWebhookRepository.ts +7 -0
- package/src/adapter/repositories/GitHubIssueCommentRepository.ts +291 -0
- package/src/adapter/repositories/OauthAPIClaudeRepository.ts +279 -0
- package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +28 -0
- package/src/domain/usecases/HandleScheduledEventUseCase.ts +30 -0
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +722 -16
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +117 -20
- package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +2 -0
- package/src/domain/usecases/adapter-interfaces/WebhookRepository.ts +3 -0
- package/types/adapter/entry-points/cli/index.d.ts +19 -0
- 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/repositories/FetchWebhookRepository.d.ts +5 -0
- package/types/adapter/repositories/FetchWebhookRepository.d.ts.map +1 -0
- package/types/adapter/repositories/GitHubIssueCommentRepository.d.ts +12 -0
- package/types/adapter/repositories/GitHubIssueCommentRepository.d.ts.map +1 -0
- package/types/adapter/repositories/OauthAPIClaudeRepository.d.ts +13 -0
- package/types/adapter/repositories/OauthAPIClaudeRepository.d.ts.map +1 -0
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +10 -1
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +5 -1
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +2 -0
- package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/WebhookRepository.d.ts +4 -0
- package/types/domain/usecases/adapter-interfaces/WebhookRepository.d.ts.map +1 -0
|
@@ -1,21 +1,1321 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import YAML from 'yaml';
|
|
4
|
+
import {
|
|
5
|
+
program,
|
|
6
|
+
loadConfigFile,
|
|
7
|
+
parseProjectReadmeConfig,
|
|
8
|
+
mergeConfigs,
|
|
9
|
+
fetchProjectReadme,
|
|
10
|
+
} from './index';
|
|
11
|
+
import { StartPreparationUseCase } from '../../../domain/usecases/StartPreparationUseCase';
|
|
12
|
+
import { NotifyFinishedIssuePreparationUseCase } from '../../../domain/usecases/NotifyFinishedIssuePreparationUseCase';
|
|
2
13
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
14
|
+
jest.mock('../../../domain/usecases/StartPreparationUseCase');
|
|
15
|
+
jest.mock('../../../domain/usecases/NotifyFinishedIssuePreparationUseCase');
|
|
16
|
+
jest.mock('../../repositories/LocalStorageRepository', () => ({
|
|
17
|
+
LocalStorageRepository: jest.fn().mockImplementation(() => ({})),
|
|
18
|
+
}));
|
|
19
|
+
jest.mock('../../repositories/LocalStorageCacheRepository', () => ({
|
|
20
|
+
LocalStorageCacheRepository: jest.fn().mockImplementation(() => ({})),
|
|
21
|
+
}));
|
|
22
|
+
jest.mock('../../repositories/GraphqlProjectRepository', () => ({
|
|
23
|
+
GraphqlProjectRepository: jest.fn().mockImplementation(() => ({})),
|
|
24
|
+
}));
|
|
25
|
+
jest.mock('../../repositories/CheerioProjectRepository', () => ({
|
|
26
|
+
CheerioProjectRepository: jest.fn().mockImplementation(() => ({})),
|
|
27
|
+
}));
|
|
28
|
+
jest.mock('../../repositories/issue/ApiV3IssueRepository', () => ({
|
|
29
|
+
ApiV3IssueRepository: jest.fn().mockImplementation(() => ({})),
|
|
30
|
+
}));
|
|
31
|
+
jest.mock('../../repositories/issue/RestIssueRepository', () => ({
|
|
32
|
+
RestIssueRepository: jest.fn().mockImplementation(() => ({})),
|
|
33
|
+
}));
|
|
34
|
+
jest.mock('../../repositories/issue/GraphqlProjectItemRepository', () => ({
|
|
35
|
+
GraphqlProjectItemRepository: jest.fn().mockImplementation(() => ({})),
|
|
36
|
+
}));
|
|
37
|
+
jest.mock('../../repositories/issue/ApiV3CheerioRestIssueRepository', () => ({
|
|
38
|
+
ApiV3CheerioRestIssueRepository: jest.fn().mockImplementation(() => ({})),
|
|
39
|
+
}));
|
|
40
|
+
jest.mock('../../repositories/NodeLocalCommandRunner', () => ({
|
|
41
|
+
NodeLocalCommandRunner: jest.fn().mockImplementation(() => ({})),
|
|
42
|
+
}));
|
|
43
|
+
jest.mock('../../repositories/OauthAPIClaudeRepository', () => ({
|
|
44
|
+
OauthAPIClaudeRepository: jest.fn().mockImplementation(() => ({
|
|
45
|
+
getUsage: jest.fn(),
|
|
46
|
+
isClaudeAvailable: jest.fn(),
|
|
47
|
+
})),
|
|
48
|
+
}));
|
|
49
|
+
jest.mock('../../repositories/GitHubIssueCommentRepository', () => ({
|
|
50
|
+
GitHubIssueCommentRepository: jest.fn().mockImplementation(() => ({})),
|
|
51
|
+
}));
|
|
52
|
+
jest.mock('../../repositories/FetchWebhookRepository', () => ({
|
|
53
|
+
FetchWebhookRepository: jest.fn().mockImplementation(() => ({
|
|
54
|
+
sendGetRequest: jest.fn(),
|
|
55
|
+
})),
|
|
56
|
+
}));
|
|
57
|
+
jest.mock('../handlers/HandleScheduledEventUseCaseHandler', () => ({
|
|
58
|
+
HandleScheduledEventUseCaseHandler: jest.fn().mockImplementation(() => ({
|
|
59
|
+
handle: jest.fn().mockResolvedValue(null),
|
|
60
|
+
})),
|
|
61
|
+
}));
|
|
8
62
|
|
|
9
|
-
|
|
10
|
-
|
|
63
|
+
describe('CLI', () => {
|
|
64
|
+
const originalEnv = process.env;
|
|
65
|
+
const tmpDir = path.join(__dirname, '../../../../tmp/test-cli');
|
|
66
|
+
const configFilePath = path.join(tmpDir, 'config.yml');
|
|
11
67
|
|
|
12
|
-
|
|
68
|
+
const defaultConfig = {
|
|
69
|
+
projectUrl: 'https://github.com/orgs/test/projects/1',
|
|
70
|
+
awaitingWorkspaceStatus: 'Awaiting',
|
|
71
|
+
preparationStatus: 'Preparing',
|
|
72
|
+
defaultAgentName: 'agent1',
|
|
73
|
+
awaitingQualityCheckStatus: 'Awaiting QC',
|
|
74
|
+
projectName: 'test-project',
|
|
75
|
+
};
|
|
13
76
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
77
|
+
const writeConfig = (config: Record<string, unknown>): void => {
|
|
78
|
+
fs.writeFileSync(configFilePath, YAML.stringify(config));
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const mockFetchReturningReadme = (readme: string | null): void => {
|
|
82
|
+
const responseBody =
|
|
83
|
+
readme === null
|
|
84
|
+
? { data: {} }
|
|
85
|
+
: { data: { organization: { projectV2: { readme } } } };
|
|
86
|
+
jest.spyOn(global, 'fetch').mockResolvedValue(
|
|
87
|
+
new Response(JSON.stringify(responseBody), {
|
|
88
|
+
status: 200,
|
|
89
|
+
headers: { 'Content-Type': 'application/json' },
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
beforeAll(() => {
|
|
95
|
+
if (!fs.existsSync(tmpDir)) {
|
|
96
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
afterAll(() => {
|
|
101
|
+
if (fs.existsSync(configFilePath)) {
|
|
102
|
+
fs.unlinkSync(configFilePath);
|
|
103
|
+
}
|
|
104
|
+
if (fs.existsSync(tmpDir)) {
|
|
105
|
+
fs.rmdirSync(tmpDir);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
beforeEach(() => {
|
|
110
|
+
jest.clearAllMocks();
|
|
111
|
+
mockFetchReturningReadme(null);
|
|
112
|
+
process.env = { ...originalEnv, GH_TOKEN: 'test-token' };
|
|
113
|
+
writeConfig(defaultConfig);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
afterEach(() => {
|
|
117
|
+
process.env = originalEnv;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should export program', () => {
|
|
121
|
+
expect(program).toBeDefined();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('loadConfigFile', () => {
|
|
125
|
+
it('should load config from YAML file', () => {
|
|
126
|
+
const config = {
|
|
127
|
+
projectUrl: 'https://github.com/orgs/test/projects/1',
|
|
128
|
+
awaitingWorkspaceStatus: 'Awaiting',
|
|
129
|
+
preparationStatus: 'Preparing',
|
|
130
|
+
defaultAgentName: 'agent1',
|
|
131
|
+
logFilePath: '/path/to/log.txt',
|
|
132
|
+
maximumPreparingIssuesCount: 10,
|
|
133
|
+
allowIssueCacheMinutes: 5,
|
|
134
|
+
awaitingQualityCheckStatus: 'Awaiting QC',
|
|
135
|
+
thresholdForAutoReject: 5,
|
|
136
|
+
workflowBlockerResolvedWebhookUrl: 'https://example.com/webhook',
|
|
137
|
+
projectName: 'test-project',
|
|
138
|
+
};
|
|
139
|
+
writeConfig(config);
|
|
140
|
+
|
|
141
|
+
const result = loadConfigFile(configFilePath);
|
|
142
|
+
|
|
143
|
+
expect(result).toEqual(config);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should return empty config for empty YAML', () => {
|
|
147
|
+
fs.writeFileSync(configFilePath, '');
|
|
148
|
+
|
|
149
|
+
const result = loadConfigFile(configFilePath);
|
|
150
|
+
|
|
151
|
+
expect(result).toEqual({});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should ignore non-string values for string fields', () => {
|
|
155
|
+
const config = {
|
|
156
|
+
projectUrl: 123,
|
|
157
|
+
awaitingWorkspaceStatus: true,
|
|
158
|
+
};
|
|
159
|
+
writeConfig(config);
|
|
160
|
+
|
|
161
|
+
const result = loadConfigFile(configFilePath);
|
|
162
|
+
|
|
163
|
+
expect(result.projectUrl).toBeUndefined();
|
|
164
|
+
expect(result.awaitingWorkspaceStatus).toBeUndefined();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should ignore non-number values for number fields', () => {
|
|
168
|
+
const config = {
|
|
169
|
+
maximumPreparingIssuesCount: 'abc',
|
|
170
|
+
allowIssueCacheMinutes: 'def',
|
|
171
|
+
thresholdForAutoReject: 'ghi',
|
|
172
|
+
};
|
|
173
|
+
writeConfig(config);
|
|
174
|
+
|
|
175
|
+
const result = loadConfigFile(configFilePath);
|
|
176
|
+
|
|
177
|
+
expect(result.maximumPreparingIssuesCount).toBeUndefined();
|
|
178
|
+
expect(result.allowIssueCacheMinutes).toBeUndefined();
|
|
179
|
+
expect(result.thresholdForAutoReject).toBeUndefined();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should return empty config for array YAML', () => {
|
|
183
|
+
fs.writeFileSync(configFilePath, '- item1\n- item2\n');
|
|
184
|
+
|
|
185
|
+
const result = loadConfigFile(configFilePath);
|
|
186
|
+
|
|
187
|
+
expect(result).toEqual({});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should exit with error when config file does not exist', () => {
|
|
191
|
+
const nonExistentPath = path.join(tmpDir, 'nonexistent-config.yml');
|
|
192
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
193
|
+
const processExitSpy = jest
|
|
194
|
+
.spyOn(process, 'exit')
|
|
195
|
+
.mockImplementation(() => {
|
|
196
|
+
throw new Error('process.exit called');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(() => loadConfigFile(nonExistentPath)).toThrow(
|
|
200
|
+
'process.exit called',
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
204
|
+
expect.stringContaining(
|
|
205
|
+
`Failed to load configuration file "${nonExistentPath}"`,
|
|
206
|
+
),
|
|
207
|
+
);
|
|
208
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
209
|
+
|
|
210
|
+
consoleErrorSpy.mockRestore();
|
|
211
|
+
processExitSpy.mockRestore();
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('parseProjectReadmeConfig', () => {
|
|
216
|
+
it('should parse YAML from details/summary config section', () => {
|
|
217
|
+
const readme = `# Project
|
|
218
|
+
Some description
|
|
219
|
+
<details>
|
|
220
|
+
<summary>config</summary>
|
|
221
|
+
awaitingWorkspaceStatus: 'Custom Awaiting'
|
|
222
|
+
preparationStatus: 'Custom Preparing'
|
|
223
|
+
defaultAgentName: 'readme-agent'
|
|
224
|
+
</details>`;
|
|
225
|
+
|
|
226
|
+
const result = parseProjectReadmeConfig(readme);
|
|
227
|
+
|
|
228
|
+
expect(result.awaitingWorkspaceStatus).toBe('Custom Awaiting');
|
|
229
|
+
expect(result.preparationStatus).toBe('Custom Preparing');
|
|
230
|
+
expect(result.defaultAgentName).toBe('readme-agent');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('should return empty config when no details/summary section exists', () => {
|
|
234
|
+
const readme = '# Project\nSome description without config section';
|
|
235
|
+
|
|
236
|
+
const result = parseProjectReadmeConfig(readme);
|
|
237
|
+
|
|
238
|
+
expect(result).toEqual({});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('should return empty config when details section has empty content', () => {
|
|
242
|
+
const readme = '<details>\n<summary>config</summary>\n</details>';
|
|
243
|
+
|
|
244
|
+
const result = parseProjectReadmeConfig(readme);
|
|
245
|
+
|
|
246
|
+
expect(result).toEqual({});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('should return empty config when YAML content is not a record', () => {
|
|
250
|
+
const readme =
|
|
251
|
+
'<details>\n<summary>config</summary>\n- item1\n- item2\n</details>';
|
|
252
|
+
|
|
253
|
+
const result = parseProjectReadmeConfig(readme);
|
|
254
|
+
|
|
255
|
+
expect(result).toEqual({});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('should handle invalid YAML gracefully', () => {
|
|
259
|
+
const readme =
|
|
260
|
+
'<details>\n<summary>config</summary>\ninvalid: [unclosed\n</details>';
|
|
261
|
+
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
|
262
|
+
|
|
263
|
+
const result = parseProjectReadmeConfig(readme);
|
|
264
|
+
|
|
265
|
+
expect(result).toEqual({});
|
|
266
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
267
|
+
'Failed to parse YAML from project README config section',
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
consoleWarnSpy.mockRestore();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('should parse number fields from README config', () => {
|
|
274
|
+
const readme = `<details>
|
|
275
|
+
<summary>config</summary>
|
|
276
|
+
maximumPreparingIssuesCount: 15
|
|
277
|
+
allowIssueCacheMinutes: 10
|
|
278
|
+
thresholdForAutoReject: 5
|
|
279
|
+
</details>`;
|
|
280
|
+
|
|
281
|
+
const result = parseProjectReadmeConfig(readme);
|
|
282
|
+
|
|
283
|
+
expect(result.maximumPreparingIssuesCount).toBe(15);
|
|
284
|
+
expect(result.allowIssueCacheMinutes).toBe(10);
|
|
285
|
+
expect(result.thresholdForAutoReject).toBe(5);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('should be case-insensitive for the summary tag', () => {
|
|
289
|
+
const readme = `<details>
|
|
290
|
+
<SUMMARY>config</SUMMARY>
|
|
291
|
+
defaultAgentName: 'case-test-agent'
|
|
292
|
+
</details>`;
|
|
293
|
+
|
|
294
|
+
const result = parseProjectReadmeConfig(readme);
|
|
295
|
+
|
|
296
|
+
expect(result.defaultAgentName).toBe('case-test-agent');
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe('mergeConfigs', () => {
|
|
301
|
+
it('should use configFile values when no overrides', () => {
|
|
302
|
+
const configFile = {
|
|
303
|
+
projectUrl: 'https://github.com/config/project',
|
|
304
|
+
defaultAgentName: 'config-agent',
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const result = mergeConfigs(configFile, {}, {});
|
|
308
|
+
|
|
309
|
+
expect(result.projectUrl).toBe('https://github.com/config/project');
|
|
310
|
+
expect(result.defaultAgentName).toBe('config-agent');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('should use CLI overrides over configFile', () => {
|
|
314
|
+
const configFile = {
|
|
315
|
+
projectUrl: 'https://github.com/config/project',
|
|
316
|
+
defaultAgentName: 'config-agent',
|
|
317
|
+
};
|
|
318
|
+
const cliOverrides = {
|
|
319
|
+
defaultAgentName: 'cli-agent',
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const result = mergeConfigs(configFile, cliOverrides, {});
|
|
323
|
+
|
|
324
|
+
expect(result.projectUrl).toBe('https://github.com/config/project');
|
|
325
|
+
expect(result.defaultAgentName).toBe('cli-agent');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('should use README overrides over both CLI and configFile', () => {
|
|
329
|
+
const configFile = {
|
|
330
|
+
projectUrl: 'https://github.com/config/project',
|
|
331
|
+
defaultAgentName: 'config-agent',
|
|
332
|
+
};
|
|
333
|
+
const cliOverrides = {
|
|
334
|
+
defaultAgentName: 'cli-agent',
|
|
335
|
+
};
|
|
336
|
+
const readmeOverrides = {
|
|
337
|
+
defaultAgentName: 'readme-agent',
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const result = mergeConfigs(configFile, cliOverrides, readmeOverrides);
|
|
341
|
+
|
|
342
|
+
expect(result.projectUrl).toBe('https://github.com/config/project');
|
|
343
|
+
expect(result.defaultAgentName).toBe('readme-agent');
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('should preserve projectName from configFile', () => {
|
|
347
|
+
const configFile = {
|
|
348
|
+
projectName: 'my-project',
|
|
349
|
+
projectUrl: 'https://github.com/config/project',
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const result = mergeConfigs(configFile, {}, {});
|
|
353
|
+
|
|
354
|
+
expect(result.projectName).toBe('my-project');
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
describe('fetchProjectReadme', () => {
|
|
359
|
+
it('should fetch README from GitHub GraphQL API', async () => {
|
|
360
|
+
const responseBody = {
|
|
361
|
+
data: {
|
|
362
|
+
organization: {
|
|
363
|
+
projectV2: { readme: '# Project README' },
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
jest
|
|
368
|
+
.spyOn(global, 'fetch')
|
|
369
|
+
.mockResolvedValue(
|
|
370
|
+
new Response(JSON.stringify(responseBody), { status: 200 }),
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
const result = await fetchProjectReadme(
|
|
374
|
+
'https://github.com/orgs/test-org/projects/1',
|
|
375
|
+
'test-token',
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
expect(result).toBe('# Project README');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('should try user if organization readme is null', async () => {
|
|
382
|
+
const responseBody = {
|
|
383
|
+
data: {
|
|
384
|
+
organization: { projectV2: { readme: null } },
|
|
385
|
+
user: { projectV2: { readme: '# User Project README' } },
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
jest
|
|
389
|
+
.spyOn(global, 'fetch')
|
|
390
|
+
.mockResolvedValue(
|
|
391
|
+
new Response(JSON.stringify(responseBody), { status: 200 }),
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
const result = await fetchProjectReadme(
|
|
395
|
+
'https://github.com/users/test-user/projects/1',
|
|
396
|
+
'test-token',
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
expect(result).toBe('# User Project README');
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('should return null and warn on failure', async () => {
|
|
403
|
+
jest.spyOn(global, 'fetch').mockRejectedValue(new Error('Network error'));
|
|
404
|
+
|
|
405
|
+
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
|
406
|
+
|
|
407
|
+
const result = await fetchProjectReadme(
|
|
408
|
+
'https://github.com/orgs/test/projects/1',
|
|
409
|
+
'test-token',
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
expect(result).toBeNull();
|
|
413
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
414
|
+
'Failed to fetch project README',
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
consoleWarnSpy.mockRestore();
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
describe('startDaemon', () => {
|
|
422
|
+
it('should read parameters from config file', async () => {
|
|
423
|
+
const mockRun = jest.fn().mockResolvedValue(undefined);
|
|
424
|
+
const MockedStartPreparationUseCase = jest.mocked(
|
|
425
|
+
StartPreparationUseCase,
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
MockedStartPreparationUseCase.mockImplementation(function (
|
|
429
|
+
this: StartPreparationUseCase,
|
|
430
|
+
) {
|
|
431
|
+
this.run = mockRun;
|
|
432
|
+
return this;
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
await program.parseAsync([
|
|
436
|
+
'node',
|
|
437
|
+
'test',
|
|
438
|
+
'startDaemon',
|
|
439
|
+
'--configFilePath',
|
|
440
|
+
configFilePath,
|
|
441
|
+
]);
|
|
442
|
+
|
|
443
|
+
expect(mockRun).toHaveBeenCalledTimes(1);
|
|
444
|
+
expect(mockRun).toHaveBeenCalledWith({
|
|
445
|
+
projectUrl: 'https://github.com/orgs/test/projects/1',
|
|
446
|
+
awaitingWorkspaceStatus: 'Awaiting',
|
|
447
|
+
preparationStatus: 'Preparing',
|
|
448
|
+
defaultAgentName: 'agent1',
|
|
449
|
+
logFilePath: undefined,
|
|
450
|
+
maximumPreparingIssuesCount: null,
|
|
451
|
+
allowIssueCacheMinutes: 0,
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('should allow CLI args to override config file values', async () => {
|
|
456
|
+
const mockRun = jest.fn().mockResolvedValue(undefined);
|
|
457
|
+
const MockedStartPreparationUseCase = jest.mocked(
|
|
458
|
+
StartPreparationUseCase,
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
MockedStartPreparationUseCase.mockImplementation(function (
|
|
462
|
+
this: StartPreparationUseCase,
|
|
463
|
+
) {
|
|
464
|
+
this.run = mockRun;
|
|
465
|
+
return this;
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
await program.parseAsync([
|
|
469
|
+
'node',
|
|
470
|
+
'test',
|
|
471
|
+
'startDaemon',
|
|
472
|
+
'--configFilePath',
|
|
473
|
+
configFilePath,
|
|
474
|
+
'--projectUrl',
|
|
475
|
+
'https://github.com/orgs/override/projects/2',
|
|
476
|
+
'--defaultAgentName',
|
|
477
|
+
'override-agent',
|
|
478
|
+
]);
|
|
479
|
+
|
|
480
|
+
expect(mockRun).toHaveBeenCalledTimes(1);
|
|
481
|
+
expect(mockRun).toHaveBeenCalledWith({
|
|
482
|
+
projectUrl: 'https://github.com/orgs/override/projects/2',
|
|
483
|
+
awaitingWorkspaceStatus: 'Awaiting',
|
|
484
|
+
preparationStatus: 'Preparing',
|
|
485
|
+
defaultAgentName: 'override-agent',
|
|
486
|
+
logFilePath: undefined,
|
|
487
|
+
maximumPreparingIssuesCount: null,
|
|
488
|
+
allowIssueCacheMinutes: 0,
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('should pass logFilePath from config file', async () => {
|
|
493
|
+
const configWithLog = {
|
|
494
|
+
...defaultConfig,
|
|
495
|
+
logFilePath: '/path/to/log.txt',
|
|
496
|
+
};
|
|
497
|
+
writeConfig(configWithLog);
|
|
498
|
+
|
|
499
|
+
const mockRun = jest.fn().mockResolvedValue(undefined);
|
|
500
|
+
const MockedStartPreparationUseCase = jest.mocked(
|
|
501
|
+
StartPreparationUseCase,
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
MockedStartPreparationUseCase.mockImplementation(function (
|
|
505
|
+
this: StartPreparationUseCase,
|
|
506
|
+
) {
|
|
507
|
+
this.run = mockRun;
|
|
508
|
+
return this;
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
await program.parseAsync([
|
|
512
|
+
'node',
|
|
513
|
+
'test',
|
|
514
|
+
'startDaemon',
|
|
515
|
+
'--configFilePath',
|
|
516
|
+
configFilePath,
|
|
517
|
+
]);
|
|
518
|
+
|
|
519
|
+
expect(mockRun).toHaveBeenCalledWith(
|
|
520
|
+
expect.objectContaining({
|
|
521
|
+
logFilePath: '/path/to/log.txt',
|
|
522
|
+
}),
|
|
523
|
+
);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it('should pass maximumPreparingIssuesCount from config file', async () => {
|
|
527
|
+
const configWithCount = {
|
|
528
|
+
...defaultConfig,
|
|
529
|
+
maximumPreparingIssuesCount: 10,
|
|
530
|
+
};
|
|
531
|
+
writeConfig(configWithCount);
|
|
532
|
+
|
|
533
|
+
const mockRun = jest.fn().mockResolvedValue(undefined);
|
|
534
|
+
const MockedStartPreparationUseCase = jest.mocked(
|
|
535
|
+
StartPreparationUseCase,
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
MockedStartPreparationUseCase.mockImplementation(function (
|
|
539
|
+
this: StartPreparationUseCase,
|
|
540
|
+
) {
|
|
541
|
+
this.run = mockRun;
|
|
542
|
+
return this;
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
await program.parseAsync([
|
|
546
|
+
'node',
|
|
547
|
+
'test',
|
|
548
|
+
'startDaemon',
|
|
549
|
+
'--configFilePath',
|
|
550
|
+
configFilePath,
|
|
551
|
+
]);
|
|
552
|
+
|
|
553
|
+
expect(mockRun).toHaveBeenCalledWith(
|
|
554
|
+
expect.objectContaining({
|
|
555
|
+
maximumPreparingIssuesCount: 10,
|
|
556
|
+
}),
|
|
557
|
+
);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('should pass maximumPreparingIssuesCount from CLI overriding config', async () => {
|
|
561
|
+
const mockRun = jest.fn().mockResolvedValue(undefined);
|
|
562
|
+
const MockedStartPreparationUseCase = jest.mocked(
|
|
563
|
+
StartPreparationUseCase,
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
MockedStartPreparationUseCase.mockImplementation(function (
|
|
567
|
+
this: StartPreparationUseCase,
|
|
568
|
+
) {
|
|
569
|
+
this.run = mockRun;
|
|
570
|
+
return this;
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
await program.parseAsync([
|
|
574
|
+
'node',
|
|
575
|
+
'test',
|
|
576
|
+
'startDaemon',
|
|
577
|
+
'--configFilePath',
|
|
578
|
+
configFilePath,
|
|
579
|
+
'--maximumPreparingIssuesCount',
|
|
580
|
+
'20',
|
|
581
|
+
]);
|
|
582
|
+
|
|
583
|
+
expect(mockRun).toHaveBeenCalledWith(
|
|
584
|
+
expect.objectContaining({
|
|
585
|
+
maximumPreparingIssuesCount: 20,
|
|
586
|
+
}),
|
|
587
|
+
);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it('should exit with error for non-numeric maximumPreparingIssuesCount', async () => {
|
|
591
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
592
|
+
const processExitSpy = jest
|
|
593
|
+
.spyOn(process, 'exit')
|
|
594
|
+
.mockImplementation(() => {
|
|
595
|
+
throw new Error('process.exit called');
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
await expect(
|
|
599
|
+
program.parseAsync([
|
|
600
|
+
'node',
|
|
601
|
+
'test',
|
|
602
|
+
'startDaemon',
|
|
603
|
+
'--configFilePath',
|
|
604
|
+
configFilePath,
|
|
605
|
+
'--maximumPreparingIssuesCount',
|
|
606
|
+
'abc',
|
|
607
|
+
]),
|
|
608
|
+
).rejects.toThrow('process.exit called');
|
|
609
|
+
|
|
610
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
611
|
+
'Invalid value for --maximumPreparingIssuesCount. It must be a positive integer.',
|
|
612
|
+
);
|
|
613
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
614
|
+
|
|
615
|
+
consoleErrorSpy.mockRestore();
|
|
616
|
+
processExitSpy.mockRestore();
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
it('should exit with error for negative maximumPreparingIssuesCount', async () => {
|
|
620
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
621
|
+
const processExitSpy = jest
|
|
622
|
+
.spyOn(process, 'exit')
|
|
623
|
+
.mockImplementation(() => {
|
|
624
|
+
throw new Error('process.exit called');
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
await expect(
|
|
628
|
+
program.parseAsync([
|
|
629
|
+
'node',
|
|
630
|
+
'test',
|
|
631
|
+
'startDaemon',
|
|
632
|
+
'--configFilePath',
|
|
633
|
+
configFilePath,
|
|
634
|
+
'--maximumPreparingIssuesCount',
|
|
635
|
+
'-5',
|
|
636
|
+
]),
|
|
637
|
+
).rejects.toThrow('process.exit called');
|
|
638
|
+
|
|
639
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
640
|
+
'Invalid value for --maximumPreparingIssuesCount. It must be a positive integer.',
|
|
641
|
+
);
|
|
642
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
643
|
+
|
|
644
|
+
consoleErrorSpy.mockRestore();
|
|
645
|
+
processExitSpy.mockRestore();
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it('should exit with error when GH_TOKEN is missing', async () => {
|
|
649
|
+
delete process.env.GH_TOKEN;
|
|
650
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
651
|
+
const processExitSpy = jest
|
|
652
|
+
.spyOn(process, 'exit')
|
|
653
|
+
.mockImplementation(() => {
|
|
654
|
+
throw new Error('process.exit called');
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
await expect(
|
|
658
|
+
program.parseAsync([
|
|
659
|
+
'node',
|
|
660
|
+
'test',
|
|
661
|
+
'startDaemon',
|
|
662
|
+
'--configFilePath',
|
|
663
|
+
configFilePath,
|
|
664
|
+
]),
|
|
665
|
+
).rejects.toThrow('process.exit called');
|
|
666
|
+
|
|
667
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
668
|
+
'GH_TOKEN environment variable is required',
|
|
669
|
+
);
|
|
670
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
671
|
+
|
|
672
|
+
consoleErrorSpy.mockRestore();
|
|
673
|
+
processExitSpy.mockRestore();
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it('should exit with error when projectUrl is missing from both CLI and config', async () => {
|
|
677
|
+
const configWithoutProjectUrl = {
|
|
678
|
+
awaitingWorkspaceStatus: 'Awaiting',
|
|
679
|
+
preparationStatus: 'Preparing',
|
|
680
|
+
defaultAgentName: 'agent1',
|
|
681
|
+
};
|
|
682
|
+
writeConfig(configWithoutProjectUrl);
|
|
683
|
+
|
|
684
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
685
|
+
const processExitSpy = jest
|
|
686
|
+
.spyOn(process, 'exit')
|
|
687
|
+
.mockImplementation(() => {
|
|
688
|
+
throw new Error('process.exit called');
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
await expect(
|
|
692
|
+
program.parseAsync([
|
|
693
|
+
'node',
|
|
694
|
+
'test',
|
|
695
|
+
'startDaemon',
|
|
696
|
+
'--configFilePath',
|
|
697
|
+
configFilePath,
|
|
698
|
+
]),
|
|
699
|
+
).rejects.toThrow('process.exit called');
|
|
700
|
+
|
|
701
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
702
|
+
'projectUrl is required. Provide via --projectUrl, config file, or project README.',
|
|
703
|
+
);
|
|
704
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
705
|
+
|
|
706
|
+
consoleErrorSpy.mockRestore();
|
|
707
|
+
processExitSpy.mockRestore();
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
it('should exit with error when awaitingWorkspaceStatus is missing', async () => {
|
|
711
|
+
const configMissing = {
|
|
712
|
+
projectUrl: 'https://github.com/orgs/test/projects/1',
|
|
713
|
+
preparationStatus: 'Preparing',
|
|
714
|
+
defaultAgentName: 'agent1',
|
|
715
|
+
};
|
|
716
|
+
writeConfig(configMissing);
|
|
717
|
+
|
|
718
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
719
|
+
const processExitSpy = jest
|
|
720
|
+
.spyOn(process, 'exit')
|
|
721
|
+
.mockImplementation(() => {
|
|
722
|
+
throw new Error('process.exit called');
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
await expect(
|
|
726
|
+
program.parseAsync([
|
|
727
|
+
'node',
|
|
728
|
+
'test',
|
|
729
|
+
'startDaemon',
|
|
730
|
+
'--configFilePath',
|
|
731
|
+
configFilePath,
|
|
732
|
+
]),
|
|
733
|
+
).rejects.toThrow('process.exit called');
|
|
734
|
+
|
|
735
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
736
|
+
'awaitingWorkspaceStatus is required. Provide via --awaitingWorkspaceStatus, config file, or project README.',
|
|
737
|
+
);
|
|
738
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
739
|
+
|
|
740
|
+
consoleErrorSpy.mockRestore();
|
|
741
|
+
processExitSpy.mockRestore();
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
it('should exit with error when preparationStatus is missing', async () => {
|
|
745
|
+
const configMissing = {
|
|
746
|
+
projectUrl: 'https://github.com/orgs/test/projects/1',
|
|
747
|
+
awaitingWorkspaceStatus: 'Awaiting',
|
|
748
|
+
defaultAgentName: 'agent1',
|
|
749
|
+
};
|
|
750
|
+
writeConfig(configMissing);
|
|
751
|
+
|
|
752
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
753
|
+
const processExitSpy = jest
|
|
754
|
+
.spyOn(process, 'exit')
|
|
755
|
+
.mockImplementation(() => {
|
|
756
|
+
throw new Error('process.exit called');
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
await expect(
|
|
760
|
+
program.parseAsync([
|
|
761
|
+
'node',
|
|
762
|
+
'test',
|
|
763
|
+
'startDaemon',
|
|
764
|
+
'--configFilePath',
|
|
765
|
+
configFilePath,
|
|
766
|
+
]),
|
|
767
|
+
).rejects.toThrow('process.exit called');
|
|
768
|
+
|
|
769
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
770
|
+
'preparationStatus is required. Provide via --preparationStatus, config file, or project README.',
|
|
771
|
+
);
|
|
772
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
773
|
+
|
|
774
|
+
consoleErrorSpy.mockRestore();
|
|
775
|
+
processExitSpy.mockRestore();
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it('should exit with error when defaultAgentName is missing', async () => {
|
|
779
|
+
const configMissing = {
|
|
780
|
+
projectUrl: 'https://github.com/orgs/test/projects/1',
|
|
781
|
+
awaitingWorkspaceStatus: 'Awaiting',
|
|
782
|
+
preparationStatus: 'Preparing',
|
|
783
|
+
};
|
|
784
|
+
writeConfig(configMissing);
|
|
785
|
+
|
|
786
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
787
|
+
const processExitSpy = jest
|
|
788
|
+
.spyOn(process, 'exit')
|
|
789
|
+
.mockImplementation(() => {
|
|
790
|
+
throw new Error('process.exit called');
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
await expect(
|
|
794
|
+
program.parseAsync([
|
|
795
|
+
'node',
|
|
796
|
+
'test',
|
|
797
|
+
'startDaemon',
|
|
798
|
+
'--configFilePath',
|
|
799
|
+
configFilePath,
|
|
800
|
+
]),
|
|
801
|
+
).rejects.toThrow('process.exit called');
|
|
802
|
+
|
|
803
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
804
|
+
'defaultAgentName is required. Provide via --defaultAgentName, config file, or project README.',
|
|
805
|
+
);
|
|
806
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
807
|
+
|
|
808
|
+
consoleErrorSpy.mockRestore();
|
|
809
|
+
processExitSpy.mockRestore();
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
it('should log maximumPreparingIssuesCount before calling useCase.run', async () => {
|
|
813
|
+
const configWithValues = {
|
|
814
|
+
...defaultConfig,
|
|
815
|
+
maximumPreparingIssuesCount: 10,
|
|
816
|
+
};
|
|
817
|
+
writeConfig(configWithValues);
|
|
818
|
+
|
|
819
|
+
const callOrder: string[] = [];
|
|
820
|
+
const consoleLogSpy = jest
|
|
821
|
+
.spyOn(console, 'log')
|
|
822
|
+
.mockImplementation(() => {
|
|
823
|
+
callOrder.push('console.log');
|
|
824
|
+
});
|
|
825
|
+
const mockRun = jest.fn().mockImplementation(() => {
|
|
826
|
+
callOrder.push('useCase.run');
|
|
827
|
+
return Promise.resolve(undefined);
|
|
828
|
+
});
|
|
829
|
+
const MockedStartPreparationUseCase = jest.mocked(
|
|
830
|
+
StartPreparationUseCase,
|
|
831
|
+
);
|
|
832
|
+
|
|
833
|
+
MockedStartPreparationUseCase.mockImplementation(function (
|
|
834
|
+
this: StartPreparationUseCase,
|
|
835
|
+
) {
|
|
836
|
+
this.run = mockRun;
|
|
837
|
+
return this;
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
await program.parseAsync([
|
|
841
|
+
'node',
|
|
842
|
+
'test',
|
|
843
|
+
'startDaemon',
|
|
844
|
+
'--configFilePath',
|
|
845
|
+
configFilePath,
|
|
846
|
+
]);
|
|
847
|
+
|
|
848
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
849
|
+
'maximumPreparingIssuesCount: 10',
|
|
850
|
+
);
|
|
851
|
+
expect(mockRun).toHaveBeenCalledTimes(1);
|
|
852
|
+
const logIndex = callOrder.indexOf('console.log');
|
|
853
|
+
const runIndex = callOrder.indexOf('useCase.run');
|
|
854
|
+
expect(logIndex).toBeLessThan(runIndex);
|
|
855
|
+
|
|
856
|
+
consoleLogSpy.mockRestore();
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
it('should apply README config overrides', async () => {
|
|
860
|
+
const readmeContent = [
|
|
861
|
+
'# Project',
|
|
862
|
+
'<details>',
|
|
863
|
+
'<summary>config</summary>',
|
|
864
|
+
'defaultAgentName: readme-agent',
|
|
865
|
+
'</details>',
|
|
866
|
+
].join('\n');
|
|
867
|
+
mockFetchReturningReadme(readmeContent);
|
|
868
|
+
|
|
869
|
+
const mockRun = jest.fn().mockResolvedValue(undefined);
|
|
870
|
+
const MockedStartPreparationUseCase = jest.mocked(
|
|
871
|
+
StartPreparationUseCase,
|
|
872
|
+
);
|
|
873
|
+
|
|
874
|
+
MockedStartPreparationUseCase.mockImplementation(function (
|
|
875
|
+
this: StartPreparationUseCase,
|
|
876
|
+
) {
|
|
877
|
+
this.run = mockRun;
|
|
878
|
+
return this;
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
await program.parseAsync([
|
|
882
|
+
'node',
|
|
883
|
+
'test',
|
|
884
|
+
'startDaemon',
|
|
885
|
+
'--configFilePath',
|
|
886
|
+
configFilePath,
|
|
887
|
+
]);
|
|
888
|
+
|
|
889
|
+
expect(mockRun).toHaveBeenCalledWith(
|
|
890
|
+
expect.objectContaining({
|
|
891
|
+
defaultAgentName: 'readme-agent',
|
|
892
|
+
}),
|
|
893
|
+
);
|
|
894
|
+
});
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
describe('notifyFinishedIssuePreparation', () => {
|
|
898
|
+
it('should read parameters from config file', async () => {
|
|
899
|
+
const mockRun = jest.fn().mockResolvedValue(undefined);
|
|
900
|
+
const MockedNotifyFinishedUseCase = jest.mocked(
|
|
901
|
+
NotifyFinishedIssuePreparationUseCase,
|
|
902
|
+
);
|
|
903
|
+
|
|
904
|
+
MockedNotifyFinishedUseCase.mockImplementation(function (
|
|
905
|
+
this: NotifyFinishedIssuePreparationUseCase,
|
|
906
|
+
) {
|
|
907
|
+
this.run = mockRun;
|
|
908
|
+
return this;
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
await program.parseAsync([
|
|
912
|
+
'node',
|
|
913
|
+
'test',
|
|
914
|
+
'notifyFinishedIssuePreparation',
|
|
915
|
+
'--configFilePath',
|
|
916
|
+
configFilePath,
|
|
917
|
+
'--issueUrl',
|
|
918
|
+
'https://github.com/test/repo/issues/1',
|
|
919
|
+
]);
|
|
920
|
+
|
|
921
|
+
expect(mockRun).toHaveBeenCalledTimes(1);
|
|
922
|
+
expect(mockRun).toHaveBeenCalledWith({
|
|
923
|
+
projectUrl: 'https://github.com/orgs/test/projects/1',
|
|
924
|
+
issueUrl: 'https://github.com/test/repo/issues/1',
|
|
925
|
+
preparationStatus: 'Preparing',
|
|
926
|
+
awaitingWorkspaceStatus: 'Awaiting',
|
|
927
|
+
awaitingQualityCheckStatus: 'Awaiting QC',
|
|
928
|
+
thresholdForAutoReject: 3,
|
|
929
|
+
workflowBlockerResolvedWebhookUrl: null,
|
|
930
|
+
});
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
it('should allow CLI args to override config file values', async () => {
|
|
934
|
+
const mockRun = jest.fn().mockResolvedValue(undefined);
|
|
935
|
+
const MockedNotifyFinishedUseCase = jest.mocked(
|
|
936
|
+
NotifyFinishedIssuePreparationUseCase,
|
|
937
|
+
);
|
|
938
|
+
|
|
939
|
+
MockedNotifyFinishedUseCase.mockImplementation(function (
|
|
940
|
+
this: NotifyFinishedIssuePreparationUseCase,
|
|
941
|
+
) {
|
|
942
|
+
this.run = mockRun;
|
|
943
|
+
return this;
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
await program.parseAsync([
|
|
947
|
+
'node',
|
|
948
|
+
'test',
|
|
949
|
+
'notifyFinishedIssuePreparation',
|
|
950
|
+
'--configFilePath',
|
|
951
|
+
configFilePath,
|
|
952
|
+
'--issueUrl',
|
|
953
|
+
'https://github.com/test/repo/issues/1',
|
|
954
|
+
'--projectUrl',
|
|
955
|
+
'https://github.com/orgs/override/projects/2',
|
|
956
|
+
'--awaitingQualityCheckStatus',
|
|
957
|
+
'Override QC',
|
|
958
|
+
]);
|
|
959
|
+
|
|
960
|
+
expect(mockRun).toHaveBeenCalledWith({
|
|
961
|
+
projectUrl: 'https://github.com/orgs/override/projects/2',
|
|
962
|
+
issueUrl: 'https://github.com/test/repo/issues/1',
|
|
963
|
+
preparationStatus: 'Preparing',
|
|
964
|
+
awaitingWorkspaceStatus: 'Awaiting',
|
|
965
|
+
awaitingQualityCheckStatus: 'Override QC',
|
|
966
|
+
thresholdForAutoReject: 3,
|
|
967
|
+
workflowBlockerResolvedWebhookUrl: null,
|
|
968
|
+
});
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
it('should pass custom thresholdForAutoReject from config file', async () => {
|
|
972
|
+
const configWithThreshold = {
|
|
973
|
+
...defaultConfig,
|
|
974
|
+
thresholdForAutoReject: 5,
|
|
975
|
+
};
|
|
976
|
+
writeConfig(configWithThreshold);
|
|
977
|
+
|
|
978
|
+
const mockRun = jest.fn().mockResolvedValue(undefined);
|
|
979
|
+
const MockedNotifyFinishedUseCase = jest.mocked(
|
|
980
|
+
NotifyFinishedIssuePreparationUseCase,
|
|
981
|
+
);
|
|
982
|
+
|
|
983
|
+
MockedNotifyFinishedUseCase.mockImplementation(function (
|
|
984
|
+
this: NotifyFinishedIssuePreparationUseCase,
|
|
985
|
+
) {
|
|
986
|
+
this.run = mockRun;
|
|
987
|
+
return this;
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
await program.parseAsync([
|
|
991
|
+
'node',
|
|
992
|
+
'test',
|
|
993
|
+
'notifyFinishedIssuePreparation',
|
|
994
|
+
'--configFilePath',
|
|
995
|
+
configFilePath,
|
|
996
|
+
'--issueUrl',
|
|
997
|
+
'https://github.com/test/repo/issues/1',
|
|
998
|
+
]);
|
|
999
|
+
|
|
1000
|
+
expect(mockRun).toHaveBeenCalledWith(
|
|
1001
|
+
expect.objectContaining({
|
|
1002
|
+
thresholdForAutoReject: 5,
|
|
1003
|
+
}),
|
|
1004
|
+
);
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
it('should pass custom thresholdForAutoReject from CLI overriding config', async () => {
|
|
1008
|
+
const mockRun = jest.fn().mockResolvedValue(undefined);
|
|
1009
|
+
const MockedNotifyFinishedUseCase = jest.mocked(
|
|
1010
|
+
NotifyFinishedIssuePreparationUseCase,
|
|
1011
|
+
);
|
|
1012
|
+
|
|
1013
|
+
MockedNotifyFinishedUseCase.mockImplementation(function (
|
|
1014
|
+
this: NotifyFinishedIssuePreparationUseCase,
|
|
1015
|
+
) {
|
|
1016
|
+
this.run = mockRun;
|
|
1017
|
+
return this;
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
await program.parseAsync([
|
|
1021
|
+
'node',
|
|
1022
|
+
'test',
|
|
1023
|
+
'notifyFinishedIssuePreparation',
|
|
1024
|
+
'--configFilePath',
|
|
1025
|
+
configFilePath,
|
|
1026
|
+
'--issueUrl',
|
|
1027
|
+
'https://github.com/test/repo/issues/1',
|
|
1028
|
+
'--thresholdForAutoReject',
|
|
1029
|
+
'7',
|
|
1030
|
+
]);
|
|
1031
|
+
|
|
1032
|
+
expect(mockRun).toHaveBeenCalledWith(
|
|
1033
|
+
expect.objectContaining({
|
|
1034
|
+
thresholdForAutoReject: 7,
|
|
1035
|
+
}),
|
|
1036
|
+
);
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
it('should exit with error for invalid thresholdForAutoReject', async () => {
|
|
1040
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
1041
|
+
const processExitSpy = jest
|
|
1042
|
+
.spyOn(process, 'exit')
|
|
1043
|
+
.mockImplementation(() => {
|
|
1044
|
+
throw new Error('process.exit called');
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
await expect(
|
|
1048
|
+
program.parseAsync([
|
|
1049
|
+
'node',
|
|
1050
|
+
'test',
|
|
1051
|
+
'notifyFinishedIssuePreparation',
|
|
1052
|
+
'--configFilePath',
|
|
1053
|
+
configFilePath,
|
|
1054
|
+
'--issueUrl',
|
|
1055
|
+
'https://github.com/test/repo/issues/1',
|
|
1056
|
+
'--thresholdForAutoReject',
|
|
1057
|
+
'abc',
|
|
1058
|
+
]),
|
|
1059
|
+
).rejects.toThrow('process.exit called');
|
|
1060
|
+
|
|
1061
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
1062
|
+
'Invalid value for --thresholdForAutoReject. It must be a positive integer.',
|
|
1063
|
+
);
|
|
1064
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
1065
|
+
|
|
1066
|
+
consoleErrorSpy.mockRestore();
|
|
1067
|
+
processExitSpy.mockRestore();
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
it('should exit with error when GH_TOKEN is missing', async () => {
|
|
1071
|
+
delete process.env.GH_TOKEN;
|
|
1072
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
1073
|
+
const processExitSpy = jest
|
|
1074
|
+
.spyOn(process, 'exit')
|
|
1075
|
+
.mockImplementation(() => {
|
|
1076
|
+
throw new Error('process.exit called');
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
await expect(
|
|
1080
|
+
program.parseAsync([
|
|
1081
|
+
'node',
|
|
1082
|
+
'test',
|
|
1083
|
+
'notifyFinishedIssuePreparation',
|
|
1084
|
+
'--configFilePath',
|
|
1085
|
+
configFilePath,
|
|
1086
|
+
'--issueUrl',
|
|
1087
|
+
'https://github.com/test/repo/issues/1',
|
|
1088
|
+
]),
|
|
1089
|
+
).rejects.toThrow('process.exit called');
|
|
1090
|
+
|
|
1091
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
1092
|
+
'GH_TOKEN environment variable is required',
|
|
1093
|
+
);
|
|
1094
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
1095
|
+
|
|
1096
|
+
consoleErrorSpy.mockRestore();
|
|
1097
|
+
processExitSpy.mockRestore();
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
it('should exit with error when projectUrl is missing', async () => {
|
|
1101
|
+
const configMissing = {
|
|
1102
|
+
preparationStatus: 'Preparing',
|
|
1103
|
+
awaitingWorkspaceStatus: 'Awaiting',
|
|
1104
|
+
awaitingQualityCheckStatus: 'Awaiting QC',
|
|
1105
|
+
};
|
|
1106
|
+
writeConfig(configMissing);
|
|
1107
|
+
|
|
1108
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
1109
|
+
const processExitSpy = jest
|
|
1110
|
+
.spyOn(process, 'exit')
|
|
1111
|
+
.mockImplementation(() => {
|
|
1112
|
+
throw new Error('process.exit called');
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
await expect(
|
|
1116
|
+
program.parseAsync([
|
|
1117
|
+
'node',
|
|
1118
|
+
'test',
|
|
1119
|
+
'notifyFinishedIssuePreparation',
|
|
1120
|
+
'--configFilePath',
|
|
1121
|
+
configFilePath,
|
|
1122
|
+
'--issueUrl',
|
|
1123
|
+
'https://github.com/test/repo/issues/1',
|
|
1124
|
+
]),
|
|
1125
|
+
).rejects.toThrow('process.exit called');
|
|
1126
|
+
|
|
1127
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
1128
|
+
'projectUrl is required. Provide via --projectUrl, config file, or project README.',
|
|
1129
|
+
);
|
|
1130
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
1131
|
+
|
|
1132
|
+
consoleErrorSpy.mockRestore();
|
|
1133
|
+
processExitSpy.mockRestore();
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
it('should exit with error when preparationStatus is missing', async () => {
|
|
1137
|
+
const configMissing = {
|
|
1138
|
+
projectUrl: 'https://github.com/orgs/test/projects/1',
|
|
1139
|
+
awaitingWorkspaceStatus: 'Awaiting',
|
|
1140
|
+
awaitingQualityCheckStatus: 'Awaiting QC',
|
|
1141
|
+
};
|
|
1142
|
+
writeConfig(configMissing);
|
|
1143
|
+
|
|
1144
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
1145
|
+
const processExitSpy = jest
|
|
1146
|
+
.spyOn(process, 'exit')
|
|
1147
|
+
.mockImplementation(() => {
|
|
1148
|
+
throw new Error('process.exit called');
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
await expect(
|
|
1152
|
+
program.parseAsync([
|
|
1153
|
+
'node',
|
|
1154
|
+
'test',
|
|
1155
|
+
'notifyFinishedIssuePreparation',
|
|
1156
|
+
'--configFilePath',
|
|
1157
|
+
configFilePath,
|
|
1158
|
+
'--issueUrl',
|
|
1159
|
+
'https://github.com/test/repo/issues/1',
|
|
1160
|
+
]),
|
|
1161
|
+
).rejects.toThrow('process.exit called');
|
|
1162
|
+
|
|
1163
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
1164
|
+
'preparationStatus is required. Provide via --preparationStatus, config file, or project README.',
|
|
1165
|
+
);
|
|
1166
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
1167
|
+
|
|
1168
|
+
consoleErrorSpy.mockRestore();
|
|
1169
|
+
processExitSpy.mockRestore();
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
it('should exit with error when awaitingWorkspaceStatus is missing', async () => {
|
|
1173
|
+
const configMissing = {
|
|
1174
|
+
projectUrl: 'https://github.com/orgs/test/projects/1',
|
|
1175
|
+
preparationStatus: 'Preparing',
|
|
1176
|
+
awaitingQualityCheckStatus: 'Awaiting QC',
|
|
1177
|
+
};
|
|
1178
|
+
writeConfig(configMissing);
|
|
1179
|
+
|
|
1180
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
1181
|
+
const processExitSpy = jest
|
|
1182
|
+
.spyOn(process, 'exit')
|
|
1183
|
+
.mockImplementation(() => {
|
|
1184
|
+
throw new Error('process.exit called');
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
await expect(
|
|
1188
|
+
program.parseAsync([
|
|
1189
|
+
'node',
|
|
1190
|
+
'test',
|
|
1191
|
+
'notifyFinishedIssuePreparation',
|
|
1192
|
+
'--configFilePath',
|
|
1193
|
+
configFilePath,
|
|
1194
|
+
'--issueUrl',
|
|
1195
|
+
'https://github.com/test/repo/issues/1',
|
|
1196
|
+
]),
|
|
1197
|
+
).rejects.toThrow('process.exit called');
|
|
1198
|
+
|
|
1199
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
1200
|
+
'awaitingWorkspaceStatus is required. Provide via --awaitingWorkspaceStatus, config file, or project README.',
|
|
1201
|
+
);
|
|
1202
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
1203
|
+
|
|
1204
|
+
consoleErrorSpy.mockRestore();
|
|
1205
|
+
processExitSpy.mockRestore();
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
it('should exit with error when awaitingQualityCheckStatus is missing', async () => {
|
|
1209
|
+
const configMissing = {
|
|
1210
|
+
projectUrl: 'https://github.com/orgs/test/projects/1',
|
|
1211
|
+
preparationStatus: 'Preparing',
|
|
1212
|
+
awaitingWorkspaceStatus: 'Awaiting',
|
|
1213
|
+
};
|
|
1214
|
+
writeConfig(configMissing);
|
|
1215
|
+
|
|
1216
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
1217
|
+
const processExitSpy = jest
|
|
1218
|
+
.spyOn(process, 'exit')
|
|
1219
|
+
.mockImplementation(() => {
|
|
1220
|
+
throw new Error('process.exit called');
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
await expect(
|
|
1224
|
+
program.parseAsync([
|
|
1225
|
+
'node',
|
|
1226
|
+
'test',
|
|
1227
|
+
'notifyFinishedIssuePreparation',
|
|
1228
|
+
'--configFilePath',
|
|
1229
|
+
configFilePath,
|
|
1230
|
+
'--issueUrl',
|
|
1231
|
+
'https://github.com/test/repo/issues/1',
|
|
1232
|
+
]),
|
|
1233
|
+
).rejects.toThrow('process.exit called');
|
|
1234
|
+
|
|
1235
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
1236
|
+
'awaitingQualityCheckStatus is required. Provide via --awaitingQualityCheckStatus, config file, or project README.',
|
|
1237
|
+
);
|
|
1238
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
1239
|
+
|
|
1240
|
+
consoleErrorSpy.mockRestore();
|
|
1241
|
+
processExitSpy.mockRestore();
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
it('should pass workflowBlockerResolvedWebhookUrl from config file', async () => {
|
|
1245
|
+
const configWithWebhook = {
|
|
1246
|
+
...defaultConfig,
|
|
1247
|
+
workflowBlockerResolvedWebhookUrl:
|
|
1248
|
+
'https://example.com/webhook?url={URL}',
|
|
1249
|
+
};
|
|
1250
|
+
writeConfig(configWithWebhook);
|
|
1251
|
+
|
|
1252
|
+
const mockRun = jest.fn().mockResolvedValue(undefined);
|
|
1253
|
+
const MockedNotifyFinishedUseCase = jest.mocked(
|
|
1254
|
+
NotifyFinishedIssuePreparationUseCase,
|
|
1255
|
+
);
|
|
1256
|
+
|
|
1257
|
+
MockedNotifyFinishedUseCase.mockImplementation(function (
|
|
1258
|
+
this: NotifyFinishedIssuePreparationUseCase,
|
|
1259
|
+
) {
|
|
1260
|
+
this.run = mockRun;
|
|
1261
|
+
return this;
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
await program.parseAsync([
|
|
1265
|
+
'node',
|
|
1266
|
+
'test',
|
|
1267
|
+
'notifyFinishedIssuePreparation',
|
|
1268
|
+
'--configFilePath',
|
|
1269
|
+
configFilePath,
|
|
1270
|
+
'--issueUrl',
|
|
1271
|
+
'https://github.com/test/repo/issues/1',
|
|
1272
|
+
]);
|
|
1273
|
+
|
|
1274
|
+
expect(mockRun).toHaveBeenCalledWith(
|
|
1275
|
+
expect.objectContaining({
|
|
1276
|
+
workflowBlockerResolvedWebhookUrl:
|
|
1277
|
+
'https://example.com/webhook?url={URL}',
|
|
1278
|
+
}),
|
|
1279
|
+
);
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
it('should apply README config overrides', async () => {
|
|
1283
|
+
const readmeContent = [
|
|
1284
|
+
'# Project',
|
|
1285
|
+
'<details>',
|
|
1286
|
+
'<summary>config</summary>',
|
|
1287
|
+
"awaitingQualityCheckStatus: 'README QC'",
|
|
1288
|
+
'</details>',
|
|
1289
|
+
].join('\n');
|
|
1290
|
+
mockFetchReturningReadme(readmeContent);
|
|
1291
|
+
|
|
1292
|
+
const mockRun = jest.fn().mockResolvedValue(undefined);
|
|
1293
|
+
const MockedNotifyFinishedUseCase = jest.mocked(
|
|
1294
|
+
NotifyFinishedIssuePreparationUseCase,
|
|
1295
|
+
);
|
|
1296
|
+
|
|
1297
|
+
MockedNotifyFinishedUseCase.mockImplementation(function (
|
|
1298
|
+
this: NotifyFinishedIssuePreparationUseCase,
|
|
1299
|
+
) {
|
|
1300
|
+
this.run = mockRun;
|
|
1301
|
+
return this;
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
await program.parseAsync([
|
|
1305
|
+
'node',
|
|
1306
|
+
'test',
|
|
1307
|
+
'notifyFinishedIssuePreparation',
|
|
1308
|
+
'--configFilePath',
|
|
1309
|
+
configFilePath,
|
|
1310
|
+
'--issueUrl',
|
|
1311
|
+
'https://github.com/test/repo/issues/1',
|
|
1312
|
+
]);
|
|
1313
|
+
|
|
1314
|
+
expect(mockRun).toHaveBeenCalledWith(
|
|
1315
|
+
expect.objectContaining({
|
|
1316
|
+
awaitingQualityCheckStatus: 'README QC',
|
|
1317
|
+
}),
|
|
1318
|
+
);
|
|
1319
|
+
});
|
|
20
1320
|
});
|
|
21
1321
|
});
|