@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/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 declare function updateIssue(context: DeliveryContext, input: UpdateIssueInput): Promise<Awaited<ReturnType<typeof issueInfo>>>;
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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
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
- ...(input.labels === undefined ? {} : { labels: labels(input.labels) }),
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
- return issueInfo(context, input.issueNumber);
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) {