github-issue-tower-defence-management 1.41.0 → 1.42.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.
@@ -138,6 +138,7 @@ describe('CLI', () => {
138
138
  thresholdForAutoReject: 5,
139
139
  workflowBlockerResolvedWebhookUrl: 'https://example.com/webhook',
140
140
  projectName: 'test-project',
141
+ codexHomeCandidates: ['.codex-dev1', '.codex-main'],
141
142
  };
142
143
  writeConfig(config);
143
144
 
@@ -146,6 +147,42 @@ describe('CLI', () => {
146
147
  expect(result).toEqual(config);
147
148
  });
148
149
 
150
+ it('should load codexHomeCandidates string array from config file', () => {
151
+ const config = {
152
+ ...defaultConfig,
153
+ codexHomeCandidates: ['.codex-dev1', '.codex-dev2', '.codex-main'],
154
+ };
155
+ writeConfig(config);
156
+
157
+ const result = loadConfigFile(configFilePath);
158
+
159
+ expect(result.codexHomeCandidates).toEqual([
160
+ '.codex-dev1',
161
+ '.codex-dev2',
162
+ '.codex-main',
163
+ ]);
164
+ });
165
+
166
+ it('should return undefined codexHomeCandidates when not in config', () => {
167
+ writeConfig(defaultConfig);
168
+
169
+ const result = loadConfigFile(configFilePath);
170
+
171
+ expect(result.codexHomeCandidates).toBeUndefined();
172
+ });
173
+
174
+ it('should return undefined codexHomeCandidates when array contains non-string items', () => {
175
+ const config = {
176
+ ...defaultConfig,
177
+ codexHomeCandidates: ['.codex-dev1', 123],
178
+ };
179
+ writeConfig(config);
180
+
181
+ const result = loadConfigFile(configFilePath);
182
+
183
+ expect(result.codexHomeCandidates).toBeUndefined();
184
+ });
185
+
149
186
  it('should return empty config for empty YAML', () => {
150
187
  fs.writeFileSync(configFilePath, '');
151
188
 
@@ -300,6 +337,24 @@ defaultAgentName: 'case-test-agent'
300
337
 
301
338
  expect(result.defaultAgentName).toBe('case-test-agent');
302
339
  });
340
+
341
+ it('should parse codexHomeCandidates string array from README config', () => {
342
+ const readme = `<details>
343
+ <summary>config</summary>
344
+ codexHomeCandidates:
345
+ - .codex-dev1
346
+ - .codex-dev2
347
+ - .codex-main
348
+ </details>`;
349
+
350
+ const result = parseProjectReadmeConfig(readme);
351
+
352
+ expect(result.codexHomeCandidates).toEqual([
353
+ '.codex-dev1',
354
+ '.codex-dev2',
355
+ '.codex-main',
356
+ ]);
357
+ });
303
358
  });
304
359
 
305
360
  describe('mergeConfigs', () => {
@@ -457,6 +512,7 @@ defaultAgentName: 'case-test-agent'
457
512
  maximumPreparingIssuesCount: null,
458
513
  utilizationPercentageThreshold: 90,
459
514
  allowedIssueAuthors: null,
515
+ codexHomeCandidates: null,
460
516
  });
461
517
  });
462
518
 
@@ -497,6 +553,7 @@ defaultAgentName: 'case-test-agent'
497
553
  maximumPreparingIssuesCount: null,
498
554
  utilizationPercentageThreshold: 90,
499
555
  allowedIssueAuthors: null,
556
+ codexHomeCandidates: null,
500
557
  });
501
558
  });
502
559
 
@@ -905,6 +962,107 @@ defaultAgentName: 'case-test-agent'
905
962
  }),
906
963
  );
907
964
  });
