@aviaratech/ai-delivery 0.3.20 → 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 +82 -1
- package/dist/agent.d.ts +5 -1
- package/dist/agent.js +2 -1
- package/dist/agent.js.map +1 -1
- package/dist/cli.js +32 -1
- package/dist/cli.js.map +1 -1
- package/dist/cliResult.test.js +28 -0
- package/dist/cliResult.test.js.map +1 -1
- package/dist/dispatch.js +21 -6
- 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 +79 -1
- package/dist/issue.js +432 -4
- package/dist/issue.js.map +1 -1
- package/dist/issueJournal.d.ts +104 -0
- package/dist/issueJournal.js +145 -0
- package/dist/issueJournal.js.map +1 -0
- package/dist/issueJournal.test.d.ts +1 -0
- package/dist/issueJournal.test.js +223 -0
- package/dist/issueJournal.test.js.map +1 -0
- 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 +70 -6
- package/dist/lifecycle.test.js.map +1 -1
- package/dist/mcp/tools.d.ts +89 -0
- package/dist/mcp/tools.js +47 -2
- package/dist/mcp/tools.js.map +1 -1
- package/dist/pr.d.ts +1 -1
- package/dist/pr.js +23 -0
- package/dist/pr.js.map +1 -1
- package/dist/setup.test.js +4 -2
- package/dist/setup.test.js.map +1 -1
- package/dist/version.d.ts +1 -0
- package/dist/version.js +10 -0
- package/dist/version.js.map +1 -0
- package/package.json +1 -1
- package/plugins/ai-delivery/.claude-plugin/plugin.json +1 -1
package/dist/issue.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
3
4
|
import { loadDeliveryConfig } from './config/deliveryConfig.js';
|
|
4
5
|
import { assertPrivateFile, digestBytes, stableJson } from './delivery/common.js';
|
|
5
6
|
import { digestValue } from './delivery/index.js';
|
|
6
7
|
import { DeliveryError } from './errors.js';
|
|
7
8
|
import { createDeliveryGitHubClients } from './github/client.js';
|
|
8
|
-
import { clearConfiguredNativeIssuePoints, getConfiguredNativeIssueMetadata, nativeIssueSettingsFromDeliveryConfig, setConfiguredNativeIssueMetadata, } from './github/nativeIssueMetadata.js';
|
|
9
|
+
import { NATIVE_ISSUE_API_VERSION, clearConfiguredNativeIssuePoints, getConfiguredNativeIssueMetadata, nativeIssueSettingsFromDeliveryConfig, setConfiguredNativeIssueMetadata, } from './github/nativeIssueMetadata.js';
|
|
9
10
|
import { getIssueProjectStatus, projectSettingsFromDeliveryConfig, syncIssueProjectStatus, } from './github/projectDelivery.js';
|
|
10
11
|
import { getNativeBlockerRelationships, hasNativeSubIssues, replaceBlockedBy, replaceParentIssue, resolveNativeRelationshipTargets, } from './github/relationships.js';
|
|
11
12
|
import { resolveDeliveryRepo } from './github/repo.js';
|
|
@@ -17,7 +18,86 @@ import { addWorktreeEntry, assertNativeIssueTrackingAdmission, getIssueWorktreeS
|
|
|
17
18
|
import { withRuntimeSetupWriter, withWorktreeTransitionWriterAbsent } from './verification.js';
|
|
18
19
|
import { authenticatedWorktreeActors, assertNativeWorktreeAcceptance } from './worktreeTransition.js';
|
|
19
20
|
import { assertIssueWorktreeLocation, prepareIssueWorktree } from './worktree.js';
|
|
20
|
-
import { writePrivateJsonFileAtomically } from './utils/atomicJson.js';
|
|
21
|
+
import { ensurePrivateDirectoryDurably, writePrivateJsonFileAtomically } from './utils/atomicJson.js';
|
|
22
|
+
import { withLock } from './utils/lockfile.js';
|
|
23
|
+
import { JournalInputSchema, renderJournal } from './issueJournal.js';
|
|
24
|
+
// Matches GitHub's first-party JavaScript comment-length validation:
|
|
25
|
+
// https://github.com/github/gh-aw/blob/3ead8042c7b1edc2128ba1b28b1b80d00b7f4c22/actions/setup/js/comment_limit_helpers.cjs
|
|
26
|
+
export const ISSUE_COMMENT_BODY_LIMIT = 65_536;
|
|
27
|
+
const RawIssueCommentSchema = z.strictObject({ issueNumber: z.number().int().positive(), body: z.string() });
|
|
28
|
+
/** One canonical exact-body operation shared by direct calls, MCP, and lifecycle journals. */
|
|
29
|
+
export async function commentIssue(context, input, lifecycleKey) {
|
|
30
|
+
const accepted = 'body' in input ? RawIssueCommentSchema.parse(input) : JournalInputSchema.parse(input);
|
|
31
|
+
const rawBody = 'body' in accepted ? accepted.body : undefined;
|
|
32
|
+
if (rawBody !== undefined && lifecycleKey !== undefined)
|
|
33
|
+
throw new DeliveryError('Raw issue comments do not accept a lifecycle journal key.');
|
|
34
|
+
const marker = rawBody === undefined
|
|
35
|
+
? `<!-- ai-delivery:journal@1:${digestValue({ issueNumber: accepted.issueNumber, key: lifecycleKey ?? accepted })} -->`
|
|
36
|
+
: undefined;
|
|
37
|
+
const body = rawBody ?? renderJournal(accepted, marker);
|
|
38
|
+
if (body.length > ISSUE_COMMENT_BODY_LIMIT || !body.trim())
|
|
39
|
+
throw new DeliveryError(`Issue comment body must contain text and fit ${String(ISSUE_COMMENT_BODY_LIMIT)} UTF-16 code units.`);
|
|
40
|
+
if (context.clients.role !== 'author' || !context.clients.authenticatedAuthor)
|
|
41
|
+
throw new DeliveryError('Issue comments require the authenticated author role.');
|
|
42
|
+
const actor = (await context.clients.authenticatedAuthor()).actorLogin;
|
|
43
|
+
if (!actor)
|
|
44
|
+
throw new DeliveryError('Issue comment author identity is missing.');
|
|
45
|
+
const directory = join(gitCommonDir(context.root), 'ai-delivery', 'journals');
|
|
46
|
+
ensurePrivateDirectoryDurably(directory);
|
|
47
|
+
return withLock(join(directory, String(input.issueNumber)), {
|
|
48
|
+
operation: async () => {
|
|
49
|
+
const issue = (await context.clients.rest.issues.get({ ...context.repo, issue_number: input.issueNumber })).data;
|
|
50
|
+
if ('pull_request' in issue || issue.number !== input.issueNumber)
|
|
51
|
+
throw new DeliveryError('Issue comment target is not the requested issue.');
|
|
52
|
+
const matches = [];
|
|
53
|
+
for (let page = 1;; page += 1) {
|
|
54
|
+
const batch = (await context.clients.rest.issues.listComments({
|
|
55
|
+
...context.repo,
|
|
56
|
+
issue_number: input.issueNumber,
|
|
57
|
+
page,
|
|
58
|
+
per_page: 100,
|
|
59
|
+
})).data;
|
|
60
|
+
matches.push(...batch.filter((comment) => (marker === undefined ? comment.body === body : comment.body?.endsWith(marker)) &&
|
|
61
|
+
comment.user?.login.toLowerCase() === actor.toLowerCase()));
|
|
62
|
+
if (batch.length < 100)
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
if (matches.length > 1)
|
|
66
|
+
throw new DeliveryError('Issue comment has duplicate authored retry matches.');
|
|
67
|
+
const existing = matches[0];
|
|
68
|
+
const created = existing ??
|
|
69
|
+
(await context.clients.rest.issues.createComment({ ...context.repo, issue_number: input.issueNumber, body }))
|
|
70
|
+
.data;
|
|
71
|
+
const readback = (await context.clients.rest.issues.getComment({ ...context.repo, comment_id: created.id })).data;
|
|
72
|
+
const expectedIssuePath = `/repos/${context.repo.owner}/${context.repo.repo}/issues/${String(input.issueNumber)}`;
|
|
73
|
+
if (!Number.isSafeInteger(readback.id) ||
|
|
74
|
+
readback.id <= 0 ||
|
|
75
|
+
readback.id !== created.id ||
|
|
76
|
+
readback.html_url !== created.html_url ||
|
|
77
|
+
readback.body !== (existing?.body ?? body) ||
|
|
78
|
+
(lifecycleKey === undefined && readback.body !== body) ||
|
|
79
|
+
readback.user?.login.toLowerCase() !== actor.toLowerCase() ||
|
|
80
|
+
new URL(readback.issue_url).pathname.toLowerCase() !== expectedIssuePath.toLowerCase() ||
|
|
81
|
+
!readback.html_url.startsWith('https://'))
|
|
82
|
+
throw new DeliveryError('Issue comment readback disagrees with the authored issue comment.');
|
|
83
|
+
return { commentId: readback.id, url: readback.html_url, body: readback.body, reused: existing !== undefined };
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
export async function journalIssueStart(context, issueNumber) {
|
|
88
|
+
const issue = (await context.clients.rest.issues.get({ ...context.repo, issue_number: issueNumber })).data;
|
|
89
|
+
return commentIssue(context, {
|
|
90
|
+
issueNumber,
|
|
91
|
+
kind: 'start',
|
|
92
|
+
summary: `Work started on ${issue.title}.`,
|
|
93
|
+
status: 'Started',
|
|
94
|
+
outcome: issue.title,
|
|
95
|
+
keyNumbers: [`Issue #${String(issueNumber)}`],
|
|
96
|
+
evidence: [issue.html_url],
|
|
97
|
+
nextStep: 'Implement the issue acceptance criteria and run its verification',
|
|
98
|
+
nextDate: null,
|
|
99
|
+
}, `start:${issue.created_at}`);
|
|
100
|
+
}
|
|
21
101
|
export async function loadDeliveryContext(input) {
|
|
22
102
|
const root = gitRoot(input.repoRoot);
|
|
23
103
|
const loaded = await loadDeliveryConfig(root, {
|
|
@@ -150,9 +230,272 @@ export async function createIssue(context, input) {
|
|
|
150
230
|
created: { number: issueNumber, title: created.data.title, url: created.data.html_url },
|
|
151
231
|
};
|
|
152
232
|
}
|
|
233
|
+
const FieldCatalogEntry = z.object({
|
|
234
|
+
id: z.number().int().positive().safe(),
|
|
235
|
+
name: z.string().min(1),
|
|
236
|
+
data_type: z.enum(['text', 'number', 'date', 'single_select', 'multi_select']),
|
|
237
|
+
});
|
|
238
|
+
const FieldValueEntry = z.object({
|
|
239
|
+
issue_field_id: z.number().int().positive().safe(),
|
|
240
|
+
issue_field_name: z.string().min(1),
|
|
241
|
+
data_type: FieldCatalogEntry.shape.data_type,
|
|
242
|
+
value: z.unknown().optional(),
|
|
243
|
+
single_select_option: z
|
|
244
|
+
.object({ name: z.string().min(1) })
|
|
245
|
+
.nullable()
|
|
246
|
+
.optional(),
|
|
247
|
+
multi_select_options: z.array(z.object({ name: z.string().min(1) })).optional(),
|
|
248
|
+
});
|
|
249
|
+
const ListedIssue = z.object({
|
|
250
|
+
number: z.number().int().positive().safe(),
|
|
251
|
+
node_id: z.string().min(1),
|
|
252
|
+
title: z.string(),
|
|
253
|
+
state: z.enum(['open', 'closed']),
|
|
254
|
+
labels: z.array(z.union([z.string(), z.object({ name: z.string() })])),
|
|
255
|
+
type: z.object({ name: z.string() }).nullable().optional(),
|
|
256
|
+
html_url: z.url(),
|
|
257
|
+
updated_at: z.iso.datetime({ offset: true }),
|
|
258
|
+
});
|
|
259
|
+
async function issueFieldPages(context, route, parameters) {
|
|
260
|
+
const values = [];
|
|
261
|
+
const seenPages = new Set();
|
|
262
|
+
for (let page = 1;; page += 1) {
|
|
263
|
+
const response = await context.clients.rest.request(route, {
|
|
264
|
+
...parameters,
|
|
265
|
+
headers: { 'X-GitHub-Api-Version': NATIVE_ISSUE_API_VERSION },
|
|
266
|
+
page,
|
|
267
|
+
per_page: 100,
|
|
268
|
+
});
|
|
269
|
+
if (!Array.isArray(response.data))
|
|
270
|
+
throw new DeliveryError('GitHub returned invalid native issue fields.');
|
|
271
|
+
const fingerprint = digestValue(response.data);
|
|
272
|
+
if (response.data.length > 0 && seenPages.has(fingerprint))
|
|
273
|
+
throw new DeliveryError('Native issue fields repeated a pagination page.');
|
|
274
|
+
seenPages.add(fingerprint);
|
|
275
|
+
values.push(...response.data);
|
|
276
|
+
if (response.data.length < 100)
|
|
277
|
+
return values;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/** A bounded GitHub page; local filters can produce an empty page with a nextPage. */
|
|
281
|
+
export async function listIssues(context, input) {
|
|
282
|
+
const page = input.page ?? 1;
|
|
283
|
+
const perPage = input.perPage ?? 30;
|
|
284
|
+
if (!Number.isSafeInteger(page) || page <= 0 || !Number.isSafeInteger(perPage) || perPage <= 0 || perPage > 100)
|
|
285
|
+
throw new DeliveryError('Issue pagination must use a positive page and perPage from 1 to 100.');
|
|
286
|
+
if (input.query !== undefined && (!input.query.trim() || input.query.length > 256 || /["\\\r\n]/u.test(input.query)))
|
|
287
|
+
throw new DeliveryError('Issue search requires literal text without quotes, backslashes or newlines.');
|
|
288
|
+
if (input.labels?.some((label) => !label || /["\\\r\n]/u.test(label)))
|
|
289
|
+
throw new DeliveryError('Issue filter labels must be nonempty literal names.');
|
|
290
|
+
if (input.updatedSince !== undefined && !z.iso.datetime({ offset: true }).safeParse(input.updatedSince).success)
|
|
291
|
+
throw new DeliveryError('updatedSince must be an ISO timestamp.');
|
|
292
|
+
let raw;
|
|
293
|
+
let nextPage;
|
|
294
|
+
if (input.query !== undefined) {
|
|
295
|
+
const q = [
|
|
296
|
+
`repo:${context.repo.owner}/${context.repo.repo}`,
|
|
297
|
+
'is:issue',
|
|
298
|
+
JSON.stringify(input.query.trim()),
|
|
299
|
+
...(input.state === undefined || input.state === 'all' ? [] : [`is:${input.state}`]),
|
|
300
|
+
...(input.labels ?? []).map((label) => `label:${JSON.stringify(label)}`),
|
|
301
|
+
...(input.updatedSince === undefined ? [] : [`updated:>=${input.updatedSince}`]),
|
|
302
|
+
].join(' ');
|
|
303
|
+
const response = (await context.clients.rest.search.issuesAndPullRequests({
|
|
304
|
+
q,
|
|
305
|
+
page,
|
|
306
|
+
per_page: perPage,
|
|
307
|
+
sort: 'updated',
|
|
308
|
+
order: 'desc',
|
|
309
|
+
headers: { 'X-GitHub-Api-Version': NATIVE_ISSUE_API_VERSION },
|
|
310
|
+
})).data;
|
|
311
|
+
if (response.incomplete_results)
|
|
312
|
+
throw new DeliveryError('GitHub issue search returned incomplete results; narrow the query.');
|
|
313
|
+
if (response.total_count > 1000)
|
|
314
|
+
throw new DeliveryError('GitHub issue search exceeds its 1000-result window; narrow the query.');
|
|
315
|
+
raw = response.items;
|
|
316
|
+
nextPage = page * perPage < response.total_count ? page + 1 : null;
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
raw = (await context.clients.rest.issues.listForRepo({
|
|
320
|
+
...context.repo,
|
|
321
|
+
state: input.state ?? 'open',
|
|
322
|
+
...(input.labels === undefined ? {} : { labels: input.labels.join(',') }),
|
|
323
|
+
...(input.updatedSince === undefined ? {} : { since: input.updatedSince }),
|
|
324
|
+
page,
|
|
325
|
+
per_page: perPage,
|
|
326
|
+
sort: 'updated',
|
|
327
|
+
direction: 'desc',
|
|
328
|
+
headers: { 'X-GitHub-Api-Version': NATIVE_ISSUE_API_VERSION },
|
|
329
|
+
})).data;
|
|
330
|
+
nextPage = raw.length === perPage ? page + 1 : null;
|
|
331
|
+
}
|
|
332
|
+
const catalog = z
|
|
333
|
+
.array(FieldCatalogEntry)
|
|
334
|
+
.parse(await issueFieldPages(context, 'GET /orgs/{org}/issue-fields', { org: context.config.native.organization }));
|
|
335
|
+
if (new Set(catalog.map((field) => field.name)).size !== catalog.length ||
|
|
336
|
+
new Set(catalog.map((field) => field.id)).size !== catalog.length)
|
|
337
|
+
throw new DeliveryError('Native issue field catalog has duplicate identities or names.');
|
|
338
|
+
const issues = [];
|
|
339
|
+
const seen = new Set();
|
|
340
|
+
for (const entry of raw) {
|
|
341
|
+
if (typeof entry === 'object' && entry !== null && 'pull_request' in entry)
|
|
342
|
+
continue;
|
|
343
|
+
const current = ListedIssue.parse(entry);
|
|
344
|
+
if (seen.has(current.number))
|
|
345
|
+
throw new DeliveryError('GitHub issue page repeats an issue.');
|
|
346
|
+
seen.add(current.number);
|
|
347
|
+
if (new URL(current.html_url).pathname.toLowerCase() !==
|
|
348
|
+
`/${context.repo.owner}/${context.repo.repo}/issues/${String(current.number)}`.toLowerCase())
|
|
349
|
+
throw new DeliveryError('GitHub issue list returned a foreign repository issue.');
|
|
350
|
+
const selectedLabels = current.labels.map((label) => (typeof label === 'string' ? label : label.name));
|
|
351
|
+
if (input.state !== undefined && input.state !== 'all' && current.state !== input.state)
|
|
352
|
+
continue;
|
|
353
|
+
if ((input.labels ?? []).some((label) => !selectedLabels.some((selected) => selected.toLowerCase() === label.toLowerCase())))
|
|
354
|
+
continue;
|
|
355
|
+
if (input.issueType !== undefined && current.type?.name !== input.issueType)
|
|
356
|
+
continue;
|
|
357
|
+
if (input.updatedSince !== undefined && Date.parse(current.updated_at) < Date.parse(input.updatedSince))
|
|
358
|
+
continue;
|
|
359
|
+
const relationships = await getNativeBlockerRelationships({
|
|
360
|
+
graphql: context.clients.graphql,
|
|
361
|
+
issueNumber: current.number,
|
|
362
|
+
repo: context.repo,
|
|
363
|
+
requireRepositoryIdentity: true,
|
|
364
|
+
});
|
|
365
|
+
const localParent = relationships.parentRepository?.toLowerCase() === `${context.repo.owner}/${context.repo.repo}`.toLowerCase()
|
|
366
|
+
? relationships.parentNumber
|
|
367
|
+
: null;
|
|
368
|
+
if (input.parentIssueNumber !== undefined &&
|
|
369
|
+
(input.parentIssueNumber === null ? relationships.parentNumber !== null : localParent !== input.parentIssueNumber))
|
|
370
|
+
continue;
|
|
371
|
+
const project = await getIssueProjectStatus({
|
|
372
|
+
graphql: context.clients.graphql,
|
|
373
|
+
issueNodeId: current.node_id,
|
|
374
|
+
org: context.config.native.organization,
|
|
375
|
+
...(context.projectConfiguration ? { configuration: context.projectConfiguration } : {}),
|
|
376
|
+
settings: projectSettingsFromDeliveryConfig(context.config),
|
|
377
|
+
});
|
|
378
|
+
if (input.projectStatus !== undefined && (project?.status ?? null) !== input.projectStatus)
|
|
379
|
+
continue;
|
|
380
|
+
const fields = Object.fromEntries(catalog.map((field) => [field.name, null]));
|
|
381
|
+
const values = z.array(FieldValueEntry).parse(await issueFieldPages(context, 'GET /repos/{owner}/{repo}/issues/{issue_number}/issue-field-values', {
|
|
382
|
+
...context.repo,
|
|
383
|
+
issue_number: current.number,
|
|
384
|
+
}));
|
|
385
|
+
const observed = new Set();
|
|
386
|
+
for (const field of values) {
|
|
387
|
+
const definition = catalog.find((candidate) => candidate.id === field.issue_field_id);
|
|
388
|
+
if (!definition ||
|
|
389
|
+
definition.name !== field.issue_field_name ||
|
|
390
|
+
definition.data_type !== field.data_type ||
|
|
391
|
+
observed.has(field.issue_field_id))
|
|
392
|
+
throw new DeliveryError('Native issue field readback conflicts with its catalog.');
|
|
393
|
+
observed.add(field.issue_field_id);
|
|
394
|
+
if (((field.data_type === 'single_select' && !field.single_select_option) ||
|
|
395
|
+
(field.data_type === 'multi_select' && field.multi_select_options === undefined)) &&
|
|
396
|
+
field.value !== null &&
|
|
397
|
+
field.value !== undefined)
|
|
398
|
+
throw new DeliveryError('Native select field omitted its named option readback.');
|
|
399
|
+
const value = field.data_type === 'single_select'
|
|
400
|
+
? (field.single_select_option?.name ?? null)
|
|
401
|
+
: field.data_type === 'multi_select'
|
|
402
|
+
? field.multi_select_options?.map((option) => option.name)
|
|
403
|
+
: field.value;
|
|
404
|
+
if (field.data_type === 'date' && value !== null && value !== undefined && !z.iso.date().safeParse(value).success)
|
|
405
|
+
throw new DeliveryError('Native date field has an invalid date value.');
|
|
406
|
+
if (value === null || value === undefined)
|
|
407
|
+
fields[definition.name] = null;
|
|
408
|
+
else if (field.data_type === 'number'
|
|
409
|
+
? typeof value === 'number' && Number.isFinite(value)
|
|
410
|
+
: field.data_type === 'multi_select'
|
|
411
|
+
? Array.isArray(value)
|
|
412
|
+
: typeof value === 'string')
|
|
413
|
+
fields[definition.name] = value;
|
|
414
|
+
else
|
|
415
|
+
throw new DeliveryError('Native issue field has an invalid value type.');
|
|
416
|
+
}
|
|
417
|
+
issues.push({
|
|
418
|
+
number: current.number,
|
|
419
|
+
title: current.title,
|
|
420
|
+
state: current.state,
|
|
421
|
+
labels: selectedLabels,
|
|
422
|
+
issueType: current.type?.name ?? null,
|
|
423
|
+
parentIssueNumber: localParent,
|
|
424
|
+
parent: relationships.parentNumber === null
|
|
425
|
+
? null
|
|
426
|
+
: { repository: relationships.parentRepository, number: relationships.parentNumber },
|
|
427
|
+
blockers: relationships.blockers.map(({ number, title, state, repository }) => ({
|
|
428
|
+
repository: repository,
|
|
429
|
+
number,
|
|
430
|
+
title,
|
|
431
|
+
state,
|
|
432
|
+
})),
|
|
433
|
+
projectStatus: project?.status ?? null,
|
|
434
|
+
fields,
|
|
435
|
+
url: current.html_url,
|
|
436
|
+
updatedAt: current.updated_at,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
return { issues, page, perPage, nextPage };
|
|
440
|
+
}
|
|
441
|
+
/** @internal Render previous content as inert text without trimming or interpreting its Markdown. */
|
|
442
|
+
export function renderIssueHistory(title, body) {
|
|
443
|
+
const escape = (value) => value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
444
|
+
return `<details>\n<summary>Previous issue title and body</summary>\n\nTitle:\n<pre>${escape(title)}</pre>\n\nBody:\n<pre>${escape(body)}</pre>\n\n</details>`;
|
|
445
|
+
}
|
|
446
|
+
/** @internal Native closure evidence is independent of an input or a posted comment. */
|
|
447
|
+
export async function readIssueClosure(context, issueNumber) {
|
|
448
|
+
const Repository = z.object({ nameWithOwner: z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u) });
|
|
449
|
+
const response = await context.clients.graphql(`
|
|
450
|
+
query IssueClosureReadback($owner: String!, $name: String!, $number: Int!) {
|
|
451
|
+
repository(owner: $owner, name: $name) {
|
|
452
|
+
issue(number: $number) {
|
|
453
|
+
number repository { nameWithOwner } state stateReason
|
|
454
|
+
duplicateOf { number repository { nameWithOwner } }
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
`, { owner: context.repo.owner, name: context.repo.repo, number: issueNumber });
|
|
459
|
+
const parsed = z
|
|
460
|
+
.object({
|
|
461
|
+
repository: z.object({
|
|
462
|
+
issue: z.object({
|
|
463
|
+
number: z.number().int().positive().safe(),
|
|
464
|
+
repository: Repository,
|
|
465
|
+
state: z.enum(['OPEN', 'CLOSED']),
|
|
466
|
+
stateReason: z.enum(['COMPLETED', 'NOT_PLANNED', 'DUPLICATE', 'REOPENED']).nullable(),
|
|
467
|
+
duplicateOf: z.object({ number: z.number().int().positive().safe(), repository: Repository }).nullable(),
|
|
468
|
+
}),
|
|
469
|
+
}),
|
|
470
|
+
})
|
|
471
|
+
.parse(response).repository.issue;
|
|
472
|
+
if (parsed.number !== issueNumber ||
|
|
473
|
+
parsed.repository.nameWithOwner.toLowerCase() !== `${context.repo.owner}/${context.repo.repo}`.toLowerCase())
|
|
474
|
+
throw new DeliveryError('Native closure readback returned a foreign issue identity.');
|
|
475
|
+
if ((parsed.state === 'OPEN' && parsed.stateReason !== null && parsed.stateReason !== 'REOPENED') ||
|
|
476
|
+
(parsed.state === 'CLOSED' && parsed.stateReason === 'REOPENED') ||
|
|
477
|
+
(parsed.duplicateOf !== null && parsed.stateReason !== 'DUPLICATE'))
|
|
478
|
+
throw new DeliveryError('Native closure readback has contradictory state evidence.');
|
|
479
|
+
return {
|
|
480
|
+
state: parsed.state === 'OPEN' ? 'open' : 'closed',
|
|
481
|
+
closeReason: parsed.stateReason === null || parsed.stateReason === 'REOPENED'
|
|
482
|
+
? null
|
|
483
|
+
: parsed.stateReason.toLowerCase(),
|
|
484
|
+
duplicateOf: parsed.duplicateOf === null
|
|
485
|
+
? null
|
|
486
|
+
: { number: parsed.duplicateOf.number, repository: parsed.duplicateOf.repository.nameWithOwner },
|
|
487
|
+
};
|
|
488
|
+
}
|
|
153
489
|
export async function updateIssue(context, input) {
|
|
490
|
+
if ((input.closeReason !== undefined && !['completed', 'not_planned', 'duplicate'].includes(input.closeReason)) ||
|
|
491
|
+
((input.closeReason !== undefined || input.supersededBy !== undefined) && input.state !== 'closed'))
|
|
492
|
+
throw new DeliveryError('Closure details require state closed and a supported close reason.');
|
|
493
|
+
if (input.supersededBy !== undefined &&
|
|
494
|
+
(!Number.isSafeInteger(input.supersededBy) || input.supersededBy <= 0 || input.supersededBy === input.issueNumber))
|
|
495
|
+
throw new DeliveryError('A superseding issue must be a different positive issue number.');
|
|
154
496
|
assertNativeIssueTrackingAdmission(input.issueNumber, context.root);
|
|
155
497
|
const { clients, config, repo } = context;
|
|
498
|
+
const acceptedLabels = input.labels === undefined ? undefined : labels(input.labels);
|
|
156
499
|
if (input.issueType !== undefined && !config.native.issueTypes.includes(input.issueType)) {
|
|
157
500
|
throw new DeliveryError('Unsupported configured Issue Type.');
|
|
158
501
|
}
|
|
@@ -184,17 +527,89 @@ export async function updateIssue(context, input) {
|
|
|
184
527
|
rest: clients.rest,
|
|
185
528
|
});
|
|
186
529
|
}
|
|
530
|
+
const closeReason = input.closeReason ?? (input.supersededBy === undefined ? undefined : 'completed');
|
|
531
|
+
let supersedingIssueId;
|
|
532
|
+
if (input.supersededBy !== undefined) {
|
|
533
|
+
const target = (await clients.rest.issues.get({ ...repo, issue_number: input.supersededBy })).data;
|
|
534
|
+
if (target.number !== input.supersededBy ||
|
|
535
|
+
'pull_request' in target ||
|
|
536
|
+
!Number.isSafeInteger(target.id) ||
|
|
537
|
+
target.id <= 0 ||
|
|
538
|
+
new URL(target.html_url).pathname.toLowerCase() !==
|
|
539
|
+
`/${repo.owner}/${repo.repo}/issues/${String(input.supersededBy)}`.toLowerCase())
|
|
540
|
+
throw new DeliveryError('Superseding issue readback is not the requested repository issue.');
|
|
541
|
+
supersedingIssueId = target.id;
|
|
542
|
+
}
|
|
543
|
+
const assertClosure = (closure) => {
|
|
544
|
+
if (closure.state !== 'closed' ||
|
|
545
|
+
closure.closeReason !== closeReason ||
|
|
546
|
+
(closeReason === 'duplicate' &&
|
|
547
|
+
input.supersededBy !== undefined &&
|
|
548
|
+
(closure.duplicateOf?.number !== input.supersededBy ||
|
|
549
|
+
closure.duplicateOf.repository.toLowerCase() !== `${repo.owner}/${repo.repo}`.toLowerCase())))
|
|
550
|
+
throw new DeliveryError('Native closure readback disagrees with the requested reason or superseding issue.');
|
|
551
|
+
};
|
|
552
|
+
if (closeReason !== undefined) {
|
|
553
|
+
const current = await readIssueClosure(context, input.issueNumber);
|
|
554
|
+
if (current.state === 'closed') {
|
|
555
|
+
try {
|
|
556
|
+
assertClosure(current);
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
throw new DeliveryError('Issue is already closed with different closure evidence; reopen it explicitly first.');
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
let historyComment;
|
|
564
|
+
let priorContent;
|
|
565
|
+
if (input.preserveHistory === true && (input.title !== undefined || input.body !== undefined)) {
|
|
566
|
+
const current = (await clients.rest.issues.get({ ...repo, issue_number: input.issueNumber })).data;
|
|
567
|
+
if (current.number !== input.issueNumber || 'pull_request' in current)
|
|
568
|
+
throw new DeliveryError('History target is not the requested issue.');
|
|
569
|
+
const prior = { title: current.title, body: current.body ?? '' };
|
|
570
|
+
if ((input.title !== undefined && input.title !== prior.title) ||
|
|
571
|
+
(input.body !== undefined && input.body !== prior.body)) {
|
|
572
|
+
const body = renderIssueHistory(prior.title, prior.body);
|
|
573
|
+
if (body.length > ISSUE_COMMENT_BODY_LIMIT)
|
|
574
|
+
throw new DeliveryError('Complete issue history exceeds the supported comment body limit; issue was not rewritten.');
|
|
575
|
+
priorContent = prior;
|
|
576
|
+
historyComment = await commentIssue(context, { issueNumber: input.issueNumber, body });
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
let closureComment;
|
|
580
|
+
if (closeReason !== undefined)
|
|
581
|
+
closureComment = await commentIssue(context, {
|
|
582
|
+
issueNumber: input.issueNumber,
|
|
583
|
+
body: `Requested closure reason: ${closeReason}.${input.supersededBy === undefined ? '' : `\nSuperseded by ${repo.owner}/${repo.repo}#${String(input.supersededBy)}.`}`,
|
|
584
|
+
});
|
|
585
|
+
if (priorContent !== undefined) {
|
|
586
|
+
const current = (await clients.rest.issues.get({ ...repo, issue_number: input.issueNumber })).data;
|
|
587
|
+
if (current.number !== input.issueNumber ||
|
|
588
|
+
'pull_request' in current ||
|
|
589
|
+
current.title !== priorContent.title ||
|
|
590
|
+
(current.body ?? '') !== priorContent.body)
|
|
591
|
+
throw new DeliveryError('Issue content changed after history readback; retry against the current title and body.');
|
|
592
|
+
}
|
|
187
593
|
await clients.rest.issues.update({
|
|
188
594
|
issue_number: input.issueNumber,
|
|
189
595
|
owner: repo.owner,
|
|
190
596
|
repo: repo.repo,
|
|
191
597
|
...(input.body === undefined ? {} : { body: input.body }),
|
|
192
598
|
...(input.issueType === undefined ? {} : { type: input.issueType }),
|
|
193
|
-
...(
|
|
599
|
+
...(acceptedLabels === undefined ? {} : { labels: acceptedLabels }),
|
|
194
600
|
...(input.milestone === undefined ? {} : { milestone: input.milestone }),
|
|
195
601
|
...(input.state === undefined ? {} : { state: input.state }),
|
|
602
|
+
...(closeReason === undefined
|
|
603
|
+
? {}
|
|
604
|
+
: { state_reason: closeReason, headers: { 'X-GitHub-Api-Version': NATIVE_ISSUE_API_VERSION } }),
|
|
605
|
+
...(closeReason !== 'duplicate' || supersedingIssueId === undefined
|
|
606
|
+
? {}
|
|
607
|
+
: { duplicate_issue_id: supersedingIssueId }),
|
|
196
608
|
...(input.title === undefined ? {} : { title: input.title }),
|
|
197
609
|
});
|
|
610
|
+
const closure = closeReason === undefined ? undefined : await readIssueClosure(context, input.issueNumber);
|
|
611
|
+
if (closure !== undefined)
|
|
612
|
+
assertClosure(closure);
|
|
198
613
|
if (input.points !== undefined || input.priority !== undefined)
|
|
199
614
|
await setConfiguredNativeIssueMetadata({
|
|
200
615
|
issueNumber: input.issueNumber,
|
|
@@ -252,7 +667,19 @@ export async function updateIssue(context, input) {
|
|
|
252
667
|
settings: projectSettingsFromDeliveryConfig(config),
|
|
253
668
|
status,
|
|
254
669
|
});
|
|
255
|
-
|
|
670
|
+
const readback = await issueInfo(context, input.issueNumber);
|
|
671
|
+
if ((input.preserveHistory === true || closeReason !== undefined) &&
|
|
672
|
+
((input.title !== undefined && readback.title !== input.title) ||
|
|
673
|
+
(input.body !== undefined && readback.body !== input.body) ||
|
|
674
|
+
(input.state !== undefined && readback.state !== input.state)))
|
|
675
|
+
throw new DeliveryError('Issue update readback disagrees with the requested content or state.');
|
|
676
|
+
return {
|
|
677
|
+
...readback,
|
|
678
|
+
...(historyComment === undefined ? {} : { historyComment }),
|
|
679
|
+
...(closure === undefined || closureComment === undefined
|
|
680
|
+
? {}
|
|
681
|
+
: { closure: { ...closure, supersededBy: input.supersededBy ?? null, comment: closureComment } }),
|
|
682
|
+
};
|
|
256
683
|
}
|
|
257
684
|
/** Complete the native tracking and Project state of an issue created before an interrupted response. */
|
|
258
685
|
export async function resumeCreatedIssue(context, input) {
|
|
@@ -569,6 +996,7 @@ export async function developIssue(context, issueNumber, runtimeEntryPath) {
|
|
|
569
996
|
settings: projectSettingsFromDeliveryConfig(context.config),
|
|
570
997
|
status: 'In Progress',
|
|
571
998
|
});
|
|
999
|
+
await journalIssueStart(context, issueNumber);
|
|
572
1000
|
return { branch: row.branch, path: row.path };
|
|
573
1001
|
}
|
|
574
1002
|
//# sourceMappingURL=issue.js.map
|