@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/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('&', '&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
+ }
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
- ...(input.labels === undefined ? {} : { labels: labels(input.labels) }),
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
- 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
+ };
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