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.
- package/.github/workflows/console-ui.yml +1 -1
- package/.github/workflows/publish.yml +40 -7
- package/.github/workflows/test.yml +1 -1
- package/.prettierignore +1 -0
- package/CHANGELOG.md +16 -0
- package/README.md +1 -1
- package/bin/adapter/entry-points/cli/index.js +10 -3
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js +1 -1
- package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js.map +1 -1
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +6 -2
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
- package/bin/domain/usecases/ChangeStatusByStoryColorUseCase.js +6 -0
- package/bin/domain/usecases/ChangeStatusByStoryColorUseCase.js.map +1 -1
- package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js +2 -11
- package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js.map +1 -1
- package/bin/domain/usecases/HandleScheduledEventUseCase.js +1 -0
- package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +19 -0
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
- package/package.json +1 -1
- package/scripts/defaultBranchTipVerify.sh +23 -0
- package/scripts/testWorkflowRunVerify.sh +47 -0
- package/src/adapter/ci/publishTestWorkflowGate.test.ts +653 -0
- package/src/adapter/ci/workflowRunCancellation.test.ts +86 -0
- package/src/adapter/entry-points/cli/index.test.ts +51 -6
- package/src/adapter/entry-points/cli/index.ts +12 -5
- package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +2 -1
- package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.test.ts +7 -23
- package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.ts +2 -7
- package/src/adapter/repositories/GoogleSpreadsheetRepository.integration.test.ts +10 -0
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +108 -0
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +10 -2
- package/src/domain/usecases/ChangeStatusByStoryColorUseCase.test.ts +188 -0
- package/src/domain/usecases/ChangeStatusByStoryColorUseCase.ts +12 -0
- package/src/domain/usecases/DefaultSilentSessionMessageComposer.test.ts +27 -42
- package/src/domain/usecases/DefaultSilentSessionMessageComposer.ts +1 -10
- package/src/domain/usecases/HandleScheduledEventUseCase.ts +1 -0
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +28 -0
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +212 -0
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +32 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts.map +1 -1
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +1 -1
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
- package/types/domain/usecases/ChangeStatusByStoryColorUseCase.d.ts +2 -0
- package/types/domain/usecases/ChangeStatusByStoryColorUseCase.d.ts.map +1 -1
- package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts +0 -1
- package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts.map +1 -1
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
- package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +3 -2
- 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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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('
|
|
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 (
|
|
915
|
-
|
|
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);
|