github-issue-tower-defence-management 1.148.23 → 1.148.25

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 (52) hide show
  1. package/.github/workflows/console-ui.yml +1 -1
  2. package/.github/workflows/publish.yml +40 -7
  3. package/.github/workflows/test.yml +1 -1
  4. package/.prettierignore +1 -0
  5. package/CHANGELOG.md +16 -0
  6. package/README.md +1 -1
  7. package/bin/adapter/entry-points/cli/index.js +10 -3
  8. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  9. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js +1 -1
  10. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js.map +1 -1
  11. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +6 -2
  12. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  13. package/bin/domain/usecases/ChangeStatusByStoryColorUseCase.js +6 -0
  14. package/bin/domain/usecases/ChangeStatusByStoryColorUseCase.js.map +1 -1
  15. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js +2 -11
  16. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js.map +1 -1
  17. package/bin/domain/usecases/HandleScheduledEventUseCase.js +1 -0
  18. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  19. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +19 -0
  20. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
  21. package/package.json +1 -1
  22. package/scripts/defaultBranchTipVerify.sh +23 -0
  23. package/scripts/testWorkflowRunVerify.sh +47 -0
  24. package/src/adapter/ci/publishTestWorkflowGate.test.ts +653 -0
  25. package/src/adapter/ci/workflowRunCancellation.test.ts +86 -0
  26. package/src/adapter/entry-points/cli/index.test.ts +51 -6
  27. package/src/adapter/entry-points/cli/index.ts +12 -5
  28. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +2 -1
  29. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.test.ts +7 -23
  30. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.ts +2 -7
  31. package/src/adapter/repositories/GoogleSpreadsheetRepository.integration.test.ts +10 -0
  32. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +108 -0
  33. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +10 -2
  34. package/src/domain/usecases/ChangeStatusByStoryColorUseCase.test.ts +188 -0
  35. package/src/domain/usecases/ChangeStatusByStoryColorUseCase.ts +12 -0
  36. package/src/domain/usecases/DefaultSilentSessionMessageComposer.test.ts +27 -42
  37. package/src/domain/usecases/DefaultSilentSessionMessageComposer.ts +1 -10
  38. package/src/domain/usecases/HandleScheduledEventUseCase.ts +1 -0
  39. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +28 -0
  40. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +212 -0
  41. package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +32 -0
  42. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  43. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts.map +1 -1
  44. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +1 -1
  45. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  46. package/types/domain/usecases/ChangeStatusByStoryColorUseCase.d.ts +2 -0
  47. package/types/domain/usecases/ChangeStatusByStoryColorUseCase.d.ts.map +1 -1
  48. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts +0 -1
  49. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts.map +1 -1
  50. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  51. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +3 -2
  52. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
