github-issue-tower-defence-management 1.67.1 → 1.67.3

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.
Files changed (24) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/bin/adapter/entry-points/cli/index.js +2 -2
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/cli/projectConfig.js +24 -2
  6. package/bin/adapter/entry-points/cli/projectConfig.js.map +1 -1
  7. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +16 -1
  8. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  9. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js +38 -7
  10. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
  11. package/package.json +1 -1
  12. package/src/adapter/entry-points/cli/index.test.ts +117 -0
  13. package/src/adapter/entry-points/cli/index.ts +2 -2
  14. package/src/adapter/entry-points/cli/projectConfig.ts +31 -1
  15. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +94 -0
  16. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +47 -1
  17. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +136 -7
  18. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +81 -5
  19. package/src/adapter/repositories/issue/RestIssueRepository.test.ts +195 -77
  20. package/types/adapter/entry-points/cli/projectConfig.d.ts +2 -1
  21. package/types/adapter/entry-points/cli/projectConfig.d.ts.map +1 -1
  22. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  23. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts +2 -0
  24. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
@@ -457,4 +457,98 @@ claudeCodeOauthTokenListJsonPath: /readme/tokens.json
457
457
  );
458
458
  });
459
459
  });
460
+
461
+ describe('effective config logging', () => {
462
+ let consoleLogSpy: jest.SpyInstance;
463
+
464
+ beforeEach(() => {
465
+ consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
466
+ });
467
+
468
+ afterEach(() => {
469
+ consoleLogSpy.mockRestore();
470
+ });
471
+
472
+ it('should log the effective values with configFile source when only the YAML config sets them', async () => {
473
+ const configWithPreparation = {
474
+ ...validConfig,
475
+ startPreparation: {
476
+ defaultAgentName: 'yaml-agent',
477
+ defaultLlmModelName: 'yaml-model',
478
+ configFilePath: './config.yml',
479
+ maximumPreparingIssuesCount: 10,
480
+ },
481
+ };
482
+ mockFetchReturningReadme(null);
483
+ jest
484
+ .mocked(fs.readFileSync)
485
+ .mockReturnValue(YAML.stringify(configWithPreparation));
486
+
487
+ const handler = new HandleScheduledEventUseCaseHandler();
488
+ await handler.handle('config.yml', false);
489
+
490
+ expect(consoleLogSpy).toHaveBeenCalledWith(
491
+ 'Effective maximumPreparingIssuesCount: 10 (source: configFile)',
492
+ );
493
+ expect(consoleLogSpy).toHaveBeenCalledWith(
494
+ 'Effective defaultLlmModelName: yaml-model (source: configFile)',
495
+ );
496
+ expect(consoleLogSpy).toHaveBeenCalledWith(
497
+ 'Effective defaultAgentName: yaml-agent (source: configFile)',
498
+ );
499
+ });
500
+
501
+ it('should log the effective values with readmeOverride source when the README config overrides them', async () => {
502
+ const readmeContent = `<details>
503
+ <summary>config</summary>
504
+ maximumPreparingIssuesCount: 3
505
+ defaultLlmModelName: readme-model
506
+ defaultAgentName: readme-agent
507
+ </details>`;
508
+ mockFetchReturningReadme(readmeContent);
509
+ const configWithPreparation = {
510
+ ...validConfig,
511
+ startPreparation: {
512
+ defaultAgentName: 'yaml-agent',
513
+ defaultLlmModelName: 'yaml-model',
514
+ configFilePath: './config.yml',
515
+ maximumPreparingIssuesCount: 10,
516
+ },
517
+ };
518
+ jest
519
+ .mocked(fs.readFileSync)
520
+ .mockReturnValue(YAML.stringify(configWithPreparation));
521
+
522
+ const handler = new HandleScheduledEventUseCaseHandler();
523
+ await handler.handle('config.yml', false);
524
+
525
+ expect(consoleLogSpy).toHaveBeenCalledWith(
526
+ 'Effective maximumPreparingIssuesCount: 3 (source: readmeOverride)',
527
+ );
528
+ expect(consoleLogSpy).toHaveBeenCalledWith(
529
+ 'Effective defaultLlmModelName: readme-model (source: readmeOverride)',
530
+ );
531
+ expect(consoleLogSpy).toHaveBeenCalledWith(
532
+ 'Effective defaultAgentName: readme-agent (source: readmeOverride)',
533
+ );
534
+ });
535
+
536
+ it('should log null with unset (default) source when neither README nor config provides the value', async () => {
537
+ mockFetchReturningReadme(null);
538
+ jest.mocked(fs.readFileSync).mockReturnValue(YAML.stringify(validConfig));
539
+
540
+ const handler = new HandleScheduledEventUseCaseHandler();
541
+ await handler.handle('config.yml', false);
542
+
543
+ expect(consoleLogSpy).toHaveBeenCalledWith(
544
+ 'Effective maximumPreparingIssuesCount: null (source: unset (default))',
545
+ );
546
+ expect(consoleLogSpy).toHaveBeenCalledWith(
547
+ 'Effective defaultLlmModelName: null (source: unset (default))',
548
+ );
549
+ expect(consoleLogSpy).toHaveBeenCalledWith(
550
+ 'Effective defaultAgentName: null (source: unset (default))',
551
+ );
552
+ });
553
+ });
460
554
  });
