@kungfu-tech/buildchain 3.0.7 → 3.0.8

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.
Files changed (45) hide show
  1. package/contracts/engineering-housekeeper-v1.schema.json +11 -1
  2. package/contracts/fixtures/engineering-housekeeper-v1/cases.json +10 -0
  3. package/contracts/release-cut-v1.schema.json +70 -0
  4. package/contracts/release-train-transition-v1.schema.json +85 -0
  5. package/contracts/release-train-v1.schema.json +82 -0
  6. package/dist/site/buildchain-contract.json +302 -16
  7. package/dist/site/buildchain-site.json +83 -27
  8. package/dist/site/capability-registry.json +4 -3
  9. package/dist/site/kfd-claims.json +45 -8
  10. package/dist/site/kfd-upstream-aggregate.json +1 -1
  11. package/dist/site/manual-registry.json +22 -6
  12. package/dist/site/node-api-registry.json +1218 -489
  13. package/dist/site/page-registry.json +65 -17
  14. package/dist/site/public-surface-audit.json +13 -8
  15. package/dist/site/publication-authority-registry.json +2 -4
  16. package/dist/site/publication-registry.json +4 -4
  17. package/dist/site/release-provenance.json +1 -0
  18. package/dist/site/site-manifest.json +18 -10
  19. package/dist/site/workflow-registry.json +10 -9
  20. package/docs/MAP.md +1 -1
  21. package/docs/aws-us-elastic-runner-burst-plane.md +20 -7
  22. package/docs/engineering-housekeeper.md +61 -28
  23. package/docs/node-api-reference.md +109 -77
  24. package/docs/release-governance.md +13 -10
  25. package/docs/release-train.md +102 -0
  26. package/docs/reusable-build-surface.md +16 -6
  27. package/docs/site-bundle-contract.md +5 -1
  28. package/docs/versioning.md +1 -0
  29. package/package.json +2 -1
  30. package/packages/core/buildchain-agent-manuals.js +1 -0
  31. package/packages/core/buildchain-compatibility-proof.js +577 -0
  32. package/packages/core/buildchain-contract.js +46 -181
  33. package/packages/core/engineering-housekeeper-github-client.js +18 -1
  34. package/packages/core/engineering-housekeeper-github.js +247 -41
  35. package/packages/core/engineering-housekeeper.js +15 -0
  36. package/packages/core/release-train.js +520 -0
  37. package/scripts/aws-macos-jit-controller-core.mjs +33 -11
  38. package/scripts/aws-macos-jit-controller-runtime.mjs +208 -0
  39. package/scripts/aws-macos-jit-controller.mjs +50 -37
  40. package/scripts/aws-macos-jit-core.mjs +41 -3
  41. package/scripts/buildchain-contract-lock.mjs +6 -0
  42. package/scripts/dispatch-artifact-signing-authority.mjs +4 -7
  43. package/scripts/engineering-housekeeper-workflow.mjs +13 -6
  44. package/scripts/generate-site-bundle.mjs +2 -0
  45. package/scripts/site-capability-metadata.mjs +1 -0
@@ -1,14 +1,34 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import {
5
+ assertBuildchainCompatibilityProjection,
6
+ createBuildchainCompatibilityProofRegistry,
7
+ createHistoricalBuildchainCompatibilityProofs,
8
+ } from "./buildchain-compatibility-proof.js";
4
9
  import { createControllerRegistry } from "./controller-evidence.js";
5
10
  import { enumerateWorkflowInputs } from "./public-surface-audit.js";
6
11
 
12
+ export {
13
+ BUILDCHAIN_COMPATIBILITY_PROOF_REGISTRY_SCHEMA,
14
+ BUILDCHAIN_COMPATIBILITY_PROOF_SCHEMA,
15
+ BUILDCHAIN_COMPATIBILITY_VERIFICATION_RECEIPT_SCHEMA,
16
+ assertBuildchainCompatibilityProjection,
17
+ contractSummary,
18
+ createBuildchainCompatibilityProof,
19
+ createBuildchainCompatibilityProofRegistry,
20
+ createBuildchainCompatibilityVerificationReceipt,
21
+ createHistoricalBuildchainCompatibilityProofs,
22
+ evaluateBuildchainContractLock,
23
+ renderBuildchainContractDriftIssueBody,
24
+ verifyBuildchainCompatibilityProof,
25
+ verifyBuildchainCompatibilityVerificationReceipt,
26
+ } from "./buildchain-compatibility-proof.js";
27
+
7
28
  export const BUILDCHAIN_RUNTIME_CONTRACT_WORLD = "kungfu-buildchain-runtime-contract-world";