@@ -0,0 +1,86 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { parse } from 'yaml';
4
+
5
+ const repositoryRoot = path.resolve(__dirname, '..', '..', '..');
6
+ const workflowDirectory = path.join(repositoryRoot, '.github', 'workflows');
7
+ const defaultBranchRef = 'refs/heads/main';
8
+ const nonDefaultBranchOnlyCancellation = `\${{ github.ref != '${defaultBranchRef}' }}`;
9
+
10
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
11
+ typeof value === 'object' && value !== null && !Array.isArray(value);
12
+
13
+ const readWorkflowSource = (fileName: string): string =>
14
+ fs.readFileSync(path.join(workflowDirectory, fileName), 'utf8');
15
+
16
+ const readWorkflowConcurrency = (fileName: string): Record<string, unknown> => {
17
+ const workflow: unknown = parse(readWorkflowSource(fileName));
18
+ if (!isRecord(workflow)) {
19
+ throw new Error(`${fileName} does not parse to a workflow mapping`);
20
+ }
21
+ const concurrency = workflow['concurrency'];
22
+ if (concurrency === undefined) {
23
+ return {};
24
+ }
25
+ if (!isRecord(concurrency)) {
26
+ throw new Error(
27
+ `${fileName} declares a concurrency value that is not a mapping`,
28
+ );
29
+ }
30
+ return concurrency;
31
+ };
32
+
33
+ const workflowFileNames = (): string[] =>
34
+ fs
35
+ .readdirSync(workflowDirectory)
36
+ .filter((name) => name.endsWith('.yml') || name.endsWith('.yaml'))
37
+ .sort();
38
+
39
+ const pushesCommitsToTheRepository = (fileName: string): boolean => {
40
+ const source = readWorkflowSource(fileName);
41
+ return (
42
+ source.includes('git-auto-commit-action') || source.includes('git push')
43
+ );
44
+ };
45
+
46
+ const guardedWorkflows = [
47
+ { fileName: 'test.yml', concurrencyGroup: `test-\${{ github.ref }}` },
48
+ {
49
+ fileName: 'console-ui.yml',
50
+ concurrencyGroup: `console-ui-\${{ github.ref }}`,
51
+ },
52
+ ];
53
+
54
+ describe('push-triggered workflow run cancellation', () => {
55
+ it.each(guardedWorkflows)(
56
+ 'keys the concurrency group of $fileName on the workflow and the pushed ref',
57
+ ({ fileName, concurrencyGroup }) => {
58
+ expect(readWorkflowConcurrency(fileName)['group']).toBe(concurrencyGroup);
59
+ },
60
+ );
61
+
62
+ it('gives every guarded workflow its own concurrency group so unrelated runs never cancel each other', () => {
63
+ const groups = guardedWorkflows.map(
64
+ ({ fileName }) => readWorkflowConcurrency(fileName)['group'],
65
+ );
66
+ expect(new Set(groups).size).toBe(groups.length);
67
+ });
68
+
69
+ it.each(guardedWorkflows)(
70
+ 'cancels a superseded run of $fileName only when the pushed ref is not the default branch',
71
+ ({ fileName }) => {
72
+ expect(readWorkflowConcurrency(fileName)['cancel-in-progress']).toBe(
73
+ nonDefaultBranchOnlyCancellation,
74
+ );
75
+ },
76
+ );
77
+
78
+ it.each(workflowFileNames().filter(pushesCommitsToTheRepository))(
79
+ 'never cancels a run of %s, which pushes commits to the repository',
80
+ (fileName) => {
81
+ expect([undefined, false]).toContain(
82
+ readWorkflowConcurrency(fileName)['cancel-in-progress'],
83
+ );
84
+ },
85
+ );
86
+ });
@@ -96,6 +96,7 @@ jest.mock('../console/ensureConsoleRunning', () => ({
96
96
  ensureConsoleRunning: jest.fn().mockResolvedValue(null),
97
97
  }));
98
98
  import * as ensureConsoleRunningModule from '../console/ensureConsoleRunning';
99
+ import { GraphqlProjectRepository } from '../../repositories/GraphqlProjectRepository';
99
100
 
100
101
  import type { StartWebServerOptions } from '../console/webServer';
101
102
 
@@ -1952,7 +1953,12 @@ mysteryKey: 'value'
1952
1953
 
1953
1954
  it('should exit with error when --dashboardProjectNames is omitted', async () => {
1954
1955
  writeConfig({ ...defaultConfig, consoleAccessToken: 'config-token' });
1955
- const logSpy = jest.spyOn(console, 'log').mockImplementation();
1956
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
1957
+ const processExitSpy = jest
1958
+ .spyOn(process, 'exit')
1959
+ .mockImplementation(() => {
1960
+ throw new Error('process.exit called');
1961
+ });
1956
1962
 
1957
1963
  await expect(
1958
1964
  program.parseAsync([
@@ -1962,17 +1968,26 @@ mysteryKey: 'value'
1962
1968
  '--configFilePath',
1963
1969
  configFilePath,
1964
1970
  ]),
1965
- ).rejects.toThrow(
1971
+ ).rejects.toThrow('process.exit called');
1972
+
1973
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
1966
1974
  '--dashboardProjectNames must list at least one project name',
1967
1975
  );
1976
+ expect(processExitSpy).toHaveBeenCalledWith(1);
1968
1977
  expect(mockStartWebServer).not.toHaveBeenCalled();
1969
1978
 
1970
- logSpy.mockRestore();
1979
+ consoleErrorSpy.mockRestore();
1980
+ processExitSpy.mockRestore();
1971
1981
  });
