github-issue-tower-defence-management 1.126.1 → 1.126.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.
- package/CHANGELOG.md +14 -0
- package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js +49 -24
- package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js.map +1 -1
- package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js +50 -36
- package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
- package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +33 -2
- package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
- package/package.json +4 -4
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.test.ts +5 -2
- package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.test.ts +183 -18
- package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.ts +59 -26
- package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +148 -0
- package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +62 -45
- package/src/domain/entities/LiveSessionActivitySnapshot.ts +1 -0
- package/src/domain/entities/LiveSessionOutputActivity.ts +1 -0
- package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +70 -0
- package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +37 -1
- package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts +1 -11
- package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts.map +1 -1
- package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
- package/types/domain/entities/LiveSessionActivitySnapshot.d.ts +1 -0
- package/types/domain/entities/LiveSessionActivitySnapshot.d.ts.map +1 -1
- package/types/domain/entities/LiveSessionOutputActivity.d.ts +1 -0
- package/types/domain/entities/LiveSessionOutputActivity.d.ts.map +1 -1
- package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +1 -0
- package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
|
@@ -25,6 +25,14 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
25
25
|
},
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
+
const assistantEntryWithoutTimestamp = (): object => ({
|
|
29
|
+
type: 'assistant',
|
|
30
|
+
message: {
|
|
31
|
+
role: 'assistant',
|
|
32
|
+
content: [{ type: 'text', text: 'no timestamp on this entry' }],
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
28
36
|
const userEntry = (timestamp: string): object => ({
|
|
29
37
|
type: 'user',
|
|
30
38
|
timestamp,
|
|
@@ -37,6 +45,51 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
37
45
|
toolUseResult: { stdout: 'done', stderr: '' },
|
|
38
46
|
});
|
|
39
47
|
|
|
48
|
+
const systemEntry = (timestamp: string): object => ({
|
|
49
|
+
type: 'system',
|
|
50
|
+
timestamp,
|
|
51
|
+
content: 'Compacting conversation history',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const assistantEntryWithToolUse = (
|
|
55
|
+
timestamp: string,
|
|
56
|
+
toolUseId: string,
|
|
57
|
+
): object => ({
|
|
58
|
+
type: 'assistant',
|
|
59
|
+
timestamp,
|
|
60
|
+
message: {
|
|
61
|
+
role: 'assistant',
|
|
62
|
+
stop_reason: 'tool_use',
|
|
63
|
+
content: [
|
|
64
|
+
{ type: 'text', text: 'running a command' },
|
|
65
|
+
{
|
|
66
|
+
type: 'tool_use',
|
|
67
|
+
id: toolUseId,
|
|
68
|
+
name: 'Bash',
|
|
69
|
+
input: { command: 'sleep 590' },
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const toolResultUserEntry = (
|
|
76
|
+
timestamp: string,
|
|
77
|
+
toolUseId: string,
|
|
78
|
+
): object => ({
|
|
79
|
+
type: 'user',
|
|
80
|
+
timestamp,
|
|
81
|
+
message: {
|
|
82
|
+
role: 'user',
|
|
83
|
+
content: [
|
|
84
|
+
{
|
|
85
|
+
type: 'tool_result',
|
|
86
|
+
tool_use_id: toolUseId,
|
|
87
|
+
content: 'done',
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
40
93
|
const untimestampedEntry = (): object => ({
|
|
41
94
|
type: 'summary',
|
|
42
95
|
summary: 'no timestamp on this entry',
|
|
@@ -52,11 +105,56 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
52
105
|
return filePath;
|
|
53
106
|
};
|
|
54
107
|
|
|
55
|
-
it('returns the
|
|
108
|
+
it('returns the old last-assistant timestamp when recent system and tool entries follow it', async () => {
|
|
109
|
+
const transcriptPath = writeTranscript('thrashing.jsonl', [
|
|
110
|
+
assistantEntry('2026-06-27T01:00:00.000Z'),
|
|
111
|
+
systemEntry('2026-06-27T09:30:00.000Z'),
|
|
112
|
+
toolResultEntry('2026-06-27T09:45:00.000Z'),
|
|
113
|
+
systemEntry('2026-06-27T10:00:00.000Z'),
|
|
114
|
+
]);
|
|
115
|
+
const repository = new FileSystemSessionOutputActivityRepository();
|
|
116
|
+
|
|
117
|
+
const result = await repository.listSessionOutputActivities(
|
|
118
|
+
new Map([['thrashing', transcriptPath]]),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
expect(result).toEqual([
|
|
122
|
+
{
|
|
123
|
+
sessionName: 'thrashing',
|
|
124
|
+
lastOutputEpochSeconds: Math.floor(
|
|
125
|
+
Date.parse('2026-06-27T01:00:00.000Z') / 1000,
|
|
126
|
+
),
|
|
127
|
+
hasInProgressToolCall: false,
|
|
128
|
+
},
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('returns the recent timestamp when the latest assistant entry is recent', async () => {
|
|
133
|
+
const transcriptPath = writeTranscript('working.jsonl', [
|
|
134
|
+
userEntry('2026-06-27T09:00:00.000Z'),
|
|
135
|
+
assistantEntry('2026-06-27T09:55:00.000Z'),
|
|
136
|
+
]);
|
|
137
|
+
const repository = new FileSystemSessionOutputActivityRepository();
|
|
138
|
+
|
|
139
|
+
const result = await repository.listSessionOutputActivities(
|
|
140
|
+
new Map([['working', transcriptPath]]),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
expect(result).toEqual([
|
|
144
|
+
{
|
|
145
|
+
sessionName: 'working',
|
|
146
|
+
lastOutputEpochSeconds: Math.floor(
|
|
147
|
+
Date.parse('2026-06-27T09:55:00.000Z') / 1000,
|
|
148
|
+
),
|
|
149
|
+
hasInProgressToolCall: false,
|
|
150
|
+
},
|
|
151
|
+
]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('ignores a later user entry and returns the last assistant timestamp', async () => {
|
|
56
155
|
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
57
156
|
assistantEntry('2026-06-27T10:00:00.000Z'),
|
|
58
|
-
userEntry('2026-06-
|
|
59
|
-
assistantEntry('2026-06-27T10:05:00.000Z'),
|
|
157
|
+
userEntry('2026-06-27T11:00:00.000Z'),
|
|
60
158
|
]);
|
|
61
159
|
const repository = new FileSystemSessionOutputActivityRepository();
|
|
62
160
|
|
|
@@ -68,16 +166,41 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
68
166
|
{
|
|
69
167
|
sessionName: 'workbench',
|
|
70
168
|
lastOutputEpochSeconds: Math.floor(
|
|
71
|
-
Date.parse('2026-06-27T10:
|
|
169
|
+
Date.parse('2026-06-27T10:00:00.000Z') / 1000,
|
|
72
170
|
),
|
|
171
|
+
hasInProgressToolCall: false,
|
|
73
172
|
},
|
|
74
173
|
]);
|
|
75
174
|
});
|
|
76
175
|
|
|
77
|
-
it('
|
|
176
|
+
it('ignores a later tool_result entry and returns the last assistant timestamp', async () => {
|
|
78
177
|
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
79
178
|
assistantEntry('2026-06-27T10:00:00.000Z'),
|
|
80
|
-
|
|
179
|
+
toolResultEntry('2026-06-27T10:45:00.000Z'),
|
|
180
|
+
]);
|
|
181
|
+
const repository = new FileSystemSessionOutputActivityRepository();
|
|
182
|
+
|
|
183
|
+
const result = await repository.listSessionOutputActivities(
|
|
184
|
+
new Map([['workbench', transcriptPath]]),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
expect(result).toEqual([
|
|
188
|
+
{
|
|
189
|
+
sessionName: 'workbench',
|
|
190
|
+
lastOutputEpochSeconds: Math.floor(
|
|
191
|
+
Date.parse('2026-06-27T10:00:00.000Z') / 1000,
|
|
192
|
+
),
|
|
193
|
+
hasInProgressToolCall: false,
|
|
194
|
+
},
|
|
195
|
+
]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('returns the last assistant entry timestamp when several entry types interleave', async () => {
|
|
199
|
+
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
200
|
+
assistantEntry('2026-06-27T10:00:00.000Z'),
|
|
201
|
+
userEntry('2026-06-27T10:10:00.000Z'),
|
|
202
|
+
assistantEntry('2026-06-27T10:05:00.000Z'),
|
|
203
|
+
systemEntry('2026-06-27T10:40:00.000Z'),
|
|
81
204
|
]);
|
|
82
205
|
const repository = new FileSystemSessionOutputActivityRepository();
|
|
83
206
|
|
|
@@ -89,16 +212,17 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
89
212
|
{
|
|
90
213
|
sessionName: 'workbench',
|
|
91
214
|
lastOutputEpochSeconds: Math.floor(
|
|
92
|
-
Date.parse('2026-06-
|
|
215
|
+
Date.parse('2026-06-27T10:05:00.000Z') / 1000,
|
|
93
216
|
),
|
|
217
|
+
hasInProgressToolCall: false,
|
|
94
218
|
},
|
|
95
219
|
]);
|
|
96
220
|
});
|
|
97
221
|
|
|
98
|
-
it('
|
|
222
|
+
it('falls back to the last assistant entry that has a parseable timestamp', async () => {
|
|
99
223
|
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
100
224
|
assistantEntry('2026-06-27T10:00:00.000Z'),
|
|
101
|
-
|
|
225
|
+
assistantEntryWithoutTimestamp(),
|
|
102
226
|
]);
|
|
103
227
|
const repository = new FileSystemSessionOutputActivityRepository();
|
|
104
228
|
|
|
@@ -110,8 +234,9 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
110
234
|
{
|
|
111
235
|
sessionName: 'workbench',
|
|
112
236
|
lastOutputEpochSeconds: Math.floor(
|
|
113
|
-
Date.parse('2026-06-27T10:
|
|
237
|
+
Date.parse('2026-06-27T10:00:00.000Z') / 1000,
|
|
114
238
|
),
|
|
239
|
+
hasInProgressToolCall: false,
|
|
115
240
|
},
|
|
116
241
|
]);
|
|
117
242
|
});
|
|
@@ -135,13 +260,29 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
135
260
|
lastOutputEpochSeconds: Math.floor(
|
|
136
261
|
Date.parse('2026-06-27T10:00:00.000Z') / 1000,
|
|
137
262
|
),
|
|
263
|
+
hasInProgressToolCall: false,
|
|
138
264
|
},
|
|
139
265
|
]);
|
|
140
266
|
});
|
|
141
267
|
|
|
142
|
-
it('
|
|
268
|
+
it('omits a transcript whose only entries are non-assistant entries', async () => {
|
|
143
269
|
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
144
270
|
userEntry('2026-06-27T10:00:00.000Z'),
|
|
271
|
+
toolResultEntry('2026-06-27T10:05:00.000Z'),
|
|
272
|
+
systemEntry('2026-06-27T10:10:00.000Z'),
|
|
273
|
+
]);
|
|
274
|
+
const repository = new FileSystemSessionOutputActivityRepository();
|
|
275
|
+
|
|
276
|
+
const result = await repository.listSessionOutputActivities(
|
|
277
|
+
new Map([['workbench', transcriptPath]]),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
expect(result).toEqual([]);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('omits sessions whose transcript has no parseable timestamp', async () => {
|
|
284
|
+
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
285
|
+
untimestampedEntry(),
|
|
145
286
|
]);
|
|
146
287
|
const repository = new FileSystemSessionOutputActivityRepository();
|
|
147
288
|
|
|
@@ -149,27 +290,51 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
149
290
|
new Map([['workbench', transcriptPath]]),
|
|
150
291
|
);
|
|
151
292
|
|
|
293
|
+
expect(result).toEqual([]);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('flags a session as waiting on a running tool when the last assistant tool_use has no matching tool_result', async () => {
|
|
297
|
+
const transcriptPath = writeTranscript('busy.jsonl', [
|
|
298
|
+
assistantEntry('2026-06-27T09:00:00.000Z'),
|
|
299
|
+
assistantEntryWithToolUse('2026-06-27T09:01:00.000Z', 'toolu_running'),
|
|
300
|
+
]);
|
|
301
|
+
const repository = new FileSystemSessionOutputActivityRepository();
|
|
302
|
+
|
|
303
|
+
const result = await repository.listSessionOutputActivities(
|
|
304
|
+
new Map([['busy', transcriptPath]]),
|
|
305
|
+
);
|
|
306
|
+
|
|
152
307
|
expect(result).toEqual([
|
|
153
308
|
{
|
|
154
|
-
sessionName: '
|
|
309
|
+
sessionName: 'busy',
|
|
155
310
|
lastOutputEpochSeconds: Math.floor(
|
|
156
|
-
Date.parse('2026-06-
|
|
311
|
+
Date.parse('2026-06-27T09:01:00.000Z') / 1000,
|
|
157
312
|
),
|
|
313
|
+
hasInProgressToolCall: true,
|
|
158
314
|
},
|
|
159
315
|
]);
|
|
160
316
|
});
|
|
161
317
|
|
|
162
|
-
it('
|
|
163
|
-
const transcriptPath = writeTranscript('
|
|
164
|
-
|
|
318
|
+
it('does not flag a session once the matching tool_result for its last tool_use is appended', async () => {
|
|
319
|
+
const transcriptPath = writeTranscript('completed.jsonl', [
|
|
320
|
+
assistantEntryWithToolUse('2026-06-27T09:01:00.000Z', 'toolu_done'),
|
|
321
|
+
toolResultUserEntry('2026-06-27T09:05:00.000Z', 'toolu_done'),
|
|
165
322
|
]);
|
|
166
323
|
const repository = new FileSystemSessionOutputActivityRepository();
|
|
167
324
|
|
|
168
325
|
const result = await repository.listSessionOutputActivities(
|
|
169
|
-
new Map([['
|
|
326
|
+
new Map([['completed', transcriptPath]]),
|
|
170
327
|
);
|
|
171
328
|
|
|
172
|
-
expect(result).toEqual([
|
|
329
|
+
expect(result).toEqual([
|
|
330
|
+
{
|
|
331
|
+
sessionName: 'completed',
|
|
332
|
+
lastOutputEpochSeconds: Math.floor(
|
|
333
|
+
Date.parse('2026-06-27T09:01:00.000Z') / 1000,
|
|
334
|
+
),
|
|
335
|
+
hasInProgressToolCall: false,
|
|
336
|
+
},
|
|
337
|
+
]);
|
|
173
338
|
});
|
|
174
339
|
|
|
175
340
|
it('returns an empty list when no sessions are requested', async () => {
|
|
@@ -21,41 +21,54 @@ const parseEpochMilliseconds = (timestamp: string | null): number | null => {
|
|
|
21
21
|
return Number.isNaN(parsed) ? null : parsed;
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
24
|
+
const readContentBlocks = (
|
|
25
|
+
message: Record<string, unknown>,
|
|
26
|
+
): Record<string, unknown>[] => {
|
|
27
|
+
const content = message.content;
|
|
28
|
+
if (!Array.isArray(content)) {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
return content.filter(isRecord);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type TranscriptActivity = {
|
|
35
|
+
lastAssistantOutputEpochSeconds: number | null;
|
|
36
|
+
hasInProgressToolCall: boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
34
39
|
export class FileSystemSessionOutputActivityRepository implements SessionOutputActivityRepository {
|
|
35
40
|
listSessionOutputActivities = async (
|
|
36
41
|
transcriptPathBySessionName: Map<string, string>,
|
|
37
42
|
): Promise<LiveSessionOutputActivity[]> => {
|
|
38
43
|
const activities: LiveSessionOutputActivity[] = [];
|
|
39
44
|
for (const [sessionName, transcriptPath] of transcriptPathBySessionName) {
|
|
40
|
-
const
|
|
41
|
-
this.
|
|
42
|
-
if (
|
|
43
|
-
activities.push({
|
|
45
|
+
const { lastAssistantOutputEpochSeconds, hasInProgressToolCall } =
|
|
46
|
+
this.readTranscriptActivity(transcriptPath);
|
|
47
|
+
if (lastAssistantOutputEpochSeconds !== null) {
|
|
48
|
+
activities.push({
|
|
49
|
+
sessionName,
|
|
50
|
+
lastOutputEpochSeconds: lastAssistantOutputEpochSeconds,
|
|
51
|
+
hasInProgressToolCall,
|
|
52
|
+
});
|
|
44
53
|
}
|
|
45
54
|
}
|
|
46
55
|
return activities;
|
|
47
56
|
};
|
|
48
57
|
|
|
49
|
-
private
|
|
58
|
+
private readTranscriptActivity = (
|
|
50
59
|
transcriptPath: string,
|
|
51
|
-
):
|
|
60
|
+
): TranscriptActivity => {
|
|
52
61
|
let content: string;
|
|
53
62
|
try {
|
|
54
63
|
content = fs.readFileSync(transcriptPath, 'utf8');
|
|
55
64
|
} catch {
|
|
56
|
-
return
|
|
65
|
+
return {
|
|
66
|
+
lastAssistantOutputEpochSeconds: null,
|
|
67
|
+
hasInProgressToolCall: false,
|
|
68
|
+
};
|
|
57
69
|
}
|
|
58
|
-
let
|
|
70
|
+
let lastAssistantOutputEpochMs: number | null = null;
|
|
71
|
+
const pendingToolUseIds = new Set<string>();
|
|
59
72
|
for (const line of content.split('\n')) {
|
|
60
73
|
const trimmed = line.trim();
|
|
61
74
|
if (trimmed.length === 0) {
|
|
@@ -70,17 +83,37 @@ export class FileSystemSessionOutputActivityRepository implements SessionOutputA
|
|
|
70
83
|
if (!isRecord(parsed)) {
|
|
71
84
|
continue;
|
|
72
85
|
}
|
|
73
|
-
const
|
|
74
|
-
if (
|
|
86
|
+
const message = parsed.message;
|
|
87
|
+
if (readString(parsed, 'type') === 'assistant') {
|
|
88
|
+
const epochMs = parseEpochMilliseconds(readString(parsed, 'timestamp'));
|
|
89
|
+
if (epochMs !== null) {
|
|
90
|
+
lastAssistantOutputEpochMs = epochMs;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (!isRecord(message)) {
|
|
75
94
|
continue;
|
|
76
95
|
}
|
|
77
|
-
|
|
78
|
-
|
|
96
|
+
for (const block of readContentBlocks(message)) {
|
|
97
|
+
const blockType = readString(block, 'type');
|
|
98
|
+
if (blockType === 'tool_use') {
|
|
99
|
+
const toolUseId = readString(block, 'id');
|
|
100
|
+
if (toolUseId !== null) {
|
|
101
|
+
pendingToolUseIds.add(toolUseId);
|
|
102
|
+
}
|
|
103
|
+
} else if (blockType === 'tool_result') {
|
|
104
|
+
const toolUseId = readString(block, 'tool_use_id');
|
|
105
|
+
if (toolUseId !== null) {
|
|
106
|
+
pendingToolUseIds.delete(toolUseId);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
79
109
|
}
|
|
80
110
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
111
|
+
return {
|
|
112
|
+
lastAssistantOutputEpochSeconds:
|
|
113
|
+
lastAssistantOutputEpochMs === null
|
|
114
|
+
? null
|
|
115
|
+
: Math.floor(lastAssistantOutputEpochMs / 1000),
|
|
116
|
+
hasInProgressToolCall: pendingToolUseIds.size > 0,
|
|
117
|
+
};
|
|
85
118
|
};
|
|
86
119
|
}
|
|
@@ -838,6 +838,154 @@ describe('GraphqlProjectItemRepository', () => {
|
|
|
838
838
|
|
|
839
839
|
expect(result.map((item) => item.id)).toEqual(['PVTI_1']);
|
|
840
840
|
});
|
|
841
|
+
|
|
842
|
+
it('retries the full fetch exactly once when the last page reports fewer accumulated items than totalCount and returns the consistent retry result', async () => {
|
|
843
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
844
|
+
const repository = new GraphqlProjectItemRepository(
|
|
845
|
+
new LocalStorageRepository(),
|
|
846
|
+
'dummy-token',
|
|
847
|
+
);
|
|
848
|
+
mockPost
|
|
849
|
+
.mockReturnValueOnce(
|
|
850
|
+
makeLightPageResponse(
|
|
851
|
+
false,
|
|
852
|
+
'cursor-1',
|
|
853
|
+
[
|
|
854
|
+
{
|
|
855
|
+
id: 'PVTI_1',
|
|
856
|
+
updatedAt: '2026-07-07T10:00:00Z',
|
|
857
|
+
content: { url: 'https://github.com/o/r/issues/1', number: 1 },
|
|
858
|
+
},
|
|
859
|
+
],
|
|
860
|
+
2,
|
|
861
|
+
),
|
|
862
|
+
)
|
|
863
|
+
.mockReturnValueOnce(
|
|
864
|
+
makeLightPageResponse(
|
|
865
|
+
false,
|
|
866
|
+
'cursor-1',
|
|
867
|
+
[
|
|
868
|
+
{
|
|
869
|
+
id: 'PVTI_1',
|
|
870
|
+
updatedAt: '2026-07-07T10:00:00Z',
|
|
871
|
+
content: { url: 'https://github.com/o/r/issues/1', number: 1 },
|
|
872
|
+
},
|
|
873
|
+
{
|
|
874
|
+
id: 'PVTI_2',
|
|
875
|
+
updatedAt: '2026-07-07T11:00:00Z',
|
|
876
|
+
content: { url: 'https://github.com/o/r/issues/2', number: 2 },
|
|
877
|
+
},
|
|
878
|
+
],
|
|
879
|
+
2,
|
|
880
|
+
),
|
|
881
|
+
);
|
|
882
|
+
|
|
883
|
+
const result = await repository.fetchProjectItemsLight(
|
|
884
|
+
'test-project-id',
|
|
885
|
+
'updated:>=2026-07-07',
|
|
886
|
+
);
|
|
887
|
+
|
|
888
|
+
expect(mockPost).toHaveBeenCalledTimes(2);
|
|
889
|
+
expect(result.map((item) => item.id)).toEqual(['PVTI_1', 'PVTI_2']);
|
|
890
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
891
|
+
expect.stringContaining(
|
|
892
|
+
'page 1 has 1 nodes with hasNextPage=false but only 1/2 items accumulated',
|
|
893
|
+
),
|
|
894
|
+
);
|
|
895
|
+
warnSpy.mockRestore();
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
it('warns and returns the accumulated items without throwing when the retry is still inconsistent', async () => {
|
|
899
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
900
|
+
const repository = new GraphqlProjectItemRepository(
|
|
901
|
+
new LocalStorageRepository(),
|
|
902
|
+
'dummy-token',
|
|
903
|
+
);
|
|
904
|
+
const inconsistentPage = () =>
|
|
905
|
+
makeLightPageResponse(
|
|
906
|
+
false,
|
|
907
|
+
'cursor-1',
|
|
908
|
+
[
|
|
909
|
+
{
|
|
910
|
+
id: 'PVTI_1',
|
|
911
|
+
updatedAt: '2026-07-07T10:00:00Z',
|
|
912
|
+
content: { url: 'https://github.com/o/r/issues/1', number: 1 },
|
|
913
|
+
},
|
|
914
|
+
],
|
|
915
|
+
2,
|
|
916
|
+
);
|
|
917
|
+
mockPost
|
|
918
|
+
.mockReturnValueOnce(inconsistentPage())
|
|
919
|
+
.mockReturnValueOnce(inconsistentPage());
|
|
920
|
+
|
|
921
|
+
const result = await repository.fetchProjectItemsLight(
|
|
922
|
+
'test-project-id',
|
|
923
|
+
'updated:>=2026-07-07',
|
|
924
|
+
);
|
|
925
|
+
|
|
926
|
+
expect(mockPost).toHaveBeenCalledTimes(2);
|
|
927
|
+
expect(result.map((item) => item.id)).toEqual(['PVTI_1']);
|
|
928
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
929
|
+
expect.stringContaining(
|
|
930
|
+
'page 1 has 1 nodes with hasNextPage=false but only 1/2 items accumulated',
|
|
931
|
+
),
|
|
932
|
+
);
|
|
933
|
+
warnSpy.mockRestore();
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
it('retries exactly once when the accumulated count exceeds totalCount after the loop and returns the consistent retry result', async () => {
|
|
937
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
938
|
+
const repository = new GraphqlProjectItemRepository(
|
|
939
|
+
new LocalStorageRepository(),
|
|
940
|
+
'dummy-token',
|
|
941
|
+
);
|
|
942
|
+
mockPost
|
|
943
|
+
.mockReturnValueOnce(
|
|
944
|
+
makeLightPageResponse(
|
|
945
|
+
false,
|
|
946
|
+
'cursor-1',
|
|
947
|
+
[
|
|
948
|
+
{
|
|
949
|
+
id: 'PVTI_1',
|
|
950
|
+
updatedAt: '2026-07-07T10:00:00Z',
|
|
951
|
+
content: { url: 'https://github.com/o/r/issues/1', number: 1 },
|
|
952
|
+
},
|
|
953
|
+
{
|
|
954
|
+
id: 'PVTI_2',
|
|
955
|
+
updatedAt: '2026-07-07T11:00:00Z',
|
|
956
|
+
content: { url: 'https://github.com/o/r/issues/2', number: 2 },
|
|
957
|
+
},
|
|
958
|
+
],
|
|
959
|
+
1,
|
|
960
|
+
),
|
|
961
|
+
)
|
|
962
|
+
.mockReturnValueOnce(
|
|
963
|
+
makeLightPageResponse(
|
|
964
|
+
false,
|
|
965
|
+
'cursor-1',
|
|
966
|
+
[
|
|
967
|
+
{
|
|
968
|
+
id: 'PVTI_1',
|
|
969
|
+
updatedAt: '2026-07-07T10:00:00Z',
|
|
970
|
+
content: { url: 'https://github.com/o/r/issues/1', number: 1 },
|
|
971
|
+
},
|
|
972
|
+
],
|
|
973
|
+
1,
|
|
974
|
+
),
|
|
975
|
+
);
|
|
976
|
+
|
|
977
|
+
const result = await repository.fetchProjectItemsLight(
|
|
978
|
+
'test-project-id',
|
|
979
|
+
'updated:>=2026-07-07',
|
|
980
|
+
);
|
|
981
|
+
|
|
982
|
+
expect(mockPost).toHaveBeenCalledTimes(2);
|
|
983
|
+
expect(result.map((item) => item.id)).toEqual(['PVTI_1']);
|
|
984
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
985
|
+
expect.stringContaining('expected 1 items but accumulated 2'),
|
|
986
|
+
);
|
|
987
|
+
warnSpy.mockRestore();
|
|
988
|
+
});
|
|
841
989
|
});
|
|
842
990
|
|
|
843
991
|
describe('fetchProjectItemsByIds', () => {
|
|
@@ -719,59 +719,76 @@ query GetProjectItemsLight($projectId: ID!, $after: String, $first: Int!, $query
|
|
|
719
719
|
}
|
|
720
720
|
return rawData.node.items;
|
|
721
721
|
};
|
|
722
|
-
const
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
722
|
+
const fetchAllLightPages = async (): Promise<{
|
|
723
|
+
lightItems: ProjectItemLight[];
|
|
724
|
+
inconsistencyMessage: string | null;
|
|
725
|
+
}> => {
|
|
726
|
+
const lightItems: ProjectItemLight[] = [];
|
|
727
|
+
let after: string | null = null;
|
|
728
|
+
let hasNextPage = true;
|
|
729
|
+
let totalCount = 0;
|
|
730
|
+
let cumulativeRawNodes = 0;
|
|
731
|
+
let pageIndex = 0;
|
|
732
|
+
let inconsistencyMessage: string | null = null;
|
|
733
|
+
while (hasNextPage) {
|
|
734
|
+
if (after !== null) {
|
|
735
|
+
await new Promise((resolve) =>
|
|
736
|
+
setTimeout(resolve, PAGINATION_DELAY_MS),
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
const items = await callGraphql(after);
|
|
740
|
+
const pageNodes = items.nodes;
|
|
741
|
+
const pageInfo = items.pageInfo;
|
|
742
|
+
totalCount = items.totalCount;
|
|
743
|
+
cumulativeRawNodes += pageNodes.length;
|
|
744
|
+
pageIndex++;
|
|
745
|
+
console.log(
|
|
746
|
+
`fetchProjectItemsLight: page ${pageIndex}, nodes: ${pageNodes.length}, cumulative: ${cumulativeRawNodes}/${totalCount}`,
|
|
732
747
|
);
|
|
748
|
+
pageNodes.forEach((node) => {
|
|
749
|
+
if (!node || !node.content || !node.content.url) {
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
lightItems.push({
|
|
753
|
+
id: node.id,
|
|
754
|
+
updatedAt: node.updatedAt,
|
|
755
|
+
url: node.content.url,
|
|
756
|
+
number: node.content.number,
|
|
757
|
+
});
|
|
758
|
+
});
|
|
759
|
+
if (
|
|
760
|
+
pageNodes.length > 0 &&
|
|
761
|
+
!pageInfo.hasNextPage &&
|
|
762
|
+
cumulativeRawNodes < totalCount
|
|
763
|
+
) {
|
|
764
|
+
inconsistencyMessage = `fetchProjectItemsLight: page ${pageIndex} has ${pageNodes.length} nodes with hasNextPage=false but only ${cumulativeRawNodes}/${totalCount} items accumulated`;
|
|
765
|
+
}
|
|
766
|
+
hasNextPage = pageInfo.hasNextPage;
|
|
767
|
+
after = pageInfo.endCursor;
|
|
733
768
|
}
|
|
734
|
-
const items = await callGraphql(after);
|
|
735
|
-
const pageNodes = items.nodes;
|
|
736
|
-
const pageInfo = items.pageInfo;
|
|
737
|
-
totalCount = items.totalCount;
|
|
738
|
-
cumulativeRawNodes += pageNodes.length;
|
|
739
|
-
pageIndex++;
|
|
740
769
|
console.log(
|
|
741
|
-
`fetchProjectItemsLight:
|
|
770
|
+
`fetchProjectItemsLight: completed, totalCount: ${totalCount}, cumulativeRawNodes: ${cumulativeRawNodes}, items: ${lightItems.length}`,
|
|
742
771
|
);
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
return;
|
|
746
|
-
}
|
|
747
|
-
lightItems.push({
|
|
748
|
-
id: node.id,
|
|
749
|
-
updatedAt: node.updatedAt,
|
|
750
|
-
url: node.content.url,
|
|
751
|
-
number: node.content.number,
|
|
752
|
-
});
|
|
753
|
-
});
|
|
754
|
-
if (
|
|
755
|
-
pageNodes.length > 0 &&
|
|
756
|
-
!pageInfo.hasNextPage &&
|
|
757
|
-
cumulativeRawNodes < totalCount
|
|
758
|
-
) {
|
|
759
|
-
throw new Error(
|
|
760
|
-
`fetchProjectItemsLight: page ${pageIndex} has ${pageNodes.length} nodes with hasNextPage=false but only ${cumulativeRawNodes}/${totalCount} items accumulated`,
|
|
761
|
-
);
|
|
772
|
+
if (inconsistencyMessage === null && cumulativeRawNodes !== totalCount) {
|
|
773
|
+
inconsistencyMessage = `fetchProjectItemsLight: expected ${totalCount} items but accumulated ${cumulativeRawNodes}`;
|
|
762
774
|
}
|
|
763
|
-
|
|
764
|
-
|
|
775
|
+
return { lightItems, inconsistencyMessage };
|
|
776
|
+
};
|
|
777
|
+
const firstAttempt = await fetchAllLightPages();
|
|
778
|
+
if (firstAttempt.inconsistencyMessage === null) {
|
|
779
|
+
return firstAttempt.lightItems;
|
|
765
780
|
}
|
|
766
|
-
console.
|
|
767
|
-
|
|
781
|
+
console.warn(
|
|
782
|
+
`${firstAttempt.inconsistencyMessage}, retrying full fetch once`,
|
|
768
783
|
);
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
);
|
|
784
|
+
const retryAttempt = await fetchAllLightPages();
|
|
785
|
+
if (retryAttempt.inconsistencyMessage === null) {
|
|
786
|
+
return retryAttempt.lightItems;
|
|
773
787
|
}
|
|
774
|
-
|
|
788
|
+
console.warn(
|
|
789
|
+
`${retryAttempt.inconsistencyMessage}, continuing with accumulated items after retry`,
|
|
790
|
+
);
|
|
791
|
+
return retryAttempt.lightItems;
|
|
775
792
|
};
|
|
776
793
|
fetchProjectItemsByIds = async (ids: string[]): Promise<ProjectItem[]> => {
|
|
777
794
|
if (ids.length === 0) {
|