8
29
  export const BUILDCHAIN_CONTRACT_LOCK = "kungfu-buildchain-contract-lock";
9
30
 
10
31
  const DEFAULT_POLICY = "major-compatible";
11
- const FLOATING_CLASSES = new Set(["stable", "alpha"]);
12
32
 
13
33
  function optionalString(value) {
14
34
  return value === undefined || value === null ? "" : String(value);
@@ -284,9 +304,6 @@ export function createBuildchainContractWorld({
284
304
  "publish-gate source locks are created by the wrapper and enforced by promote-buildchain-ref before publish side effects",
285
305
  "GitHub Release passport and evidence publication is delegated to promote-buildchain-ref after the semver release transaction completes",
286
306
  ],
287
- compatibleBreakingDigests: [
288
- "sha256:acd401cfc46450115a3763fd4b679d85f185e8757ebd52510d6262e1533df4cf",
289
- ],
290
307
  }),
291
308
  surface(root, {
292
309
  id: "advanced-release-candidate-promote",
@@ -328,9 +345,6 @@ export function createBuildchainContractWorld({
328
345
  "promotion reuses PR-stage release-candidate artifacts and does not run the heavy native build matrix",
329
346
  "resume-from-candidate-run is fail-closed and uses a fresh caller event instead of rerunning a frozen startup graph",
330
347
  ],
331
- compatibleBreakingDigests: [
332
- "sha256:aa30f22e3af0a89841310bdbdc900844dd95a66974db173fa140a71bbd7e82c0",
333
- ],
334
348
  }),
335
349
  surface(root, {
336
350
  id: "web-surface",
@@ -477,9 +491,6 @@ export function createBuildchainContractWorld({
477
491
  "publish transactions can require a resolved publish-gate source lock to prevent floating-ref drift",
478
492
  "semver GitHub Releases are created or updated only after transaction completion, with prerelease/latest metadata bound to the authoritative publication channel and tag syntax retained as the fallback for ordinary callers",
479
493
  ],
480
- compatibleBreakingDigests: [
481
- "sha256:a59f0910e6df842e7699139472e5dd69ac2fdd7f7213bf2cb346d1d622556874",
482
- ],
483
494
  }),
484
495
  surface(root, {
485
496
  id: "macos-credential-island-action",
@@ -755,14 +766,6 @@ export function createBuildchainContractWorld({
755
766
  registryDigest: resolvedControllerRegistry.digest,
756
767
  inputClassifications: descriptor.inputs,
757
768
  },
758
- ...(descriptor.id === "build-lifecycle"
759
- ? {
760
- compatibleBreakingDigests: [
761
- "sha256:e264a79f9f399038c2fcfd21e4168c68c2e1485ee5c651c02242a02b622ac2be",
762
- "sha256:30745921541e9b0f70475bb2178c2559f6aef248f6680670ccd44d8c5a69a6b1",
763
- ],
764
- }
765
- : {}),
766
769
  guarantees: [
767
770
  "plans bind exact consumer source SHA, exact Buildchain runtime SHA, and the runtime contract digest",
768
771
  "receipts bind the plan digest and preserve pass, fail, skip, and partial stage outcomes",
@@ -782,24 +785,45 @@ export function createBuildchainContractWorld({
782
785
  },
783
786
  majorLine,
784
787
  compatibilityPolicy: DEFAULT_POLICY,
788
+ compatibilityProofs: createHistoricalBuildchainCompatibilityProofs(),
785
789
  surfaces,
786
790
  };
787
791
  return finalizeBuildchainContractWorld(base);
788
792
  }
789
793
 
790
794
  export function finalizeBuildchainContractWorld(contractWorld) {
795
+ const sourceSurfaces = (contractWorld.surfaces || []).map((entry) => ({ ...entry }));
796
+ const proofRegistry = createBuildchainCompatibilityProofRegistry({
797
+ proofs: contractWorld.compatibilityProofs || createHistoricalBuildchainCompatibilityProofs(),
798
+ surfaces: sourceSurfaces,
799
+ majorLine: contractWorld.majorLine,
800
+ });
791
801
  const world = {
792
802
  ...contractWorld,
793
- surfaces: (contractWorld.surfaces || []).map((entry) => ({ ...entry })),
803
+ compatibilityProofs: proofRegistry.proofs,
804
+ compatibilityProofRegistryRoot: proofRegistry.registryRoot,
805
+ surfaces: sourceSurfaces.map((entry) => {
806
+ const surfaceEntry = { ...entry };
807
+ delete surfaceEntry.compatibleBreakingDigests;
808
+ delete surfaceEntry.compatibilityProofRoots;
809
+ const projection = proofRegistry.projections[entry.id] || [];
810
+ if (projection.length > 0) {
811
+ surfaceEntry.compatibleBreakingDigests = projection.map((item) => item.breakingDigest);
812
+ surfaceEntry.compatibilityProofRoots = projection.map((item) => item.proofRoot);
813
+ }
814
+ return surfaceEntry;
815
+ }),
794
816
  };
795
817
  const compatibilityModel = {
796
818
  schemaVersion: world.schemaVersion,
797
819
  contract: world.contract,
798
820
  majorLine: world.majorLine,
821
+ compatibilityProofRegistryRoot: world.compatibilityProofRegistryRoot,
799
822
  surfaces: world.surfaces.map((entry) => ({
800
823
  id: entry.id,
801
824
  kind: entry.kind,
802
825
  breakingDigest: entry.breakingDigest,
826
+ compatibilityProofRoots: entry.compatibilityProofRoots || [],
803
827
  })),
804
828
  };
805
829
  const digestModel = {
@@ -831,6 +855,7 @@ export function createBuildchainContractLock({
831
855
  contract: contractWorld.contract,
832
856
  contractDigest: contractWorld.contractDigest,
833
857
  compatibilityDigest: contractWorld.compatibilityDigest,
858
+ compatibilityProofRegistryRoot: contractWorld.compatibilityProofRegistryRoot,
834
859
  majorLine: contractWorld.majorLine,
835
860
  compatibilityPolicy,
836
861
  acceptedAt,
@@ -838,6 +863,7 @@ export function createBuildchainContractLock({
838
863
  id: entry.id,
839
864
  kind: entry.kind,
840
865
  breakingDigest: entry.breakingDigest,
866
+ compatibilityProofRoots: entry.compatibilityProofRoots || [],
841
867
  })),
842
868
  },
843
869
  };
@@ -848,6 +874,7 @@ export function readBuildchainContractWorld(filePath) {
848
874
  if (!value || value.contract !== BUILDCHAIN_RUNTIME_CONTRACT_WORLD) {
849
875
  throw new Error(`Buildchain contract world is missing or invalid: ${filePath}`);
850
876
  }
877
+ assertBuildchainCompatibilityProjection(value);
851
878
  const computed = finalizeBuildchainContractWorld(value);
852
879
  for (const digestName of ["compatibilityDigest", "contractDigest"]) {
853
880
  const published = String(value[digestName] || "").trim();
@@ -873,165 +900,3 @@ export function readBuildchainContractLock(filePath) {
873
900
  }
874
901
  return value;
875
902
  }
876
-
877
- function surfaceMap(surfaces = []) {
878
- return new Map(surfaces.map((entry) => [entry.id, entry]));
879
- }
880
-
881
- export function evaluateBuildchainContractLock({
882
- lock,
883
- current,
884
- runtimeRef = "",
885
- runtimeSha = "",
886
- runtimeClass = "",
887
- compatibilityPolicy = "",
888
- } = {}) {
889
- if (!current || current.contract !== BUILDCHAIN_RUNTIME_CONTRACT_WORLD) {
890
- throw new Error("current must be a Buildchain runtime contract world");
891
- }
892
- const floatingRuntime = FLOATING_CLASSES.has(runtimeClass);
893
- if (!floatingRuntime) {
894
- return {
895
- ok: true,
896
- status: "non-floating-runtime",
897
- drift: false,
898
- compatible: true,
899
- issueRecommended: false,
900
- reason: `runtime class ${runtimeClass || "unknown"} is not a stable floating ref`,
901
- };
902
- }
903
- if (!lock) {
904
- return {
905
- ok: true,
906
- status: "missing-lock",
907
- drift: false,
908
- compatible: true,
909
- issueRecommended: false,
910
- reason: "consumer repository has no Buildchain contract lock",
911
- };
912
- }
913
- const accepted = lock.buildchain || {};
914
- const policy = compatibilityPolicy || accepted.compatibilityPolicy || DEFAULT_POLICY;
915
- const shaDrift = !!accepted.resolvedSha && !!runtimeSha && accepted.resolvedSha !== runtimeSha;
916
- const contractDrift = !!accepted.contractDigest && accepted.contractDigest !== current.contractDigest;
917
- if (!shaDrift && !contractDrift) {
918
- return {
919
- ok: true,
920
- status: "unchanged",
921
- drift: false,
922
- compatible: true,
923
- issueRecommended: false,
924
- policy,
925
- accepted,
926
- current: contractSummary(current, runtimeRef, runtimeSha),
927
- };
928
- }
929
- const reasons = [];
930
- if (accepted.contract !== current.contract) {
931
- reasons.push(`contract changed from ${accepted.contract || "(unknown)"} to ${current.contract}`);
932
- }
933
- if (accepted.majorLine && accepted.majorLine !== current.majorLine) {
934
- reasons.push(`major line changed from ${accepted.majorLine} to ${current.majorLine}`);
935
- }
936
- if (policy === "exact" && accepted.contractDigest !== current.contractDigest) {
937
- reasons.push("exact policy requires the contract digest to remain unchanged");
938
- }
939
- if (!["major-compatible", "allow-additive", "exact"].includes(policy)) {
940
- reasons.push(`unsupported compatibility policy: ${policy}`);
941
- }
942
- const currentSurfaces = surfaceMap(current.surfaces);
943
- for (const oldSurface of accepted.surfaces || []) {
944
- const nextSurface = currentSurfaces.get(oldSurface.id);
945
- if (!nextSurface) {
946
- reasons.push(`surface removed: ${oldSurface.id}`);
947
- continue;
948
- }
949
- const compatibleBreakingDigests = new Set(nextSurface.compatibleBreakingDigests || []);
950
- if (
951
- nextSurface.breakingDigest !== oldSurface.breakingDigest
952
- && !compatibleBreakingDigests.has(oldSurface.breakingDigest)
953
- ) {
954
- reasons.push(`surface breaking digest changed: ${oldSurface.id}`);
955
- }
956
- }
957
- const compatible = reasons.length === 0;
958
- return {
959
- ok: compatible,
960
- status: compatible ? "compatible-drift" : "breaking-drift",
961
- drift: shaDrift || contractDrift,
962
- shaDrift,
963
- contractDrift,
964
- compatible,
965
- issueRecommended: true,
966
- policy,
967
- reasons,
968
- accepted,
969
- current: contractSummary(current, runtimeRef, runtimeSha),
970
- };
971
- }
972
-
973
- export function contractSummary(contractWorld, runtimeRef = "", runtimeSha = "") {
974
- return {
975
- ref: runtimeRef,
976
- resolvedSha: runtimeSha,
977
- contract: contractWorld.contract,
978
- contractDigest: contractWorld.contractDigest,
979
- compatibilityDigest: contractWorld.compatibilityDigest,
980
- majorLine: contractWorld.majorLine,
981
- surfaceCount: Array.isArray(contractWorld.surfaces) ? contractWorld.surfaces.length : 0,
982
- };
983
- }
984
-
985
- export function renderBuildchainContractDriftIssueBody({
986
- repository = "",
987
- workflow = "",
988
- runUrl = "",
989
- lockPath = "",
990
- evaluation,
991
- } = {}) {
992
- const accepted = evaluation.accepted || {};
993
- const current = evaluation.current || {};
994
- const severity = evaluation.compatible ? "compatible" : "breaking";
995
- return [
996
- "# Buildchain contract drift",
997
- "",
998
- "## Summary",
999
- "",
1000
- `Buildchain detected ${severity} contract drift for a floating runtime ref before expensive Buildchain work continued.`,
1001
- "",
1002
- "## Consumer",
1003
- "",
1004
- `- Repository: ${repository || "(unknown)"}`,
1005
- `- Workflow: ${workflow || "(unknown)"}`,
1006
- `- Run: ${runUrl || "(unknown)"}`,
1007
- `- Lock path: ${lockPath || "(unknown)"}`,
1008
- "",
1009
- "## Accepted Buildchain contract",
1010
- "",
1011
- `- Ref: ${accepted.ref || "(unknown)"}`,
1012
- `- SHA: ${accepted.resolvedSha || "(unknown)"}`,
1013
- `- Contract digest: ${accepted.contractDigest || "(unknown)"}`,
1014
- `- Compatibility digest: ${accepted.compatibilityDigest || "(unknown)"}`,
1015
- `- Policy: ${evaluation.policy || accepted.compatibilityPolicy || "(unknown)"}`,
1016
- "",
1017
- "## Current Buildchain contract",
1018
- "",
1019
- `- Ref: ${current.ref || "(unknown)"}`,
1020
- `- SHA: ${current.resolvedSha || "(unknown)"}`,
1021
- `- Contract digest: ${current.contractDigest || "(unknown)"}`,
1022
- `- Compatibility digest: ${current.compatibilityDigest || "(unknown)"}`,
1023
- `- Major line: ${current.majorLine || "(unknown)"}`,
1024
- "",
1025
- "## Compatibility",
1026
- "",
1027
- `- Status: ${evaluation.status || "(unknown)"}`,
1028
- `- Compatible: ${evaluation.compatible ? "yes" : "no"}`,
1029
- evaluation.reasons?.length ? evaluation.reasons.map((reason) => `- ${reason}`).join("\n") : "- No breaking drift detected.",
1030
- "",
1031
- "## Suggested next action",
1032
- "",
1033
- evaluation.compatible
1034
- ? "Review the Buildchain release notes, then update the consumer contract lock to the current SHA and contract digest."
1035
- : "Failing before heavy build is intentional. Review the Buildchain contract change, update the consumer workflow/configuration, or pin the previous Buildchain SHA.",
1036
- ].join("\n");
1037
- }
@@ -7,7 +7,8 @@ function requiredString(value, field) {
7
7
  function normalizeRepository(value) {
8
8
  const normalized = requiredString(value?.fullName || value, "repository");
9
9
  const match = normalized.match(/^([^/\s]+)\/([^/\s]+)$/);
10
- if (!match) throw new Error(`repository must be owner/repo, got: ${normalized}`);
10
+ if (!match)
11
+ throw new Error(`repository must be owner/repo, got: ${normalized}`);
11
12
  return { owner: match[1], repo: match[2], fullName: normalized };
12
13
  }
13
14
 
@@ -162,6 +163,22 @@ export class GitHubHousekeeperClient {
162
163
  );
163
164
  }
164
165
 
166
+ async listClosedPullRequests(repository) {
167
+ const coordinate = normalizeRepository(repository);
168
+ return this.paginate(
169
+ `/repos/${coordinate.owner}/${coordinate.repo}/pulls?state=closed&sort=updated&direction=asc&per_page=100`,
170
+ );
171
+ }
172
+
173
+ async listPullRequestsForCommit(repository, commitOid) {
174
+ const coordinate = normalizeRepository(repository);
175
+ return this.paginate(
176
+ `/repos/${coordinate.owner}/${coordinate.repo}/commits/${encodeURIComponent(
177
+ requiredString(commitOid, "commit OID"),
178
+ )}/pulls?per_page=100`,
179
+ );
180
+ }
181
+
165
182
  async getBranch(repository, name) {
166
183
  const coordinate = normalizeRepository(repository);
167
184
  return (
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  HOUSEKEEPER_REASON_CODES,
3
+ classifyHousekeeperBranch,
3
4
  classifyHousekeeperReplay,
4
5
  createEngineeringHousekeeperPlan,
5
6
  createEngineeringHousekeeperReceipt,
@@ -15,6 +16,7 @@ export {
15
16
 
16
17
  const DEFAULT_STALE_DAYS = 30;
17
18
  const DEFAULT_MAX_ACTIONS = 20;
19
+ const DEFAULT_INVENTORY_CONCURRENCY = 8;
18
20
  function requiredString(value, field) {
19
21
  const normalized = String(value || "").trim();
20
22
  if (!normalized) throw new Error(`${field} is required`);
@@ -40,6 +42,23 @@ function normalizePositiveInteger(value, fallback, field) {
40
42
  }
41
43
  return selected;
42
44
  }
45
+ async function mapConcurrently(values, concurrency, operation) {
46
+ const results = new Array(values.length);
47
+ let nextIndex = 0;
48
+ async function worker() {
49
+ while (nextIndex < values.length) {
50
+ const index = nextIndex;
51
+ nextIndex += 1;
52
+ results[index] = await operation(values[index], index);
53
+ }
54
+ }
55
+ await Promise.all(
56
+ Array.from({ length: Math.min(concurrency, values.length) }, () =>
57
+ worker(),
58
+ ),
59
+ );
60
+ return results;
61
+ }
43
62
  function providerError(error, operation) {
44
63
  if (error instanceof GitHubHousekeeperProviderError) return error;
45
64
  return new GitHubHousekeeperProviderError(
@@ -94,6 +113,123 @@ function openPullRequestNumbers(pullRequests, repository, branchName) {
94
113
  .sort((left, right) => left - right);
95
114
  }
96
115
 
116
+ function normalizeOptionalBranchName(value) {
117
+ return String(value || "")
118
+ .trim()
119
+ .replace(/^refs\/heads\//, "");
120
+ }
121
+
122
+ function branchObservation({
123
+ branchState,
124
+ repository,
125
+ repositoryState,
126
+ pullRequests,
127
+ target,
128
+ ancestry,
129
+ }) {
130
+ return {
131
+ repository,
132
+ sourceRepository: repository,
133
+ name: String(branchState.name),
134
+ headOid: headOid(branchState),
135
+ target,
136
+ isDefault:
137
+ String(repositoryState.default_branch) === String(branchState.name),
138
+ isProtected: branchState.protected === true,
139
+ ancestry,
140
+ openPullRequestNumbers: openPullRequestNumbers(
141
+ pullRequests,
142
+ repository,
143
+ String(branchState.name),
144
+ ),
145
+ };
146
+ }
147
+
148
+ function mainlineTargetNames({
149
+ branchStates,
150
+ repository,
151
+ repositoryState,
152
+ requestedTarget,
153
+ policy,
154
+ }) {
155
+ const protectedTargets = branchStates
156
+ .filter((branchState) => {
157
+ const classification = classifyHousekeeperBranch(
158
+ branchObservation({
159
+ branchState,
160
+ repository,
161
+ repositoryState,
162
+ pullRequests: [],
163
+ target: { name: "__housekeeper_target__", headOid: "0".repeat(40) },
164
+ ancestry: "ancestor",
165
+ }),
166
+ policy,
167
+ );
168
+ return classification.reasonCodes.includes(
169
+ HOUSEKEEPER_REASON_CODES.PROTECTED_BRANCH,
170
+ );
171
+ })
172
+ .map((branchState) => String(branchState.name));
173
+ return [
174
+ ...new Set(
175
+ [
176
+ requestedTarget,
177
+ repositoryState.default_branch,
178
+ ...protectedTargets,
179
+ ].filter(Boolean),
180
+ ),
181
+ ].sort((left, right) => {
182
+ const priority = (name) =>
183
+ name === requestedTarget
184
+ ? 0
185
+ : name === repositoryState.default_branch
186
+ ? 1
187
+ : name.startsWith("dev/")
188
+ ? 2
189
+ : name.startsWith("alpha/")
190
+ ? 3
191
+ : name.startsWith("release/")
192
+ ? 4
193
+ : name.startsWith("publish-gate/")
194
+ ? 5
195
+ : 6;
196
+ return priority(left) - priority(right) || left.localeCompare(right);
197
+ });
198
+ }
199
+
200
+ async function branchCandidateTargets({
201
+ repository,
202
+ branchName,
203
+ branchOid,
204
+ targets,
205
+ associatedPullRequests,
206
+ }) {
207
+ const mergedTargetNames = new Set(
208
+ associatedPullRequests
209
+ .filter(
210
+ (pullRequest) =>
211
+ Boolean(pullRequest.merged_at || pullRequest.mergedAt) &&
212
+ pullRequestHeadRepository(pullRequest) === repository &&
213
+ String(pullRequest?.head?.ref || pullRequest.headRef || "") ===
214
+ branchName &&
215
+ String(
216
+ pullRequest?.head?.sha || pullRequest.headOid || "",
217
+ ).toLowerCase() === branchOid,
218
+ )
219
+ .map((pullRequest) =>
220
+ String(pullRequest?.base?.ref || pullRequest.baseRef || ""),
221
+ )
222
+ .filter(Boolean),
223
+ );
224
+ return targets
225
+ .filter((target) => mergedTargetNames.has(target.name))
226
+ .sort((left, right) => {
227
+ const associated = (target) =>
228
+ mergedTargetNames.has(target.name) ? 0 : 1;
229
+ return associated(left) - associated(right);
230
+ });
231
+ }
232
+
97
233
  async function branchAncestry(client, repository, branchOid, targetOid) {
98
234
  if (branchOid === targetOid) return "ancestor";
99
235
  const comparison = await client.compareCommits(
@@ -116,10 +252,7 @@ export async function collectGitHubHousekeeperInventory({
116
252
  policy = {},
117
253
  }) {
118
254
  const coordinate = normalizeRepository(repository);
119
- const targetName = requiredString(targetBranch, "targetBranch").replace(
120
- /^refs\/heads\//,
121
- "",
122
- );
255
+ const requestedTarget = normalizeOptionalBranchName(targetBranch);
123
256
  const observation = normalizeDate(observedAt, "observedAt");
124
257
  const staleWindow = normalizePositiveInteger(
125
258
  staleDays,
@@ -127,16 +260,42 @@ export async function collectGitHubHousekeeperInventory({
127
260
  "staleDays",
128
261
  );
129
262
  try {
130
- const [repositoryState, branchStates, pullRequestStates, targetState] =
131
- await Promise.all([
132
- client.getRepository(coordinate.fullName),
133
- client.listBranches(coordinate.fullName),
134
- client.listOpenPullRequests(coordinate.fullName),
135
- client.getBranch(coordinate.fullName, targetName),
136
- ]);
137
- const target = { name: targetName, headOid: headOid(targetState) };
138
- if (!target.headOid)
139
- throw new Error(`target branch ${targetName} has no head OID`);
263
+ const [
264
+ repositoryState,
265
+ branchStates,
266
+ pullRequestStates,
267
+ closedPullRequestStates,
268
+ ] = await Promise.all([
269
+ client.getRepository(coordinate.fullName),
270
+ client.listBranches(coordinate.fullName),
271
+ client.listOpenPullRequests(coordinate.fullName),
272
+ client.listClosedPullRequests(coordinate.fullName),
273
+ ]);
274
+ const branchStatesByName = new Map(
275
+ branchStates.map((branchState) => [
276
+ String(branchState.name),
277
+ branchState,
278
+ ]),
279
+ );
280
+ const targetNames = mainlineTargetNames({
281
+ branchStates,
282
+ repository: coordinate.fullName,
283
+ repositoryState,
284
+ requestedTarget,
285
+ policy,
286
+ });
287
+ const targets = targetNames.map((name) => {
288
+ const state = branchStatesByName.get(name);
289
+ if (!state)
290
+ throw new Error(`mainline target branch ${name} was not found`);
291
+ const target = { name, headOid: headOid(state) };
292
+ if (!target.headOid)
293
+ throw new Error(`mainline target branch ${name} has no head OID`);
294
+ return target;
295
+ });
296
+ const target = targets[0];
297
+ if (!target)
298
+ throw new Error("repository has no discoverable mainline target");
140
299
  const pullRequests = pullRequestStates
141
300
  .map((entry) =>
142
301
  normalizePullRequest(
@@ -147,35 +306,82 @@ export async function collectGitHubHousekeeperInventory({
147
306
  ),
148
307
  )
149
308
  .sort((left, right) => left.number - right.number);
150
- const branches = [];
151
- for (const branchState of [...branchStates].sort((left, right) =>
309
+ const mergedPullRequestsByExactHead = new Map();
310
+ for (const pullRequest of closedPullRequestStates) {
311
+ if (
312
+ !Boolean(pullRequest.merged_at || pullRequest.mergedAt) ||
313
+ pullRequestHeadRepository(pullRequest) !== coordinate.fullName
314
+ )
315
+ continue;
316
+ const key = `${String(
317
+ pullRequest?.head?.ref || pullRequest.headRef || "",
318
+ )}\0${String(
319
+ pullRequest?.head?.sha || pullRequest.headOid || "",
320
+ ).toLowerCase()}`;
321
+ const existing = mergedPullRequestsByExactHead.get(key) || [];
322
+ existing.push(pullRequest);
323
+ mergedPullRequestsByExactHead.set(key, existing);
324
+ }
325
+ const sortedBranchStates = [...branchStates].sort((left, right) =>
152
326
  String(left.name).localeCompare(String(right.name)),
153
- )) {
154
- const branchOid = headOid(branchState);
155
- if (!branchOid)
156
- throw new Error(`branch ${branchState.name} has no head OID`);
157
- branches.push({
158
- repository: coordinate.fullName,
159
- sourceRepository: coordinate.fullName,
160
- name: String(branchState.name),
161
- headOid: branchOid,
162
- target,
163
- isDefault:
164
- String(repositoryState.default_branch) === String(branchState.name),
165
- isProtected: branchState.protected === true,
166
- ancestry: await branchAncestry(
167
- client,
168
- coordinate.fullName,
169
- branchOid,
170
- target.headOid,
171
- ),
172
- openPullRequestNumbers: openPullRequestNumbers(
327
+ );
328
+ const branches = await mapConcurrently(
329
+ sortedBranchStates,
330
+ DEFAULT_INVENTORY_CONCURRENCY,
331
+ async (branchState) => {
332
+ const branchOid = headOid(branchState);
333
+ if (!branchOid)
334
+ throw new Error(`branch ${branchState.name} has no head OID`);
335
+ const staticObservation = branchObservation({
336
+ branchState,
337
+ repository: coordinate.fullName,
338
+ repositoryState,
173
339
  pullRequests,
174
- coordinate.fullName,
175
- String(branchState.name),
176
- ),
177
- });
178
- }
340
+ target,
341
+ ancestry: "ancestor",
342
+ });
343
+ const staticallyEligible = classifyHousekeeperBranch(
344
+ staticObservation,
345
+ policy,
346
+ ).eligible;
347
+ let selectedTarget = target;
348
+ let ancestry = "ambiguous";
349
+ if (staticallyEligible) {
350
+ const candidateTargets = await branchCandidateTargets({
351
+ repository: coordinate.fullName,
352
+ branchName: String(branchState.name),
353
+ branchOid,
354
+ targets,
355
+ associatedPullRequests:
356
+ mergedPullRequestsByExactHead.get(
357
+ `${String(branchState.name)}\0${branchOid}`,
358
+ ) || [],
359
+ });
360
+ for (const candidateTarget of candidateTargets) {
361
+ if (String(branchState.name) === candidateTarget.name) continue;
362
+ const candidateAncestry = await branchAncestry(
363
+ client,
364
+ coordinate.fullName,
365
+ branchOid,
366
+ candidateTarget.headOid,
367
+ );
368
+ if (candidateAncestry === "ancestor") {
369
+ selectedTarget = candidateTarget;
370
+ ancestry = candidateAncestry;
371
+ break;
372
+ }
373
+ }
374
+ }
375
+ return branchObservation({
376
+ branchState,
377
+ repository: coordinate.fullName,
378
+ repositoryState,
379
+ pullRequests,
380
+ target: selectedTarget,
381
+ ancestry,
382
+ });
383
+ },
384
+ );
179
385
  return createEngineeringHousekeeperPlan({
180
386
  repository: coordinate.fullName,
181
387
  target,