1972
1982
 
1973
1983
  it('should exit with error when two --dashboardProjectNames share a display label', async () => {
1974
1984
  writeConfig({ ...defaultConfig, consoleAccessToken: 'config-token' });
1975
- const logSpy = jest.spyOn(console, 'log').mockImplementation();
1985
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
1986
+ const processExitSpy = jest
1987
+ .spyOn(process, 'exit')
1988
+ .mockImplementation(() => {
1989
+ throw new Error('process.exit called');
1990
+ });
1976
1991
 
1977
1992
  await expect(
1978
1993
  program.parseAsync([
@@ -1984,12 +1999,42 @@ mysteryKey: 'value'
1984
1999
  '--dashboardProjectNames',
1985
2000
  'acme,acmelabs',
1986
2001
  ]),
1987
- ).rejects.toThrow(
2002
+ ).rejects.toThrow('process.exit called');
2003
+
2004
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
1988
2005
  'Dashboard project names acme and acmelabs share the display label ac',
1989
2006
  );
2007
+ expect(processExitSpy).toHaveBeenCalledWith(1);
1990
2008
  expect(mockStartWebServer).not.toHaveBeenCalled();
1991
2009
 
1992
- logSpy.mockRestore();
2010
+ consoleErrorSpy.mockRestore();
2011
+ processExitSpy.mockRestore();
2012
+ });
2013
+
2014
+ it('should validate --dashboardProjectNames before constructing the GitHub repositories', async () => {
2015
+ writeConfig({ ...defaultConfig, consoleAccessToken: 'config-token' });
2016
+ jest.mocked(GraphqlProjectRepository).mockClear();
2017
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
2018
+ const processExitSpy = jest
2019
+ .spyOn(process, 'exit')
2020
+ .mockImplementation(() => {
2021
+ throw new Error('process.exit called');
2022
+ });
2023
+
2024
+ await expect(
2025
+ program.parseAsync([
2026
+ 'node',
2027
+ 'test',
2028
+ 'serveWeb',
2029
+ '--configFilePath',
2030
+ configFilePath,
2031
+ ]),
2032
+ ).rejects.toThrow('process.exit called');
2033
+
2034
+ expect(jest.mocked(GraphqlProjectRepository)).not.toHaveBeenCalled();
2035
+
2036
+ consoleErrorSpy.mockRestore();
2037
+ processExitSpy.mockRestore();
1993
2038
  });
1994
2039
 
