pm-github 2026.10.24 → 2026.10.26
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/CHANGELOG.md +6 -0
- package/README.md +125 -7
- package/dist/closeout.d.ts +20 -5
- package/dist/closeout.d.ts.map +1 -1
- package/dist/closeout.js +292 -66
- package/dist/closeout.js.map +1 -1
- package/dist/gate.d.ts.map +1 -1
- package/dist/gate.js +5 -3
- package/dist/gate.js.map +1 -1
- package/dist/identity.d.ts.map +1 -1
- package/dist/identity.js +3 -2
- package/dist/identity.js.map +1 -1
- package/dist/import-fidelity.d.ts +1 -1
- package/dist/import-fidelity.d.ts.map +1 -1
- package/dist/import-fidelity.js +39 -50
- package/dist/import-fidelity.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +66 -39
- package/dist/index.js.map +1 -1
- package/dist/issue-forms.d.ts +72 -0
- package/dist/issue-forms.d.ts.map +1 -0
- package/dist/issue-forms.js +195 -0
- package/dist/issue-forms.js.map +1 -0
- package/dist/own-record.d.ts +8 -0
- package/dist/own-record.d.ts.map +1 -0
- package/dist/own-record.js +14 -0
- package/dist/own-record.js.map +1 -0
- package/dist/projects.d.ts.map +1 -1
- package/dist/projects.js +2 -15
- package/dist/projects.js.map +1 -1
- package/dist/refresh.d.ts.map +1 -1
- package/dist/refresh.js +3 -2
- package/dist/refresh.js.map +1 -1
- package/dist/webhooks.js +1 -1
- package/dist/webhooks.js.map +1 -1
- package/manifest.json +1 -1
- package/package.json +10 -5
package/dist/closeout.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { ownValue } from "./own-record.js";
|
|
1
2
|
/** Close delivered tracker items using merged pull requests and head-check evidence. */
|
|
2
|
-
import { close, listAllComplete, certifyCompleteListResult, PmCliError } from "@unbrained/pm-cli/sdk";
|
|
3
|
+
import { close, hashDocument, locateItem, readLocatedItem, readSettings, resolveItemTypeRegistry, runWithActiveExtensions, setActiveExtensionHooks, listAllComplete, certifyCompleteListResult, getHistoryPath, readHistoryEntries, verifyHistoryEntries, PmCliError, EXIT_CODE } from "@unbrained/pm-cli/sdk";
|
|
3
4
|
/** Reject non-object API payloads before reading safety-critical fields. */
|
|
4
5
|
function record(value) {
|
|
5
6
|
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
@@ -23,9 +24,10 @@ function isoDate(value) {
|
|
|
23
24
|
}
|
|
24
25
|
/** Normalize repeatable flags without silently dropping malformed members. */
|
|
25
26
|
function repeated(options, key) {
|
|
26
|
-
|
|
27
|
+
const value = ownValue(options, key);
|
|
28
|
+
if (value === undefined)
|
|
27
29
|
return [];
|
|
28
|
-
const values = Array.isArray(
|
|
30
|
+
const values = Array.isArray(value) ? value : [value];
|
|
29
31
|
if (values.length === 0 || values.some(value => typeof value !== "string" || value.trim() === ""))
|
|
30
32
|
throw new Error(`--${key} requires a value.`);
|
|
31
33
|
return [...new Set(values.map(value => value.trim()))];
|
|
@@ -82,23 +84,40 @@ function pullRequest(value, repo, number) {
|
|
|
82
84
|
const raw = record(value);
|
|
83
85
|
const base = record(raw.base);
|
|
84
86
|
const baseRepo = record(base.repo);
|
|
85
|
-
const head = record(raw.head);
|
|
87
|
+
const head = raw.head === null || raw.head === undefined ? {} : record(raw.head);
|
|
86
88
|
if (raw.number !== number || text(baseRepo.full_name).toLowerCase() !== repo.toLowerCase()
|
|
87
89
|
|| text(raw.html_url).toLowerCase() !== `https://github.com/${repo}/pull/${number}`.toLowerCase()
|
|
88
90
|
|| (raw.body !== null && typeof raw.body !== "string"))
|
|
89
91
|
throw new Error("Pull-request identity or body is unverifiable.");
|
|
90
92
|
return {
|
|
91
|
-
number, body: raw.body, merged: raw.merged ===
|
|
93
|
+
number, body: raw.body, merged: typeof raw.merged === "boolean" ? raw.merged : null,
|
|
94
|
+
commits: Number.isSafeInteger(raw.commits) && Number(raw.commits) >= 0 ? Number(raw.commits) : null,
|
|
92
95
|
merged_at: raw.merged_at, merge_commit_sha: raw.merge_commit_sha,
|
|
93
|
-
html_url: raw.html_url, head: { sha:
|
|
96
|
+
html_url: raw.html_url, head: { sha: typeof head.sha === "string" ? head.sha : "" },
|
|
94
97
|
base: { ref: text(base.ref), repo: { full_name: baseRepo.full_name } },
|
|
95
98
|
};
|
|
96
99
|
}
|
|
100
|
+
/** Read a validated merge time; missing or malformed chronology remains unknown. */
|
|
101
|
+
function mergeTime(value) {
|
|
102
|
+
try {
|
|
103
|
+
return Date.parse(isoDate(text(value)));
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return Number.NaN;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Name each missing merge fact without copying untrusted API values. */
|
|
110
|
+
function mergeEvidenceGaps(pr) {
|
|
111
|
+
return [
|
|
112
|
+
...(pr.merged === true ? [] : ["merged"]),
|
|
113
|
+
...(Number.isFinite(mergeTime(pr.merged_at)) ? [] : ["merged_at"]),
|
|
114
|
+
...(typeof pr.merge_commit_sha === "string" && /^[a-f0-9]{40}$/.test(pr.merge_commit_sha) ? [] : ["merge_commit_sha"]),
|
|
115
|
+
...(/^[a-f0-9]{40}$/.test(pr.head.sha) ? [] : ["head.sha"]),
|
|
116
|
+
];
|
|
117
|
+
}
|
|
97
118
|
/** Require complete, well-formed merge facts before any check queries or writes. */
|
|
98
119
|
function mergeEvidence(pr) {
|
|
99
|
-
return
|
|
100
|
-
&& typeof pr.merge_commit_sha === "string" && /^[a-f0-9]{40}$/.test(pr.merge_commit_sha)
|
|
101
|
-
&& /^[a-f0-9]{40}$/.test(pr.head.sha);
|
|
120
|
+
return mergeEvidenceGaps(pr).length === 0;
|
|
102
121
|
}
|
|
103
122
|
/** Treat only permission and not-found responses as protection metadata gaps. */
|
|
104
123
|
async function readProtectionSource(read) {
|
|
@@ -313,6 +332,54 @@ export function extractCloseoutItems(body) {
|
|
|
313
332
|
return [entry];
|
|
314
333
|
}).map(({ repo, item, declared }) => ({ repo, item, declared }));
|
|
315
334
|
}
|
|
335
|
+
/** Read the last criteria edit from immutable SDK history; unavailable chronology cannot select delivery. */
|
|
336
|
+
async function acceptanceChangedAt(pmRoot, id, document) {
|
|
337
|
+
try {
|
|
338
|
+
const history = await readHistoryEntries(getHistoryPath(pmRoot, id), id);
|
|
339
|
+
if (history.length === 0 || !verifyHistoryEntries(history, document).ok)
|
|
340
|
+
return Number.NaN;
|
|
341
|
+
let changedAt = Date.parse(document.metadata.created_at);
|
|
342
|
+
for (const event of history) {
|
|
343
|
+
if (event.patch.some(patch => ["", "/metadata", "/metadata/acceptance_criteria"].includes(patch.path))) {
|
|
344
|
+
changedAt = Math.max(changedAt, Date.parse(event.ts));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return changedAt;
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
return Number.NaN;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/** Extract exact item declarations from descriptions or commit messages using the same grammar. */
|
|
354
|
+
function deliveryReferences(body, repo, ids) {
|
|
355
|
+
const refs = extractCloseoutItems(body);
|
|
356
|
+
const declarations = new Set();
|
|
357
|
+
for (const match of body.matchAll(/^(?:[-*][ \t]+)?([a-z]+|pm-github:|PM item:)[ \t]+(\S+)[ \t]*$/gim)) {
|
|
358
|
+
const keyword = match[1].toLowerCase();
|
|
359
|
+
if (!CLOSING_KEYWORDS.has(keyword) && keyword !== "pm-github:" && keyword !== "pm item:")
|
|
360
|
+
continue;
|
|
361
|
+
const id = declarationKey(match[2]);
|
|
362
|
+
declarations.add(id);
|
|
363
|
+
if (/[.!]$/.test(id))
|
|
364
|
+
declarations.add(id.slice(0, -1));
|
|
365
|
+
}
|
|
366
|
+
for (const id of ids) {
|
|
367
|
+
if (declarations.has(declarationKey(id))) {
|
|
368
|
+
const ref = refs.find(ref => ref.item === id && ref.repo.toLowerCase() === repo.toLowerCase());
|
|
369
|
+
if (ref)
|
|
370
|
+
ref.declared = true;
|
|
371
|
+
else
|
|
372
|
+
refs.push({ item: id, repo, declared: true });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return refs;
|
|
376
|
+
}
|
|
377
|
+
/** Classify delivery once: only claims are candidates; unreadable claims remain unresolved. */
|
|
378
|
+
function deliveryRole(declared, tagged, commitsReadable) {
|
|
379
|
+
if (declared || tagged)
|
|
380
|
+
return "candidate";
|
|
381
|
+
return commitsReadable ? "context" : "unresolved";
|
|
382
|
+
}
|
|
316
383
|
/** Render only the allowed GitHub facts; never include body, title or comment text. */
|
|
317
384
|
function closeReason(pr, passed) {
|
|
318
385
|
return JSON.stringify({ pr: pr.number, url: pr.html_url, merge_commit_sha: pr.merge_commit_sha,
|
|
@@ -357,8 +424,29 @@ export function checkEvidence(gate) {
|
|
|
357
424
|
const successes = gate.passed.length > 0 ? `successful checks: ${gate.passed.join(", ")}` : "no check reported success";
|
|
358
425
|
return `no readable required checks, so every completed head check run had to succeed, be neutral or be skipped; ${successes}`;
|
|
359
426
|
}
|
|
360
|
-
/**
|
|
361
|
-
|
|
427
|
+
/** Project one item decision onto all its candidates, including incomplete competitors. */
|
|
428
|
+
function reportItemDecision(rows, decision) {
|
|
429
|
+
const candidates = rows.map(row => row.pr).sort((a, b) => a - b);
|
|
430
|
+
for (const row of rows) {
|
|
431
|
+
if (decision.kind === "chosen") {
|
|
432
|
+
if (row === decision.row)
|
|
433
|
+
continue;
|
|
434
|
+
Object.assign(row, { action: "skip", code: "duplicate_selection",
|
|
435
|
+
reason: "Another merged PR uniquely supplies the delivery evidence for this item." });
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
Object.assign(row, { action: "needs_review", code: "needs_review", reason: decision.reason, candidates });
|
|
439
|
+
if (decision.missing.length > 0)
|
|
440
|
+
row.missing_evidence = decision.missing;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/** Plan selected PRs and apply only decisions whose exact item snapshot still matches. */
|
|
445
|
+
export function runCloseout(pmRoot, repo, options, api, observer) {
|
|
446
|
+
return closeoutAttempt(pmRoot, repo, options, api, true, observer);
|
|
447
|
+
}
|
|
448
|
+
/** Re-select a conflicted item once; each attempt installs a lock-scoped SDK precondition. */
|
|
449
|
+
async function closeoutAttempt(pmRoot, repo, options, api, retry, observer, onlyItem) {
|
|
362
450
|
if (!repo || !/^(?!\.{1,2}\/)[A-Za-z0-9_.-]+\/(?!\.{1,2}$)[A-Za-z0-9_.-]+$/.test(repo))
|
|
363
451
|
throw new CloseoutUsageError("closeout requires --repo owner/repo or PM_GITHUB_REPO.");
|
|
364
452
|
const numbers = usage(() => repeated(options, "pr")).map(value => {
|
|
@@ -369,9 +457,9 @@ export async function runCloseout(pmRoot, repo, options, api) {
|
|
|
369
457
|
const since = options.since === undefined ? undefined : usage(() => isoDate(text(options.since)));
|
|
370
458
|
if ((numbers.length === 0) === (since === undefined))
|
|
371
459
|
throw new CloseoutUsageError("Select either repeatable --pr <n> or --since <ISO date>.");
|
|
372
|
-
const declared = usage(() => requiredContexts(options
|
|
460
|
+
const declared = usage(() => requiredContexts(ownValue(options, "required-checks") ?? options.requiredChecks));
|
|
373
461
|
const skip = new Set(usage(() => repeated(options, "skip")));
|
|
374
|
-
const apply = options.apply === true && options
|
|
462
|
+
const apply = options.apply === true && ownValue(options, "dry-run") !== true && options.dryRun !== true;
|
|
375
463
|
if (since !== undefined) {
|
|
376
464
|
// Rows arrive newest-updated first and a merge never postdates its PR's update, so a row updated
|
|
377
465
|
// before the cutoff proves no later row can have merged at or after it.
|
|
@@ -379,7 +467,8 @@ export async function runCloseout(pmRoot, repo, options, api) {
|
|
|
379
467
|
const older = (row) => typeof row === "object" && row !== null && Date.parse(String(row.updated_at)) < cutoff;
|
|
380
468
|
for (const value of await pages(api, `/repos/${repo}/pulls?state=closed&sort=updated&direction=desc&per_page=100`, false, older)) {
|
|
381
469
|
const pr = record(value);
|
|
382
|
-
|
|
470
|
+
// A missing or malformed merge time is uncertainty, never proof of an unmerged PR.
|
|
471
|
+
if (!Number.isFinite(mergeTime(pr.merged_at)) || mergeTime(pr.merged_at) >= cutoff) {
|
|
383
472
|
if (!Number.isSafeInteger(pr.number) || Number(pr.number) < 1)
|
|
384
473
|
throw new Error("Invalid selected pull-request number.");
|
|
385
474
|
numbers.push(Number(pr.number));
|
|
@@ -387,10 +476,26 @@ export async function runCloseout(pmRoot, repo, options, api) {
|
|
|
387
476
|
}
|
|
388
477
|
}
|
|
389
478
|
const corpus = certifyCompleteListResult(await listAllComplete({}, { pmRoot, noExtensions: true }));
|
|
479
|
+
await observer?.("listed");
|
|
480
|
+
const settings = await readSettings(pmRoot);
|
|
481
|
+
const types = resolveItemTypeRegistry(settings).type_to_folder;
|
|
482
|
+
const snapshots = new Map();
|
|
483
|
+
const documents = new Map();
|
|
390
484
|
const items = new Map(corpus.items.map(item => [item.id, item]));
|
|
485
|
+
// Selection and its guard must use the same authoritative document, including collections and body.
|
|
486
|
+
for (const id of items.keys()) {
|
|
487
|
+
const located = await locateItem(pmRoot, id, settings.id_prefix, settings.item_format, types);
|
|
488
|
+
if (!located)
|
|
489
|
+
throw new Error("Closeout item snapshot unavailable.");
|
|
490
|
+
const { document } = await readLocatedItem(located, { schema: settings.schema });
|
|
491
|
+
snapshots.set(id, hashDocument(document));
|
|
492
|
+
documents.set(id, document);
|
|
493
|
+
items.set(id, document.metadata);
|
|
494
|
+
}
|
|
495
|
+
await observer?.("snapshot");
|
|
391
496
|
// Containers close when their children are done, never because a pull request mentions them.
|
|
392
497
|
const activeChildren = new Map();
|
|
393
|
-
for (const item of
|
|
498
|
+
for (const item of items.values()) {
|
|
394
499
|
const parent = item.parent;
|
|
395
500
|
if (typeof parent === "string" && !TERMINAL_STATUSES.includes(item.status))
|
|
396
501
|
activeChildren.set(parent, (activeChildren.get(parent) ?? 0) + 1);
|
|
@@ -398,35 +503,51 @@ export async function runCloseout(pmRoot, repo, options, api) {
|
|
|
398
503
|
const plan = [];
|
|
399
504
|
const checks = [];
|
|
400
505
|
const closures = [];
|
|
506
|
+
const candidates = new Map();
|
|
507
|
+
const unreadable = [];
|
|
401
508
|
for (const number of [...new Set(numbers)].sort((a, b) => a - b)) {
|
|
402
|
-
|
|
403
|
-
|
|
509
|
+
let evidence;
|
|
510
|
+
try {
|
|
511
|
+
evidence = await payload(api, `/repos/${repo}/pulls/${number}`);
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
unreadable.push(number);
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
const pr = pullRequest(evidence, repo, number);
|
|
518
|
+
let gate = { pr: number, code: "incomplete_merge", rule: "unavailable", ok: false, passed: [], reason: `Missing or invalid merge evidence: ${mergeEvidenceGaps(pr).join(", ")}.` };
|
|
404
519
|
if (mergeEvidence(pr))
|
|
405
520
|
gate = await checkCloseoutHead(api, repo, pr, declared);
|
|
406
521
|
checks.push(gate);
|
|
407
|
-
const refs =
|
|
408
|
-
//
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
if (
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
522
|
+
const refs = deliveryReferences(pr.body ?? "", repo, items.keys());
|
|
523
|
+
// Commit messages can independently claim delivery. Missing commit evidence never upgrades a mention.
|
|
524
|
+
let messages = [];
|
|
525
|
+
let commitsReadable = false;
|
|
526
|
+
try {
|
|
527
|
+
if (pr.merged !== false) {
|
|
528
|
+
const commits = await pages(api, `/repos/${repo}/pulls/${number}/commits?per_page=100`);
|
|
529
|
+
if (pr.commits === null || commits.length !== pr.commits)
|
|
530
|
+
throw new Error("Incomplete commit evidence.");
|
|
531
|
+
messages = commits.map(value => text(record(record(value).commit).message));
|
|
532
|
+
commitsReadable = true;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
catch { /* Retain description references; unresolved commit evidence is held during attribution. */ }
|
|
536
|
+
for (const ref of deliveryReferences(messages.join("\n"), repo, items.keys())) {
|
|
537
|
+
const existing = refs.find(entry => entry.item === ref.item && entry.repo.toLowerCase() === ref.repo.toLowerCase());
|
|
538
|
+
if (existing)
|
|
539
|
+
existing.declared ||= ref.declared;
|
|
540
|
+
else
|
|
541
|
+
refs.push(ref);
|
|
418
542
|
}
|
|
543
|
+
// An unreadable commit set cannot prove that an item has no commit-only delivery link.
|
|
419
544
|
for (const item of items.values()) {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
const ref = refs.find(ref => ref.item === item.id && ref.repo.toLowerCase() === repo.toLowerCase());
|
|
423
|
-
if (ref)
|
|
424
|
-
ref.declared = true;
|
|
425
|
-
else
|
|
426
|
-
refs.push({ item: item.id, repo, declared: true });
|
|
545
|
+
if ((item.tags.includes(`delivery-pr:${pr.html_url}`) || (!commitsReadable && pr.merged !== false)) && !refs.some(ref => ref.item === item.id && ref.repo.toLowerCase() === repo.toLowerCase())) {
|
|
546
|
+
refs.push({ item: item.id, repo, declared: false });
|
|
427
547
|
}
|
|
428
548
|
}
|
|
429
549
|
for (const ref of refs) {
|
|
550
|
+
const role = deliveryRole(ref.declared, items.get(ref.item)?.tags.includes(`delivery-pr:${pr.html_url}`) === true, commitsReadable);
|
|
430
551
|
let reason;
|
|
431
552
|
let code;
|
|
432
553
|
if (ref.repo.toLowerCase() !== repo.toLowerCase()) {
|
|
@@ -445,9 +566,9 @@ export async function runCloseout(pmRoot, repo, options, api) {
|
|
|
445
566
|
code = "already_terminal";
|
|
446
567
|
reason = "Item is already closed or canceled.";
|
|
447
568
|
}
|
|
448
|
-
else if (
|
|
449
|
-
code =
|
|
450
|
-
reason =
|
|
569
|
+
else if (pr.merged === false) {
|
|
570
|
+
code = "not_merged";
|
|
571
|
+
reason = "Pull request is confirmed unmerged and cannot deliver this item.";
|
|
451
572
|
}
|
|
452
573
|
else if (items.get(ref.item).type === "Epic") {
|
|
453
574
|
code = "epic";
|
|
@@ -457,9 +578,17 @@ export async function runCloseout(pmRoot, repo, options, api) {
|
|
|
457
578
|
code = "active_children";
|
|
458
579
|
reason = `Item has ${activeChildren.get(ref.item)} non-terminal child item(s).`;
|
|
459
580
|
}
|
|
460
|
-
else if (
|
|
581
|
+
else if (role === "context") {
|
|
461
582
|
code = "not_delivery";
|
|
462
|
-
reason = "Mentioned, not delivered. A mention is not delivery.
|
|
583
|
+
reason = "Mentioned, not delivered. A mention is not delivery. This fully inspected PR has no delivery claim and is context only.";
|
|
584
|
+
}
|
|
585
|
+
else if (!gate.ok) {
|
|
586
|
+
code = gate.code;
|
|
587
|
+
reason = gate.reason;
|
|
588
|
+
}
|
|
589
|
+
else if (role === "unresolved") {
|
|
590
|
+
code = "head_evidence_unavailable";
|
|
591
|
+
reason = "Commit delivery evidence unavailable or incomplete.";
|
|
463
592
|
}
|
|
464
593
|
else {
|
|
465
594
|
reason = gate.reason;
|
|
@@ -472,60 +601,157 @@ export async function runCloseout(pmRoot, repo, options, api) {
|
|
|
472
601
|
actualResult: `Merged at ${pr.merged_at}; ${checkEvidence(gate)}`,
|
|
473
602
|
});
|
|
474
603
|
plan.push(row);
|
|
604
|
+
const group = candidates.get(ref.item) ?? [];
|
|
605
|
+
group.push({ row, pr, declared: ref.declared, commitsReadable });
|
|
606
|
+
candidates.set(ref.item, group);
|
|
475
607
|
continue;
|
|
476
608
|
}
|
|
477
|
-
|
|
609
|
+
const row = { item: ref.item, pr: number, action: "skip", code, reason };
|
|
610
|
+
plan.push(row);
|
|
611
|
+
if (role !== "context" && pr.merged !== false && items.has(ref.item) && ref.repo.toLowerCase() === repo.toLowerCase()
|
|
612
|
+
&& !skip.has(ref.item) && !TERMINAL_STATUSES.includes(items.get(ref.item).status)
|
|
613
|
+
&& items.get(ref.item).type !== "Epic" && !activeChildren.has(ref.item)) {
|
|
614
|
+
const group = candidates.get(ref.item) ?? [];
|
|
615
|
+
group.push({ row, pr, declared: ref.declared, commitsReadable });
|
|
616
|
+
candidates.set(ref.item, group);
|
|
617
|
+
}
|
|
478
618
|
}
|
|
479
619
|
}
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
|
|
620
|
+
// A failed PR read can hide linkage and a competing claim. Retain its number for
|
|
621
|
+
// every active eligible item; without the PR evidence no item can be proved unrelated.
|
|
622
|
+
for (const number of unreadable) {
|
|
623
|
+
const url = `https://github.com/${repo}/pull/${number}`;
|
|
624
|
+
for (const item of items.values()) {
|
|
625
|
+
if (skip.has(item.id) || TERMINAL_STATUSES.includes(item.status) || item.type === "Epic" || activeChildren.has(item.id))
|
|
626
|
+
continue;
|
|
627
|
+
const row = { item: item.id, pr: number, action: "skip", code: "head_evidence_unavailable", reason: "Pull-request fetch failed; merge, linkage and delivery evidence unavailable." };
|
|
628
|
+
plan.push(row);
|
|
629
|
+
const group = candidates.get(item.id) ?? [];
|
|
630
|
+
group.push({ row, pr: { number, html_url: url, body: null, merged: true, merged_at: null, merge_commit_sha: null, commits: null,
|
|
631
|
+
head: { sha: "" }, base: { ref: "", repo: { full_name: repo } } }, declared: false, commitsReadable: false });
|
|
632
|
+
candidates.set(item.id, group);
|
|
487
633
|
}
|
|
488
634
|
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
635
|
+
// Resolve attribution before any SDK mutation; passing checks never break a delivery tie.
|
|
636
|
+
for (const [id, group] of candidates) {
|
|
637
|
+
const item = items.get(id);
|
|
638
|
+
const explicit = item.tags.filter(tag => tag.startsWith("delivery-pr:"));
|
|
639
|
+
const cutoff = await acceptanceChangedAt(pmRoot, id, documents.get(id));
|
|
640
|
+
await observer?.("history");
|
|
641
|
+
const plausible = group.filter(entry => !Number.isFinite(mergeTime(entry.pr.merged_at)) || mergeTime(entry.pr.merged_at) > cutoff);
|
|
642
|
+
const eligible = explicit.length > 0
|
|
643
|
+
? group.filter(entry => explicit.length === 1 && explicit[0] === `delivery-pr:${entry.pr.html_url}`)
|
|
644
|
+
: plausible.filter(entry => entry.declared);
|
|
645
|
+
const uncertain = explicit.length === 1 && eligible.length === 1 ? eligible : plausible;
|
|
646
|
+
const missing = uncertain.flatMap(entry => {
|
|
647
|
+
const gaps = ["incomplete_merge", "head_evidence_unavailable", "required_checks_unverified", "head_checks_failed"].includes(entry.row.code)
|
|
648
|
+
? [{ pr: entry.pr.number, code: entry.row.code, reason: entry.row.reason }] : [];
|
|
649
|
+
if (!entry.commitsReadable)
|
|
650
|
+
gaps.push({ pr: entry.pr.number, code: "head_evidence_unavailable", reason: "Commit delivery evidence unavailable or incomplete." });
|
|
651
|
+
return gaps;
|
|
652
|
+
});
|
|
653
|
+
// One item decision owns every candidate status. Evidence codes describe PR facts,
|
|
654
|
+
// but an unchosen PR cannot remain a fatal refusal after another PR wins.
|
|
655
|
+
const chosen = eligible.length === 1 && missing.length === 0 ? eligible[0].row : undefined;
|
|
656
|
+
if (chosen)
|
|
657
|
+
reportItemDecision(group.map(entry => entry.row), { kind: "chosen", row: chosen });
|
|
658
|
+
else if (group.length > 1 || explicit.length > 0 || missing.length > 0 || group[0].row.code !== "not_delivery") {
|
|
659
|
+
reportItemDecision(group.map(entry => entry.row), { kind: "blocking_hold",
|
|
660
|
+
reason: "Delivery attribution or chosen PR evidence is unresolved; set one delivery-pr:<canonical PR URL> tag or verify a unique delivery claim after the last acceptance-criteria change."
|
|
661
|
+
+ missing.map(gap => ` PR #${gap.pr}: ${gap.reason}`).join(""), missing });
|
|
494
662
|
}
|
|
495
663
|
}
|
|
664
|
+
await observer?.("selection");
|
|
496
665
|
let closed = 0;
|
|
497
666
|
let failed = 0;
|
|
667
|
+
const handled = new Set();
|
|
668
|
+
const dependentConflicts = new Map();
|
|
669
|
+
/** Replace stale decisions with one fresh selection, retaining a reason when linkage disappeared. */
|
|
670
|
+
async function reselect(id, pr) {
|
|
671
|
+
handled.add(id);
|
|
672
|
+
const fresh = await closeoutAttempt(pmRoot, repo, options, api, false, observer, id);
|
|
673
|
+
checks.splice(0, checks.length, ...fresh.checks);
|
|
674
|
+
const replacement = fresh.plan;
|
|
675
|
+
if (!replacement.some(row => row.item === id))
|
|
676
|
+
replacement.push({ item: id, pr, action: "needs_review", code: "needs_review",
|
|
677
|
+
candidates: [pr], reason: "Item revision changed; fresh selection found no delivery evidence. Hold for review." });
|
|
678
|
+
const replaced = new Set(replacement.map(row => row.item));
|
|
679
|
+
const retained = plan.filter(row => !replaced.has(row.item));
|
|
680
|
+
plan.splice(0, plan.length, ...retained, ...replacement);
|
|
681
|
+
closed += fresh.closed;
|
|
682
|
+
failed += fresh.failed;
|
|
683
|
+
}
|
|
498
684
|
if (apply) {
|
|
499
685
|
for (const entry of closures) {
|
|
500
|
-
if (entry.row.action !== "close")
|
|
686
|
+
if (entry.row.action !== "close" || (onlyItem !== undefined && entry.row.item !== onlyItem))
|
|
501
687
|
continue;
|
|
502
688
|
try {
|
|
503
|
-
await
|
|
504
|
-
|
|
505
|
-
|
|
689
|
+
await observer?.("before_close");
|
|
690
|
+
// The pinned SDK has no public close(expectedRevision). Its transactional hook runs
|
|
691
|
+
// inside the same per-item lock as lifecycle validation, persistence and history.
|
|
692
|
+
await runWithActiveExtensions({ path: pmRoot, noExtensions: true }, async () => {
|
|
693
|
+
setActiveExtensionHooks({ beforeCommand: [], afterCommand: [], onRead: [], onWrite: [], onIndex: [],
|
|
694
|
+
beforeMutation: [{ layer: "project", name: "closeout-revision", run: async (context) => {
|
|
695
|
+
await observer?.("locked");
|
|
696
|
+
// SDK close may also auto-unblock dependents. Every resulting write needs its own snapshot.
|
|
697
|
+
const before = context.before;
|
|
698
|
+
const id = before.metadata.id;
|
|
699
|
+
if (hashDocument(before) !== snapshots.get(id)) {
|
|
700
|
+
if (id !== entry.row.item)
|
|
701
|
+
dependentConflicts.set(id, entry.row.pr);
|
|
702
|
+
throw new PmCliError("Closeout item revision changed.", 1, { code: "closeout_revision_changed" });
|
|
703
|
+
}
|
|
704
|
+
} }],
|
|
705
|
+
});
|
|
706
|
+
await close(entry.row.item, entry.reason, { author: "pm-github", completedAt: entry.at,
|
|
707
|
+
resolution: entry.resolution, expectedResult: entry.expectedResult, actualResult: entry.actualResult,
|
|
708
|
+
}, { pmRoot, noExtensions: true });
|
|
709
|
+
});
|
|
506
710
|
closed++;
|
|
711
|
+
handled.add(entry.row.item);
|
|
507
712
|
}
|
|
508
713
|
catch (error) {
|
|
714
|
+
if (error instanceof PmCliError && (error.code === "closeout_revision_changed"
|
|
715
|
+
|| (error.code === undefined && error.exitCode === EXIT_CODE.CONFLICT))) {
|
|
716
|
+
if (retry) {
|
|
717
|
+
await reselect(entry.row.item, entry.row.pr);
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
reportItemDecision(plan.filter(row => row.item === entry.row.item && row.code !== "not_delivery"), { kind: "blocking_hold",
|
|
721
|
+
reason: "Item revision changed again after one delivery re-selection; hold for review.", missing: [] });
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
509
724
|
failed++;
|
|
510
|
-
|
|
511
|
-
entry.row.code
|
|
512
|
-
|
|
725
|
+
const reason = closeoutFailureReason(error);
|
|
726
|
+
reportItemDecision(plan.filter(row => row.item === entry.row.item && row.code !== "not_delivery"), { kind: "blocking_hold", reason,
|
|
727
|
+
missing: [{ pr: entry.row.pr, code: "close_failed", reason }] });
|
|
513
728
|
}
|
|
514
729
|
}
|
|
730
|
+
for (const [id, pr] of dependentConflicts) {
|
|
731
|
+
if (handled.has(id))
|
|
732
|
+
continue;
|
|
733
|
+
if (retry)
|
|
734
|
+
await reselect(id, pr);
|
|
735
|
+
else
|
|
736
|
+
plan.push({ item: id, pr, action: "needs_review", code: "needs_review", candidates: [pr],
|
|
737
|
+
reason: "Dependent item revision changed during re-selection; automatic unblock held for review." });
|
|
738
|
+
}
|
|
515
739
|
}
|
|
516
|
-
return { command: "github closeout", repo, dry_run: !apply,
|
|
740
|
+
return { command: "github closeout", repo, dry_run: !apply,
|
|
741
|
+
plan: onlyItem === undefined ? plan : plan.filter(row => row.item === onlyItem || dependentConflicts.has(row.item)), checks, closed, failed };
|
|
517
742
|
}
|
|
518
743
|
/** Keep actionable decisions and their checks while counting routine skips by stable code. */
|
|
519
744
|
export function compactCloseoutReport(report) {
|
|
520
|
-
const skipped =
|
|
745
|
+
const skipped = new Map();
|
|
521
746
|
const plan = report.plan.flatMap(row => {
|
|
522
|
-
if (["already_terminal", "other_repository", "duplicate_selection", "explicit_skip"].includes(row.code)) {
|
|
523
|
-
skipped
|
|
747
|
+
if (["already_terminal", "other_repository", "duplicate_selection", "explicit_skip", "not_merged"].includes(row.code)) {
|
|
748
|
+
skipped.set(row.code, (skipped.get(row.code) ?? 0) + 1);
|
|
524
749
|
return [];
|
|
525
750
|
}
|
|
526
|
-
return [{ ...row, action: row.action === "
|
|
751
|
+
return [{ ...row, action: row.action === "skip" && row.code !== "not_delivery" ? "refuse" : row.action }];
|
|
527
752
|
});
|
|
528
753
|
const prs = new Set(plan.map(row => row.pr));
|
|
529
|
-
|
|
754
|
+
const counts = Object.fromEntries(skipped);
|
|
755
|
+
return { ...report, plan, skipped: counts, checks: report.checks.filter(gate => prs.has(gate.pr)) };
|
|
530
756
|
}
|
|
531
757
|
//# sourceMappingURL=closeout.js.map
|