@deftai/directive-runtime 0.129.6 → 0.129.7
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/Taskfile.yml +94 -13
- package/commands.md +5 -3
- package/dist/session-start.d.ts +8 -2
- package/dist/session-start.js +33 -12
- package/dist/vbrief-preflight.d.ts +4 -0
- package/dist/vbrief-preflight.js +34 -0
- package/dist/verify-scope-provenance.d.ts +13 -1
- package/dist/verify-scope-provenance.js +44 -1
- package/docs/getting-started.md +2 -0
- package/docs/scope-provenance.md +15 -1
- package/package.json +1 -1
- package/packs/skills/skills-pack-0.1.json +1 -1
- package/skills/deft-directive-review-cycle/SKILL.md +15 -6
- package/skills/deft-directive-swarm/references/host-grok-build.md +3 -2
- package/vendor/directive-content/Taskfile.yml +94 -13
- package/vendor/directive-content/commands.md +5 -3
- package/vendor/directive-content/docs/getting-started.md +2 -0
- package/vendor/directive-content/docs/scope-provenance.md +15 -1
- package/vendor/directive-content/packs/skills/skills-pack-0.1.json +1 -1
- package/vendor/directive-content/skills/deft-directive-review-cycle/SKILL.md +15 -6
- package/vendor/directive-content/skills/deft-directive-swarm/references/host-grok-build.md +3 -2
- package/vendor/directive-core/dist/.tsbuildinfo +1 -1
- package/vendor/directive-core/dist/fs/contained-write.d.ts +8 -2
- package/vendor/directive-core/dist/fs/contained-write.d.ts.map +1 -1
- package/vendor/directive-core/dist/fs/contained-write.js +22 -6
- package/vendor/directive-core/dist/fs/contained-write.js.map +1 -1
- package/vendor/directive-core/dist/hooks/dispatcher.d.ts +1 -1
- package/vendor/directive-core/dist/hooks/dispatcher.d.ts.map +1 -1
- package/vendor/directive-core/dist/hooks/dispatcher.js +116 -4
- package/vendor/directive-core/dist/hooks/dispatcher.js.map +1 -1
- package/vendor/directive-core/dist/init-deposit/agent-hooks.d.ts +26 -1
- package/vendor/directive-core/dist/init-deposit/agent-hooks.d.ts.map +1 -1
- package/vendor/directive-core/dist/init-deposit/agent-hooks.js +52 -3
- package/vendor/directive-core/dist/init-deposit/agent-hooks.js.map +1 -1
- package/vendor/directive-core/dist/init-deposit/host-hook-outer.d.ts +43 -0
- package/vendor/directive-core/dist/init-deposit/host-hook-outer.d.ts.map +1 -1
- package/vendor/directive-core/dist/init-deposit/host-hook-outer.js +148 -0
- package/vendor/directive-core/dist/init-deposit/host-hook-outer.js.map +1 -1
- package/vendor/directive-core/dist/pr-merge-readiness/slizard-gate.d.ts +10 -5
- package/vendor/directive-core/dist/pr-merge-readiness/slizard-gate.d.ts.map +1 -1
- package/vendor/directive-core/dist/pr-merge-readiness/slizard-gate.js +41 -18
- package/vendor/directive-core/dist/pr-merge-readiness/slizard-gate.js.map +1 -1
- package/vendor/directive-core/dist/preflight-gh/pre-push.d.ts +3 -2
- package/vendor/directive-core/dist/preflight-gh/pre-push.d.ts.map +1 -1
- package/vendor/directive-core/dist/preflight-gh/pre-push.js +19 -18
- package/vendor/directive-core/dist/preflight-gh/pre-push.js.map +1 -1
- package/vendor/directive-core/dist/scope/delivery-evidence.d.ts +202 -19
- package/vendor/directive-core/dist/scope/delivery-evidence.d.ts.map +1 -1
- package/vendor/directive-core/dist/scope/delivery-evidence.js +948 -107
- package/vendor/directive-core/dist/scope/delivery-evidence.js.map +1 -1
- package/vendor/directive-core/dist/scope/transition.d.ts +4 -1
- package/vendor/directive-core/dist/scope/transition.d.ts.map +1 -1
- package/vendor/directive-core/dist/scope/transition.js +2 -2
- package/vendor/directive-core/dist/scope/transition.js.map +1 -1
- package/vendor/directive-core/dist/scope-provenance/base-fence.d.ts +11 -2
- package/vendor/directive-core/dist/scope-provenance/base-fence.d.ts.map +1 -1
- package/vendor/directive-core/dist/scope-provenance/base-fence.js +16 -1
- package/vendor/directive-core/dist/scope-provenance/base-fence.js.map +1 -1
- package/vendor/directive-core/dist/scope-provenance/evaluate.d.ts +12 -0
- package/vendor/directive-core/dist/scope-provenance/evaluate.d.ts.map +1 -1
- package/vendor/directive-core/dist/scope-provenance/evaluate.js +41 -5
- package/vendor/directive-core/dist/scope-provenance/evaluate.js.map +1 -1
- package/vendor/directive-core/dist/scope-provenance/forge-approval.d.ts +106 -0
- package/vendor/directive-core/dist/scope-provenance/forge-approval.d.ts.map +1 -0
- package/vendor/directive-core/dist/scope-provenance/forge-approval.js +382 -0
- package/vendor/directive-core/dist/scope-provenance/forge-approval.js.map +1 -0
- package/vendor/directive-core/dist/session/orientation-compression.d.ts +18 -2
- package/vendor/directive-core/dist/session/orientation-compression.d.ts.map +1 -1
- package/vendor/directive-core/dist/session/orientation-compression.js +38 -5
- package/vendor/directive-core/dist/session/orientation-compression.js.map +1 -1
- package/vendor/directive-core/dist/session/session-start.d.ts +47 -2
- package/vendor/directive-core/dist/session/session-start.d.ts.map +1 -1
- package/vendor/directive-core/dist/session/session-start.js +149 -1
- package/vendor/directive-core/dist/session/session-start.js.map +1 -1
- package/vendor/directive-core/dist/swarm/approach1-babysitter.d.ts +18 -4
- package/vendor/directive-core/dist/swarm/approach1-babysitter.d.ts.map +1 -1
- package/vendor/directive-core/dist/swarm/approach1-babysitter.js +28 -4
- package/vendor/directive-core/dist/swarm/approach1-babysitter.js.map +1 -1
- package/vendor/directive-core/dist/swarm/finalize-cohort.d.ts +29 -0
- package/vendor/directive-core/dist/swarm/finalize-cohort.d.ts.map +1 -1
- package/vendor/directive-core/dist/swarm/finalize-cohort.js +122 -11
- package/vendor/directive-core/dist/swarm/finalize-cohort.js.map +1 -1
- package/vendor/directive-core/dist/triage/evaluate/worktrees.d.ts.map +1 -1
- package/vendor/directive-core/dist/triage/evaluate/worktrees.js +219 -60
- package/vendor/directive-core/dist/triage/evaluate/worktrees.js.map +1 -1
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Deploy / UAT are separate axes and MUST NOT be inferred from Git alone.
|
|
10
10
|
*/
|
|
11
|
-
import { closerSetFromIssueIds } from "../one-pr-unit/closer-set.js";
|
|
12
11
|
import { productPullRequestFromPlan } from "../orphan-active/running-briefs.js";
|
|
13
12
|
import { assertSafeBranchName, FIELD_DELIVERY_BRANCH, isSafeBranchName, resolveDeliveryBranch, trackingFetchArgv, } from "../policy/delivery-branch.js";
|
|
13
|
+
import { parseAllDeftStoryMarks } from "../pr-closing-keywords/main.js";
|
|
14
14
|
import { defaultRunGh } from "../pr-protected-issues/gh.js";
|
|
15
15
|
import { defaultGitRunner, gitIsAncestor } from "../session/git.js";
|
|
16
16
|
import { readRitualState } from "../session/ritual-sentinel.js";
|
|
17
|
+
import { parseGitHubRepoSlug } from "../text/redos-safe.js";
|
|
17
18
|
import { LIFECYCLE_FOLDERS } from "./constants.js";
|
|
18
19
|
/** Prefer-A #5545: refuse resolver error when branch is empty or unsafe. */
|
|
19
20
|
function refuseUnsafeResolvedDelivery(resolved) {
|
|
@@ -379,51 +380,618 @@ export function prDiffAddsBrief(files, briefBasename) {
|
|
|
379
380
|
const touchesCode = files.some((f) => lifecycleBriefBasename(f.filename) === null);
|
|
380
381
|
return addsBrief && touchesCode;
|
|
381
382
|
}
|
|
383
|
+
/** Recut-13 uniqueness-scan budget: units and caps (#5710). */
|
|
384
|
+
export const MERGE_TIME_SCAN_WALL_CLOCK_MS = 20 * 60 * 1000;
|
|
385
|
+
export const MERGE_TIME_SCAN_MIN_GRAPHQL_REMAINING = 200;
|
|
386
|
+
export const MERGE_TIME_SCAN_MIN_CORE_REMAINING = 100;
|
|
387
|
+
/** GitHub `pulls/{n}/files` lists at most 3000 files. */
|
|
388
|
+
export const PR_FILES_LISTING_CAP = 3000;
|
|
389
|
+
/** The three accepted merge-time bases, named in every stranded-shape refusal (#5710). */
|
|
390
|
+
export const MERGE_TIME_ASSOCIATION_BASES = "(a) a unique sole `deft-story: N` mark in the PR body as of merged_at, " +
|
|
391
|
+
"(b) a closing keyword (close/fix/resolve forms) naming the issue in the PR body as of merged_at, " +
|
|
392
|
+
"(c) a Connected issue link in effect at merged_at";
|
|
393
|
+
/** Start the Recut-13 scan budget: wall clock from now, live rate limits per call. */
|
|
394
|
+
export function makeScanBudget(forge) {
|
|
395
|
+
const startMs = forge.nowMs();
|
|
396
|
+
const wallClock = () => forge.nowMs() - startMs > MERGE_TIME_SCAN_WALL_CLOCK_MS
|
|
397
|
+
? `scan budget exhausted: wall-clock cap ${MERGE_TIME_SCAN_WALL_CLOCK_MS / 60000} minutes`
|
|
398
|
+
: null;
|
|
399
|
+
const remaining = (bucket, floor, label) => {
|
|
400
|
+
const clock = wallClock();
|
|
401
|
+
if (clock !== null)
|
|
402
|
+
return clock;
|
|
403
|
+
const rate = forge.probeRateLimit();
|
|
404
|
+
if (rate === null)
|
|
405
|
+
return "scan budget unknown: live rate-limit probe failed";
|
|
406
|
+
return rate[bucket] < floor
|
|
407
|
+
? `scan budget exhausted: ${label} remaining ${rate[bucket]} < ${floor}`
|
|
408
|
+
: null;
|
|
409
|
+
};
|
|
410
|
+
return {
|
|
411
|
+
beforeGraphql: () => remaining("graphqlRemaining", MERGE_TIME_SCAN_MIN_GRAPHQL_REMAINING, "graphql"),
|
|
412
|
+
beforeRest: () => remaining("coreRemaining", MERGE_TIME_SCAN_MIN_CORE_REMAINING, "REST core"),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
/** Next cursor of a page with more nodes, or null when the cursor is missing or stuck. */
|
|
416
|
+
function nextCursor(endCursor, after) {
|
|
417
|
+
const next = endCursor?.trim() ?? "";
|
|
418
|
+
return next.length === 0 || next === after ? null : next;
|
|
419
|
+
}
|
|
420
|
+
/** Walk every page of a connection; any gap, guard refusal or stuck cursor fails closed. */
|
|
421
|
+
function collectAllPages(fetchPage, guard, label) {
|
|
422
|
+
const nodes = [];
|
|
423
|
+
let after = null;
|
|
424
|
+
for (;;) {
|
|
425
|
+
const refused = guard();
|
|
426
|
+
if (refused !== null) {
|
|
427
|
+
return { ok: false, error: `${label}: ${refused}` };
|
|
428
|
+
}
|
|
429
|
+
const page = fetchPage(after);
|
|
430
|
+
if (!page.ok) {
|
|
431
|
+
return { ok: false, error: `${label}: ${page.error}` };
|
|
432
|
+
}
|
|
433
|
+
nodes.push(...page.nodes);
|
|
434
|
+
if (!page.hasNextPage) {
|
|
435
|
+
return { ok: true, nodes };
|
|
436
|
+
}
|
|
437
|
+
const next = nextCursor(page.endCursor, after);
|
|
438
|
+
if (next === null) {
|
|
439
|
+
return {
|
|
440
|
+
ok: false,
|
|
441
|
+
error: `${label}: incomplete page (hasNextPage without a new endCursor)`,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
after = next;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
382
447
|
/**
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
* with per-issue repository preserved (not reassigned to the plan repo), and
|
|
387
|
-
* merge_commit_sha identity equality. Does not require implementationCommit
|
|
388
|
-
* ancestry (squash-safe).
|
|
448
|
+
* Merge-time body (#5710): the latest `userContentEdits` revision with
|
|
449
|
+
* editedAt ≤ merged_at, or the current body when the PR has no edit history.
|
|
450
|
+
* A hidden revision or no revision at or before merged_at is unknown (fail closed).
|
|
389
451
|
*/
|
|
390
|
-
export function
|
|
391
|
-
const
|
|
392
|
-
if (
|
|
452
|
+
export function selectMergeTimeBody(revisions, currentBody, mergedAt) {
|
|
453
|
+
const mergedMs = Date.parse(mergedAt);
|
|
454
|
+
if (Number.isNaN(mergedMs)) {
|
|
455
|
+
return { ok: false, error: `unreadable merged_at ${JSON.stringify(mergedAt)}` };
|
|
456
|
+
}
|
|
457
|
+
if (revisions.length === 0) {
|
|
458
|
+
return { ok: true, body: currentBody ?? "" };
|
|
459
|
+
}
|
|
460
|
+
let chosen = null;
|
|
461
|
+
let chosenMs = Number.NEGATIVE_INFINITY;
|
|
462
|
+
for (const revision of revisions) {
|
|
463
|
+
const editedMs = Date.parse(revision.editedAt);
|
|
464
|
+
if (Number.isNaN(editedMs)) {
|
|
465
|
+
return {
|
|
466
|
+
ok: false,
|
|
467
|
+
error: `unreadable revision editedAt ${JSON.stringify(revision.editedAt)}`,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
if (editedMs <= mergedMs && editedMs >= chosenMs) {
|
|
471
|
+
chosen = revision;
|
|
472
|
+
chosenMs = editedMs;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (chosen === null) {
|
|
476
|
+
return { ok: false, error: "no body revision at or before merged_at" };
|
|
477
|
+
}
|
|
478
|
+
if (chosen.body === null) {
|
|
479
|
+
return { ok: false, error: "merge-time body revision is unreadable" };
|
|
480
|
+
}
|
|
481
|
+
return { ok: true, body: chosen.body };
|
|
482
|
+
}
|
|
483
|
+
const CLOSING_KEYWORD_REF_RE = /\b(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved):?[ \t]+(?:#(\d+)|([\w.-]+)\/([\w.-]+)#(\d+)|https?:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/issues\/(\d+))(?![\w-])/gi;
|
|
484
|
+
/**
|
|
485
|
+
* True when the body has a GitHub closing keyword naming the issue as `#N`,
|
|
486
|
+
* `owner/repo#N` or the issue URL (#5710 basis b). `#N` means the PR's repository.
|
|
487
|
+
*/
|
|
488
|
+
export function bodyHasClosingKeywordFor(body, repository, issueNumber) {
|
|
489
|
+
const repoKey = repository.trim().toLowerCase();
|
|
490
|
+
const re = new RegExp(CLOSING_KEYWORD_REF_RE.source, CLOSING_KEYWORD_REF_RE.flags);
|
|
491
|
+
for (let match = re.exec(body); match !== null; match = re.exec(body)) {
|
|
492
|
+
const [, bare, owner, name, qualified, urlOwner, urlName, urlNumber] = match;
|
|
493
|
+
if (bare !== undefined) {
|
|
494
|
+
if (Number(bare) === issueNumber)
|
|
495
|
+
return true;
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
const refRepo = `${owner ?? urlOwner ?? ""}/${name ?? urlName ?? ""}`.toLowerCase();
|
|
499
|
+
if (refRepo === repoKey && Number(qualified ?? urlNumber) === issueNumber) {
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Replay Connected / Disconnected events up to merged_at (#5710 basis c).
|
|
507
|
+
* Events after merged_at do not count.
|
|
508
|
+
*/
|
|
509
|
+
export function connectedAtMergedAt(events, repository, issueNumber, mergedAt) {
|
|
510
|
+
const mergedMs = Date.parse(mergedAt);
|
|
511
|
+
if (Number.isNaN(mergedMs)) {
|
|
512
|
+
return { ok: false, error: `unreadable merged_at ${JSON.stringify(mergedAt)}` };
|
|
513
|
+
}
|
|
514
|
+
const repoKey = repository.trim().toLowerCase();
|
|
515
|
+
const relevant = [];
|
|
516
|
+
for (const event of events) {
|
|
517
|
+
const names = event.issues.some((ref) => ref.repository.trim().toLowerCase() === repoKey && ref.issueNumber === issueNumber);
|
|
518
|
+
if (!names)
|
|
519
|
+
continue;
|
|
520
|
+
const ms = Date.parse(event.createdAt);
|
|
521
|
+
if (Number.isNaN(ms)) {
|
|
522
|
+
return { ok: false, error: `unreadable event createdAt ${JSON.stringify(event.createdAt)}` };
|
|
523
|
+
}
|
|
524
|
+
if (ms <= mergedMs) {
|
|
525
|
+
relevant.push({ ms, kind: event.kind });
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
relevant.sort((a, b) => a.ms - b.ms);
|
|
529
|
+
const last = relevant[relevant.length - 1];
|
|
530
|
+
return { ok: true, connected: last?.kind === "connected" };
|
|
531
|
+
}
|
|
532
|
+
const DELIVERY_BRIEF_LAND_RE = /^(?:xbrief|vbrief)\/(?:proposed|pending|active|completed)\//;
|
|
533
|
+
function isDeliveryBriefLandPath(path) {
|
|
534
|
+
const p = path.replace(/\\/g, "/");
|
|
535
|
+
return p === "CHANGELOG.md" || DELIVERY_BRIEF_LAND_RE.test(p);
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Delivery-brief-land exclusion (#5710): every changed path, rename sources
|
|
539
|
+
* included, is a brief under `xbrief|vbrief/(proposed|pending|active|completed)/`
|
|
540
|
+
* or root `CHANGELOG.md`, with at least one such path for a positive
|
|
541
|
+
* exclusion. CHANGELOG-only, completed-folder renames and deletion-only diffs
|
|
542
|
+
* on those paths are excluded. A zero-file diff is not a positive exclusion:
|
|
543
|
+
* as a competitor it still counts (fail closed), and as a candidate it is
|
|
544
|
+
* refused separately because it carries no delivery. Not identical to
|
|
545
|
+
* activation `isBriefLandShapedDiff`, which denies completed/ and needs a
|
|
546
|
+
* non-removed nonterminal brief.
|
|
547
|
+
*/
|
|
548
|
+
export function isDeliveryBriefLandExcluded(files) {
|
|
549
|
+
return (files.length > 0 &&
|
|
550
|
+
files.every((file) => isDeliveryBriefLandPath(file.filename) &&
|
|
551
|
+
((file.previousFilename ?? "").length === 0 ||
|
|
552
|
+
isDeliveryBriefLandPath(file.previousFilename ?? ""))));
|
|
553
|
+
}
|
|
554
|
+
/** The single distinct `deft-story` mark in a body, or null when zero or several (#5710). */
|
|
555
|
+
export function soleDeftStoryMark(body) {
|
|
556
|
+
const marks = parseAllDeftStoryMarks(body);
|
|
557
|
+
return marks.length === 1 ? (marks[0] ?? null) : null;
|
|
558
|
+
}
|
|
559
|
+
function reconstructMergeTimeBody(forge, budget, target, currentBody, mergedAt) {
|
|
560
|
+
const { repository, prNumber } = target;
|
|
561
|
+
const revisions = collectAllPages((after) => forge.fetchBodyRevisionsPage(repository, prNumber, after), budget.beforeGraphql, `PR #${prNumber} body revisions`);
|
|
562
|
+
if (!revisions.ok) {
|
|
563
|
+
return revisions;
|
|
564
|
+
}
|
|
565
|
+
const selected = selectMergeTimeBody(revisions.nodes, currentBody, mergedAt);
|
|
566
|
+
return selected.ok
|
|
567
|
+
? selected
|
|
568
|
+
: { ok: false, error: `PR #${prNumber} merge-time body: ${selected.error}` };
|
|
569
|
+
}
|
|
570
|
+
function classifyDeliveryExclusion(forge, budget, repository, prNumber) {
|
|
571
|
+
// REST budget is checked before every page, not once per PR (#5710).
|
|
572
|
+
const listed = collectAllPages((after) => forge.fetchPrFilesPage(repository, prNumber, after), budget.beforeRest, `PR #${prNumber} changed files`);
|
|
573
|
+
if (!listed.ok) {
|
|
574
|
+
return listed;
|
|
575
|
+
}
|
|
576
|
+
// The forge stops listing at its cap, so reaching it means the list may be truncated.
|
|
577
|
+
if (listed.nodes.length >= PR_FILES_LISTING_CAP) {
|
|
393
578
|
return {
|
|
394
579
|
ok: false,
|
|
395
|
-
|
|
396
|
-
"to derive expected repository/issue (#3675).",
|
|
580
|
+
error: `PR #${prNumber} changed files: listing truncated at the ${PR_FILES_LISTING_CAP}-file cap`,
|
|
397
581
|
};
|
|
398
582
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
:
|
|
402
|
-
|
|
583
|
+
return {
|
|
584
|
+
ok: true,
|
|
585
|
+
excluded: isDeliveryBriefLandExcluded(listed.nodes),
|
|
586
|
+
fileCount: listed.nodes.length,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
/** Merge-time body of one listed PR; edits only replayed when one landed after merge. */
|
|
590
|
+
function listedMergeTimeBody(forge, budget, repository, node) {
|
|
591
|
+
const mergedMs = Date.parse(node.mergedAt);
|
|
592
|
+
const lastEditedMs = node.lastEditedAt === null ? null : Date.parse(node.lastEditedAt);
|
|
593
|
+
if (Number.isNaN(mergedMs) || (lastEditedMs !== null && Number.isNaN(lastEditedMs))) {
|
|
594
|
+
return { ok: false, error: `PR #${node.number} has unreadable mergedAt / lastEditedAt` };
|
|
595
|
+
}
|
|
596
|
+
// No edit after merge: the latest revision at or before merged_at is the
|
|
597
|
+
// latest revision, which is the current body.
|
|
598
|
+
if (lastEditedMs === null || lastEditedMs <= mergedMs) {
|
|
599
|
+
return { ok: true, body: node.body };
|
|
600
|
+
}
|
|
601
|
+
return reconstructMergeTimeBody(forge, budget, { repository, prNumber: node.number }, node.body, node.mergedAt);
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Bounded exhaustive uniqueness scan (#5710 basis a). Enumerates every merged
|
|
605
|
+
* PR, reconstructs each merge-time body, and classifies sole-mark hits for
|
|
606
|
+
* `issueNumber`. Returns the non-excluded sole-mark PRs other than the
|
|
607
|
+
* candidate. Any incomplete page, unreadable revision or competitor, truncated
|
|
608
|
+
* listing, or exhausted budget fails closed. No recent-window shortcut.
|
|
609
|
+
*/
|
|
610
|
+
export function scanSoleMarkCompetitors(input) {
|
|
611
|
+
const { repository, issueNumber, candidatePr, forge } = input;
|
|
612
|
+
const budget = input.budget ?? makeScanBudget(forge);
|
|
613
|
+
const competitors = [];
|
|
614
|
+
let after = null;
|
|
615
|
+
for (;;) {
|
|
616
|
+
const refused = budget.beforeGraphql();
|
|
617
|
+
if (refused !== null) {
|
|
618
|
+
return { ok: false, error: `merged-PR scan: ${refused}` };
|
|
619
|
+
}
|
|
620
|
+
const page = forge.listMergedPrsPage(repository, after);
|
|
621
|
+
if (!page.ok) {
|
|
622
|
+
return { ok: false, error: `merged-PR scan: ${page.error}` };
|
|
623
|
+
}
|
|
624
|
+
for (const node of page.nodes) {
|
|
625
|
+
if (node.number === candidatePr)
|
|
626
|
+
continue;
|
|
627
|
+
const body = listedMergeTimeBody(forge, budget, repository, node);
|
|
628
|
+
if (!body.ok) {
|
|
629
|
+
return { ok: false, error: `merged-PR scan: ${body.error}` };
|
|
630
|
+
}
|
|
631
|
+
if (soleDeftStoryMark(body.body) !== issueNumber)
|
|
632
|
+
continue;
|
|
633
|
+
const exclusion = classifyDeliveryExclusion(forge, budget, repository, node.number);
|
|
634
|
+
if (!exclusion.ok) {
|
|
635
|
+
return { ok: false, error: `merged-PR scan: possible competitor ${exclusion.error}` };
|
|
636
|
+
}
|
|
637
|
+
if (!exclusion.excluded) {
|
|
638
|
+
competitors.push(node.number);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
if (!page.hasNextPage) {
|
|
642
|
+
return { ok: true, competitors };
|
|
643
|
+
}
|
|
644
|
+
const next = nextCursor(page.endCursor, after);
|
|
645
|
+
if (next === null) {
|
|
646
|
+
return {
|
|
647
|
+
ok: false,
|
|
648
|
+
error: "merged-PR scan: incomplete page (hasNextPage without a new endCursor)",
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
after = next;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
/** Basis a: unique sole mark among merged non-excluded PRs (#5710). */
|
|
655
|
+
function proveSoleMarkBasis(input) {
|
|
656
|
+
const { forge, budget, repository, issueNumber, prNumber } = input;
|
|
657
|
+
if (soleDeftStoryMark(input.mergeTimeBody) !== issueNumber) {
|
|
403
658
|
return {
|
|
404
659
|
ok: false,
|
|
405
|
-
|
|
406
|
-
`plan-derived expected repository '${expected.repository}' (#3675).`,
|
|
660
|
+
reason: `(a) PR #${prNumber} body as of merged_at has no sole \`deft-story: ${issueNumber}\` mark`,
|
|
407
661
|
};
|
|
408
662
|
}
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
663
|
+
const exclusion = classifyDeliveryExclusion(forge, budget, repository, prNumber);
|
|
664
|
+
if (!exclusion.ok) {
|
|
665
|
+
return { ok: false, reason: `(a) ${exclusion.error}` };
|
|
666
|
+
}
|
|
667
|
+
if (exclusion.fileCount === 0) {
|
|
668
|
+
return {
|
|
669
|
+
ok: false,
|
|
670
|
+
reason: `(a) PR #${prNumber} changes no files (a zero-file PR carries no delivery)`,
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
if (exclusion.excluded) {
|
|
674
|
+
return {
|
|
675
|
+
ok: false,
|
|
676
|
+
reason: `(a) PR #${prNumber} changes only briefs and CHANGELOG.md ` +
|
|
677
|
+
`(delivery-brief-land excluded; not delivery)`,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
const scan = scanSoleMarkCompetitors({
|
|
681
|
+
repository,
|
|
682
|
+
issueNumber,
|
|
683
|
+
candidatePr: prNumber,
|
|
684
|
+
forge,
|
|
685
|
+
budget,
|
|
686
|
+
});
|
|
687
|
+
if (!scan.ok) {
|
|
688
|
+
return { ok: false, reason: `(a) uniqueness unknown, refusing: ${scan.error}` };
|
|
689
|
+
}
|
|
690
|
+
if (scan.competitors.length > 0) {
|
|
691
|
+
const list = scan.competitors.map((n) => `#${n}`).join(", ");
|
|
692
|
+
return {
|
|
693
|
+
ok: false,
|
|
694
|
+
reason: `(a) mark is not unique: merged non-excluded PR(s) ${list} also carry a sole ` +
|
|
695
|
+
`\`deft-story: ${issueNumber}\` mark`,
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
return { ok: true };
|
|
699
|
+
}
|
|
700
|
+
/** One GraphQL call through gh; returns `data` or an error (any `errors` entry fails). */
|
|
701
|
+
function runGhGraphql(runGh, query, vars) {
|
|
702
|
+
const cmd = ["gh", "api", "graphql", "-f", `query=${query}`];
|
|
703
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
704
|
+
if (value !== null) {
|
|
705
|
+
cmd.push("-F", `${key}=${String(value)}`);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
let result;
|
|
709
|
+
try {
|
|
710
|
+
result = runGh(cmd);
|
|
711
|
+
}
|
|
712
|
+
catch (exc) {
|
|
713
|
+
return { ok: false, error: exc instanceof Error ? exc.message : String(exc) };
|
|
714
|
+
}
|
|
715
|
+
if (result.returncode !== 0) {
|
|
716
|
+
return { ok: false, error: result.stderr.trim() || "gh api graphql non-zero" };
|
|
717
|
+
}
|
|
718
|
+
let payload;
|
|
719
|
+
try {
|
|
720
|
+
payload = JSON.parse(result.stdout);
|
|
721
|
+
}
|
|
722
|
+
catch {
|
|
723
|
+
return { ok: false, error: "unparseable GraphQL JSON" };
|
|
724
|
+
}
|
|
725
|
+
const rec = asRecord(payload);
|
|
726
|
+
if (rec === null || (Array.isArray(rec.errors) && rec.errors.length > 0)) {
|
|
727
|
+
return { ok: false, error: "GraphQL response carried errors" };
|
|
728
|
+
}
|
|
729
|
+
const data = asRecord(rec.data);
|
|
730
|
+
return data === null ? { ok: false, error: "GraphQL response missing data" } : { ok: true, data };
|
|
731
|
+
}
|
|
732
|
+
function readConnection(value) {
|
|
733
|
+
const conn = asRecord(value);
|
|
734
|
+
const pageInfo = asRecord(conn?.pageInfo);
|
|
735
|
+
if (conn === null || pageInfo === null || !Array.isArray(conn.nodes))
|
|
736
|
+
return null;
|
|
737
|
+
if (typeof pageInfo.hasNextPage !== "boolean")
|
|
738
|
+
return null;
|
|
739
|
+
const endCursor = typeof pageInfo.endCursor === "string" ? pageInfo.endCursor : null;
|
|
740
|
+
return { nodes: conn.nodes, hasNextPage: pageInfo.hasNextPage, endCursor };
|
|
741
|
+
}
|
|
742
|
+
const MERGED_PRS_GRAPHQL = "query($owner:String!,$name:String!,$after:String){repository(owner:$owner,name:$name){" +
|
|
743
|
+
"pullRequests(states:MERGED,first:100,after:$after,orderBy:{field:CREATED_AT,direction:ASC}){" +
|
|
744
|
+
"pageInfo{hasNextPage endCursor} nodes{number mergedAt body lastEditedAt}}}}";
|
|
745
|
+
const BODY_REVISIONS_GRAPHQL = "query($owner:String!,$name:String!,$number:Int!,$after:String){repository(owner:$owner,name:$name){" +
|
|
746
|
+
"pullRequest(number:$number){userContentEdits(first:100,after:$after){" +
|
|
747
|
+
"pageInfo{hasNextPage endCursor} nodes{editedAt deletedAt diff}}}}}";
|
|
748
|
+
const SUBJECT_FIELDS = "__typename ... on Issue{number repository{nameWithOwner}}";
|
|
749
|
+
const CONNECTION_EVENTS_GRAPHQL = "query($owner:String!,$name:String!,$number:Int!,$after:String){repository(owner:$owner,name:$name){" +
|
|
750
|
+
"pullRequest(number:$number){timelineItems(first:100,after:$after," +
|
|
751
|
+
"itemTypes:[CONNECTED_EVENT,DISCONNECTED_EVENT]){pageInfo{hasNextPage endCursor} nodes{__typename " +
|
|
752
|
+
`... on ConnectedEvent{createdAt source{${SUBJECT_FIELDS}} subject{${SUBJECT_FIELDS}}} ` +
|
|
753
|
+
`... on DisconnectedEvent{createdAt source{${SUBJECT_FIELDS}} subject{${SUBJECT_FIELDS}}}}}}}}`;
|
|
754
|
+
function parseEventIssue(value) {
|
|
755
|
+
const rec = asRecord(value);
|
|
756
|
+
if (rec === null || rec.__typename !== "Issue")
|
|
757
|
+
return null;
|
|
758
|
+
const repo = asRecord(rec.repository)?.nameWithOwner;
|
|
759
|
+
if (typeof rec.number !== "number" || typeof repo !== "string")
|
|
760
|
+
return null;
|
|
761
|
+
return { repository: repo, issueNumber: rec.number };
|
|
762
|
+
}
|
|
763
|
+
function parseMergedPrNode(value) {
|
|
764
|
+
const rec = asRecord(value);
|
|
765
|
+
if (rec === null || typeof rec.number !== "number" || typeof rec.mergedAt !== "string") {
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
768
|
+
if (rec.body !== null && typeof rec.body !== "string")
|
|
769
|
+
return null;
|
|
770
|
+
if (rec.lastEditedAt !== null && typeof rec.lastEditedAt !== "string")
|
|
771
|
+
return null;
|
|
772
|
+
return {
|
|
773
|
+
number: rec.number,
|
|
774
|
+
mergedAt: rec.mergedAt,
|
|
775
|
+
body: typeof rec.body === "string" ? rec.body : "",
|
|
776
|
+
lastEditedAt: typeof rec.lastEditedAt === "string" ? rec.lastEditedAt : null,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
function parseRevisionNode(value) {
|
|
780
|
+
const rec = asRecord(value);
|
|
781
|
+
if (rec === null || typeof rec.editedAt !== "string")
|
|
782
|
+
return null;
|
|
783
|
+
const hidden = rec.deletedAt !== null && rec.deletedAt !== undefined;
|
|
784
|
+
return {
|
|
785
|
+
editedAt: rec.editedAt,
|
|
786
|
+
body: !hidden && typeof rec.diff === "string" ? rec.diff : null,
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
function parseConnectionEventNode(value) {
|
|
790
|
+
const rec = asRecord(value);
|
|
791
|
+
if (rec === null || typeof rec.createdAt !== "string")
|
|
792
|
+
return null;
|
|
793
|
+
let kind;
|
|
794
|
+
if (rec.__typename === "ConnectedEvent") {
|
|
795
|
+
kind = "connected";
|
|
796
|
+
}
|
|
797
|
+
else if (rec.__typename === "DisconnectedEvent") {
|
|
798
|
+
kind = "disconnected";
|
|
799
|
+
}
|
|
800
|
+
else {
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
const issues = [parseEventIssue(rec.source), parseEventIssue(rec.subject)].filter((ref) => ref !== null);
|
|
804
|
+
return { kind, createdAt: rec.createdAt, issues };
|
|
805
|
+
}
|
|
806
|
+
function graphqlPage(runGh, request, pick, parseNode) {
|
|
807
|
+
const response = runGhGraphql(runGh, request.query, request.vars);
|
|
808
|
+
if (!response.ok)
|
|
809
|
+
return response;
|
|
810
|
+
const conn = readConnection(pick(response.data));
|
|
811
|
+
if (conn === null) {
|
|
812
|
+
return { ok: false, error: "incomplete page (missing connection or pageInfo)" };
|
|
813
|
+
}
|
|
814
|
+
const nodes = [];
|
|
815
|
+
for (const raw of conn.nodes) {
|
|
816
|
+
const parsed = parseNode(raw);
|
|
817
|
+
if (parsed === null)
|
|
818
|
+
return { ok: false, error: "unreadable node in page" };
|
|
819
|
+
nodes.push(parsed);
|
|
820
|
+
}
|
|
821
|
+
return { ok: true, nodes, hasNextPage: conn.hasNextPage, endCursor: conn.endCursor };
|
|
822
|
+
}
|
|
823
|
+
function parsePrFileEntry(value) {
|
|
824
|
+
const rec = asRecord(value);
|
|
825
|
+
if (rec === null || typeof rec.filename !== "string" || rec.filename.length === 0)
|
|
826
|
+
return null;
|
|
827
|
+
if (typeof rec.status !== "string")
|
|
828
|
+
return null;
|
|
829
|
+
const previous = typeof rec.previous_filename === "string" ? rec.previous_filename : "";
|
|
830
|
+
return previous.length > 0
|
|
831
|
+
? { status: rec.status, filename: rec.filename, previousFilename: previous }
|
|
832
|
+
: { status: rec.status, filename: rec.filename };
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* One REST page of `pulls/{n}/files` with rename sources, read uncached. The
|
|
836
|
+
* cursor is the next page number; paging ends at the first empty page.
|
|
837
|
+
*/
|
|
838
|
+
function fetchPrFilesPageLive(runGh, repository, prNumber, after) {
|
|
839
|
+
const parsed = parseOwnerRepo(repository);
|
|
840
|
+
if (parsed === null)
|
|
841
|
+
return { ok: false, error: `invalid repository ${repository}` };
|
|
842
|
+
const page = after === null ? 1 : Number(after);
|
|
843
|
+
if (!Number.isInteger(page) || page < 1)
|
|
844
|
+
return { ok: false, error: `invalid page ${after}` };
|
|
845
|
+
const path = `repos/${parsed.owner}/${parsed.name}/pulls/${prNumber}/files?per_page=100&page=${page}`;
|
|
846
|
+
let result;
|
|
847
|
+
try {
|
|
848
|
+
result = liveRunGh(runGh)(["gh", "api", path]);
|
|
849
|
+
}
|
|
850
|
+
catch (exc) {
|
|
851
|
+
return { ok: false, error: exc instanceof Error ? exc.message : String(exc) };
|
|
852
|
+
}
|
|
853
|
+
if (result.returncode !== 0) {
|
|
854
|
+
return { ok: false, error: result.stderr.trim() || "gh api files non-zero" };
|
|
855
|
+
}
|
|
856
|
+
let payload;
|
|
857
|
+
try {
|
|
858
|
+
payload = JSON.parse(result.stdout);
|
|
859
|
+
}
|
|
860
|
+
catch {
|
|
861
|
+
return { ok: false, error: "unparseable files page" };
|
|
862
|
+
}
|
|
863
|
+
if (!Array.isArray(payload))
|
|
864
|
+
return { ok: false, error: "files page is not an array" };
|
|
865
|
+
const nodes = [];
|
|
866
|
+
for (const raw of payload) {
|
|
867
|
+
const entry = parsePrFileEntry(raw);
|
|
868
|
+
if (entry === null)
|
|
869
|
+
return { ok: false, error: "unreadable files entry" };
|
|
870
|
+
nodes.push(entry);
|
|
871
|
+
}
|
|
872
|
+
const hasNextPage = nodes.length > 0;
|
|
873
|
+
return { ok: true, nodes, hasNextPage, endCursor: hasNextPage ? String(page + 1) : null };
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Pin plain `gh` for single-path GETs the default runner would send to the
|
|
877
|
+
* `ghx` cache proxy. A cached read can be stale: a PR cached before merge
|
|
878
|
+
* reads as unmerged, a rate bucket reads as fuller than it is (#954 / #5710).
|
|
879
|
+
* Injected runners are used as given.
|
|
880
|
+
*/
|
|
881
|
+
export function liveRunGh(runGh) {
|
|
882
|
+
return runGh === defaultRunGh ? (cmd) => defaultRunGh(cmd, "gh") : runGh;
|
|
883
|
+
}
|
|
884
|
+
function probeLiveRateLimit(runGh) {
|
|
885
|
+
const cmd = ["gh", "api", "rate_limit"];
|
|
886
|
+
let result;
|
|
887
|
+
try {
|
|
888
|
+
result = liveRunGh(runGh)(cmd);
|
|
889
|
+
}
|
|
890
|
+
catch {
|
|
891
|
+
return null;
|
|
892
|
+
}
|
|
893
|
+
if (result.returncode !== 0)
|
|
894
|
+
return null;
|
|
895
|
+
try {
|
|
896
|
+
const resources = asRecord(asRecord(JSON.parse(result.stdout))?.resources);
|
|
897
|
+
const graphql = asRecord(resources?.graphql)?.remaining;
|
|
898
|
+
const core = asRecord(resources?.core)?.remaining;
|
|
899
|
+
if (typeof graphql !== "number" || typeof core !== "number")
|
|
900
|
+
return null;
|
|
901
|
+
return { graphqlRemaining: graphql, coreRemaining: core };
|
|
902
|
+
}
|
|
903
|
+
catch {
|
|
904
|
+
return null;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Live forge reads for merge-time association (#5710): GraphQL pages, REST
|
|
909
|
+
* changed files with rename sources, and the uncached `gh api rate_limit`.
|
|
910
|
+
*/
|
|
911
|
+
export function defaultMergeTimeForge(runGh = defaultRunGh) {
|
|
912
|
+
const prVars = (repository, prNumber, after) => {
|
|
913
|
+
const parsed = parseOwnerRepo(repository);
|
|
914
|
+
return parsed === null
|
|
915
|
+
? null
|
|
916
|
+
: { owner: parsed.owner, name: parsed.name, number: prNumber, after };
|
|
917
|
+
};
|
|
918
|
+
const pullRequestOf = (data) => asRecord(asRecord(data.repository)?.pullRequest);
|
|
919
|
+
const invalid = (repository) => ({
|
|
920
|
+
ok: false,
|
|
921
|
+
error: `invalid repository ${repository}`,
|
|
922
|
+
});
|
|
923
|
+
return {
|
|
924
|
+
listMergedPrsPage: (repository, after) => {
|
|
925
|
+
const parsed = parseOwnerRepo(repository);
|
|
926
|
+
if (parsed === null)
|
|
927
|
+
return invalid(repository);
|
|
928
|
+
return graphqlPage(runGh, { query: MERGED_PRS_GRAPHQL, vars: { owner: parsed.owner, name: parsed.name, after } }, (data) => asRecord(data.repository)?.pullRequests, parseMergedPrNode);
|
|
929
|
+
},
|
|
930
|
+
fetchBodyRevisionsPage: (repository, prNumber, after) => {
|
|
931
|
+
const vars = prVars(repository, prNumber, after);
|
|
932
|
+
if (vars === null)
|
|
933
|
+
return invalid(repository);
|
|
934
|
+
return graphqlPage(runGh, { query: BODY_REVISIONS_GRAPHQL, vars }, (data) => pullRequestOf(data)?.userContentEdits, parseRevisionNode);
|
|
935
|
+
},
|
|
936
|
+
fetchConnectionEventsPage: (repository, prNumber, after) => {
|
|
937
|
+
const vars = prVars(repository, prNumber, after);
|
|
938
|
+
if (vars === null)
|
|
939
|
+
return invalid(repository);
|
|
940
|
+
return graphqlPage(runGh, { query: CONNECTION_EVENTS_GRAPHQL, vars }, (data) => pullRequestOf(data)?.timelineItems, parseConnectionEventNode);
|
|
941
|
+
},
|
|
942
|
+
fetchPrFilesPage: (repository, prNumber, after) => fetchPrFilesPageLive(runGh, repository, prNumber, after),
|
|
943
|
+
probeRateLimit: () => probeLiveRateLimit(runGh),
|
|
944
|
+
nowMs: () => Date.now(),
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
/**
|
|
948
|
+
* Canonical project repository (#5710 P1): OWNER/NAME of the `origin` remote.
|
|
949
|
+
* `--repo` is a matching assertion only and is never the source of it.
|
|
950
|
+
*/
|
|
951
|
+
export function resolveCanonicalProjectRepository(projectRoot, runGit = defaultGitRunner) {
|
|
952
|
+
let result;
|
|
953
|
+
try {
|
|
954
|
+
result = runGit(projectRoot, ["remote", "get-url", "origin"]);
|
|
955
|
+
}
|
|
956
|
+
catch {
|
|
957
|
+
return null;
|
|
958
|
+
}
|
|
959
|
+
if (result.code !== 0)
|
|
960
|
+
return null;
|
|
961
|
+
return parseGitHubRepoSlug(result.stdout.trim());
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* PR-only delivery identity (#5710 P1): only when the plan has no GitHub issue
|
|
965
|
+
* reference (today's resolver). Identity is (canonical remote, stamped
|
|
966
|
+
* productPullRequest). `x-xbrief/github-pr` references are never identity.
|
|
967
|
+
*/
|
|
968
|
+
export function resolvePrOnlyDeliveryIdentity(plan, resolveCanonicalRepository) {
|
|
969
|
+
const prNumber = productPullRequestFromPlan(plan);
|
|
414
970
|
if (prNumber === null) {
|
|
415
971
|
return {
|
|
416
972
|
ok: false,
|
|
417
|
-
message: "Delivery identity join failed:
|
|
418
|
-
"
|
|
973
|
+
message: "Delivery identity join failed: plan has no x-xbrief/github-issue reference and no " +
|
|
974
|
+
"plan.metadata.productPullRequest stamp. Stamp productPullRequest (issue-less briefs only) " +
|
|
975
|
+
"or add an issue origin. An x-xbrief/github-pr reference is not a delivery identity (#5710).",
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
const repository = resolveCanonicalRepository();
|
|
979
|
+
if (repository === null) {
|
|
980
|
+
return {
|
|
981
|
+
ok: false,
|
|
982
|
+
message: `Delivery identity join failed: plan has productPullRequest=${prNumber} but the ` +
|
|
983
|
+
`canonical project remote (git remote get-url origin) did not resolve to OWNER/NAME (#5710).`,
|
|
419
984
|
};
|
|
420
985
|
}
|
|
421
|
-
|
|
986
|
+
return { ok: true, repository, prNumber };
|
|
987
|
+
}
|
|
988
|
+
function fetchMergedPayload(fetchPrPayload, prNumber, repository) {
|
|
989
|
+
const payload = fetchPrPayload(prNumber, repository);
|
|
422
990
|
if (payload === null) {
|
|
423
991
|
return {
|
|
424
992
|
ok: false,
|
|
425
993
|
message: `Delivery identity join failed: could not fetch PR #${prNumber} in ` +
|
|
426
|
-
`${
|
|
994
|
+
`${repository} (lookup failure) (#3675).`,
|
|
427
995
|
};
|
|
428
996
|
}
|
|
429
997
|
const mergedAt = typeof payload.merged_at === "string" && payload.merged_at.trim().length > 0
|
|
@@ -435,86 +1003,282 @@ export function verifyStoryPrMergeIdentity(input) {
|
|
|
435
1003
|
if (mergedAt === null || mergeCommitSha === null) {
|
|
436
1004
|
return {
|
|
437
1005
|
ok: false,
|
|
438
|
-
message: `Delivery identity join failed: PR #${prNumber} in ${
|
|
1006
|
+
message: `Delivery identity join failed: PR #${prNumber} in ${repository} ` +
|
|
439
1007
|
`is not merged (missing merged_at / merge_commit_sha) (#3675).`,
|
|
440
1008
|
};
|
|
441
1009
|
}
|
|
442
|
-
|
|
443
|
-
|
|
1010
|
+
return { ok: true, payload, mergedAt, mergeCommitSha };
|
|
1011
|
+
}
|
|
1012
|
+
function payloadHeadSha(payload) {
|
|
1013
|
+
const head = asRecord(payload.head);
|
|
1014
|
+
if (typeof head?.sha === "string" && head.sha.trim().length > 0) {
|
|
1015
|
+
return head.sha.trim();
|
|
1016
|
+
}
|
|
1017
|
+
if (typeof payload.head_sha === "string" && payload.head_sha.trim().length > 0) {
|
|
1018
|
+
return payload.head_sha.trim();
|
|
1019
|
+
}
|
|
1020
|
+
return null;
|
|
1021
|
+
}
|
|
1022
|
+
function mergeCommitMismatch(mergeCommit, merged, prNumber) {
|
|
1023
|
+
if (shasEqual(mergeCommit, merged.mergeCommitSha)) {
|
|
1024
|
+
return null;
|
|
1025
|
+
}
|
|
1026
|
+
return (`Delivery identity join failed: supplied merge commit ${mergeCommit} does not ` +
|
|
1027
|
+
`equal PR #${prNumber} merge_commit_sha ${merged.mergeCommitSha} (identity, not reachability) (#3675).`);
|
|
1028
|
+
}
|
|
1029
|
+
function checkRecordedImplementation(evidence, payload, prNumber) {
|
|
1030
|
+
const recordedImpl = typeof evidence.implementationCommit === "string" &&
|
|
1031
|
+
evidence.implementationCommit.trim().length > 0
|
|
1032
|
+
? evidence.implementationCommit.trim()
|
|
1033
|
+
: null;
|
|
1034
|
+
if (recordedImpl === null) {
|
|
1035
|
+
return null;
|
|
1036
|
+
}
|
|
1037
|
+
const headSha = payloadHeadSha(payload);
|
|
1038
|
+
if (headSha !== null && shasEqual(recordedImpl, headSha)) {
|
|
1039
|
+
return null;
|
|
1040
|
+
}
|
|
1041
|
+
return (`Delivery identity join failed: recorded implementationCommit ${recordedImpl} ` +
|
|
1042
|
+
`does not match PR #${prNumber} head at merge time` +
|
|
1043
|
+
`${headSha !== null ? ` (${headSha})` : ""} (#3675).`);
|
|
1044
|
+
}
|
|
1045
|
+
/** Shared tail: head check and the success result. */
|
|
1046
|
+
function finishIdentityJoin(input) {
|
|
1047
|
+
const { evidence, merged, prNumber } = input;
|
|
1048
|
+
const headCheck = checkRecordedImplementation(evidence, merged.payload, prNumber);
|
|
1049
|
+
if (headCheck !== null) {
|
|
1050
|
+
return { ok: false, message: headCheck };
|
|
1051
|
+
}
|
|
1052
|
+
const base = asRecord(merged.payload.base);
|
|
1053
|
+
return {
|
|
1054
|
+
ok: true,
|
|
1055
|
+
message: `story→PR→merge identity joined: ${input.subject} via PR #${prNumber} ` +
|
|
1056
|
+
`(${input.identityBasis})`,
|
|
1057
|
+
mergeCommitSha: merged.mergeCommitSha,
|
|
1058
|
+
mergedAt: merged.mergedAt,
|
|
1059
|
+
prBase: typeof base?.ref === "string" ? base.ref : null,
|
|
1060
|
+
headSha: payloadHeadSha(merged.payload),
|
|
1061
|
+
identityBasis: input.identityBasis,
|
|
1062
|
+
repository: input.repository,
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
function evidencePrNumber(evidence) {
|
|
1066
|
+
return typeof evidence.prNumber === "number" &&
|
|
1067
|
+
Number.isInteger(evidence.prNumber) &&
|
|
1068
|
+
evidence.prNumber > 0
|
|
1069
|
+
? evidence.prNumber
|
|
1070
|
+
: null;
|
|
1071
|
+
}
|
|
1072
|
+
function evidenceRepository(evidence) {
|
|
1073
|
+
return typeof evidence.repository === "string" && evidence.repository.trim().length > 0
|
|
1074
|
+
? evidence.repository.trim()
|
|
1075
|
+
: null;
|
|
1076
|
+
}
|
|
1077
|
+
/** PR-only join (#5710 P1): stamp + canonical remote; no issue closer, no merge-time standard. */
|
|
1078
|
+
function verifyPrOnlyIdentity(input) {
|
|
1079
|
+
const resolved = resolvePrOnlyDeliveryIdentity(input.plan, input.resolveCanonicalRepository ?? (() => null));
|
|
1080
|
+
if (!resolved.ok) {
|
|
1081
|
+
return { ok: false, message: resolved.message };
|
|
1082
|
+
}
|
|
1083
|
+
const { repository, prNumber: stamped } = resolved;
|
|
1084
|
+
const assertedRepo = evidenceRepository(input.evidence);
|
|
1085
|
+
if (assertedRepo !== null && assertedRepo.toLowerCase() !== repository.toLowerCase()) {
|
|
444
1086
|
return {
|
|
445
1087
|
ok: false,
|
|
446
|
-
message: `Delivery identity join failed:
|
|
447
|
-
`
|
|
1088
|
+
message: `Delivery identity join failed: --repo '${assertedRepo}' does not match the canonical ` +
|
|
1089
|
+
`project remote '${repository}' (matching assertion only) (#5710).`,
|
|
448
1090
|
};
|
|
449
1091
|
}
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
const briefBasename = input.briefBasename?.trim() ?? "";
|
|
458
|
-
if (briefBasename.length === 0 || input.fetchPrChangedFiles === undefined) {
|
|
459
|
-
return { ok: false, message: closerMiss };
|
|
460
|
-
}
|
|
461
|
-
const files = input.fetchPrChangedFiles(prNumber, expected.repository);
|
|
462
|
-
if (files === null) {
|
|
463
|
-
return {
|
|
464
|
-
ok: false,
|
|
465
|
-
message: `${closerMiss} Brief-in-PR-diff fallback could not read PR #${prNumber} changed files ` +
|
|
466
|
-
`(lookup failure).`,
|
|
467
|
-
};
|
|
468
|
-
}
|
|
469
|
-
if (!prDiffAddsBrief(files, briefBasename)) {
|
|
470
|
-
return {
|
|
471
|
-
ok: false,
|
|
472
|
-
message: `${closerMiss} Brief-in-PR-diff fallback also failed: PR #${prNumber} did not add ` +
|
|
473
|
-
`${briefBasename} alongside code changes.`,
|
|
474
|
-
};
|
|
475
|
-
}
|
|
476
|
-
identityBasis = "brief-in-pr-diff";
|
|
1092
|
+
const prNumber = evidencePrNumber(input.evidence);
|
|
1093
|
+
if (prNumber === null) {
|
|
1094
|
+
return {
|
|
1095
|
+
ok: false,
|
|
1096
|
+
message: `Delivery identity join failed: PR-only brief needs --pr ${stamped} ` +
|
|
1097
|
+
`(plan.metadata.productPullRequest) (#5710).`,
|
|
1098
|
+
};
|
|
477
1099
|
}
|
|
478
|
-
if (
|
|
1100
|
+
if (prNumber !== stamped) {
|
|
479
1101
|
return {
|
|
480
1102
|
ok: false,
|
|
481
|
-
message: `Delivery identity join failed:
|
|
482
|
-
|
|
1103
|
+
message: `Delivery identity join failed: --pr ${prNumber} is not plan.metadata.productPullRequest ` +
|
|
1104
|
+
`${stamped}. On a PR-only brief the stamp is the identity; an x-xbrief/github-pr ` +
|
|
1105
|
+
`reference does not override it (#5710).`,
|
|
483
1106
|
};
|
|
484
1107
|
}
|
|
485
|
-
const
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
input.evidence
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
1108
|
+
const merged = fetchMergedPayload(input.fetchPrPayload, prNumber, repository);
|
|
1109
|
+
if (!merged.ok) {
|
|
1110
|
+
return { ok: false, message: merged.message };
|
|
1111
|
+
}
|
|
1112
|
+
const mismatch = mergeCommitMismatch(input.mergeCommit, merged, prNumber);
|
|
1113
|
+
if (mismatch !== null) {
|
|
1114
|
+
return { ok: false, message: mismatch };
|
|
1115
|
+
}
|
|
1116
|
+
return finishIdentityJoin({
|
|
1117
|
+
evidence: input.evidence,
|
|
1118
|
+
merged,
|
|
1119
|
+
prNumber,
|
|
1120
|
+
repository,
|
|
1121
|
+
identityBasis: "pr-only",
|
|
1122
|
+
subject: `${repository} productPullRequest`,
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
/** Brief-in-PR-diff basis (#3675, unchanged). Null = proven; "" = not attempted; else reason. */
|
|
1126
|
+
function proveBriefInPrDiff(input, repository, prNumber) {
|
|
1127
|
+
const briefBasename = input.briefBasename?.trim() ?? "";
|
|
1128
|
+
if (briefBasename.length === 0 || input.fetchPrChangedFiles === undefined) {
|
|
1129
|
+
return "";
|
|
1130
|
+
}
|
|
1131
|
+
const files = input.fetchPrChangedFiles(prNumber, repository);
|
|
1132
|
+
if (files === null) {
|
|
1133
|
+
return `brief-in-PR-diff: could not read PR #${prNumber} changed files (lookup failure)`;
|
|
1134
|
+
}
|
|
1135
|
+
if (!prDiffAddsBrief(files, briefBasename)) {
|
|
1136
|
+
return `brief-in-PR-diff: PR #${prNumber} did not add ${briefBasename} alongside code changes`;
|
|
1137
|
+
}
|
|
1138
|
+
return null;
|
|
1139
|
+
}
|
|
1140
|
+
/** Bases b and c from forge reads; returns the proven basis or appends reasons. */
|
|
1141
|
+
function proveKeywordOrConnected(forge, budget, target, reasons) {
|
|
1142
|
+
const { repository, issueNumber, prNumber, mergedAt } = target;
|
|
1143
|
+
let mergeTimeBody = null;
|
|
1144
|
+
const body = reconstructMergeTimeBody(forge, budget, { repository, prNumber }, target.currentBody, mergedAt);
|
|
1145
|
+
if (body.ok) {
|
|
1146
|
+
mergeTimeBody = body.body;
|
|
1147
|
+
if (bodyHasClosingKeywordFor(body.body, repository, issueNumber)) {
|
|
1148
|
+
return { basis: "merge-time-keyword", mergeTimeBody };
|
|
505
1149
|
}
|
|
1150
|
+
reasons.push(`(b) PR #${prNumber} body as of merged_at has no closing keyword for #${issueNumber}`);
|
|
1151
|
+
}
|
|
1152
|
+
else {
|
|
1153
|
+
reasons.push(`(a)/(b) ${body.error}`);
|
|
1154
|
+
}
|
|
1155
|
+
const events = collectAllPages((after) => forge.fetchConnectionEventsPage(repository, prNumber, after), budget.beforeGraphql, `PR #${prNumber} connection events`);
|
|
1156
|
+
if (!events.ok) {
|
|
1157
|
+
reasons.push(`(c) ${events.error}`);
|
|
1158
|
+
return { basis: null, mergeTimeBody };
|
|
1159
|
+
}
|
|
1160
|
+
const state = connectedAtMergedAt(events.nodes, repository, issueNumber, mergedAt);
|
|
1161
|
+
if (state.ok && state.connected) {
|
|
1162
|
+
return { basis: "merge-time-connected", mergeTimeBody };
|
|
1163
|
+
}
|
|
1164
|
+
reasons.push(state.ok
|
|
1165
|
+
? `(c) no Connected link to #${issueNumber} in effect at merged_at`
|
|
1166
|
+
: `(c) ${state.error}`);
|
|
1167
|
+
return { basis: null, mergeTimeBody };
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* Issue-anchored association as of merged_at (#5710 P3). Cheap bases first
|
|
1171
|
+
* (b, c, brief-in-PR-diff), then the exhaustive sole-mark scan (a). A failed
|
|
1172
|
+
* lookup only removes a basis; success always needs a positive proof.
|
|
1173
|
+
*/
|
|
1174
|
+
function proveIssueAnchoredAssociation(input, target) {
|
|
1175
|
+
const { repository, issueNumber, prNumber } = target;
|
|
1176
|
+
const reasons = [];
|
|
1177
|
+
const forge = input.mergeTimeForge;
|
|
1178
|
+
const budget = forge === undefined ? null : makeScanBudget(forge);
|
|
1179
|
+
let mergeTimeBody = null;
|
|
1180
|
+
if (forge !== undefined && budget !== null) {
|
|
1181
|
+
const cheap = proveKeywordOrConnected(forge, budget, target, reasons);
|
|
1182
|
+
if (cheap.basis !== null) {
|
|
1183
|
+
return { ok: true, basis: cheap.basis };
|
|
1184
|
+
}
|
|
1185
|
+
mergeTimeBody = cheap.mergeTimeBody;
|
|
1186
|
+
}
|
|
1187
|
+
else {
|
|
1188
|
+
reasons.push("merge-time association lookups are unavailable");
|
|
1189
|
+
}
|
|
1190
|
+
const briefDiff = proveBriefInPrDiff(input, repository, prNumber);
|
|
1191
|
+
if (briefDiff === null) {
|
|
1192
|
+
return { ok: true, basis: "brief-in-pr-diff" };
|
|
1193
|
+
}
|
|
1194
|
+
if (briefDiff.length > 0) {
|
|
1195
|
+
reasons.push(briefDiff);
|
|
1196
|
+
}
|
|
1197
|
+
if (forge !== undefined && budget !== null && mergeTimeBody !== null) {
|
|
1198
|
+
const mark = proveSoleMarkBasis({
|
|
1199
|
+
forge,
|
|
1200
|
+
budget,
|
|
1201
|
+
repository,
|
|
1202
|
+
issueNumber,
|
|
1203
|
+
prNumber,
|
|
1204
|
+
mergeTimeBody,
|
|
1205
|
+
});
|
|
1206
|
+
if (mark.ok) {
|
|
1207
|
+
return { ok: true, basis: "merge-time-mark" };
|
|
1208
|
+
}
|
|
1209
|
+
reasons.unshift(mark.reason);
|
|
506
1210
|
}
|
|
507
1211
|
return {
|
|
508
|
-
ok:
|
|
509
|
-
message: `
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
identityBasis,
|
|
1212
|
+
ok: false,
|
|
1213
|
+
message: `Delivery identity join failed: PR #${prNumber} has no merge-time association with ` +
|
|
1214
|
+
`${repository}#${issueNumber}. Accepted bases: ${MERGE_TIME_ASSOCIATION_BASES}. ` +
|
|
1215
|
+
`A mark, keyword or link added after merged_at does not count, and current ` +
|
|
1216
|
+
`closingIssuesReferences alone is insufficient. Findings: ${reasons.join("; ")}. ` +
|
|
1217
|
+
`Already-merged work with no merge-time basis stays refused until the parked ` +
|
|
1218
|
+
`identity-migration ships (#5710).`,
|
|
516
1219
|
};
|
|
517
1220
|
}
|
|
1221
|
+
/**
|
|
1222
|
+
* Prefer-A story→PR→merge identity join before delivery liveness (#3675 / #5710).
|
|
1223
|
+
*
|
|
1224
|
+
* Issue-anchored briefs (today's `resolvePlanGithubIssueRef`): the association
|
|
1225
|
+
* must hold as of merged_at by (a) a unique sole `deft-story` mark, (b) a
|
|
1226
|
+
* closing keyword in the merge-time body, or (c) a Connected link at merged_at,
|
|
1227
|
+
* or by the unchanged brief-in-PR-diff basis. Current-state
|
|
1228
|
+
* closingIssuesReferences alone is insufficient, and `productPullRequest === --pr`
|
|
1229
|
+
* is not a proof here. A failed issue-anchored check never falls back to the stamp.
|
|
1230
|
+
*
|
|
1231
|
+
* Issue-less briefs use the PR-only identity (#5710 P1). merge_commit_sha must
|
|
1232
|
+
* equal the supplied merge commit on both paths (squash-safe).
|
|
1233
|
+
*/
|
|
1234
|
+
export function verifyStoryPrMergeIdentity(input) {
|
|
1235
|
+
const expected = resolvePlanGithubIssueRef(input.plan);
|
|
1236
|
+
if (expected === null) {
|
|
1237
|
+
return verifyPrOnlyIdentity(input);
|
|
1238
|
+
}
|
|
1239
|
+
const evidenceRepo = evidenceRepository(input.evidence);
|
|
1240
|
+
if (evidenceRepo !== null && evidenceRepo !== expected.repository) {
|
|
1241
|
+
return {
|
|
1242
|
+
ok: false,
|
|
1243
|
+
message: `Delivery identity join failed: evidence repository '${evidenceRepo}' does not match ` +
|
|
1244
|
+
`plan-derived expected repository '${expected.repository}' (#3675).`,
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
const prNumber = evidencePrNumber(input.evidence);
|
|
1248
|
+
if (prNumber === null) {
|
|
1249
|
+
return {
|
|
1250
|
+
ok: false,
|
|
1251
|
+
message: "Delivery identity join failed: missing story-associated PR number. " +
|
|
1252
|
+
"Pass --pr <n> for the PR that delivered this story; --pr alone is not identity (#3675).",
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
const merged = fetchMergedPayload(input.fetchPrPayload, prNumber, expected.repository);
|
|
1256
|
+
if (!merged.ok) {
|
|
1257
|
+
return { ok: false, message: merged.message };
|
|
1258
|
+
}
|
|
1259
|
+
const mismatch = mergeCommitMismatch(input.mergeCommit, merged, prNumber);
|
|
1260
|
+
if (mismatch !== null) {
|
|
1261
|
+
return { ok: false, message: mismatch };
|
|
1262
|
+
}
|
|
1263
|
+
const association = proveIssueAnchoredAssociation(input, {
|
|
1264
|
+
repository: expected.repository,
|
|
1265
|
+
issueNumber: expected.issueNumber,
|
|
1266
|
+
prNumber,
|
|
1267
|
+
mergedAt: merged.mergedAt,
|
|
1268
|
+
currentBody: typeof merged.payload.body === "string" ? merged.payload.body : null,
|
|
1269
|
+
});
|
|
1270
|
+
if (!association.ok) {
|
|
1271
|
+
return { ok: false, message: association.message };
|
|
1272
|
+
}
|
|
1273
|
+
return finishIdentityJoin({
|
|
1274
|
+
evidence: input.evidence,
|
|
1275
|
+
merged,
|
|
1276
|
+
prNumber,
|
|
1277
|
+
repository: expected.repository,
|
|
1278
|
+
identityBasis: association.basis,
|
|
1279
|
+
subject: `${expected.repository}#${expected.issueNumber}`,
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
518
1282
|
/**
|
|
519
1283
|
* Classify stored completion provenance for read paths.
|
|
520
1284
|
* Legacy completed records without provenance → unknown/unverified (not delivered).
|
|
@@ -777,6 +1541,60 @@ function deliveryEvidenceRemediation(deliveryBranch) {
|
|
|
777
1541
|
`When ${deliveryBranch} is delivery, wait until the merge commit is an ancestor of ` +
|
|
778
1542
|
`origin/${deliveryBranch}, then complete. Do not use --non-delivery for work that shipped.`);
|
|
779
1543
|
}
|
|
1544
|
+
/**
|
|
1545
|
+
* #5710 P4: refuse a non-delivery disposition when the stamped productPullRequest
|
|
1546
|
+
* is merged in the authoritative repository (the issue repository, else the
|
|
1547
|
+
* canonical remote). Merged to an integration branch counts as merged in v1.
|
|
1548
|
+
* Returns null when no stamp exists or the stamped PR is confirmed unmerged.
|
|
1549
|
+
* Any failure to establish merge state fails closed with a retry diagnostic.
|
|
1550
|
+
*
|
|
1551
|
+
* Policy (Recut-13 lean, #5710 comment 6817282070):
|
|
1552
|
+
* - Guarantee: a code-bearing brief (`isCodeBearingScope`) cannot take a
|
|
1553
|
+
* non-delivery disposition while its stamped PR is merged or unreadable.
|
|
1554
|
+
* - Non-goal in v1: briefs that are not code-bearing (`docs-only` /
|
|
1555
|
+
* `process-only` tags, `metadata.delivery.required: false`, docs/process/
|
|
1556
|
+
* research kinds). They return before this check. The lean parks "Moving the
|
|
1557
|
+
* non-delivery refuse ahead of the !codeBearing early return"; widening it
|
|
1558
|
+
* needs a new contract, not a review fix.
|
|
1559
|
+
*/
|
|
1560
|
+
function refuseNonDeliveryForMergedStamp(input) {
|
|
1561
|
+
const productPr = productPullRequestFromPlan(input.plan);
|
|
1562
|
+
if (productPr === null) {
|
|
1563
|
+
return null;
|
|
1564
|
+
}
|
|
1565
|
+
const head = `Refusing scope:complete --non-delivery ${input.disposition}:`;
|
|
1566
|
+
const noWrite = "No completion or provenance was recorded (#5710).";
|
|
1567
|
+
const repository = resolvePlanGithubIssueRef(input.plan)?.repository ?? input.resolveCanonicalRepository();
|
|
1568
|
+
if (repository === null) {
|
|
1569
|
+
return (`${head} cannot resolve the repository of stamped productPullRequest #${productPr} ` +
|
|
1570
|
+
`(no issue origin and no canonical origin remote). Repair the remote or the stamp, ` +
|
|
1571
|
+
`then retry. ${noWrite}`);
|
|
1572
|
+
}
|
|
1573
|
+
let payload;
|
|
1574
|
+
try {
|
|
1575
|
+
payload = input.fetchPrPayload(productPr, repository);
|
|
1576
|
+
}
|
|
1577
|
+
catch {
|
|
1578
|
+
payload = null;
|
|
1579
|
+
}
|
|
1580
|
+
const mergedAt = payload?.merged_at;
|
|
1581
|
+
const unknown = payload === null ||
|
|
1582
|
+
payload === undefined ||
|
|
1583
|
+
!("merged_at" in payload) ||
|
|
1584
|
+
(mergedAt !== null && (typeof mergedAt !== "string" || mergedAt.trim().length === 0));
|
|
1585
|
+
if (unknown) {
|
|
1586
|
+
return (`${head} could not establish whether stamped productPullRequest #${productPr} in ` +
|
|
1587
|
+
`${repository} is merged (lookup failed or malformed response). Retry after fixing gh ` +
|
|
1588
|
+
`auth, rate limit or network, or repair plan.metadata.productPullRequest. ${noWrite}`);
|
|
1589
|
+
}
|
|
1590
|
+
if (mergedAt === null) {
|
|
1591
|
+
return null;
|
|
1592
|
+
}
|
|
1593
|
+
return (`${head} stamped productPullRequest #${productPr} in ${repository} is merged ` +
|
|
1594
|
+
`(merged_at ${String(mergedAt)}). Shipped work completes as delivered: ` +
|
|
1595
|
+
`scope:complete -- --pr ${productPr} --merge-commit <sha>. A merge to an integration ` +
|
|
1596
|
+
`branch also counts as merged here. ${noWrite}`);
|
|
1597
|
+
}
|
|
780
1598
|
/**
|
|
781
1599
|
* Gate delivered completion for a code-bearing scope (#3041 / #3380 / #3675).
|
|
782
1600
|
*
|
|
@@ -793,14 +1611,19 @@ export function evaluateDeliveryGate(options) {
|
|
|
793
1611
|
const runGh = options.runGh ?? defaultRunGh;
|
|
794
1612
|
const fetchPrPayload = options.fetchPrPayload ??
|
|
795
1613
|
((prNumber, repository) => defaultFetchPrPayload(prNumber, repository, runGh));
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
// injected test seams never fall through to a live gh call.
|
|
1614
|
+
// Live defaults only when the PR payload is not injected, so injected test
|
|
1615
|
+
// seams never fall through to a live gh call.
|
|
1616
|
+
const liveForge = options.fetchPrPayload === undefined;
|
|
800
1617
|
const fetchPrChangedFiles = options.fetchPrChangedFiles ??
|
|
801
|
-
(
|
|
1618
|
+
(liveForge
|
|
802
1619
|
? (prNumber, repository) => defaultFetchPrChangedFiles(prNumber, repository, runGh)
|
|
803
1620
|
: undefined);
|
|
1621
|
+
const mergeTimeForge = options.mergeTimeForge ?? (liveForge ? defaultMergeTimeForge(runGh) : undefined);
|
|
1622
|
+
const resolveCanonicalRepository = () => resolveCanonicalProjectRepository(options.projectRoot, runGit);
|
|
1623
|
+
// #5710 P4: the merged-stamp check needs an uncached read; a stale
|
|
1624
|
+
// "unmerged" would let shipped work record as non-delivery.
|
|
1625
|
+
const fetchStampedPrLive = options.fetchPrPayload ??
|
|
1626
|
+
((prNumber, repository) => defaultFetchPrPayload(prNumber, repository, liveRunGh(runGh)));
|
|
804
1627
|
const verifier = options.verifier ?? "scope:complete";
|
|
805
1628
|
const codeBearing = isCodeBearingScope(options.plan);
|
|
806
1629
|
// #5693: experiment_archived Findings check runs before the non-code early
|
|
@@ -835,6 +1658,16 @@ export function evaluateDeliveryGate(options) {
|
|
|
835
1658
|
codeBearing: true,
|
|
836
1659
|
};
|
|
837
1660
|
}
|
|
1661
|
+
// #5710 P4: resolve a stamped productPullRequest before any non-delivery success.
|
|
1662
|
+
const shipped = refuseNonDeliveryForMergedStamp({
|
|
1663
|
+
plan: options.plan,
|
|
1664
|
+
disposition: options.nonDeliveryDisposition,
|
|
1665
|
+
fetchPrPayload: fetchStampedPrLive,
|
|
1666
|
+
resolveCanonicalRepository,
|
|
1667
|
+
});
|
|
1668
|
+
if (shipped !== null) {
|
|
1669
|
+
return { ok: false, message: shipped, provenance: null, codeBearing: true };
|
|
1670
|
+
}
|
|
838
1671
|
const branchResult = resolveDeliveryBranch(options.projectRoot, runGit);
|
|
839
1672
|
const refused = refuseUnsafeResolvedDelivery(branchResult);
|
|
840
1673
|
if (refused !== null) {
|
|
@@ -931,9 +1764,10 @@ export function evaluateDeliveryGate(options) {
|
|
|
931
1764
|
evidence,
|
|
932
1765
|
mergeCommit,
|
|
933
1766
|
fetchPrPayload,
|
|
934
|
-
|
|
1767
|
+
mergeTimeForge,
|
|
935
1768
|
briefBasename: options.briefBasename,
|
|
936
1769
|
fetchPrChangedFiles,
|
|
1770
|
+
resolveCanonicalRepository,
|
|
937
1771
|
});
|
|
938
1772
|
if (!identity.ok) {
|
|
939
1773
|
return {
|
|
@@ -945,7 +1779,7 @@ export function evaluateDeliveryGate(options) {
|
|
|
945
1779
|
}
|
|
946
1780
|
const boundEvidence = {
|
|
947
1781
|
...evidence,
|
|
948
|
-
repository:
|
|
1782
|
+
repository: identity.repository ?? evidence.repository ?? null,
|
|
949
1783
|
prNumber: evidence.prNumber,
|
|
950
1784
|
prBase: identity.prBase ?? prBase,
|
|
951
1785
|
mergeCommit: identity.mergeCommitSha ?? mergeCommit,
|
|
@@ -1372,20 +2206,27 @@ export function discoverDeliveryEvidenceForComplete(options) {
|
|
|
1372
2206
|
const expected = resolvePlanGithubIssueRef(options.plan);
|
|
1373
2207
|
const productPr = productPullRequestFromPlan(options.plan);
|
|
1374
2208
|
if (productPr !== null) {
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
2209
|
+
// #5710 P1: an issue-less brief uses the same PR-only resolver as the identity join.
|
|
2210
|
+
let repository;
|
|
2211
|
+
if (expected !== null) {
|
|
2212
|
+
repository = expected.repository;
|
|
2213
|
+
}
|
|
2214
|
+
else {
|
|
2215
|
+
const prOnly = resolvePrOnlyDeliveryIdentity(options.plan, () => resolveCanonicalProjectRepository(options.projectRoot, runGit));
|
|
2216
|
+
if (!prOnly.ok) {
|
|
2217
|
+
return {
|
|
2218
|
+
ok: false,
|
|
2219
|
+
message: `Delivery auto-discovery failed: ${prOnly.message} ${deliveryEvidenceRemediation(deliveryBranch)}`,
|
|
2220
|
+
};
|
|
2221
|
+
}
|
|
2222
|
+
repository = prOnly.repository;
|
|
1382
2223
|
}
|
|
1383
|
-
const payload = fetchPrPayload(productPr,
|
|
2224
|
+
const payload = fetchPrPayload(productPr, repository);
|
|
1384
2225
|
if (payload === null) {
|
|
1385
2226
|
return {
|
|
1386
2227
|
ok: false,
|
|
1387
2228
|
message: `Delivery auto-discovery failed: could not fetch productPullRequest #${productPr} ` +
|
|
1388
|
-
`in ${
|
|
2229
|
+
`in ${repository} (#3583). ${deliveryEvidenceRemediation(deliveryBranch)}`,
|
|
1389
2230
|
};
|
|
1390
2231
|
}
|
|
1391
2232
|
if (!candidatePassesDeliveryBaseFilter(payload, deliveryBranch)) {
|
|
@@ -1396,7 +2237,7 @@ export function discoverDeliveryEvidenceForComplete(options) {
|
|
|
1396
2237
|
`territory in v1) (#3583). ${deliveryEvidenceRemediation(deliveryBranch)}`,
|
|
1397
2238
|
};
|
|
1398
2239
|
}
|
|
1399
|
-
const discovered = evidenceFromPrPayload(payload, productPr,
|
|
2240
|
+
const discovered = evidenceFromPrPayload(payload, productPr, repository, deliveryBranch, verifier);
|
|
1400
2241
|
return {
|
|
1401
2242
|
ok: true,
|
|
1402
2243
|
evidence: mergePreservingExplicitFacts(existing, discovered),
|