1995
2040
  it('should use the provided --port and --consoleDataOutputDir', async () => {
@@ -115,11 +115,17 @@ const parseDashboardProjectNames = (raw: string | undefined): string[] => {
115
115
  .map((name) => name.trim())
116
116
  .filter((name) => name.length > 0);
117
117
  if (names.length === 0) {
118
- throw new Error(
118
+ console.error(
119
119
  '--dashboardProjectNames must list at least one project name',
120
120
  );
121
+ return process.exit(1);
122
+ }
123
+ try {
124
+ assertDashboardDisplayLabelsUnique(names);
125
+ } catch (error) {
126
+ console.error(error instanceof Error ? error.message : String(error));
127
+ return process.exit(1);
121
128
  }
122
- assertDashboardDisplayLabelsUnique(names);
123
129
  return names;
124
130
  };
125
131
 
@@ -739,6 +745,10 @@ const runServeWeb = async (options: ServeWebOptions): Promise<void> => {
739
745
  process.exit(1);
740
746
  }
741
747
 
748
+ const dashboardProjectNames = parseDashboardProjectNames(
749
+ options.dashboardProjectNames,
750
+ );
751
+
742
752
  const projectName = config.projectName ?? 'default';
743
753
  const localStorageRepository = new LocalStorageRepository();
744
754
  const cachePath = `./tmp/cache/${projectName}`;
@@ -855,9 +865,6 @@ const runServeWeb = async (options: ServeWebOptions): Promise<void> => {
855
865
  const dashboardDir = options.dashboardDir ?? DEFAULT_DASHBOARD_DIR;
856
866
  const dashboardDataDir =
857
867
  options.dashboardDataDir ?? DEFAULT_DASHBOARD_DATA_DIR;
858
- const dashboardProjectNames = parseDashboardProjectNames(
859
- options.dashboardProjectNames,
860
- );
861
868
 
862
869
  await startWebServer({
863
870
  accessToken,
@@ -290,9 +290,10 @@ describe('notifySilentTmuxSessions', () => {
290
290
  expect(sendCall?.[1][4]).toContain(
291
291
  `${SILENT_SESSION_REMINDER_SENTINEL} CUSTOM_MAIN_TEMPLATE`,
292
292
  );
293
- expect(sendCall?.[1][4]).toContain(
293
+ expect(sendCall?.[1][4]).not.toContain(
294
294
  'in the format documented for this session',
295
295
  );
296
+ expect(sendCall?.[1][4]).not.toContain('share it through a new owner-call');
296
297
  });
297
298
 
298
299
  it('suppresses the notification while the latest owner call is unanswered', async () => {
@@ -47,7 +47,7 @@ describe('ConfigurableSilentSessionMessageComposer', () => {
47
47
  expect(fallback.composeMainStalledSection).not.toHaveBeenCalled();
48
48
  });
49
49
 
50
- it('appends the owner-call format guidance, including the self-contained requirement, to the configured main template', () => {
50
+ it('appends no owner-call guidance and no self-diagnosis guidance to the configured main template', () => {
51
51
  const fallback = createFallback();
52
52
  const composer = new ConfigurableSilentSessionMessageComposer(
53
53
  {
@@ -58,11 +58,13 @@ describe('ConfigurableSilentSessionMessageComposer', () => {
58
58
  );
59
59
  const section = composer.composeMainStalledSection(600);
60
60
  expect(section).toContain('CUSTOM_MAIN');
61
- expect(section).toContain(
62
- 'share it through a new owner-call in the format documented for this session',
61
+ expect(section).not.toContain('share it through a new owner-call');
62
+ expect(section).not.toContain('in the format documented for this session');
63
+ expect(section).not.toContain('written to be self-contained');
64
+ expect(section).not.toContain(
65
+ 'This reminder is delivered only to sessions that have no registered unanswered owner-call.',
63
66
  );
64
- expect(section).toContain('written to be self-contained');
65
- expect(section).not.toContain('marker tag');
67
+ expect(section).not.toContain('re-raise');
66
68
  expect(section).not.toContain('<');
67
69
  expect(section).not.toContain('>');
68
70
  expect(section).not.toMatch(
@@ -71,24 +73,6 @@ describe('ConfigurableSilentSessionMessageComposer', () => {
71
73
  expect(section).not.toContain('\u{FE0F}');
72
74
  });
73
75
 
74
- it('appends the self-diagnosis guidance to the configured main template', () => {
75
- const fallback = createFallback();
76
- const composer = new ConfigurableSilentSessionMessageComposer(
77
- {
78
- ...noTemplates,
79
- mainStalledMessage: 'CUSTOM_MAIN',
80
- },
81
- fallback,
82
- );
83
- const section = composer.composeMainStalledSection(600);
84
- expect(section).toContain(
85
- 'This reminder is delivered only to sessions that have no registered unanswered owner-call.',
86
- );
87
- expect(section).toContain(
88
- 're-raise the pending request as a new owner-call in that format',
89
- );
90
- });
91
-
92
76
  it('omits the self-diagnosis guidance from the configured stale-owner-call template section', () => {
93
77
  const fallback = createFallback();
94
78
  const composer = new ConfigurableSilentSessionMessageComposer(
@@ -1,8 +1,5 @@
1
1
  import { SubAgentActivity } from '../../domain/entities/LiveSessionActivitySnapshot';
2
- import {
3
- composeMainStalledSelfDiagnosisGuidance,
4
- composeOwnerCallFormatGuidance,
5
- } from '../../domain/usecases/DefaultSilentSessionMessageComposer';
2
+ import { composeOwnerCallFormatGuidance } from '../../domain/usecases/DefaultSilentSessionMessageComposer';
6
3
  import {
7
4
  SilentSessionMessageComposer,
8
5
  SubAgentStallSections,
@@ -38,9 +35,7 @@ export class ConfigurableSilentSessionMessageComposer implements SilentSessionMe
38
35
  if (this.templates.mainStalledMessage === null) {
39
36
  return this.fallback.composeMainStalledSection(mainSilentSeconds);
40
37
  }
41
- return withReminderSentinel(
42
- `${this.templates.mainStalledMessage} ${composeOwnerCallFormatGuidance()} ${composeMainStalledSelfDiagnosisGuidance()}`,
43
- );
38
+ return withReminderSentinel(this.templates.mainStalledMessage);
44
39
  };
45
40
 
46
41
  composeMainStalledWithStaleOwnerCallSection = (
@@ -157,6 +157,16 @@ describeWhenCredentials('GoogleSpreadsheetRepository integration tests', () => {
157
157
  );
158
158
  expect(result).toBeNull();
159
159
  });
160
+
161
+ test('rejects with the Google Sheets not-found status when the spreadsheet does not exist', async () => {
162
+ const missingSpreadsheetId =
163
+ '1AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
164
+ const missingSpreadsheetUrl = `https://docs.google.com/spreadsheets/d/${missingSpreadsheetId}/edit`;
165
+
166
+ await expect(
167
+ repository.getSheet(missingSpreadsheetUrl, 'Sheet1'),
168
+ ).rejects.toMatchObject({ status: 404 });
169
+ });
160
170
  });
161
171
 
162
172
  describe('createNewSheetIfNotExists', () => {
@@ -268,6 +268,114 @@ describe('ApiV3CheerioRestIssueRepository', () => {
268
268
  });
269
269
  });
270
270
 
271
+ describe('get', () => {
272
+ it('reads the single project item scoped to the given project without consulting the getAllIssues memo', async () => {
273
+ const {
274
+ repository,
275
+ graphqlProjectItemRepository,
276
+ localStorageCacheRepository,
277
+ projectRepository,
278
+ dateRepository,
279
+ } = createApiV3CheerioRestIssueRepository();
280
+ const project = buildTestProject('test-project-id');
281
+ dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:00:00Z'));
282
+ localStorageCacheRepository.getSingle.mockResolvedValue(null);
283
+ projectRepository.getProject.mockResolvedValue(project);
284
+ graphqlProjectItemRepository.fetchProjectItems.mockResolvedValue([]);
285
+ localStorageCacheRepository.setSingle.mockResolvedValue();
286
+ graphqlProjectItemRepository.fetchProjectItemByUrl.mockResolvedValue(
287
+ buildProjectItem('https://github.com/o/r/issues/1', 'live title'),
288
+ );
289
+
290
+ await repository.getAllIssues('test-project-id');
291
+ const issue = await repository.get(
292
+ 'https://github.com/o/r/issues/1',
293
+ project,
294
+ );
295
+
296
+ expect(issue?.title).toBe('live title');
297
+ expect(
298
+ graphqlProjectItemRepository.fetchProjectItemByUrl.mock.calls,
299
+ ).toEqual([['https://github.com/o/r/issues/1', 'test-project-id']]);
300
+ });
301
+
302
+ const issueUrlOnTwoProjects = 'https://github.com/o/r/issues/1';
303
+
304
+ const buildProjectScopedItem = (
305
+ itemId: string,
306
+ status: string,
307
+ story: string,
308
+ nextActionHour: string,
309
+ ): ProjectItem => ({
310
+ ...buildProjectItem(issueUrlOnTwoProjects, itemId),
311
+ id: itemId,
312
+ customFields: [
313
+ { name: 'Status', value: status },
314
+ { name: 'story', value: story },
315
+ { name: 'nextActionHour', value: nextActionHour },
316
+ ],
317
+ });
318
+
319
+ const itemOnOtherProject = buildProjectScopedItem(
320
+ 'item-on-other-project',
321
+ 'Awaiting Workspace',
322
+ 'other story',
323
+ '9',
324
+ );
325
+ const itemOnRequestedProject = buildProjectScopedItem(
326
+ 'item-on-requested-project',
327
+ 'Preparation',
328
+ 'requested story',
329
+ '17',
330
+ );
331
+
332
+ const arrangeItemsOnTwoProjects = (
333
+ graphqlProjectItemRepository: ReturnType<
334
+ typeof createApiV3CheerioRestIssueRepository
335
+ >['graphqlProjectItemRepository'],
336
+ ): void => {
337
+ const itemsByProjectId = new Map<string, ProjectItem>([
338
+ ['other-project-id', itemOnOtherProject],
339
+ ['requested-project-id', itemOnRequestedProject],
340
+ ]);
341
+ graphqlProjectItemRepository.fetchProjectItemByUrl.mockImplementation(
342
+ async (_issueUrl: string, projectId?: string) =>
343
+ projectId === undefined
344
+ ? Array.from(itemsByProjectId.values())[0]
345
+ : (itemsByProjectId.get(projectId) ?? null),
346
+ );
347
+ };
348
+
349
+ it('returns the project item of the requested project when the issue is on two projects', async () => {
350
+ const { repository, graphqlProjectItemRepository } =
351
+ createApiV3CheerioRestIssueRepository();
352
+ arrangeItemsOnTwoProjects(graphqlProjectItemRepository);
353
+
354
+ const issue = await repository.get(
355
+ issueUrlOnTwoProjects,
356
+ buildTestProject('requested-project-id'),
357
+ );
358
+
359
+ expect(issue?.itemId).toBe('item-on-requested-project');
360
+ expect(issue?.status).toBe('Preparation');
361
+ expect(issue?.story).toBe('requested story');
362
+ expect(issue?.nextActionHour).toBe(17);
363
+ });
364
+
365
+ it('returns null when the issue has project items only on other projects', async () => {
366
+ const { repository, graphqlProjectItemRepository } =
367
+ createApiV3CheerioRestIssueRepository();
368
+ arrangeItemsOnTwoProjects(graphqlProjectItemRepository);
369
+
370
+ const issue = await repository.get(
371
+ issueUrlOnTwoProjects,
372
+ buildTestProject('project-without-any-item'),
373
+ );
374
+
375
+ expect(issue).toBeNull();
376
+ });
377
+ });
378
+
271
379
  describe('getAllIssues incremental fetch', () => {
272
380
  it('light-scans the lastFetchedAt UTC day with no previous-day overlap, detail-fetches changed items by id, and upserts by url', async () => {
273
381
  const {
@@ -911,8 +911,16 @@ export class ApiV3CheerioRestIssueRepository
911
911
  searchIssues = (query: string): Promise<SearchedIssue[]> => {
912
912
  return this.restIssueRepository.searchIssues(query);
913
913
  };
914
- get = async (_issueUrl: string, _project: Project): Promise<Issue | null> => {
915
- return this.getIssueByUrl(_issueUrl);
914
+ get = async (issueUrl: string, project: Project): Promise<Issue | null> => {
915
+ const projectItem =
916
+ await this.graphqlProjectItemRepository.fetchProjectItemByUrl(
917
+ issueUrl,
918
+ project.id,
919
+ );
920
+ if (!projectItem) {
921
+ return null;
922
+ }
923
+ return this.convertProjectItemToIssue(projectItem);
916
924
  };
917
925
  update = async (issue: Issue, _project: Project): Promise<void> => {
918
926
  await this.updateIssue(issue);