965
+
966
+ it('should pass codexHomeCandidates from config file', async () => {
967
+ const configWithCandidates = {
968
+ ...defaultConfig,
969
+ codexHomeCandidates: ['.codex-dev1', '.codex-dev2', '.codex-main'],
970
+ };
971
+ writeConfig(configWithCandidates);
972
+
973
+ const mockRun = jest.fn().mockResolvedValue(undefined);
974
+ const MockedStartPreparationUseCase = jest.mocked(
975
+ StartPreparationUseCase,
976
+ );
977
+
978
+ MockedStartPreparationUseCase.mockImplementation(function (
979
+ this: StartPreparationUseCase,
980
+ ) {
981
+ this.run = mockRun;
982
+ return this;
983
+ });
984
+
985
+ await program.parseAsync([
986
+ 'node',
987
+ 'test',
988
+ 'startDaemon',
989
+ '--configFilePath',
990
+ configFilePath,
991
+ ]);
992
+
993
+ expect(mockRun).toHaveBeenCalledWith(
994
+ expect.objectContaining({
995
+ codexHomeCandidates: ['.codex-dev1', '.codex-dev2', '.codex-main'],
996
+ }),
997
+ );
998
+ });
999
+
1000
+ it('should pass codexHomeCandidates as null when not in config', async () => {
1001
+ const mockRun = jest.fn().mockResolvedValue(undefined);
1002
+ const MockedStartPreparationUseCase = jest.mocked(
1003
+ StartPreparationUseCase,
1004
+ );
1005
+
1006
+ MockedStartPreparationUseCase.mockImplementation(function (
1007
+ this: StartPreparationUseCase,
1008
+ ) {
1009
+ this.run = mockRun;
1010
+ return this;
1011
+ });
1012
+
1013
+ await program.parseAsync([
1014
+ 'node',
1015
+ 'test',
1016
+ 'startDaemon',
1017
+ '--configFilePath',
1018
+ configFilePath,
1019
+ ]);
1020
+
1021
+ expect(mockRun).toHaveBeenCalledWith(
1022
+ expect.objectContaining({
1023
+ codexHomeCandidates: null,
1024
+ }),
1025
+ );
1026
+ });
1027
+
1028
+ it('should pass codexHomeCandidates from README config', async () => {
1029
+ const readmeContent = [
1030
+ '# Project',
1031
+ '<details>',
1032
+ '<summary>config</summary>',
1033
+ 'codexHomeCandidates:',
1034
+ ' - .codex-readme1',
1035
+ ' - .codex-readme2',
1036
+ '</details>',
1037
+ ].join('\n');
1038
+ mockFetchReturningReadme(readmeContent);
1039
+
1040
+ const mockRun = jest.fn().mockResolvedValue(undefined);
1041
+ const MockedStartPreparationUseCase = jest.mocked(
1042
+ StartPreparationUseCase,
1043
+ );
1044
+
1045
+ MockedStartPreparationUseCase.mockImplementation(function (
1046
+ this: StartPreparationUseCase,
1047
+ ) {
1048
+ this.run = mockRun;
1049
+ return this;
1050
+ });
1051
+
1052
+ await program.parseAsync([
1053
+ 'node',
1054
+ 'test',
1055
+ 'startDaemon',
1056
+ '--configFilePath',
1057
+ configFilePath,
1058
+ ]);
1059
+
1060
+ expect(mockRun).toHaveBeenCalledWith(
1061
+ expect.objectContaining({
1062
+ codexHomeCandidates: ['.codex-readme1', '.codex-readme2'],
1063
+ }),
1064
+ );
1065
+ });
908
1066
  });
909
1067
 
910
1068
  describe('notifyFinishedIssuePreparation', () => {
@@ -37,6 +37,7 @@ type ConfigFile = {
37
37
  workflowBlockerResolvedWebhookUrl?: string;
38
38
  projectName?: string;
39
39
  preparationProcessCheckCommand?: string;
40
+ codexHomeCandidates?: string[];
40
41
  };
41
42
 
42
43
  type StartDaemonOptions = {
@@ -81,6 +82,24 @@ const getNumberValue = (
81
82
  return typeof value === 'number' ? value : undefined;
82
83
  };
83
84
 
85
+ const getStringArrayValue = (
86
+ obj: Record<string, unknown>,
87
+ key: string,
88
+ ): string[] | undefined => {
89
+ const value = obj[key];
90
+ if (!Array.isArray(value)) {
91
+ return undefined;
92
+ }
93
+ const strings: string[] = [];
94
+ for (const item of value) {
95
+ if (typeof item !== 'string') {
96
+ return undefined;
97
+ }
98
+ strings.push(item);
99
+ }
100
+ return strings;
101
+ };
102
+
84
103
  const isRecord = (value: unknown): value is Record<string, unknown> =>
85
104
  typeof value === 'object' && value !== null && !Array.isArray(value);
86
105
 
@@ -125,6 +144,7 @@ export const loadConfigFile = (configFilePath: string): ConfigFile => {
125
144
  parsed,
126
145
  'preparationProcessCheckCommand',
127
146
  ),
147
+ codexHomeCandidates: getStringArrayValue(parsed, 'codexHomeCandidates'),
128
148
  };
129
149
  } catch (error) {
130
150
  const message = error instanceof Error ? error.message : String(error);
@@ -183,6 +203,7 @@ export const parseProjectReadmeConfig = (readme: string): ConfigFile => {
183
203
  parsed,
184
204
  'preparationProcessCheckCommand',
185
205
  ),
206
+ codexHomeCandidates: getStringArrayValue(parsed, 'codexHomeCandidates'),
186
207
  };
187
208
  } catch {
188
209
  console.warn('Failed to parse YAML from project README config section');
@@ -249,6 +270,10 @@ export const mergeConfigs = (
249
270
  readmeOverrides.preparationProcessCheckCommand ??
250
271
  cliOverrides.preparationProcessCheckCommand ??
251
272
  configFile.preparationProcessCheckCommand,
273
+ codexHomeCandidates:
274
+ readmeOverrides.codexHomeCandidates ??
275
+ cliOverrides.codexHomeCandidates ??
276
+ configFile.codexHomeCandidates,
252
277
  });
