@aviaratech/ai-delivery 0.3.21 → 0.3.22
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/README.md +9 -4
- package/dist/agent.d.ts +2 -1
- package/dist/agent.js +1 -1
- package/dist/agent.js.map +1 -1
- package/dist/cli.js +30 -0
- package/dist/cli.js.map +1 -1
- package/dist/dispatch.js +6 -1
- package/dist/dispatch.js.map +1 -1
- package/dist/github/relationships.d.ts +4 -1
- package/dist/github/relationships.js +30 -3
- package/dist/github/relationships.js.map +1 -1
- package/dist/issue.d.ts +64 -1
- package/dist/issue.js +350 -3
- package/dist/issue.js.map +1 -1
- package/dist/issueTracking.test.d.ts +1 -0
- package/dist/issueTracking.test.js +624 -0
- package/dist/issueTracking.test.js.map +1 -0
- package/dist/lifecycle.test.js +4 -0
- package/dist/lifecycle.test.js.map +1 -1
- package/dist/mcp/tools.d.ts +55 -0
- package/dist/mcp/tools.js +40 -2
- package/dist/mcp/tools.js.map +1 -1
- package/package.json +1 -1
- package/plugins/ai-delivery/.claude-plugin/plugin.json +1 -1
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { test } from 'vitest';
|
|
7
|
+
import * as issue from './issue.js';
|
|
8
|
+
import { getAiDeliveryMcpTool } from './mcp/tools.js';
|
|
9
|
+
import { syntheticDiscoveryConfig } from './fixtures/discovery.js';
|
|
10
|
+
function updateFixture() {
|
|
11
|
+
const root = mkdtempSync(join(tmpdir(), 'ai-delivery-history-'));
|
|
12
|
+
execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: root });
|
|
13
|
+
const events = [];
|
|
14
|
+
const live = {
|
|
15
|
+
number: 17,
|
|
16
|
+
id: 117,
|
|
17
|
+
node_id: 'ISSUE17',
|
|
18
|
+
title: ' Prior <title>\r\n',
|
|
19
|
+
body: '\nprior & body\t ',
|
|
20
|
+
state: 'open',
|
|
21
|
+
html_url: 'https://github.com/example/widget/issues/17',
|
|
22
|
+
};
|
|
23
|
+
const controls = {
|
|
24
|
+
corruptComment: false,
|
|
25
|
+
failUpdate: false,
|
|
26
|
+
ignoreClosure: false,
|
|
27
|
+
drift: false,
|
|
28
|
+
targetPr: false,
|
|
29
|
+
reason: null,
|
|
30
|
+
duplicate: null,
|
|
31
|
+
};
|
|
32
|
+
const comments = [];
|
|
33
|
+
const updates = [];
|
|
34
|
+
let projectStatus = 'Active';
|
|
35
|
+
const context = {
|
|
36
|
+
root,
|
|
37
|
+
repo: { owner: 'example', repo: 'widget' },
|
|
38
|
+
config: syntheticDiscoveryConfig,
|
|
39
|
+
projectConfiguration: { ...listingFixture().context.projectConfiguration, writable: true },
|
|
40
|
+
clients: {
|
|
41
|
+
role: 'author',
|
|
42
|
+
authenticatedAuthor: async () => {
|
|
43
|
+
events.push('author');
|
|
44
|
+
return { actorLogin: 'operator', credentialIdentity: 'user:1' };
|
|
45
|
+
},
|
|
46
|
+
rest: {
|
|
47
|
+
issues: {
|
|
48
|
+
get: async (input) => ({
|
|
49
|
+
data: input.issue_number === 17
|
|
50
|
+
? { ...live }
|
|
51
|
+
: {
|
|
52
|
+
number: input.issue_number,
|
|
53
|
+
id: 118,
|
|
54
|
+
html_url: `https://github.com/example/widget/issues/${String(input.issue_number)}`,
|
|
55
|
+
...(controls.targetPr ? { pull_request: {} } : {}),
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
listComments: async () => ({ data: comments }),
|
|
59
|
+
createComment: async (input) => {
|
|
60
|
+
events.push('comment');
|
|
61
|
+
const id = 91 + comments.length;
|
|
62
|
+
const data = {
|
|
63
|
+
id,
|
|
64
|
+
body: input.body,
|
|
65
|
+
html_url: `https://github.com/example/widget/issues/17#issuecomment-${String(id)}`,
|
|
66
|
+
user: { login: 'operator' },
|
|
67
|
+
issue_url: 'https://api.github.com/repos/example/widget/issues/17',
|
|
68
|
+
};
|
|
69
|
+
comments.push(data);
|
|
70
|
+
return { data };
|
|
71
|
+
},
|
|
72
|
+
getComment: async (input) => {
|
|
73
|
+
events.push('comment-readback');
|
|
74
|
+
if (controls.drift)
|
|
75
|
+
live.body = 'Concurrent human edit';
|
|
76
|
+
return {
|
|
77
|
+
data: {
|
|
78
|
+
...comments.find((comment) => comment.id === input.comment_id),
|
|
79
|
+
...(controls.corruptComment ? { body: 'Wrong body' } : {}),
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
update: async (input) => {
|
|
84
|
+
events.push('update');
|
|
85
|
+
updates.push(input);
|
|
86
|
+
if (controls.failUpdate)
|
|
87
|
+
throw new Error('Update unavailable');
|
|
88
|
+
if (typeof input.title === 'string')
|
|
89
|
+
live.title = input.title;
|
|
90
|
+
if (typeof input.body === 'string')
|
|
91
|
+
live.body = input.body;
|
|
92
|
+
if (!controls.ignoreClosure && typeof input.state === 'string') {
|
|
93
|
+
live.state = input.state;
|
|
94
|
+
controls.reason =
|
|
95
|
+
input.state === 'closed'
|
|
96
|
+
? (typeof input.state_reason === 'string' ? input.state_reason : 'completed').toUpperCase()
|
|
97
|
+
: 'REOPENED';
|
|
98
|
+
if (input.duplicate_issue_id !== undefined)
|
|
99
|
+
controls.duplicate = 18;
|
|
100
|
+
}
|
|
101
|
+
return { data: { ...live } };
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
request: async () => ({ data: [] }),
|
|
105
|
+
},
|
|
106
|
+
graphql: async (query, variables) => {
|
|
107
|
+
if (query.includes('IssueClosureReadback'))
|
|
108
|
+
return {
|
|
109
|
+
repository: {
|
|
110
|
+
issue: {
|
|
111
|
+
number: 17,
|
|
112
|
+
repository: { nameWithOwner: 'example/widget' },
|
|
113
|
+
state: live.state.toUpperCase(),
|
|
114
|
+
stateReason: controls.reason,
|
|
115
|
+
duplicateOf: controls.duplicate === null
|
|
116
|
+
? null
|
|
117
|
+
: { number: controls.duplicate, repository: { nameWithOwner: 'example/widget' } },
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
if (query.includes('blockedBy(first:'))
|
|
122
|
+
return {
|
|
123
|
+
repository: {
|
|
124
|
+
issue: { parent: null, blockedBy: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } },
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
if (query.includes('ProjectDeliveryItems'))
|
|
128
|
+
return {
|
|
129
|
+
organization: {
|
|
130
|
+
projectV2: {
|
|
131
|
+
items: {
|
|
132
|
+
nodes: [
|
|
133
|
+
{
|
|
134
|
+
id: 'ITEM17',
|
|
135
|
+
isArchived: false,
|
|
136
|
+
content: { id: 'ISSUE17' },
|
|
137
|
+
fieldValueByName: {
|
|
138
|
+
name: projectStatus,
|
|
139
|
+
optionId: projectStatus === 'Active' ? 'ACTIVE' : 'DONE',
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
if (query.includes('UpdateProjectDeliveryStatus')) {
|
|
149
|
+
projectStatus = variables.optionId === 'DONE' ? 'Shipped' : 'Active';
|
|
150
|
+
return { updateProjectV2ItemFieldValue: { projectV2Item: { id: 'ITEM17' } } };
|
|
151
|
+
}
|
|
152
|
+
if (query.includes('ProjectDeliveryItemReadback'))
|
|
153
|
+
return {
|
|
154
|
+
node: {
|
|
155
|
+
id: 'ITEM17',
|
|
156
|
+
isArchived: false,
|
|
157
|
+
project: { id: 'PROJECT', number: 1 },
|
|
158
|
+
content: { id: 'ISSUE17' },
|
|
159
|
+
fieldValueByName: { name: projectStatus, optionId: projectStatus === 'Active' ? 'ACTIVE' : 'DONE' },
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
throw new Error(`Unexpected update fixture query: ${query}`);
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
return {
|
|
167
|
+
context,
|
|
168
|
+
live,
|
|
169
|
+
controls,
|
|
170
|
+
comments,
|
|
171
|
+
events,
|
|
172
|
+
updates,
|
|
173
|
+
cleanup: () => rmSync(root, { recursive: true, force: true }),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
test('history preserves exact prior content before a rewrite and retries a failed mutation once', async () => {
|
|
177
|
+
const fixture = updateFixture();
|
|
178
|
+
try {
|
|
179
|
+
const oldTitle = fixture.live.title;
|
|
180
|
+
const oldBody = fixture.live.body;
|
|
181
|
+
fixture.controls.failUpdate = true;
|
|
182
|
+
const input = { issueNumber: 17, title: 'New title', body: 'New body', preserveHistory: true };
|
|
183
|
+
await assert.rejects(issue.updateIssue(fixture.context, input), /Update unavailable/u);
|
|
184
|
+
assert.equal(fixture.comments.length, 1);
|
|
185
|
+
assert.equal(fixture.comments[0].body, issue.renderIssueHistory(oldTitle, oldBody));
|
|
186
|
+
assert.ok(fixture.events.indexOf('comment-readback') < fixture.events.indexOf('update'));
|
|
187
|
+
fixture.controls.failUpdate = false;
|
|
188
|
+
const result = await issue.updateIssue(fixture.context, input);
|
|
189
|
+
assert.equal(result.title, 'New title');
|
|
190
|
+
assert.equal(result.body, 'New body');
|
|
191
|
+
assert.equal(fixture.comments.length, 1);
|
|
192
|
+
assert.equal(result.historyComment.reused, true);
|
|
193
|
+
await issue.updateIssue(fixture.context, input);
|
|
194
|
+
assert.equal(fixture.comments.length, 1, 'a no-op rewrite adds no history');
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
fixture.cleanup();
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
test('history overflow, invalid metadata, failed comment readback and concurrent edits prevent rewrites', async () => {
|
|
201
|
+
for (const failure of ['overflow', 'labels', 'readback', 'drift']) {
|
|
202
|
+
const fixture = updateFixture();
|
|
203
|
+
try {
|
|
204
|
+
if (failure === 'overflow')
|
|
205
|
+
fixture.live.body = '&'.repeat(14000);
|
|
206
|
+
if (failure === 'readback')
|
|
207
|
+
fixture.controls.corruptComment = true;
|
|
208
|
+
if (failure === 'drift')
|
|
209
|
+
fixture.controls.drift = true;
|
|
210
|
+
const oldBody = fixture.live.body;
|
|
211
|
+
await assert.rejects(issue.updateIssue(fixture.context, {
|
|
212
|
+
issueNumber: 17,
|
|
213
|
+
body: 'Replacement',
|
|
214
|
+
preserveHistory: true,
|
|
215
|
+
...(failure === 'labels' ? { labels: ['invalid'] } : {}),
|
|
216
|
+
}));
|
|
217
|
+
assert.equal(fixture.updates.length, 0, failure);
|
|
218
|
+
if (failure !== 'drift')
|
|
219
|
+
assert.equal(fixture.live.body, oldBody);
|
|
220
|
+
if (failure === 'overflow' || failure === 'labels')
|
|
221
|
+
assert.equal(fixture.comments.length, 0);
|
|
222
|
+
if (failure === 'overflow')
|
|
223
|
+
assert.ok(!fixture.events.includes('author'));
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
fixture.cleanup();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
test('reasoned closure records supersession and verifies native reason and duplicate identity', async () => {
|
|
231
|
+
for (const closeReason of ['completed', 'not_planned', 'duplicate']) {
|
|
232
|
+
const fixture = updateFixture();
|
|
233
|
+
try {
|
|
234
|
+
const input = { issueNumber: 17, state: 'closed', closeReason, supersededBy: 18 };
|
|
235
|
+
const result = await issue.updateIssue(fixture.context, input);
|
|
236
|
+
const closure = result.closure;
|
|
237
|
+
assert.equal(result.state, 'closed');
|
|
238
|
+
assert.equal(closure.closeReason, closeReason);
|
|
239
|
+
assert.equal(closure.supersededBy, 18);
|
|
240
|
+
assert.match(closure.comment.body, /example\/widget#18/u);
|
|
241
|
+
assert.equal(fixture.updates[0].state_reason, closeReason);
|
|
242
|
+
if (closeReason === 'duplicate') {
|
|
243
|
+
assert.equal(fixture.updates[0].duplicate_issue_id, 118);
|
|
244
|
+
assert.deepEqual(closure.duplicateOf, { number: 18, repository: 'example/widget' });
|
|
245
|
+
}
|
|
246
|
+
await issue.updateIssue(fixture.context, input);
|
|
247
|
+
assert.equal(fixture.comments.length, 1, 'closure retries reuse authoritative comments');
|
|
248
|
+
await assert.rejects(issue.updateIssue(fixture.context, {
|
|
249
|
+
...input,
|
|
250
|
+
closeReason: closeReason === 'completed' ? 'not_planned' : 'completed',
|
|
251
|
+
}), /already closed/u);
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
fixture.cleanup();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
test('invalid superseding issues and contradictory native closure fail closed', async () => {
|
|
259
|
+
for (const failure of ['self', 'pull-request', 'reason', 'duplicate']) {
|
|
260
|
+
const fixture = updateFixture();
|
|
261
|
+
try {
|
|
262
|
+
fixture.controls.targetPr = failure === 'pull-request';
|
|
263
|
+
fixture.controls.ignoreClosure = failure === 'reason';
|
|
264
|
+
if (failure === 'duplicate') {
|
|
265
|
+
fixture.live.state = 'closed';
|
|
266
|
+
fixture.controls.reason = 'DUPLICATE';
|
|
267
|
+
fixture.controls.duplicate = 19;
|
|
268
|
+
}
|
|
269
|
+
await assert.rejects(issue.updateIssue(fixture.context, {
|
|
270
|
+
issueNumber: 17,
|
|
271
|
+
state: 'closed',
|
|
272
|
+
closeReason: failure === 'duplicate' ? 'duplicate' : 'completed',
|
|
273
|
+
supersededBy: failure === 'self' ? 17 : 18,
|
|
274
|
+
}));
|
|
275
|
+
if (failure !== 'reason') {
|
|
276
|
+
assert.equal(fixture.updates.length, 0);
|
|
277
|
+
assert.equal(fixture.comments.length, 0);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
finally {
|
|
281
|
+
fixture.cleanup();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
const listIssues = issue.listIssues;
|
|
286
|
+
function listingFixture(options = {}) {
|
|
287
|
+
const requests = [];
|
|
288
|
+
const context = {
|
|
289
|
+
root: '/synthetic',
|
|
290
|
+
repo: { owner: 'example', repo: 'widget' },
|
|
291
|
+
config: syntheticDiscoveryConfig,
|
|
292
|
+
projectConfiguration: {
|
|
293
|
+
projectId: 'PROJECT',
|
|
294
|
+
projectNumber: 1,
|
|
295
|
+
title: 'Delivery',
|
|
296
|
+
writable: false,
|
|
297
|
+
pointsFieldId: 'POINTS',
|
|
298
|
+
priorityFieldId: 'PRIORITY',
|
|
299
|
+
statusFieldId: 'STATUS',
|
|
300
|
+
statusOptionIds: { Todo: 'TODO', 'In Progress': 'ACTIVE', Blocked: 'BLOCKED', Done: 'DONE' },
|
|
301
|
+
settings: {
|
|
302
|
+
number: 1,
|
|
303
|
+
title: 'Delivery',
|
|
304
|
+
statusField: 'Flow',
|
|
305
|
+
statuses: { Todo: 'Queued', 'In Progress': 'Active', Blocked: 'Waiting', Done: 'Shipped' },
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
clients: {
|
|
309
|
+
rest: {
|
|
310
|
+
issues: {
|
|
311
|
+
listForRepo: async (params) => {
|
|
312
|
+
requests.push(params);
|
|
313
|
+
return {
|
|
314
|
+
data: [
|
|
315
|
+
{
|
|
316
|
+
number: 17,
|
|
317
|
+
node_id: 'ISSUE17',
|
|
318
|
+
title: 'Repair widget',
|
|
319
|
+
state: 'open',
|
|
320
|
+
labels: [{ name: 'area:widget' }],
|
|
321
|
+
type: { name: 'Task' },
|
|
322
|
+
html_url: options.foreign
|
|
323
|
+
? 'https://github.com/elsewhere/widget/issues/17'
|
|
324
|
+
: 'https://github.com/example/widget/issues/17',
|
|
325
|
+
updated_at: '2026-10-07T00:00:00Z',
|
|
326
|
+
},
|
|
327
|
+
{ number: 18, pull_request: {} },
|
|
328
|
+
],
|
|
329
|
+
};
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
search: {
|
|
333
|
+
issuesAndPullRequests: async (params) => {
|
|
334
|
+
requests.push(params);
|
|
335
|
+
return { data: { total_count: 0, incomplete_results: options.incomplete ?? false, items: [] } };
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
request: async (route, params) => {
|
|
339
|
+
requests.push({ route, ...params });
|
|
340
|
+
if (route.includes('/orgs/'))
|
|
341
|
+
return {
|
|
342
|
+
data: [
|
|
343
|
+
{ id: 101, name: 'Estimate', data_type: 'single_select' },
|
|
344
|
+
{ id: 102, name: 'Urgency', data_type: 'single_select' },
|
|
345
|
+
{ id: 103, name: 'Start date', data_type: 'date' },
|
|
346
|
+
...(options.missingMulti ? [{ id: 104, name: 'Tags', data_type: 'multi_select' }] : []),
|
|
347
|
+
],
|
|
348
|
+
};
|
|
349
|
+
return {
|
|
350
|
+
data: [
|
|
351
|
+
{
|
|
352
|
+
issue_field_id: 101,
|
|
353
|
+
issue_field_name: options.invalidField ? 'Foreign estimate' : 'Estimate',
|
|
354
|
+
data_type: 'single_select',
|
|
355
|
+
value: 44,
|
|
356
|
+
...(options.missingOption ? {} : { single_select_option: { id: 44, name: '4' } }),
|
|
357
|
+
},
|
|
358
|
+
{ issue_field_id: 103, issue_field_name: 'Start date', data_type: 'date', value: '2026-10-07' },
|
|
359
|
+
...(options.missingMulti
|
|
360
|
+
? [{ issue_field_id: 104, issue_field_name: 'Tags', data_type: 'multi_select', value: 'one' }]
|
|
361
|
+
: []),
|
|
362
|
+
],
|
|
363
|
+
};
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
graphql: async (query) => {
|
|
367
|
+
if (query.includes('blockedBy(first:'))
|
|
368
|
+
return {
|
|
369
|
+
repository: {
|
|
370
|
+
issue: {
|
|
371
|
+
parent: {
|
|
372
|
+
id: 'PARENT9',
|
|
373
|
+
number: 9,
|
|
374
|
+
repository: { nameWithOwner: options.foreignParent ? 'example/other' : 'example/widget' },
|
|
375
|
+
},
|
|
376
|
+
blockedBy: {
|
|
377
|
+
nodes: [
|
|
378
|
+
{
|
|
379
|
+
id: 'BLOCKER8',
|
|
380
|
+
number: 8,
|
|
381
|
+
title: 'Prerequisite',
|
|
382
|
+
state: 'CLOSED',
|
|
383
|
+
repository: { nameWithOwner: 'example/widget' },
|
|
384
|
+
},
|
|
385
|
+
...(options.dualBlockers
|
|
386
|
+
? [
|
|
387
|
+
{
|
|
388
|
+
id: 'FOREIGN8',
|
|
389
|
+
number: 8,
|
|
390
|
+
title: 'Other prerequisite',
|
|
391
|
+
state: 'OPEN',
|
|
392
|
+
repository: { nameWithOwner: 'example/other' },
|
|
393
|
+
},
|
|
394
|
+
]
|
|
395
|
+
: []),
|
|
396
|
+
],
|
|
397
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
if (query.includes('ProjectDeliveryItems'))
|
|
403
|
+
return {
|
|
404
|
+
organization: {
|
|
405
|
+
projectV2: {
|
|
406
|
+
items: {
|
|
407
|
+
nodes: [
|
|
408
|
+
{
|
|
409
|
+
id: 'ITEM17',
|
|
410
|
+
isArchived: false,
|
|
411
|
+
content: { id: 'ISSUE17' },
|
|
412
|
+
fieldValueByName: { name: 'Active', optionId: 'ACTIVE' },
|
|
413
|
+
},
|
|
414
|
+
],
|
|
415
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
throw new Error('Unexpected query');
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
return { context, requests };
|
|
425
|
+
}
|
|
426
|
+
test('typed listing filters stay repository-bound and expose named fields without identifiers', async () => {
|
|
427
|
+
const tool = getAiDeliveryMcpTool('issue_list');
|
|
428
|
+
assert.ok(tool, 'issue_list is registered');
|
|
429
|
+
const input = tool.inputSchema.parse({
|
|
430
|
+
state: 'open',
|
|
431
|
+
labels: ['area:widget'],
|
|
432
|
+
parentIssueNumber: 9,
|
|
433
|
+
issueType: 'Task',
|
|
434
|
+
projectStatus: 'In Progress',
|
|
435
|
+
updatedSince: '2026-10-01T00:00:00Z',
|
|
436
|
+
perPage: 2,
|
|
437
|
+
});
|
|
438
|
+
const { context, requests } = listingFixture();
|
|
439
|
+
const result = await listIssues(context, input);
|
|
440
|
+
assert.equal(result.nextPage, 2);
|
|
441
|
+
assert.deepEqual(result.issues, [
|
|
442
|
+
{
|
|
443
|
+
number: 17,
|
|
444
|
+
title: 'Repair widget',
|
|
445
|
+
state: 'open',
|
|
446
|
+
labels: ['area:widget'],
|
|
447
|
+
issueType: 'Task',
|
|
448
|
+
parentIssueNumber: 9,
|
|
449
|
+
parent: { repository: 'example/widget', number: 9 },
|
|
450
|
+
blockers: [{ repository: 'example/widget', number: 8, title: 'Prerequisite', state: 'CLOSED' }],
|
|
451
|
+
projectStatus: 'In Progress',
|
|
452
|
+
fields: { Estimate: '4', Urgency: null, 'Start date': '2026-10-07' },
|
|
453
|
+
url: 'https://github.com/example/widget/issues/17',
|
|
454
|
+
updatedAt: '2026-10-07T00:00:00Z',
|
|
455
|
+
},
|
|
456
|
+
]);
|
|
457
|
+
assert.equal(requests[0]?.owner, 'example');
|
|
458
|
+
assert.equal(requests[0]?.since, '2026-10-01T00:00:00Z');
|
|
459
|
+
assert.equal(JSON.stringify(result).includes('BLOCKER8'), false);
|
|
460
|
+
});
|
|
461
|
+
test('search quotes user text so it cannot change the selected repository', async () => {
|
|
462
|
+
const tool = getAiDeliveryMcpTool('issue_search');
|
|
463
|
+
assert.ok(tool, 'issue_search is registered');
|
|
464
|
+
const input = tool.inputSchema.parse({ query: 'repo:elsewhere/project', page: 2, perPage: 10 });
|
|
465
|
+
const { context, requests } = listingFixture();
|
|
466
|
+
const result = await listIssues(context, input);
|
|
467
|
+
assert.equal(result.nextPage, null);
|
|
468
|
+
assert.equal(requests[0]?.q, 'repo:example/widget is:issue "repo:elsewhere/project"');
|
|
469
|
+
});
|
|
470
|
+
test('update accepts history and reasoned closure while rejecting incoherent requests', () => {
|
|
471
|
+
const tool = getAiDeliveryMcpTool('issue_update');
|
|
472
|
+
assert.ok(tool);
|
|
473
|
+
assert.equal(tool.inputSchema.safeParse({ issueNumber: 17, body: 'new body', preserveHistory: true }).success, true);
|
|
474
|
+
assert.equal(tool.inputSchema.safeParse({ issueNumber: 17, state: 'closed', closeReason: 'duplicate', supersededBy: 9 }).success, true);
|
|
475
|
+
assert.equal(tool.inputSchema.safeParse({ issueNumber: 17, state: 'open', closeReason: 'completed' }).success, false);
|
|
476
|
+
});
|
|
477
|
+
test('local filters retain pagination when the current GitHub page has no matching issue', async () => {
|
|
478
|
+
const { context } = listingFixture();
|
|
479
|
+
const result = await listIssues(context, { parentIssueNumber: null, perPage: 2 });
|
|
480
|
+
assert.deepEqual(result.issues, []);
|
|
481
|
+
assert.equal(result.nextPage, 2);
|
|
482
|
+
});
|
|
483
|
+
test('foreign issue identity and mismatched organization fields fail rather than returning false readback', async () => {
|
|
484
|
+
await assert.rejects(listIssues(listingFixture({ foreign: true }).context, {}), /foreign repository/u);
|
|
485
|
+
await assert.rejects(listIssues(listingFixture({ invalidField: true }).context, {}), /conflicts with its catalog/u);
|
|
486
|
+
});
|
|
487
|
+
test('search refuses incomplete upstream results and invalid literal text', async () => {
|
|
488
|
+
await assert.rejects(listIssues(listingFixture({ incomplete: true }).context, { query: 'widget' }), /incomplete results/u);
|
|
489
|
+
await assert.rejects(listIssues(listingFixture().context, { query: '" repo:elsewhere/widget' }), /literal text/u);
|
|
490
|
+
assert.equal(getAiDeliveryMcpTool('issue_list')?.inputSchema.safeParse({ perPage: 101 }).success, false);
|
|
491
|
+
});
|
|
492
|
+
test('a foreign parent with the same issue number cannot satisfy a local parent filter', async () => {
|
|
493
|
+
const { context } = listingFixture({ foreignParent: true });
|
|
494
|
+
assert.deepEqual((await listIssues(context, { parentIssueNumber: 9 })).issues, []);
|
|
495
|
+
});
|
|
496
|
+
test('label filters preserve GitHub case-insensitive label identity', async () => {
|
|
497
|
+
const { context } = listingFixture();
|
|
498
|
+
assert.equal((await listIssues(context, { labels: ['AREA:Widget'] })).issues.length, 1);
|
|
499
|
+
});
|
|
500
|
+
test('a set select field without its named option fails rather than reporting unset', async () => {
|
|
501
|
+
await assert.rejects(listIssues(listingFixture({ missingOption: true }).context, {}), /option/u);
|
|
502
|
+
});
|
|
503
|
+
test('same-number blockers in different repositories retain both qualified identities', async () => {
|
|
504
|
+
const { context } = listingFixture({ dualBlockers: true });
|
|
505
|
+
const result = await issue.listIssues(context, {});
|
|
506
|
+
assert.deepEqual(result.issues[0]?.blockers, [
|
|
507
|
+
{ repository: 'example/widget', number: 8, title: 'Prerequisite', state: 'CLOSED' },
|
|
508
|
+
{ repository: 'example/other', number: 8, title: 'Other prerequisite', state: 'OPEN' },
|
|
509
|
+
]);
|
|
510
|
+
});
|
|
511
|
+
test('a set multi-select field without named options cannot be reported unset', async () => {
|
|
512
|
+
await assert.rejects(listIssues(listingFixture({ missingMulti: true }).context, {}), /option/u);
|
|
513
|
+
});
|
|
514
|
+
test('history rendering preserves whitespace and escapes content that could end the collapsed section', () => {
|
|
515
|
+
const render = issue
|
|
516
|
+
.renderIssueHistory;
|
|
517
|
+
assert.equal(typeof render, 'function');
|
|
518
|
+
const body = ' old body\n</pre></details><script>x</script> & text ';
|
|
519
|
+
const result = render('Old <title> & name', body);
|
|
520
|
+
assert.equal(result, '<details>\n<summary>Previous issue title and body</summary>\n\nTitle:\n<pre>Old <title> & name</pre>\n\nBody:\n<pre> old body\n</pre></details><script>x</script> & text </pre>\n\n</details>');
|
|
521
|
+
});
|
|
522
|
+
test('closure evidence reads native reason and preserves qualified duplicate identity', async () => {
|
|
523
|
+
const { context } = listingFixture();
|
|
524
|
+
context.clients.graphql = (async () => ({
|
|
525
|
+
repository: {
|
|
526
|
+
issue: {
|
|
527
|
+
number: 17,
|
|
528
|
+
repository: { nameWithOwner: 'example/widget' },
|
|
529
|
+
state: 'CLOSED',
|
|
530
|
+
stateReason: 'DUPLICATE',
|
|
531
|
+
duplicateOf: { number: 9, repository: { nameWithOwner: 'example/other' } },
|
|
532
|
+
},
|
|
533
|
+
},
|
|
534
|
+
}));
|
|
535
|
+
const read = issue.readIssueClosure;
|
|
536
|
+
assert.equal(typeof read, 'function');
|
|
537
|
+
assert.deepEqual(await read(context, 17), {
|
|
538
|
+
state: 'closed',
|
|
539
|
+
closeReason: 'duplicate',
|
|
540
|
+
duplicateOf: { number: 9, repository: 'example/other' },
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
test('native field catalog and values both paginate through the last named field', async () => {
|
|
544
|
+
const { context } = listingFixture();
|
|
545
|
+
const requests = [];
|
|
546
|
+
context.clients.rest.request = (async (route, params) => {
|
|
547
|
+
requests.push({ route, page: params.page });
|
|
548
|
+
const catalog = route.includes('/orgs/');
|
|
549
|
+
if (params.page === 2)
|
|
550
|
+
return {
|
|
551
|
+
data: catalog
|
|
552
|
+
? [{ id: 201, name: 'Late date', data_type: 'date' }]
|
|
553
|
+
: [{ issue_field_id: 201, issue_field_name: 'Late date', data_type: 'date', value: '2026-10-09' }],
|
|
554
|
+
};
|
|
555
|
+
return {
|
|
556
|
+
data: catalog
|
|
557
|
+
? [
|
|
558
|
+
{ id: 101, name: 'Estimate', data_type: 'single_select' },
|
|
559
|
+
{ id: 102, name: 'Urgency', data_type: 'single_select' },
|
|
560
|
+
...Array.from({ length: 98 }, (_, index) => ({
|
|
561
|
+
id: index + 103,
|
|
562
|
+
name: `Custom ${String(index)}`,
|
|
563
|
+
data_type: 'text',
|
|
564
|
+
})),
|
|
565
|
+
]
|
|
566
|
+
: [
|
|
567
|
+
{
|
|
568
|
+
issue_field_id: 101,
|
|
569
|
+
issue_field_name: 'Estimate',
|
|
570
|
+
data_type: 'single_select',
|
|
571
|
+
value: 44,
|
|
572
|
+
single_select_option: { name: '4' },
|
|
573
|
+
},
|
|
574
|
+
{ issue_field_id: 102, issue_field_name: 'Urgency', data_type: 'single_select', value: null },
|
|
575
|
+
...Array.from({ length: 98 }, (_, index) => ({
|
|
576
|
+
issue_field_id: index + 103,
|
|
577
|
+
issue_field_name: `Custom ${String(index)}`,
|
|
578
|
+
data_type: 'text',
|
|
579
|
+
value: 'retained',
|
|
580
|
+
})),
|
|
581
|
+
],
|
|
582
|
+
};
|
|
583
|
+
});
|
|
584
|
+
const result = await issue.listIssues(context, {});
|
|
585
|
+
assert.equal(result.issues[0]?.fields['Late date'], '2026-10-09');
|
|
586
|
+
assert.equal(result.issues[0]?.fields.Urgency, null);
|
|
587
|
+
assert.deepEqual(requests.map((request) => request.page), [1, 2, 1, 2]);
|
|
588
|
+
});
|
|
589
|
+
test('native field pagination fails closed on a repeated nonadvancing page', async () => {
|
|
590
|
+
const { context } = listingFixture();
|
|
591
|
+
context.clients.rest.request = (async () => ({
|
|
592
|
+
data: Array.from({ length: 100 }, (_, index) => ({
|
|
593
|
+
id: index + 1,
|
|
594
|
+
name: `Field ${String(index)}`,
|
|
595
|
+
data_type: 'text',
|
|
596
|
+
})),
|
|
597
|
+
}));
|
|
598
|
+
await assert.rejects(issue.listIssues(context, {}), /repeated a pagination page/u);
|
|
599
|
+
});
|
|
600
|
+
test('search applies the same case-insensitive label filter to authoritative result rows', async () => {
|
|
601
|
+
const { context } = listingFixture();
|
|
602
|
+
context.clients.rest.search.issuesAndPullRequests = (async () => ({
|
|
603
|
+
data: {
|
|
604
|
+
total_count: 1,
|
|
605
|
+
incomplete_results: false,
|
|
606
|
+
items: [
|
|
607
|
+
{
|
|
608
|
+
number: 17,
|
|
609
|
+
node_id: 'ISSUE17',
|
|
610
|
+
title: 'Repair widget',
|
|
611
|
+
state: 'open',
|
|
612
|
+
labels: [{ name: 'area:widget' }],
|
|
613
|
+
type: { name: 'Task' },
|
|
614
|
+
html_url: 'https://github.com/example/widget/issues/17',
|
|
615
|
+
updated_at: '2026-10-07T00:00:00Z',
|
|
616
|
+
},
|
|
617
|
+
],
|
|
618
|
+
},
|
|
619
|
+
}));
|
|
620
|
+
const result = await issue.listIssues(context, { query: 'widget', labels: ['AREA:Widget'] });
|
|
621
|
+
assert.equal(result.issues.length, 1);
|
|
622
|
+
assert.equal(result.nextPage, null);
|
|
623
|
+
});
|
|
624
|
+
//# sourceMappingURL=issueTracking.test.js.map
|