github-issue-tower-defence-management 1.87.0 → 1.88.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 +7 -0
- package/README.md +42 -0
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +54 -12
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/inTmuxByHumanDataWriter.js +67 -0
- package/bin/adapter/entry-points/handlers/inTmuxByHumanDataWriter.js.map +1 -0
- package/bin/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.js +91 -0
- package/bin/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +26 -0
- package/src/adapter/entry-points/handlers/inTmuxByHumanDataWriter.test.ts +266 -0
- package/src/adapter/entry-points/handlers/inTmuxByHumanDataWriter.ts +103 -0
- package/src/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.test.ts +285 -0
- package/src/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.ts +182 -0
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/inTmuxByHumanDataWriter.d.ts +16 -0
- package/types/adapter/entry-points/handlers/inTmuxByHumanDataWriter.d.ts.map +1 -0
- package/types/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.d.ts +57 -0
- package/types/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.d.ts.map +1 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { Issue } from '../../../domain/entities/Issue';
|
|
5
|
+
import { FieldOption, Project } from '../../../domain/entities/Project';
|
|
6
|
+
import { writeInTmuxByHumanData } from './inTmuxByHumanDataWriter';
|
|
7
|
+
|
|
8
|
+
const ASSIGNEE = 'owner-login';
|
|
9
|
+
const CONSOLE_BASE_URL = 'https://console.example.test';
|
|
10
|
+
const CONSOLE_TOKEN = 'test-token-value';
|
|
11
|
+
|
|
12
|
+
const option = (
|
|
13
|
+
id: string,
|
|
14
|
+
name: string,
|
|
15
|
+
color: FieldOption['color'],
|
|
16
|
+
): FieldOption => ({ id, name, color, description: '' });
|
|
17
|
+
|
|
18
|
+
const project: Project = {
|
|
19
|
+
id: 'project-node-id',
|
|
20
|
+
url: 'https://github.com/orgs/demo/projects/1',
|
|
21
|
+
databaseId: 1,
|
|
22
|
+
name: 'demo',
|
|
23
|
+
status: {
|
|
24
|
+
name: 'Status',
|
|
25
|
+
fieldId: 'status-field',
|
|
26
|
+
statuses: [option('st-tmux', 'In Tmux by human', 'RED')],
|
|
27
|
+
},
|
|
28
|
+
nextActionDate: null,
|
|
29
|
+
nextActionHour: null,
|
|
30
|
+
story: {
|
|
31
|
+
name: 'story',
|
|
32
|
+
fieldId: 'story-field',
|
|
33
|
+
databaseId: 2,
|
|
34
|
+
stories: [option('s1', 'Story Alpha', 'BLUE')],
|
|
35
|
+
workflowManagementStory: { id: 'wm', name: 'workflow management' },
|
|
36
|
+
},
|
|
37
|
+
remainingEstimationMinutes: null,
|
|
38
|
+
dependedIssueUrlSeparatedByComma: null,
|
|
39
|
+
completionDate50PercentConfidence: null,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const makeIssue = (overrides: Partial<Issue>): Issue => ({
|
|
43
|
+
nameWithOwner: 'demo/repo',
|
|
44
|
+
number: 1,
|
|
45
|
+
title: 'Issue 1',
|
|
46
|
+
state: 'OPEN',
|
|
47
|
+
status: 'In Tmux by human',
|
|
48
|
+
story: 'Story Alpha',
|
|
49
|
+
nextActionDate: null,
|
|
50
|
+
nextActionHour: null,
|
|
51
|
+
estimationMinutes: null,
|
|
52
|
+
dependedIssueUrls: [],
|
|
53
|
+
completionDate50PercentConfidence: null,
|
|
54
|
+
url: 'https://github.com/demo/repo/issues/1',
|
|
55
|
+
assignees: [ASSIGNEE],
|
|
56
|
+
labels: [],
|
|
57
|
+
org: 'demo',
|
|
58
|
+
repo: 'repo',
|
|
59
|
+
body: 'should never be written',
|
|
60
|
+
itemId: 'item-1',
|
|
61
|
+
isPr: false,
|
|
62
|
+
isInProgress: false,
|
|
63
|
+
isClosed: false,
|
|
64
|
+
createdAt: new Date('2026-06-13T08:18:45.000Z'),
|
|
65
|
+
author: 'someone',
|
|
66
|
+
...overrides,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const baseParams = (outDir: string) => ({
|
|
70
|
+
inTmuxDataOutputDir: outDir,
|
|
71
|
+
inTmuxConsoleBaseUrl: CONSOLE_BASE_URL,
|
|
72
|
+
inTmuxConsoleToken: CONSOLE_TOKEN,
|
|
73
|
+
inTmuxProjectOrder: ['demo'],
|
|
74
|
+
pjcode: 'demo',
|
|
75
|
+
assigneeLogin: ASSIGNEE,
|
|
76
|
+
org: 'demo-org',
|
|
77
|
+
repo: 'demo-repo',
|
|
78
|
+
project,
|
|
79
|
+
issues: [makeIssue({})],
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const readJson = (filePath: string): unknown =>
|
|
83
|
+
JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
84
|
+
|
|
85
|
+
describe('writeInTmuxByHumanData', () => {
|
|
86
|
+
let outDir: string;
|
|
87
|
+
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'intmux-out-'));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
afterEach(() => {
|
|
93
|
+
fs.rmSync(outDir, { recursive: true, force: true });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const file = (name: string): string => path.join(outDir, name);
|
|
97
|
+
|
|
98
|
+
it('writes the four per-project files and the four index files', () => {
|
|
99
|
+
writeInTmuxByHumanData(baseParams(outDir));
|
|
100
|
+
for (const name of [
|
|
101
|
+
'demo.json',
|
|
102
|
+
'demo.v2.json',
|
|
103
|
+
'demo.v3.json',
|
|
104
|
+
'demo.v4.json',
|
|
105
|
+
'index.json',
|
|
106
|
+
'index.v2.json',
|
|
107
|
+
'index.v3.json',
|
|
108
|
+
'index.v4.json',
|
|
109
|
+
]) {
|
|
110
|
+
expect(fs.existsSync(file(name))).toBe(true);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('writes pretty-printed JSON ending with a trailing newline', () => {
|
|
115
|
+
writeInTmuxByHumanData(baseParams(outDir));
|
|
116
|
+
const raw = fs.readFileSync(file('demo.json'), 'utf8');
|
|
117
|
+
expect(raw.endsWith('\n')).toBe(true);
|
|
118
|
+
expect(raw).toContain('\n ');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('writes the v1 shape with a flat url string array', () => {
|
|
122
|
+
writeInTmuxByHumanData(baseParams(outDir));
|
|
123
|
+
expect(readJson(file('demo.json'))).toEqual([
|
|
124
|
+
{
|
|
125
|
+
story: 'Story Alpha',
|
|
126
|
+
urls: ['https://github.com/demo/repo/issues/1'],
|
|
127
|
+
},
|
|
128
|
+
]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('writes the v2 shape with url and title objects', () => {
|
|
132
|
+
writeInTmuxByHumanData(baseParams(outDir));
|
|
133
|
+
expect(readJson(file('demo.v2.json'))).toEqual([
|
|
134
|
+
{
|
|
135
|
+
story: 'Story Alpha',
|
|
136
|
+
urls: [
|
|
137
|
+
{ url: 'https://github.com/demo/repo/issues/1', title: 'Issue 1' },
|
|
138
|
+
],
|
|
139
|
+
},
|
|
140
|
+
]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('writes the v3 document with overviewUrl and a token-free console url', () => {
|
|
144
|
+
writeInTmuxByHumanData(baseParams(outDir));
|
|
145
|
+
expect(readJson(file('demo.v3.json'))).toEqual({
|
|
146
|
+
version: 3,
|
|
147
|
+
overviewUrl: 'https://github.com/orgs/demo/projects/1',
|
|
148
|
+
tdpmConsoleUrl: 'https://console.example.test/projects/demo/prs',
|
|
149
|
+
groups: [
|
|
150
|
+
{
|
|
151
|
+
story: 'Story Alpha',
|
|
152
|
+
urls: [
|
|
153
|
+
{ url: 'https://github.com/demo/repo/issues/1', title: 'Issue 1' },
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('writes the v4 document with the token-bearing console url and derived new issue url', () => {
|
|
161
|
+
writeInTmuxByHumanData(baseParams(outDir));
|
|
162
|
+
expect(readJson(file('demo.v4.json'))).toEqual({
|
|
163
|
+
version: 4,
|
|
164
|
+
overviewUrl: 'https://github.com/orgs/demo/projects/1',
|
|
165
|
+
tdpmConsoleUrl:
|
|
166
|
+
'https://console.example.test/projects/demo/prs?k=test-token-value',
|
|
167
|
+
newIssueUrl:
|
|
168
|
+
'https://github.com/demo-org/demo-repo/issues/new?assignees=owner-login',
|
|
169
|
+
groups: [
|
|
170
|
+
{
|
|
171
|
+
story: 'Story Alpha',
|
|
172
|
+
sessions: [
|
|
173
|
+
{
|
|
174
|
+
name: 'https://github.com/demo/repo/issues/1',
|
|
175
|
+
description: 'Issue 1',
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('writes index files listing configured projects whose per-project file exists', () => {
|
|
184
|
+
fs.writeFileSync(file('xmile.json'), '[]\n');
|
|
185
|
+
writeInTmuxByHumanData({
|
|
186
|
+
...baseParams(outDir),
|
|
187
|
+
inTmuxProjectOrder: ['demo', 'xmile', 'xcare'],
|
|
188
|
+
});
|
|
189
|
+
expect(readJson(file('index.json'))).toEqual({
|
|
190
|
+
projects: ['demo', 'xmile'],
|
|
191
|
+
});
|
|
192
|
+
expect(readJson(file('index.v2.json'))).toEqual({
|
|
193
|
+
version: 2,
|
|
194
|
+
projects: ['demo', 'xmile'],
|
|
195
|
+
});
|
|
196
|
+
expect(readJson(file('index.v3.json'))).toEqual({
|
|
197
|
+
version: 3,
|
|
198
|
+
projects: ['demo', 'xmile'],
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('builds the v4 index path from the output dir basename and the token', () => {
|
|
203
|
+
writeInTmuxByHumanData(baseParams(outDir));
|
|
204
|
+
const basename = path.basename(outDir);
|
|
205
|
+
expect(readJson(file('index.v4.json'))).toEqual({
|
|
206
|
+
version: 4,
|
|
207
|
+
projects: [
|
|
208
|
+
{ name: 'demo', path: `/${basename}/demo.v4.json?k=test-token-value` },
|
|
209
|
+
],
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('skips the v4 file and the v4 index when the token is unset but still writes v1, v2, v3 and v1-v3 index', () => {
|
|
214
|
+
writeInTmuxByHumanData({
|
|
215
|
+
...baseParams(outDir),
|
|
216
|
+
inTmuxConsoleToken: undefined,
|
|
217
|
+
});
|
|
218
|
+
expect(fs.existsSync(file('demo.json'))).toBe(true);
|
|
219
|
+
expect(fs.existsSync(file('demo.v2.json'))).toBe(true);
|
|
220
|
+
expect(fs.existsSync(file('demo.v3.json'))).toBe(true);
|
|
221
|
+
expect(fs.existsSync(file('demo.v4.json'))).toBe(false);
|
|
222
|
+
expect(fs.existsSync(file('index.json'))).toBe(true);
|
|
223
|
+
expect(fs.existsSync(file('index.v3.json'))).toBe(true);
|
|
224
|
+
expect(fs.existsSync(file('index.v4.json'))).toBe(false);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('skips the v3 and v4 files when the console base url is unset', () => {
|
|
228
|
+
writeInTmuxByHumanData({
|
|
229
|
+
...baseParams(outDir),
|
|
230
|
+
inTmuxConsoleBaseUrl: undefined,
|
|
231
|
+
});
|
|
232
|
+
expect(fs.existsSync(file('demo.json'))).toBe(true);
|
|
233
|
+
expect(fs.existsSync(file('demo.v2.json'))).toBe(true);
|
|
234
|
+
expect(fs.existsSync(file('demo.v3.json'))).toBe(false);
|
|
235
|
+
expect(fs.existsSync(file('demo.v4.json'))).toBe(false);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('skips the index files when the project order is empty', () => {
|
|
239
|
+
writeInTmuxByHumanData({ ...baseParams(outDir), inTmuxProjectOrder: [] });
|
|
240
|
+
expect(fs.existsSync(file('demo.json'))).toBe(true);
|
|
241
|
+
expect(fs.existsSync(file('index.json'))).toBe(false);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('does not leave a temp file behind after writing', () => {
|
|
245
|
+
writeInTmuxByHumanData(baseParams(outDir));
|
|
246
|
+
expect(fs.existsSync(`${file('demo.json')}.tmp`)).toBe(false);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('is a no-op when the output dir is unset', () => {
|
|
250
|
+
writeInTmuxByHumanData({
|
|
251
|
+
...baseParams(outDir),
|
|
252
|
+
inTmuxDataOutputDir: undefined,
|
|
253
|
+
});
|
|
254
|
+
expect(fs.readdirSync(outDir)).toHaveLength(0);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('is a no-op when pjcode is unset', () => {
|
|
258
|
+
writeInTmuxByHumanData({ ...baseParams(outDir), pjcode: '' });
|
|
259
|
+
expect(fs.readdirSync(outDir)).toHaveLength(0);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('is a no-op when the assignee login is unset', () => {
|
|
263
|
+
writeInTmuxByHumanData({ ...baseParams(outDir), assigneeLogin: null });
|
|
264
|
+
expect(fs.readdirSync(outDir)).toHaveLength(0);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import type { Issue } from '../../../domain/entities/Issue';
|
|
4
|
+
import type { Project } from '../../../domain/entities/Project';
|
|
5
|
+
import {
|
|
6
|
+
GenerateInTmuxByHumanDataUseCase,
|
|
7
|
+
InTmuxByHumanData,
|
|
8
|
+
} from '../../../domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase';
|
|
9
|
+
|
|
10
|
+
export type InTmuxByHumanDataWriterParams = {
|
|
11
|
+
inTmuxDataOutputDir: string | null | undefined;
|
|
12
|
+
inTmuxConsoleBaseUrl: string | null | undefined;
|
|
13
|
+
inTmuxConsoleToken: string | null | undefined;
|
|
14
|
+
inTmuxProjectOrder: string[] | null | undefined;
|
|
15
|
+
pjcode: string | null | undefined;
|
|
16
|
+
assigneeLogin: string | null | undefined;
|
|
17
|
+
org: string;
|
|
18
|
+
repo: string;
|
|
19
|
+
project: Project;
|
|
20
|
+
issues: Issue[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const writeJsonAtomic = (filePath: string, data: unknown): void => {
|
|
24
|
+
const dir = path.dirname(filePath);
|
|
25
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
26
|
+
const tmpPath = `${filePath}.tmp`;
|
|
27
|
+
fs.writeFileSync(tmpPath, `${JSON.stringify(data, null, 2)}\n`);
|
|
28
|
+
fs.renameSync(tmpPath, filePath);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const writeInTmuxByHumanData = (
|
|
32
|
+
params: InTmuxByHumanDataWriterParams,
|
|
33
|
+
): void => {
|
|
34
|
+
const {
|
|
35
|
+
inTmuxDataOutputDir,
|
|
36
|
+
inTmuxConsoleBaseUrl,
|
|
37
|
+
inTmuxConsoleToken,
|
|
38
|
+
inTmuxProjectOrder,
|
|
39
|
+
pjcode,
|
|
40
|
+
assigneeLogin,
|
|
41
|
+
org,
|
|
42
|
+
repo,
|
|
43
|
+
project,
|
|
44
|
+
issues,
|
|
45
|
+
} = params;
|
|
46
|
+
if (!inTmuxDataOutputDir || !pjcode || !assigneeLogin) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const data: InTmuxByHumanData = new GenerateInTmuxByHumanDataUseCase().run({
|
|
51
|
+
project,
|
|
52
|
+
issues,
|
|
53
|
+
pjcode,
|
|
54
|
+
assigneeLogin,
|
|
55
|
+
org,
|
|
56
|
+
repo,
|
|
57
|
+
consoleBaseUrl: inTmuxConsoleBaseUrl ?? null,
|
|
58
|
+
consoleToken: inTmuxConsoleToken ?? null,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
writeJsonAtomic(path.join(inTmuxDataOutputDir, `${pjcode}.json`), data.v1);
|
|
62
|
+
writeJsonAtomic(path.join(inTmuxDataOutputDir, `${pjcode}.v2.json`), data.v2);
|
|
63
|
+
if (data.v3) {
|
|
64
|
+
writeJsonAtomic(
|
|
65
|
+
path.join(inTmuxDataOutputDir, `${pjcode}.v3.json`),
|
|
66
|
+
data.v3,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (data.v4) {
|
|
70
|
+
writeJsonAtomic(
|
|
71
|
+
path.join(inTmuxDataOutputDir, `${pjcode}.v4.json`),
|
|
72
|
+
data.v4,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!inTmuxProjectOrder || inTmuxProjectOrder.length === 0) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const presentProjects = inTmuxProjectOrder.filter((name) =>
|
|
80
|
+
fs.existsSync(path.join(inTmuxDataOutputDir, `${name}.json`)),
|
|
81
|
+
);
|
|
82
|
+
writeJsonAtomic(path.join(inTmuxDataOutputDir, 'index.json'), {
|
|
83
|
+
projects: presentProjects,
|
|
84
|
+
});
|
|
85
|
+
writeJsonAtomic(path.join(inTmuxDataOutputDir, 'index.v2.json'), {
|
|
86
|
+
version: 2,
|
|
87
|
+
projects: presentProjects,
|
|
88
|
+
});
|
|
89
|
+
writeJsonAtomic(path.join(inTmuxDataOutputDir, 'index.v3.json'), {
|
|
90
|
+
version: 3,
|
|
91
|
+
projects: presentProjects,
|
|
92
|
+
});
|
|
93
|
+
if (inTmuxConsoleToken) {
|
|
94
|
+
const outputDirBasename = path.basename(inTmuxDataOutputDir);
|
|
95
|
+
writeJsonAtomic(path.join(inTmuxDataOutputDir, 'index.v4.json'), {
|
|
96
|
+
version: 4,
|
|
97
|
+
projects: presentProjects.map((name) => ({
|
|
98
|
+
name,
|
|
99
|
+
path: `/${outputDirBasename}/${name}.v4.json?k=${inTmuxConsoleToken}`,
|
|
100
|
+
})),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
};
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { Issue } from '../../entities/Issue';
|
|
2
|
+
import { FieldOption, Project } from '../../entities/Project';
|
|
3
|
+
import { GenerateInTmuxByHumanDataUseCase } from './GenerateInTmuxByHumanDataUseCase';
|
|
4
|
+
|
|
5
|
+
const ASSIGNEE = 'owner-login';
|
|
6
|
+
const CONSOLE_BASE_URL = 'https://console.example.test';
|
|
7
|
+
const CONSOLE_TOKEN = 'test-token-value';
|
|
8
|
+
|
|
9
|
+
const storyOption = (
|
|
10
|
+
id: string,
|
|
11
|
+
name: string,
|
|
12
|
+
color: FieldOption['color'],
|
|
13
|
+
): FieldOption => ({ id, name, color, description: '' });
|
|
14
|
+
|
|
15
|
+
const STORY_OPTIONS: FieldOption[] = [
|
|
16
|
+
storyOption('s1', 'Story Alpha', 'BLUE'),
|
|
17
|
+
storyOption('s2', 'Story Beta', 'GREEN'),
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const STATUS_OPTIONS: FieldOption[] = [
|
|
21
|
+
storyOption('st-unread', 'Unread', 'ORANGE'),
|
|
22
|
+
storyOption('st-tmux', 'In Tmux by human', 'RED'),
|
|
23
|
+
storyOption('st-done', 'Done', 'PURPLE'),
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const baseProject = (story: Project['story']): Project => ({
|
|
27
|
+
id: 'project-node-id',
|
|
28
|
+
url: 'https://github.com/orgs/demo/projects/1',
|
|
29
|
+
databaseId: 1,
|
|
30
|
+
name: 'demo',
|
|
31
|
+
status: {
|
|
32
|
+
name: 'Status',
|
|
33
|
+
fieldId: 'status-field',
|
|
34
|
+
statuses: STATUS_OPTIONS,
|
|
35
|
+
},
|
|
36
|
+
nextActionDate: null,
|
|
37
|
+
nextActionHour: null,
|
|
38
|
+
story,
|
|
39
|
+
remainingEstimationMinutes: null,
|
|
40
|
+
dependedIssueUrlSeparatedByComma: null,
|
|
41
|
+
completionDate50PercentConfidence: null,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const projectWithStory: Project = baseProject({
|
|
45
|
+
name: 'story',
|
|
46
|
+
fieldId: 'story-field',
|
|
47
|
+
databaseId: 2,
|
|
48
|
+
stories: STORY_OPTIONS,
|
|
49
|
+
workflowManagementStory: { id: 'wm', name: 'workflow management' },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const projectWithoutStory: Project = baseProject(null);
|
|
53
|
+
|
|
54
|
+
let issueCounter = 0;
|
|
55
|
+
const makeIssue = (overrides: Partial<Issue>): Issue => {
|
|
56
|
+
issueCounter += 1;
|
|
57
|
+
return {
|
|
58
|
+
nameWithOwner: 'demo/repo',
|
|
59
|
+
number: issueCounter,
|
|
60
|
+
title: `Issue ${issueCounter}`,
|
|
61
|
+
state: 'OPEN',
|
|
62
|
+
status: 'In Tmux by human',
|
|
63
|
+
story: null,
|
|
64
|
+
nextActionDate: null,
|
|
65
|
+
nextActionHour: null,
|
|
66
|
+
estimationMinutes: null,
|
|
67
|
+
dependedIssueUrls: [],
|
|
68
|
+
completionDate50PercentConfidence: null,
|
|
69
|
+
url: `https://github.com/demo/repo/issues/${issueCounter}`,
|
|
70
|
+
assignees: [ASSIGNEE],
|
|
71
|
+
labels: [],
|
|
72
|
+
org: 'demo',
|
|
73
|
+
repo: 'repo',
|
|
74
|
+
body: 'should never be projected',
|
|
75
|
+
itemId: `item-${issueCounter}`,
|
|
76
|
+
isPr: false,
|
|
77
|
+
isInProgress: false,
|
|
78
|
+
isClosed: false,
|
|
79
|
+
createdAt: new Date('2026-06-13T08:18:45.000Z'),
|
|
80
|
+
author: 'someone',
|
|
81
|
+
...overrides,
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
describe('GenerateInTmuxByHumanDataUseCase', () => {
|
|
86
|
+
const usecase = new GenerateInTmuxByHumanDataUseCase();
|
|
87
|
+
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
issueCounter = 0;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const run = (
|
|
93
|
+
issues: Issue[],
|
|
94
|
+
overrides: {
|
|
95
|
+
project?: Project;
|
|
96
|
+
consoleBaseUrl?: string | null;
|
|
97
|
+
consoleToken?: string | null;
|
|
98
|
+
} = {},
|
|
99
|
+
) =>
|
|
100
|
+
usecase.run({
|
|
101
|
+
project: overrides.project ?? projectWithStory,
|
|
102
|
+
issues,
|
|
103
|
+
pjcode: 'demo',
|
|
104
|
+
assigneeLogin: ASSIGNEE,
|
|
105
|
+
org: 'demo-org',
|
|
106
|
+
repo: 'demo-repo',
|
|
107
|
+
consoleBaseUrl:
|
|
108
|
+
overrides.consoleBaseUrl === undefined
|
|
109
|
+
? CONSOLE_BASE_URL
|
|
110
|
+
: overrides.consoleBaseUrl,
|
|
111
|
+
consoleToken:
|
|
112
|
+
overrides.consoleToken === undefined
|
|
113
|
+
? CONSOLE_TOKEN
|
|
114
|
+
: overrides.consoleToken,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('item filter', () => {
|
|
118
|
+
it('keeps In Tmux by human open issues assigned to the assignee login', () => {
|
|
119
|
+
const result = run([makeIssue({ story: 'Story Alpha' })]);
|
|
120
|
+
expect(result.v1).toEqual([
|
|
121
|
+
{
|
|
122
|
+
story: 'Story Alpha',
|
|
123
|
+
urls: ['https://github.com/demo/repo/issues/1'],
|
|
124
|
+
},
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('rejects issues whose status is not In Tmux by human', () => {
|
|
129
|
+
const result = run([makeIssue({ status: 'Unread' })]);
|
|
130
|
+
expect(result.v1).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('rejects closed issues', () => {
|
|
134
|
+
const result = run([makeIssue({ isClosed: true })]);
|
|
135
|
+
expect(result.v1).toEqual([]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('rejects issues not assigned to the assignee login', () => {
|
|
139
|
+
const result = run([makeIssue({ assignees: ['other-person'] })]);
|
|
140
|
+
expect(result.v1).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('story grouping and ordering', () => {
|
|
145
|
+
it('orders groups by the project story option display order', () => {
|
|
146
|
+
const result = run([
|
|
147
|
+
makeIssue({ story: 'Story Beta' }),
|
|
148
|
+
makeIssue({ story: 'Story Alpha' }),
|
|
149
|
+
]);
|
|
150
|
+
expect(result.v2.map((group) => group.story)).toEqual([
|
|
151
|
+
'Story Alpha',
|
|
152
|
+
'Story Beta',
|
|
153
|
+
]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('places stories not present in the story options at the tail ordered by story string', () => {
|
|
157
|
+
const result = run([
|
|
158
|
+
makeIssue({ story: 'zzz unknown' }),
|
|
159
|
+
makeIssue({ story: 'Story Alpha' }),
|
|
160
|
+
makeIssue({ story: 'aaa unknown' }),
|
|
161
|
+
]);
|
|
162
|
+
expect(result.v2.map((group) => group.story)).toEqual([
|
|
163
|
+
'Story Alpha',
|
|
164
|
+
'aaa unknown',
|
|
165
|
+
'zzz unknown',
|
|
166
|
+
]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('maps a null story to the empty string group', () => {
|
|
170
|
+
const result = run([makeIssue({ story: null })]);
|
|
171
|
+
expect(result.v1.map((group) => group.story)).toEqual(['']);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('keeps input order of issues within a group', () => {
|
|
175
|
+
const result = run([
|
|
176
|
+
makeIssue({ story: 'Story Alpha' }),
|
|
177
|
+
makeIssue({ story: 'Story Alpha' }),
|
|
178
|
+
]);
|
|
179
|
+
expect(result.v2[0].urls).toEqual([
|
|
180
|
+
{
|
|
181
|
+
url: 'https://github.com/demo/repo/issues/1',
|
|
182
|
+
title: 'Issue 1',
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
url: 'https://github.com/demo/repo/issues/2',
|
|
186
|
+
title: 'Issue 2',
|
|
187
|
+
},
|
|
188
|
+
]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('uses an empty story order when the project has no story field', () => {
|
|
192
|
+
const result = run([makeIssue({ story: 'whatever' })], {
|
|
193
|
+
project: projectWithoutStory,
|
|
194
|
+
});
|
|
195
|
+
expect(result.v2.map((group) => group.story)).toEqual(['whatever']);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe('v3 document', () => {
|
|
200
|
+
it('builds version 3 with overviewUrl from the project and a token-free console url', () => {
|
|
201
|
+
const result = run([makeIssue({ story: 'Story Alpha' })]);
|
|
202
|
+
expect(result.v3).toEqual({
|
|
203
|
+
version: 3,
|
|
204
|
+
overviewUrl: 'https://github.com/orgs/demo/projects/1',
|
|
205
|
+
tdpmConsoleUrl: 'https://console.example.test/projects/demo/prs',
|
|
206
|
+
groups: result.v2,
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('is null when the console base url is unset', () => {
|
|
211
|
+
const result = run([makeIssue({ story: 'Story Alpha' })], {
|
|
212
|
+
consoleBaseUrl: null,
|
|
213
|
+
});
|
|
214
|
+
expect(result.v3).toBeNull();
|
|
215
|
+
expect(result.v4).toBeNull();
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('v4 document', () => {
|
|
220
|
+
it('builds version 4 with key order version, overviewUrl, tdpmConsoleUrl, newIssueUrl, groups', () => {
|
|
221
|
+
const result = run([makeIssue({ story: 'Story Alpha' })]);
|
|
222
|
+
expect(result.v4).not.toBeNull();
|
|
223
|
+
expect(Object.keys(result.v4 ?? {})).toEqual([
|
|
224
|
+
'version',
|
|
225
|
+
'overviewUrl',
|
|
226
|
+
'tdpmConsoleUrl',
|
|
227
|
+
'newIssueUrl',
|
|
228
|
+
'groups',
|
|
229
|
+
]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('embeds the token in the console url and derives the new issue url from org and repo', () => {
|
|
233
|
+
const result = run([makeIssue({ story: 'Story Alpha' })]);
|
|
234
|
+
expect(result.v4).toEqual({
|
|
235
|
+
version: 4,
|
|
236
|
+
overviewUrl: 'https://github.com/orgs/demo/projects/1',
|
|
237
|
+
tdpmConsoleUrl:
|
|
238
|
+
'https://console.example.test/projects/demo/prs?k=test-token-value',
|
|
239
|
+
newIssueUrl:
|
|
240
|
+
'https://github.com/demo-org/demo-repo/issues/new?assignees=owner-login',
|
|
241
|
+
groups: [
|
|
242
|
+
{
|
|
243
|
+
story: 'Story Alpha',
|
|
244
|
+
sessions: [
|
|
245
|
+
{
|
|
246
|
+
name: 'https://github.com/demo/repo/issues/1',
|
|
247
|
+
description: 'Issue 1',
|
|
248
|
+
},
|
|
249
|
+
],
|
|
250
|
+
},
|
|
251
|
+
],
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('maps each session to the issue url as name and the issue title as description', () => {
|
|
256
|
+
const result = run([
|
|
257
|
+
makeIssue({ story: 'Story Alpha' }),
|
|
258
|
+
makeIssue({ story: 'Story Alpha' }),
|
|
259
|
+
]);
|
|
260
|
+
expect(result.v4?.groups).toEqual([
|
|
261
|
+
{
|
|
262
|
+
story: 'Story Alpha',
|
|
263
|
+
sessions: [
|
|
264
|
+
{
|
|
265
|
+
name: 'https://github.com/demo/repo/issues/1',
|
|
266
|
+
description: 'Issue 1',
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
name: 'https://github.com/demo/repo/issues/2',
|
|
270
|
+
description: 'Issue 2',
|
|
271
|
+
},
|
|
272
|
+
],
|
|
273
|
+
},
|
|
274
|
+
]);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('is null when the console token is unset while v3 is still produced', () => {
|
|
278
|
+
const result = run([makeIssue({ story: 'Story Alpha' })], {
|
|
279
|
+
consoleToken: null,
|
|
280
|
+
});
|
|
281
|
+
expect(result.v4).toBeNull();
|
|
282
|
+
expect(result.v3).not.toBeNull();
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
});
|