253
278
 
254
279
  type GraphqlProjectV2ReadmeResponse = {
@@ -585,6 +610,11 @@ program
585
610
  .filter(Boolean)
586
611
  : null;
587
612
 
613
+ const codexHomeCandidates =
614
+ config.codexHomeCandidates && config.codexHomeCandidates.length > 0
615
+ ? config.codexHomeCandidates
616
+ : null;
617
+
588
618
  await useCase.run({
589
619
  projectUrl,
590
620
  awaitingWorkspaceStatus,
@@ -597,6 +627,7 @@ program
597
627
  utilizationPercentageThreshold:
598
628
  config.utilizationPercentageThreshold ?? 90,
599
629
  allowedIssueAuthors,
630
+ codexHomeCandidates,
600
631
  });
601
632
  });
602
633
 
@@ -80,6 +80,7 @@ export class HandleScheduledEventUseCase {
80
80
  utilizationPercentageThreshold?: number;
81
81
  allowedIssueAuthors?: string[] | null;
82
82
  preparationProcessCheckCommand?: string;
83
+ codexHomeCandidates?: string[] | null;
83
84
  } | null;
84
85
  notifyFinishedPreparation?: {
85
86
  preparationStatus: string;
@@ -356,6 +357,7 @@ ${JSON.stringify(e)}
356
357
  utilizationPercentageThreshold:
357
358
  input.startPreparation.utilizationPercentageThreshold ?? 90,
358
359
  allowedIssueAuthors: input.startPreparation.allowedIssueAuthors ?? null,
360
+ codexHomeCandidates: input.startPreparation.codexHomeCandidates ?? null,
359
361
  });
360
362
  }
361
363
  if (input.notifyFinishedPreparation) {
@@ -259,6 +259,48 @@ describe('RevertOrphanedPreparationUseCase', () => {
259
259
  ).rejects.toThrow('Project not found');
260
260
  });
261
261
 
262
+ it('should throw when getProject returns null after findProjectIdByUrl succeeds', async () => {
263
+ mockProjectRepository.findProjectIdByUrl.mockResolvedValue('project-1');
264
+ mockProjectRepository.getProject.mockResolvedValue(null);
265
+
266
+ await expect(
267
+ useCase.run({
268
+ projectUrl: 'https://github.com/user/repo',
269
+ preparationStatus: 'Preparation',
270
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
271
+ allowIssueCacheMinutes: 0,
272
+ preparationProcessCheckCommand: 'check {URL}',
273
+ }),
274
+ ).rejects.toThrow('Project not found. projectId: project-1');
275
+ });
276
+
277
+ it('should do nothing when awaitingWorkspaceStatus is not found in project statuses', async () => {
278
+ const preparationIssue = createMockIssue({
279
+ url: 'https://github.com/user/repo/issues/10',
280
+ status: 'Preparation',
281
+ });
282
+ mockIssueRepository.getAllIssues.mockResolvedValue({
283
+ issues: [preparationIssue],
284
+ cacheUsed: false,
285
+ });
286
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
287
+ stdout: '',
288
+ stderr: '',
289
+ exitCode: 1,
290
+ });
291
+
292
+ await useCase.run({
293
+ projectUrl: 'https://github.com/user/repo',
294
+ preparationStatus: 'Preparation',
295
+ awaitingWorkspaceStatus: 'NonExistentStatus',
296
+ allowIssueCacheMinutes: 0,
297
+ preparationProcessCheckCommand: 'check {URL}',
298
+ });
299
+
300
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
301
+ expect(mockIssueRepository.createComment.mock.calls).toHaveLength(0);
302
+ });
303
+
262
304
  it('should do nothing when there are no Preparation issues', async () => {
263
305
  mockIssueRepository.getAllIssues.mockResolvedValue({
264
306
  issues: [