@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
package/dist/issue.d.ts
CHANGED
|
@@ -66,6 +66,59 @@ export declare function createIssue(context: DeliveryContext, input: CreateIssue
|
|
|
66
66
|
};
|
|
67
67
|
routing?: LoadedDeliveryConfig['routing'];
|
|
68
68
|
}>;
|
|
69
|
+
export interface ListIssuesInput {
|
|
70
|
+
query?: string;
|
|
71
|
+
state?: 'open' | 'closed' | 'all';
|
|
72
|
+
labels?: string[];
|
|
73
|
+
parentIssueNumber?: number | null;
|
|
74
|
+
issueType?: string;
|
|
75
|
+
projectStatus?: 'Todo' | 'In Progress' | 'Blocked' | 'Done' | null;
|
|
76
|
+
updatedSince?: string;
|
|
77
|
+
page?: number;
|
|
78
|
+
perPage?: number;
|
|
79
|
+
}
|
|
80
|
+
export interface ListedTrackingIssue {
|
|
81
|
+
number: number;
|
|
82
|
+
title: string;
|
|
83
|
+
state: 'open' | 'closed';
|
|
84
|
+
labels: string[];
|
|
85
|
+
issueType: string | null;
|
|
86
|
+
parentIssueNumber: number | null;
|
|
87
|
+
parent: {
|
|
88
|
+
repository: string;
|
|
89
|
+
number: number;
|
|
90
|
+
} | null;
|
|
91
|
+
blockers: {
|
|
92
|
+
repository: string;
|
|
93
|
+
number: number;
|
|
94
|
+
title: string;
|
|
95
|
+
state: 'OPEN' | 'CLOSED';
|
|
96
|
+
}[];
|
|
97
|
+
projectStatus: string | null;
|
|
98
|
+
fields: Record<string, string | number | string[] | null>;
|
|
99
|
+
url: string;
|
|
100
|
+
updatedAt: string;
|
|
101
|
+
}
|
|
102
|
+
/** A bounded GitHub page; local filters can produce an empty page with a nextPage. */
|
|
103
|
+
export declare function listIssues(context: DeliveryContext, input: ListIssuesInput): Promise<{
|
|
104
|
+
issues: ListedTrackingIssue[];
|
|
105
|
+
page: number;
|
|
106
|
+
perPage: number;
|
|
107
|
+
nextPage: number | null;
|
|
108
|
+
}>;
|
|
109
|
+
/** @internal Render previous content as inert text without trimming or interpreting its Markdown. */
|
|
110
|
+
export declare function renderIssueHistory(title: string, body: string): string;
|
|
111
|
+
export type IssueCloseReason = 'completed' | 'not_planned' | 'duplicate';
|
|
112
|
+
export interface IssueClosureReadback {
|
|
113
|
+
state: 'open' | 'closed';
|
|
114
|
+
closeReason: IssueCloseReason | null;
|
|
115
|
+
duplicateOf: {
|
|
116
|
+
number: number;
|
|
117
|
+
repository: string;
|
|
118
|
+
} | null;
|
|
119
|
+
}
|
|
120
|
+
/** @internal Native closure evidence is independent of an input or a posted comment. */
|
|
121
|
+
export declare function readIssueClosure(context: DeliveryContext, issueNumber: number): Promise<IssueClosureReadback>;
|
|
69
122
|
export interface UpdateIssueInput {
|
|
70
123
|
blockedBy?: number[];
|
|
71
124
|
body?: string;
|
|
@@ -78,9 +131,19 @@ export interface UpdateIssueInput {
|
|
|
78
131
|
points?: number;
|
|
79
132
|
priority?: string;
|
|
80
133
|
state?: 'open' | 'closed';
|
|
134
|
+
preserveHistory?: boolean;
|
|
135
|
+
closeReason?: 'completed' | 'not_planned' | 'duplicate';
|
|
136
|
+
supersededBy?: number;
|
|
81
137
|
title?: string;
|
|
82
138
|
}
|
|
83
|
-
export
|
|
139
|
+
export interface UpdateIssueResult extends IssueInfo {
|
|
140
|
+
historyComment?: IssueCommentReadback;
|
|
141
|
+
closure?: IssueClosureReadback & {
|
|
142
|
+
supersededBy: number | null;
|
|
143
|
+
comment: IssueCommentReadback;
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
export declare function updateIssue(context: DeliveryContext, input: UpdateIssueInput): Promise<UpdateIssueResult>;
|
|
84
147
|
/** Complete the native tracking and Project state of an issue created before an interrupted response. */
|
|
85
148
|
export declare function resumeCreatedIssue(context: DeliveryContext, input: UpdateIssueInput): Promise<IssueInfo>;
|
|
86
149
|
export interface IssueInfo {
|
package/dist/issue.js
CHANGED
|
@@ -6,7 +6,7 @@ import { assertPrivateFile, digestBytes, stableJson } from './delivery/common.js
|
|
|
6
6
|
import { digestValue } from './delivery/index.js';
|
|
7
7
|
import { DeliveryError } from './errors.js';
|
|
8
8
|
import { createDeliveryGitHubClients } from './github/client.js';
|
|
9
|
-
import { clearConfiguredNativeIssuePoints, getConfiguredNativeIssueMetadata, nativeIssueSettingsFromDeliveryConfig, setConfiguredNativeIssueMetadata, } from './github/nativeIssueMetadata.js';
|
|
9
|
+
import { NATIVE_ISSUE_API_VERSION, clearConfiguredNativeIssuePoints, getConfiguredNativeIssueMetadata, nativeIssueSettingsFromDeliveryConfig, setConfiguredNativeIssueMetadata, } from './github/nativeIssueMetadata.js';
|
|
10
10
|
import { getIssueProjectStatus, projectSettingsFromDeliveryConfig, syncIssueProjectStatus, } from './github/projectDelivery.js';
|
|
11
11
|
import { getNativeBlockerRelationships, hasNativeSubIssues, replaceBlockedBy, replaceParentIssue, resolveNativeRelationshipTargets, } from './github/relationships.js';
|
|
12
12
|
import { resolveDeliveryRepo } from './github/repo.js';
|
|
@@ -230,9 +230,272 @@ export async function createIssue(context, input) {
|
|
|
230
230
|
created: { number: issueNumber, title: created.data.title, url: created.data.html_url },
|
|
231
231
|
};
|
|
232
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
|
+
}
|
|
233
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.');
|
|
234
496
|
assertNativeIssueTrackingAdmission(input.issueNumber, context.root);
|
|
235
497
|
const { clients, config, repo } = context;
|
|
498
|
+
const acceptedLabels = input.labels === undefined ? undefined : labels(input.labels);
|
|
236
499
|
if (input.issueType !== undefined && !config.native.issueTypes.includes(input.issueType)) {
|
|
237
500
|
throw new DeliveryError('Unsupported configured Issue Type.');
|
|
238
501
|
}
|
|
@@ -264,17 +527,89 @@ export async function updateIssue(context, input) {
|
|
|
264
527
|
rest: clients.rest,
|
|
265
528
|
});
|
|
266
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
|
+
}
|
|
267
593
|
await clients.rest.issues.update({
|
|
268
594
|
issue_number: input.issueNumber,
|
|
269
595
|
owner: repo.owner,
|
|
270
596
|
repo: repo.repo,
|
|
271
597
|
...(input.body === undefined ? {} : { body: input.body }),
|
|
272
598
|
...(input.issueType === undefined ? {} : { type: input.issueType }),
|
|
273
|
-
...(
|
|
599
|
+
...(acceptedLabels === undefined ? {} : { labels: acceptedLabels }),
|
|
274
600
|
...(input.milestone === undefined ? {} : { milestone: input.milestone }),
|
|
275
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 }),
|
|
276
608
|
...(input.title === undefined ? {} : { title: input.title }),
|
|
277
609
|
});
|
|
610
|
+
const closure = closeReason === undefined ? undefined : await readIssueClosure(context, input.issueNumber);
|
|
611
|
+
if (closure !== undefined)
|
|
612
|
+
assertClosure(closure);
|
|
278
613
|
if (input.points !== undefined || input.priority !== undefined)
|
|
279
614
|
await setConfiguredNativeIssueMetadata({
|
|
280
615
|
issueNumber: input.issueNumber,
|
|
@@ -332,7 +667,19 @@ export async function updateIssue(context, input) {
|
|
|
332
667
|
settings: projectSettingsFromDeliveryConfig(config),
|
|
333
668
|
status,
|
|
334
669
|
});
|
|
335
|
-
|
|
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
|
+
};
|
|
336
683
|
}
|
|
337
684
|
/** Complete the native tracking and Project state of an issue created before an interrupted response. */
|
|
338
685
|
export async function resumeCreatedIssue(context, input) {
|