@@ -94,7 +94,9 @@ export class HandleScheduledEventUseCaseHandler {
94
94
 
95
95
  const managerToken = input.credentials.manager.github.token;
96
96
  const readme = await fetchProjectReadme(input.projectUrl, managerToken);
97
- const readmeConfig = readme ? parseProjectReadmeConfig(readme) : {};
97
+ const readmeConfig = readme
98
+ ? parseProjectReadmeConfig(readme, input.projectUrl)
99
+ : {};
98
100
 
99
101
  const mergedInput = {
100
102
  ...input,
@@ -137,6 +139,50 @@ export class HandleScheduledEventUseCaseHandler {
137
139
  : input.startPreparation,
138
140
  };
139
141
 
142
+ type EffectiveConfigValue = string | number | null | undefined;
143
+
144
+ const resolveConfigSource = (
145
+ readmeValue: EffectiveConfigValue,
146
+ configFileValue: EffectiveConfigValue,
147
+ ): 'readmeOverride' | 'configFile' | 'unset (default)' => {
148
+ if (readmeValue !== undefined && readmeValue !== null) {
149
+ return 'readmeOverride';
150
+ }
151
+ if (configFileValue !== undefined && configFileValue !== null) {
152
+ return 'configFile';
153
+ }
154
+ return 'unset (default)';
155
+ };
156
+
157
+ const formatEffectiveConfig = (
158
+ value: EffectiveConfigValue,
159
+ readmeValue: EffectiveConfigValue,
160
+ configFileValue: EffectiveConfigValue,
161
+ ): string =>
162
+ `${value ?? 'null'} (source: ${resolveConfigSource(readmeValue, configFileValue)})`;
163
+
164
+ console.log(
165
+ `Effective maximumPreparingIssuesCount: ${formatEffectiveConfig(
166
+ mergedInput.startPreparation?.maximumPreparingIssuesCount,
167
+ readmeConfig.maximumPreparingIssuesCount,
168
+ input.startPreparation?.maximumPreparingIssuesCount,
169
+ )}`,
170
+ );
171
+ console.log(
172
+ `Effective defaultLlmModelName: ${formatEffectiveConfig(
173
+ mergedInput.startPreparation?.defaultLlmModelName,
174
+ readmeConfig.defaultLlmModelName,
175
+ input.startPreparation?.defaultLlmModelName,
176
+ )}`,
177
+ );
178
+ console.log(
179
+ `Effective defaultAgentName: ${formatEffectiveConfig(
180
+ mergedInput.startPreparation?.defaultAgentName,
181
+ readmeConfig.defaultAgentName,
182
+ input.startPreparation?.defaultAgentName,
183
+ )}`,
184
+ );
185
+
140
186
  const systemDateRepository = new SystemDateRepository();
141
187
  const localStorageRepository = new LocalStorageRepository();
142
188
  const googleSpreadsheetRepository = new GoogleSpreadsheetRepository(
@@ -24,6 +24,38 @@ const mockJsonResponse = <T>(data: T) => ({
24
24
  json: jest.fn().mockResolvedValue(data),
25
25
  });
26
26
 
27
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
28
+ typeof value === 'object' && value !== null;
29
+
30
+ const extractRequestedFirstFromMockCall = (
31
+ call: unknown,
32
+ ): number | undefined => {
33
+ if (!Array.isArray(call)) {
34
+ return undefined;
35
+ }
36
+ const second: unknown = call[1];
37
+ if (!isRecord(second)) {
38
+ return undefined;
39
+ }
40
+ const json: unknown = second.json;
41
+ if (!isRecord(json)) {
42
+ return undefined;
43
+ }
44
+ const variables: unknown = json.variables;
45
+ if (!isRecord(variables)) {
46
+ return undefined;
47
+ }
48
+ const first: unknown = variables.first;
49
+ return typeof first === 'number' ? first : undefined;
50
+ };
51
+
52
+ const extractErrorMessage = (value: unknown): string => {
53
+ if (value instanceof Error) {
54
+ return value.message;
55
+ }
56
+ return '';
57
+ };
58
+
27
59
  describe('GraphqlProjectItemRepository', () => {
28
60
  const makePageResponse = (
29
61
  hasNextPage: boolean,
@@ -76,7 +108,7 @@ describe('GraphqlProjectItemRepository', () => {
76
108
 
77
109
  afterEach(() => {
78
110
  jest.useRealTimers();
79
- mockPost.mockClear();
111
+ mockPost.mockReset();
80
112
  });
81
113
 
82
114
  it('should sleep between paginated requests to avoid 403', async () => {
@@ -111,7 +143,7 @@ describe('GraphqlProjectItemRepository', () => {
111
143
  'dummy-token',
112
144
  );
113
145
 
114
- mockPost.mockReturnValueOnce(
146
+ mockPost.mockImplementation(() =>
115
147
  mockJsonResponse({
116
148
  data: {
117
149
  node: {
@@ -148,7 +180,7 @@ describe('GraphqlProjectItemRepository', () => {
148
180
 
149
181
  await expect(
150
182
  repository.fetchProjectItems('test-project-id'),
151
- ).rejects.toThrow('GitHub GraphQL errors: RATE_LIMITED');
183
+ ).rejects.toThrow('GitHub GraphQL errors: [{"message":"RATE_LIMITED"}]');
152
184
  });
153
185
 
154
186
  it('should throw when data is null in response', async () => {
@@ -158,7 +190,7 @@ describe('GraphqlProjectItemRepository', () => {
158
190
  'dummy-token',
159
191
  );
160
192
 
161
- mockPost.mockReturnValueOnce(
193
+ mockPost.mockImplementation(() =>
162
194
  mockJsonResponse({
163
195
  data: null,
164
196
  }),
@@ -176,7 +208,7 @@ describe('GraphqlProjectItemRepository', () => {
176
208
  'dummy-token',
177
209
  );
178
210
 
179
- mockPost.mockReturnValueOnce(
211
+ mockPost.mockImplementation(() =>
180
212
  mockJsonResponse({
181
213
  data: { node: null },
182
214
  }),
@@ -187,7 +219,7 @@ describe('GraphqlProjectItemRepository', () => {
187
219
  ).rejects.toThrow('No data returned from GitHub API');
188
220
  });
189
221
 
190
- it('should throw when accumulated nodes count does not match totalCount', async () => {
222
+ it('should throw when a returned page contains nodes, declares hasNextPage=false, yet totalCount still indicates more items remain', async () => {
191
223
  const localStorageRepository = new LocalStorageRepository();
192
224
  const repository = new GraphqlProjectItemRepository(
193
225
  localStorageRepository,
@@ -231,7 +263,7 @@ describe('GraphqlProjectItemRepository', () => {
231
263
  await expect(
232
264
  repository.fetchProjectItems('test-project-id'),
233
265
  ).rejects.toThrow(
234
- 'fetchProjectItems: expected 5 items but accumulated 1',
266
+ 'fetchProjectItems: page 1 has 1 nodes with hasNextPage=false but only 1/5 items accumulated',
235
267
  );
236
268
  });
237
269
 
@@ -287,6 +319,103 @@ describe('GraphqlProjectItemRepository', () => {
287
319
  );
288
320
  setTimeoutSpy.mockRestore();
289
321
  });
322
+
323
+ it('should stringify full response errors payload including extensions when callGraphql throws', async () => {
324
+ const localStorageRepository = new LocalStorageRepository();
325
+ const repository = new GraphqlProjectItemRepository(
326
+ localStorageRepository,
327
+ 'dummy-token',
328
+ );
329
+
330
+ mockPost.mockImplementation(() =>
331
+ mockJsonResponse({
332
+ errors: [
333
+ {
334
+ message: 'Something went wrong while executing your query.',
335
+ path: ['node', 'items', 'nodes', 7, 'id'],
336
+ locations: [{ line: 11, column: 11 }],
337
+ extensions: { code: 'INTERNAL' },
338
+ },
339
+ ],
340
+ }),
341
+ );
342
+
343
+ let capturedError: unknown = null;
344
+ await expect(
345
+ repository
346
+ .fetchProjectItems('test-project-id')
347
+ .catch((error: unknown) => {
348
+ capturedError = error;
349
+ throw error;
350
+ }),
351
+ ).rejects.toThrow(/^GitHub GraphQL errors: /);
352
+ expect(capturedError).toBeInstanceOf(Error);
353
+ const message = extractErrorMessage(capturedError);
354
+ expect(message).toContain('"extensions":{"code":"INTERNAL"}');
355
+ expect(message).toContain('"path":["node","items","nodes",7,"id"]');
356
+ expect(message).toContain('"locations":[{"line":11,"column":11}]');
357
+ });
358
+
359
+ it('should fall back to half the page size when first=50 fails and first=25 succeeds', async () => {
360
+ const localStorageRepository = new LocalStorageRepository();
361
+ const repository = new GraphqlProjectItemRepository(
362
+ localStorageRepository,
363
+ 'dummy-token',
364
+ );
365
+
366
+ const failingResponse = mockJsonResponse({
367
+ errors: [
368
+ { message: 'Something went wrong while executing your query.' },
369
+ ],
370
+ });
371
+ const successPageResponse = makePageResponse(false, 'cursor-1', 1);
372
+
373
+ mockPost
374
+ .mockReturnValueOnce(failingResponse)
375
+ .mockReturnValueOnce(successPageResponse);
376
+
377
+ const result = await repository.fetchProjectItems('test-project-id');
378
+
379
+ expect(mockPost).toHaveBeenCalledTimes(2);
380
+ expect(result).toHaveLength(1);
381
+ expect(extractRequestedFirstFromMockCall(mockPost.mock.calls[0])).toBe(
382
+ 50,
383
+ );
384
+ expect(extractRequestedFirstFromMockCall(mockPost.mock.calls[1])).toBe(
385
+ 25,
386
+ );
387
+ });
388
+
389
+ it('should exhaust halving down to first=1 and rethrow the stringified errors payload', async () => {
390
+ const localStorageRepository = new LocalStorageRepository();
391
+ const repository = new GraphqlProjectItemRepository(
392
+ localStorageRepository,
393
+ 'dummy-token',
394
+ );
395
+
396
+ mockPost.mockImplementation(() =>
397
+ mockJsonResponse({
398
+ errors: [
399
+ {
400
+ message: 'Something went wrong while executing your query.',
401
+ extensions: { code: 'INTERNAL' },
402
+ },
403
+ ],
404
+ }),
405
+ );
406
+
407
+ await expect(
408
+ repository.fetchProjectItems('test-project-id'),
409
+ ).rejects.toThrow(
410
+ /GitHub GraphQL errors: .*"extensions":\{"code":"INTERNAL"\}.*/,
411
+ );
412
+
413
+ expect(mockPost).toHaveBeenCalledTimes(6);
414
+ const requestedFirstSeries = mockPost.mock.calls.map((call) =>
415
+ extractRequestedFirstFromMockCall(call),
416
+ );
417
+ expect(requestedFirstSeries).toEqual([50, 25, 12, 6, 3, 1]);
418
+ });
290
419
  });
291
420
 
292
421
  describe('getProjectItemFields', () => {
@@ -20,6 +20,21 @@ export type ProjectItem = {
20
20
  }[];
21
21
  };
22
22
  export const PAGINATION_DELAY_MS = 5000;
23
+ export const FETCH_PROJECT_ITEMS_INITIAL_PAGE_SIZE = 50;
24
+ export const FETCH_PROJECT_ITEMS_GRAPHQL_ERROR_PAYLOAD_MAX_LENGTH = 4000;
25
+
26
+ const stringifyGraphqlErrorsForLog = (
27
+ errors: { message: string }[],
28
+ ): string => {
29
+ const serialized = JSON.stringify(errors);
30
+ if (
31
+ serialized.length <= FETCH_PROJECT_ITEMS_GRAPHQL_ERROR_PAYLOAD_MAX_LENGTH
32
+ ) {
33
+ return serialized;
34
+ }
35
+ return `${serialized.slice(0, FETCH_PROJECT_ITEMS_GRAPHQL_ERROR_PAYLOAD_MAX_LENGTH)}...[truncated]`;
36
+ };
37
+
23
38
  export class GraphqlProjectItemRepository extends BaseGitHubRepository {
24
39
  fetchItemId = async (
25
40
  projectId: string,
@@ -97,10 +112,10 @@ export class GraphqlProjectItemRepository extends BaseGitHubRepository {
97
112
  };
98
113
  fetchProjectItems = async (projectId: string): Promise<ProjectItem[]> => {
99
114
  const graphqlQueryString = `
100
- query GetProjectItems($projectId: ID!, $after: String) {
115
+ query GetProjectItems($projectId: ID!, $after: String, $first: Int!) {
101
116
  node(id: $projectId) {
102
117
  ... on ProjectV2 {
103
- items(first: 100, after: $after) {
118
+ items(first: $first, after: $after) {
104
119
  totalCount
105
120
  pageInfo {
106
121
  endCursor
@@ -210,6 +225,7 @@ query GetProjectItems($projectId: ID!, $after: String) {
210
225
  const callGraphql = async (
211
226
  projectId: string,
212
227
  after: string | null,
228
+ first: number,
213
229
  ): Promise<{
214
230
  node: {
215
231
  items: {
@@ -251,6 +267,7 @@ query GetProjectItems($projectId: ID!, $after: String) {
251
267
  variables: {
252
268
  projectId: projectId,
253
269
  after: after,
270
+ first: first,
254
271
  },
255
272
  };
256
273
  const response = await ky
@@ -301,7 +318,7 @@ query GetProjectItems($projectId: ID!, $after: String) {
301
318
  }>();
302
319
  if (response.errors && response.errors.length > 0) {
303
320
  throw new Error(
304
- `GitHub GraphQL errors: ${response.errors.map((e) => e.message).join('; ')}`,
321
+ `GitHub GraphQL errors: ${stringifyGraphqlErrorsForLog(response.errors)}`,
305
322
  );
306
323
  }
307
324
  const rawData = response.data;
@@ -314,6 +331,65 @@ query GetProjectItems($projectId: ID!, $after: String) {
314
331
  }
315
332
  return { node: rawNode };
316
333
  };
334
+ const callGraphqlWithHalvingFallback = async (
335
+ after: string | null,
336
+ ): Promise<{
337
+ node: {
338
+ items: {
339
+ totalCount: number;
340
+ pageInfo: {
341
+ endCursor: string;
342
+ startCursor: string;
343
+ hasNextPage: boolean;
344
+ };
345
+ nodes: {
346
+ id: string;
347
+ fieldValues: {
348
+ nodes: {
349
+ text: string;
350
+ number: number;
351
+ date: string;
352
+ field: {
353
+ name: string;
354
+ };
355
+ }[];
356
+ };
357
+ content: {
358
+ repository: { nameWithOwner: string };
359
+ number: number;
360
+ title: string;
361
+ state: string;
362
+ url: string;
363
+ createdAt: string;
364
+ author: { login: string } | null;
365
+ labels: { nodes: { name: string }[] };
366
+ assignees: { nodes: { login: string }[] };
367
+ };
368
+ }[];
369
+ };
370
+ };
371
+ }> => {
372
+ let attemptFirst = FETCH_PROJECT_ITEMS_INITIAL_PAGE_SIZE;
373
+ let lastError: unknown = null;
374
+ while (attemptFirst >= 1) {
375
+ try {
376
+ return await callGraphql(projectId, after, attemptFirst);
377
+ } catch (error) {
378
+ lastError = error;
379
+ if (attemptFirst === 1) {
380
+ throw error;
381
+ }
382
+ const nextFirst = Math.max(1, Math.floor(attemptFirst / 2));
383
+ console.log(
384
+ `fetchProjectItems: page request with first=${attemptFirst} failed, halving to first=${nextFirst}: ${error instanceof Error ? error.message : String(error)}`,
385
+ );
386
+ attemptFirst = nextFirst;
387
+ }
388
+ }
389
+ throw lastError instanceof Error
390
+ ? lastError
391
+ : new Error(String(lastError));
392
+ };
317
393
  const issues: ProjectItem[] = [];
318
394
  let after: string | null = null;
319
395
  let hasNextPage = true;
@@ -327,7 +403,7 @@ query GetProjectItems($projectId: ID!, $after: String) {
327
403
  setTimeout(resolve, PAGINATION_DELAY_MS),
328
404
  );
329
405
  }
330
- const data = await callGraphql(projectId, after);
406
+ const data = await callGraphqlWithHalvingFallback(after);
331
407
  const pageNodes = data.node.items.nodes;
332
408
  const pageInfo = data.node.items.pageInfo;
333
409
  totalCount = data.node.items.totalCount;
@@ -393,7 +469,7 @@ query GetProjectItems($projectId: ID!, $after: String) {
393
469
  });
394
470
  });
395
471
  if (
396
- pageNodes.length === 100 &&
472
+ pageNodes.length > 0 &&
397
473
  !pageInfo.hasNextPage &&
398
474
  cumulativeRawNodes < totalCount
399
475
  ) {