@kungfu-tech/buildchain 3.0.7-alpha.0 → 3.0.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/actions/promote-buildchain-ref/README.md +10 -0
- package/contracts/auditable-demo-scenario-v1.schema.json +1 -1
- package/contracts/engineering-housekeeper-v1.schema.json +143 -0
- package/contracts/fixtures/engineering-housekeeper-v1/cases.json +68 -0
- package/dist/site/buildchain-contract.json +24 -24
- package/dist/site/buildchain-site.json +91 -30
- package/dist/site/capability-registry.json +3 -3
- package/dist/site/controller-registry.json +6 -2
- package/dist/site/kfd-claims.json +122 -11
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +8 -7
- package/dist/site/node-api-registry.json +683 -105
- package/dist/site/page-registry.json +80 -19
- package/dist/site/public-surface-audit.json +98 -7
- package/dist/site/publication-authority-registry.json +81 -1
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/release-provenance.json +2 -0
- package/dist/site/site-manifest.json +11 -11
- package/dist/site/workflow-registry.json +119 -2
- package/docs/MAP.md +1 -0
- package/docs/auditable-demo.md +2 -2
- package/docs/dev-delivery-warrant.md +49 -4
- package/docs/engineering-housekeeper.md +138 -0
- package/docs/lifecycle-protocol.md +4 -2
- package/docs/node-api-reference.md +277 -212
- package/docs/release-governance.md +17 -2
- package/docs/release-tail-provider-plane.md +1 -1
- package/docs/reusable-build-surface.md +11 -0
- package/package.json +4 -1
- package/packages/core/artifact-signing.js +61 -0
- package/packages/core/buildchain-config.js +66 -6
- package/packages/core/buildchain-publication-authority.js +4 -0
- package/packages/core/controller-evidence.js +2 -1
- package/packages/core/dev-delivery-warrant-cancellation.js +1 -0
- package/packages/core/dev-delivery-warrant-shadow.js +502 -0
- package/packages/core/dev-delivery-warrant.js +15 -6
- package/packages/core/diagnostics.js +8 -3
- package/packages/core/engineering-housekeeper-github-client.js +222 -0
- package/packages/core/engineering-housekeeper-github.js +501 -0
- package/packages/core/engineering-housekeeper.js +259 -0
- package/packages/core/index.js +3 -0
- package/packages/core/kfd-gate.js +45 -15
- package/packages/core/publication-rehearsal-runtime.js +13 -1
- package/packages/core/release-passport.js +130 -20
- package/scripts/assemble-self-publication-admission.mjs +1 -1
- package/scripts/audit-publication-control-plane.mjs +1 -1
- package/scripts/auditable-demo-bundle-verification.mjs +2 -3
- package/scripts/auditable-demo-platform.mjs +2 -2
- package/scripts/auditable-demo-renditions.mjs +1 -1
- package/scripts/auditable-demo.mjs +2 -2
- package/scripts/build-contract-core.mjs +8 -3
- package/scripts/build-standalone-binary.mjs +14 -3
- package/scripts/check-inventory.mjs +3 -1
- package/scripts/dev-delivery-warrant.mjs +31 -4
- package/scripts/dev-pr-auto-merge.mjs +30 -4
- package/scripts/dev-pr-delivery-warrant.mjs +50 -0
- package/scripts/engineering-housekeeper-workflow.mjs +394 -0
- package/scripts/generate-site-bundle.mjs +23 -4
- package/scripts/inspect-artifact-signing-requests.mjs +6 -0
- package/scripts/materialize-self-release-candidate-version.mjs +6 -0
- package/scripts/publication-commit-evidence.mjs +69 -23
- package/scripts/release-candidate-resolver.mjs +16 -10
- package/scripts/resume-from-candidate-run.mjs +123 -9
- package/scripts/seal-artifact-signing-requests.mjs +6 -0
- package/scripts/site-capability-metadata.mjs +2 -0
- package/scripts/web-surface-core.mjs +8 -2
- package/scripts/workflow-call-contract.mjs +1 -1
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
function requiredString(value, field) {
|
|
2
|
+
const normalized = String(value || "").trim();
|
|
3
|
+
if (!normalized) throw new Error(`${field} is required`);
|
|
4
|
+
return normalized;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function normalizeRepository(value) {
|
|
8
|
+
const normalized = requiredString(value?.fullName || value, "repository");
|
|
9
|
+
const match = normalized.match(/^([^/\s]+)\/([^/\s]+)$/);
|
|
10
|
+
if (!match) throw new Error(`repository must be owner/repo, got: ${normalized}`);
|
|
11
|
+
return { owner: match[1], repo: match[2], fullName: normalized };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function refPath(name) {
|
|
15
|
+
return String(name)
|
|
16
|
+
.split("/")
|
|
17
|
+
.map((part) => encodeURIComponent(part))
|
|
18
|
+
.join("/");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseLinkHeader(value) {
|
|
22
|
+
const links = {};
|
|
23
|
+
for (const part of String(value || "").split(",")) {
|
|
24
|
+
const match = part.match(/<([^>]+)>;\s*rel="([^"]+)"/);
|
|
25
|
+
if (match) links[match[2]] = match[1];
|
|
26
|
+
}
|
|
27
|
+
return links;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function branchHeadOid(branch) {
|
|
31
|
+
return String(
|
|
32
|
+
branch?.commit?.sha || branch?.object?.sha || branch?.headOid || "",
|
|
33
|
+
).toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class GitHubHousekeeperProviderError extends Error {
|
|
37
|
+
constructor(message, { operation = "github", status = 0, cause } = {}) {
|
|
38
|
+
super(message, { cause });
|
|
39
|
+
this.name = "GitHubHousekeeperProviderError";
|
|
40
|
+
this.operation = operation;
|
|
41
|
+
this.status = status;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function providerError(error, operation) {
|
|
46
|
+
if (error instanceof GitHubHousekeeperProviderError) return error;
|
|
47
|
+
return new GitHubHousekeeperProviderError(
|
|
48
|
+
`${operation} failed: ${error?.message || String(error)}`,
|
|
49
|
+
{ operation, cause: error },
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class GitHubHousekeeperClient {
|
|
54
|
+
constructor({
|
|
55
|
+
token,
|
|
56
|
+
apiUrl = "https://api.github.com",
|
|
57
|
+
fetchImpl = globalThis.fetch,
|
|
58
|
+
} = {}) {
|
|
59
|
+
if (typeof fetchImpl !== "function") throw new Error("fetch is required");
|
|
60
|
+
this.token = requiredString(token, "GitHub token");
|
|
61
|
+
this.apiUrl = String(apiUrl).replace(/\/+$/, "");
|
|
62
|
+
this.fetch = fetchImpl;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async request(method, requestPath, { body } = {}) {
|
|
66
|
+
const url = requestPath.startsWith("http")
|
|
67
|
+
? requestPath
|
|
68
|
+
: `${this.apiUrl}${requestPath}`;
|
|
69
|
+
if (new URL(url).origin !== new URL(this.apiUrl).origin) {
|
|
70
|
+
throw new GitHubHousekeeperProviderError(
|
|
71
|
+
`GitHub pagination cannot leave API origin: ${new URL(url).origin}`,
|
|
72
|
+
{ operation: `${method} ${requestPath}` },
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
let response;
|
|
76
|
+
try {
|
|
77
|
+
response = await this.fetch(url, {
|
|
78
|
+
method,
|
|
79
|
+
headers: {
|
|
80
|
+
accept: "application/vnd.github+json",
|
|
81
|
+
authorization: `Bearer ${this.token}`,
|
|
82
|
+
"content-type": "application/json",
|
|
83
|
+
"x-github-api-version": "2022-11-28",
|
|
84
|
+
},
|
|
85
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
86
|
+
});
|
|
87
|
+
} catch (error) {
|
|
88
|
+
throw providerError(error, `${method} ${requestPath}`);
|
|
89
|
+
}
|
|
90
|
+
const text = await response.text();
|
|
91
|
+
let data = null;
|
|
92
|
+
try {
|
|
93
|
+
data = text ? JSON.parse(text) : null;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
throw new GitHubHousekeeperProviderError(
|
|
96
|
+
`${method} ${requestPath} returned invalid JSON`,
|
|
97
|
+
{
|
|
98
|
+
operation: `${method} ${requestPath}`,
|
|
99
|
+
status: response.status,
|
|
100
|
+
cause: error,
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
throw new GitHubHousekeeperProviderError(
|
|
106
|
+
`${method} ${requestPath} failed with ${response.status}: ${data?.message || text}`,
|
|
107
|
+
{ operation: `${method} ${requestPath}`, status: response.status },
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return { data, response };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async paginate(requestPath, { maxPages = 1000 } = {}) {
|
|
114
|
+
const items = [];
|
|
115
|
+
const visited = new Set();
|
|
116
|
+
let next = requestPath;
|
|
117
|
+
while (next) {
|
|
118
|
+
if (visited.has(next)) {
|
|
119
|
+
throw new GitHubHousekeeperProviderError(
|
|
120
|
+
"GitHub pagination cycle detected",
|
|
121
|
+
{ operation: `GET ${requestPath}` },
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
if (visited.size >= maxPages) {
|
|
125
|
+
throw new GitHubHousekeeperProviderError(
|
|
126
|
+
`GitHub pagination exceeded ${maxPages} pages`,
|
|
127
|
+
{ operation: `GET ${requestPath}` },
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
visited.add(next);
|
|
131
|
+
const { data, response } = await this.request("GET", next);
|
|
132
|
+
if (!Array.isArray(data)) {
|
|
133
|
+
throw new GitHubHousekeeperProviderError(
|
|
134
|
+
"GitHub paginated response must be an array",
|
|
135
|
+
{ operation: `GET ${next}` },
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
items.push(...data);
|
|
139
|
+
next = parseLinkHeader(response.headers.get("link")).next || "";
|
|
140
|
+
}
|
|
141
|
+
return items;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async getRepository(repository) {
|
|
145
|
+
const coordinate = normalizeRepository(repository);
|
|
146
|
+
return (
|
|
147
|
+
await this.request("GET", `/repos/${coordinate.owner}/${coordinate.repo}`)
|
|
148
|
+
).data;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async listBranches(repository) {
|
|
152
|
+
const coordinate = normalizeRepository(repository);
|
|
153
|
+
return this.paginate(
|
|
154
|
+
`/repos/${coordinate.owner}/${coordinate.repo}/branches?per_page=100`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async listOpenPullRequests(repository) {
|
|
159
|
+
const coordinate = normalizeRepository(repository);
|
|
160
|
+
return this.paginate(
|
|
161
|
+
`/repos/${coordinate.owner}/${coordinate.repo}/pulls?state=open&sort=updated&direction=asc&per_page=100`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async getBranch(repository, name) {
|
|
166
|
+
const coordinate = normalizeRepository(repository);
|
|
167
|
+
return (
|
|
168
|
+
await this.request(
|
|
169
|
+
"GET",
|
|
170
|
+
`/repos/${coordinate.owner}/${coordinate.repo}/branches/${refPath(name)}`,
|
|
171
|
+
)
|
|
172
|
+
).data;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async getPullRequest(repository, number) {
|
|
176
|
+
const coordinate = normalizeRepository(repository);
|
|
177
|
+
return (
|
|
178
|
+
await this.request(
|
|
179
|
+
"GET",
|
|
180
|
+
`/repos/${coordinate.owner}/${coordinate.repo}/pulls/${number}`,
|
|
181
|
+
)
|
|
182
|
+
).data;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async compareCommits(repository, baseOid, headOid) {
|
|
186
|
+
const coordinate = normalizeRepository(repository);
|
|
187
|
+
return (
|
|
188
|
+
await this.request(
|
|
189
|
+
"GET",
|
|
190
|
+
`/repos/${coordinate.owner}/${coordinate.repo}/compare/${baseOid}...${headOid}`,
|
|
191
|
+
)
|
|
192
|
+
).data;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async deleteBranch(repository, name, { expectedHeadOid } = {}) {
|
|
196
|
+
const coordinate = normalizeRepository(repository);
|
|
197
|
+
const currentHeadOid = branchHeadOid(
|
|
198
|
+
await this.getBranch(coordinate.fullName, name),
|
|
199
|
+
);
|
|
200
|
+
if (!expectedHeadOid || currentHeadOid !== expectedHeadOid) {
|
|
201
|
+
throw new GitHubHousekeeperProviderError(
|
|
202
|
+
`branch ${name} changed before delete`,
|
|
203
|
+
{ operation: `delete-ref ${name}`, status: 409 },
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
await this.request(
|
|
207
|
+
"DELETE",
|
|
208
|
+
`/repos/${coordinate.owner}/${coordinate.repo}/git/refs/heads/${refPath(name)}`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async addLabels(repository, number, labels) {
|
|
213
|
+
const coordinate = normalizeRepository(repository);
|
|
214
|
+
return (
|
|
215
|
+
await this.request(
|
|
216
|
+
"POST",
|
|
217
|
+
`/repos/${coordinate.owner}/${coordinate.repo}/issues/${number}/labels`,
|
|
218
|
+
{ body: { labels } },
|
|
219
|
+
)
|
|
220
|
+
).data;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HOUSEKEEPER_REASON_CODES,
|
|
3
|
+
classifyHousekeeperReplay,
|
|
4
|
+
createEngineeringHousekeeperPlan,
|
|
5
|
+
createEngineeringHousekeeperReceipt,
|
|
6
|
+
engineeringHousekeeperRoot,
|
|
7
|
+
revalidateHousekeeperBranchAction,
|
|
8
|
+
} from "./engineering-housekeeper.js";
|
|
9
|
+
import { GitHubHousekeeperProviderError } from "./engineering-housekeeper-github-client.js";
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
GitHubHousekeeperClient,
|
|
13
|
+
GitHubHousekeeperProviderError,
|
|
14
|
+
} from "./engineering-housekeeper-github-client.js";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_STALE_DAYS = 30;
|
|
17
|
+
const DEFAULT_MAX_ACTIONS = 20;
|
|
18
|
+
function requiredString(value, field) {
|
|
19
|
+
const normalized = String(value || "").trim();
|
|
20
|
+
if (!normalized) throw new Error(`${field} is required`);
|
|
21
|
+
return normalized;
|
|
22
|
+
}
|
|
23
|
+
function normalizeRepository(value) {
|
|
24
|
+
const normalized = requiredString(value?.fullName || value, "repository");
|
|
25
|
+
const match = normalized.match(/^([^/\s]+)\/([^/\s]+)$/);
|
|
26
|
+
if (!match)
|
|
27
|
+
throw new Error(`repository must be owner/repo, got: ${normalized}`);
|
|
28
|
+
return { owner: match[1], repo: match[2], fullName: normalized };
|
|
29
|
+
}
|
|
30
|
+
function normalizeDate(value, field) {
|
|
31
|
+
const date = new Date(requiredString(value, field));
|
|
32
|
+
if (Number.isNaN(date.getTime()))
|
|
33
|
+
throw new Error(`${field} must be an ISO date-time`);
|
|
34
|
+
return date;
|
|
35
|
+
}
|
|
36
|
+
function normalizePositiveInteger(value, fallback, field) {
|
|
37
|
+
const selected = value === undefined ? fallback : Number(value);
|
|
38
|
+
if (!Number.isInteger(selected) || selected < 1) {
|
|
39
|
+
throw new Error(`${field} must be a positive integer`);
|
|
40
|
+
}
|
|
41
|
+
return selected;
|
|
42
|
+
}
|
|
43
|
+
function providerError(error, operation) {
|
|
44
|
+
if (error instanceof GitHubHousekeeperProviderError) return error;
|
|
45
|
+
return new GitHubHousekeeperProviderError(
|
|
46
|
+
`${operation} failed: ${error?.message || String(error)}`,
|
|
47
|
+
{ operation, cause: error },
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function headOid(branch) {
|
|
52
|
+
return String(
|
|
53
|
+
branch?.commit?.sha || branch?.object?.sha || branch?.headOid || "",
|
|
54
|
+
).toLowerCase();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function pullRequestHeadRepository(pullRequest) {
|
|
58
|
+
return String(
|
|
59
|
+
pullRequest?.head?.repo?.full_name || pullRequest?.headRepository || "",
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizePullRequest(pullRequest, repository, observedAt, staleDays) {
|
|
64
|
+
const updatedAt = new Date(
|
|
65
|
+
pullRequest.updated_at || pullRequest.updatedAt || "",
|
|
66
|
+
);
|
|
67
|
+
const staleBefore = observedAt.getTime() - staleDays * 24 * 60 * 60 * 1000;
|
|
68
|
+
return {
|
|
69
|
+
repository,
|
|
70
|
+
number: Number(pullRequest.number),
|
|
71
|
+
state: pullRequest.draft ? "draft" : String(pullRequest.state || "open"),
|
|
72
|
+
stale:
|
|
73
|
+
!Number.isNaN(updatedAt.getTime()) && updatedAt.getTime() <= staleBefore,
|
|
74
|
+
headRepository: pullRequestHeadRepository(pullRequest),
|
|
75
|
+
headRef: String(pullRequest?.head?.ref || pullRequest.headRef || ""),
|
|
76
|
+
headOid: String(
|
|
77
|
+
pullRequest?.head?.sha || pullRequest.headOid || "",
|
|
78
|
+
).toLowerCase(),
|
|
79
|
+
labels: (pullRequest.labels || []).map((label) =>
|
|
80
|
+
String(label?.name || label),
|
|
81
|
+
),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function openPullRequestNumbers(pullRequests, repository, branchName) {
|
|
86
|
+
return pullRequests
|
|
87
|
+
.filter(
|
|
88
|
+
(pullRequest) =>
|
|
89
|
+
pullRequest.headRepository === repository &&
|
|
90
|
+
pullRequest.headRef === branchName &&
|
|
91
|
+
["open", "draft"].includes(pullRequest.state),
|
|
92
|
+
)
|
|
93
|
+
.map((pullRequest) => pullRequest.number)
|
|
94
|
+
.sort((left, right) => left - right);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function branchAncestry(client, repository, branchOid, targetOid) {
|
|
98
|
+
if (branchOid === targetOid) return "ancestor";
|
|
99
|
+
const comparison = await client.compareCommits(
|
|
100
|
+
repository,
|
|
101
|
+
branchOid,
|
|
102
|
+
targetOid,
|
|
103
|
+
);
|
|
104
|
+
return String(comparison?.merge_base_commit?.sha || "").toLowerCase() ===
|
|
105
|
+
branchOid
|
|
106
|
+
? "ancestor"
|
|
107
|
+
: "ambiguous";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function collectGitHubHousekeeperInventory({
|
|
111
|
+
client,
|
|
112
|
+
repository,
|
|
113
|
+
targetBranch,
|
|
114
|
+
observedAt,
|
|
115
|
+
staleDays = DEFAULT_STALE_DAYS,
|
|
116
|
+
policy = {},
|
|
117
|
+
}) {
|
|
118
|
+
const coordinate = normalizeRepository(repository);
|
|
119
|
+
const targetName = requiredString(targetBranch, "targetBranch").replace(
|
|
120
|
+
/^refs\/heads\//,
|
|
121
|
+
"",
|
|
122
|
+
);
|
|
123
|
+
const observation = normalizeDate(observedAt, "observedAt");
|
|
124
|
+
const staleWindow = normalizePositiveInteger(
|
|
125
|
+
staleDays,
|
|
126
|
+
DEFAULT_STALE_DAYS,
|
|
127
|
+
"staleDays",
|
|
128
|
+
);
|
|
129
|
+
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`);
|
|
140
|
+
const pullRequests = pullRequestStates
|
|
141
|
+
.map((entry) =>
|
|
142
|
+
normalizePullRequest(
|
|
143
|
+
entry,
|
|
144
|
+
coordinate.fullName,
|
|
145
|
+
observation,
|
|
146
|
+
staleWindow,
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
.sort((left, right) => left.number - right.number);
|
|
150
|
+
const branches = [];
|
|
151
|
+
for (const branchState of [...branchStates].sort((left, right) =>
|
|
152
|
+
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(
|
|
173
|
+
pullRequests,
|
|
174
|
+
coordinate.fullName,
|
|
175
|
+
String(branchState.name),
|
|
176
|
+
),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return createEngineeringHousekeeperPlan({
|
|
180
|
+
repository: coordinate.fullName,
|
|
181
|
+
target,
|
|
182
|
+
branches,
|
|
183
|
+
pullRequests,
|
|
184
|
+
policy,
|
|
185
|
+
observedAt: observation.toISOString(),
|
|
186
|
+
});
|
|
187
|
+
} catch (error) {
|
|
188
|
+
throw providerError(error, `inventory ${coordinate.fullName}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function actionIdentity(action) {
|
|
193
|
+
return action.name
|
|
194
|
+
? `${action.kind}:${action.name}`
|
|
195
|
+
: `${action.kind}:#${action.number}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function rejectedOutcome(action, reasonCodes, details = {}) {
|
|
199
|
+
return {
|
|
200
|
+
action: actionIdentity(action),
|
|
201
|
+
status: "rejected",
|
|
202
|
+
reasonCodes: [...new Set(reasonCodes)].sort(),
|
|
203
|
+
...details,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function providerFailureOutcome(action, error) {
|
|
208
|
+
return {
|
|
209
|
+
action: actionIdentity(action),
|
|
210
|
+
status: "provider-error",
|
|
211
|
+
providerError: {
|
|
212
|
+
operation: String(error?.operation || "github"),
|
|
213
|
+
status: Number(error?.status || 0),
|
|
214
|
+
message: String(error?.message || error),
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function rootOutcome(outcome) {
|
|
220
|
+
return { ...outcome, outcomeRoot: engineeringHousekeeperRoot(outcome) };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function currentBranchForAction(client, plan, action) {
|
|
224
|
+
const repository = plan.repository;
|
|
225
|
+
let source;
|
|
226
|
+
try {
|
|
227
|
+
source = await client.getBranch(repository, action.name);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (Number(error?.status) === 404) return { missing: true };
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
const target = await client.getBranch(repository, action.targetName);
|
|
233
|
+
const pullRequests = (await client.listOpenPullRequests(repository)).map(
|
|
234
|
+
(entry) =>
|
|
235
|
+
normalizePullRequest(
|
|
236
|
+
entry,
|
|
237
|
+
repository,
|
|
238
|
+
new Date(plan.observedAt),
|
|
239
|
+
DEFAULT_STALE_DAYS,
|
|
240
|
+
),
|
|
241
|
+
);
|
|
242
|
+
const sourceOid = headOid(source);
|
|
243
|
+
const targetOid = headOid(target);
|
|
244
|
+
const ancestry = await branchAncestry(
|
|
245
|
+
client,
|
|
246
|
+
repository,
|
|
247
|
+
sourceOid,
|
|
248
|
+
targetOid,
|
|
249
|
+
);
|
|
250
|
+
const [finalSource, finalTarget, finalPullRequests, repositoryState] =
|
|
251
|
+
await Promise.all([
|
|
252
|
+
client.getBranch(repository, action.name),
|
|
253
|
+
client.getBranch(repository, action.targetName),
|
|
254
|
+
client.listOpenPullRequests(repository),
|
|
255
|
+
client.getRepository(repository),
|
|
256
|
+
]);
|
|
257
|
+
const finalSourceOid = headOid(finalSource);
|
|
258
|
+
const finalTargetOid = headOid(finalTarget);
|
|
259
|
+
const normalizedFinalPullRequests = finalPullRequests.map((entry) =>
|
|
260
|
+
normalizePullRequest(
|
|
261
|
+
entry,
|
|
262
|
+
repository,
|
|
263
|
+
new Date(plan.observedAt),
|
|
264
|
+
DEFAULT_STALE_DAYS,
|
|
265
|
+
),
|
|
266
|
+
);
|
|
267
|
+
return {
|
|
268
|
+
name: action.name,
|
|
269
|
+
repository,
|
|
270
|
+
sourceRepository: repository,
|
|
271
|
+
headOid: finalSourceOid,
|
|
272
|
+
target: { name: action.targetName, headOid: finalTargetOid },
|
|
273
|
+
isDefault: String(repositoryState.default_branch) === action.name,
|
|
274
|
+
isProtected: finalSource.protected === true,
|
|
275
|
+
ancestry:
|
|
276
|
+
sourceOid === finalSourceOid && targetOid === finalTargetOid
|
|
277
|
+
? ancestry
|
|
278
|
+
: "ambiguous",
|
|
279
|
+
openPullRequestNumbers: openPullRequestNumbers(
|
|
280
|
+
normalizedFinalPullRequests,
|
|
281
|
+
repository,
|
|
282
|
+
action.name,
|
|
283
|
+
),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function applyBranchAction(client, plan, action) {
|
|
288
|
+
const current = await currentBranchForAction(client, plan, action);
|
|
289
|
+
if (current.missing) {
|
|
290
|
+
return rejectedOutcome(action, [HOUSEKEEPER_REASON_CODES.RENAMED]);
|
|
291
|
+
}
|
|
292
|
+
const validation = revalidateHousekeeperBranchAction(
|
|
293
|
+
action,
|
|
294
|
+
current,
|
|
295
|
+
plan.policy,
|
|
296
|
+
);
|
|
297
|
+
if (!validation.ok) {
|
|
298
|
+
return rejectedOutcome(action, validation.reasonCodes, {
|
|
299
|
+
currentHeadOid: validation.currentHeadOid,
|
|
300
|
+
currentTargetHeadOid: validation.currentTargetHeadOid,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
const [fencedSource, fencedTarget, fencedPullRequests] = await Promise.all([
|
|
304
|
+
client.getBranch(plan.repository, action.name),
|
|
305
|
+
client.getBranch(plan.repository, action.targetName),
|
|
306
|
+
client.listOpenPullRequests(plan.repository),
|
|
307
|
+
]);
|
|
308
|
+
const fenced = {
|
|
309
|
+
...current,
|
|
310
|
+
headOid: headOid(fencedSource),
|
|
311
|
+
target: { name: action.targetName, headOid: headOid(fencedTarget) },
|
|
312
|
+
isProtected: fencedSource.protected === true,
|
|
313
|
+
openPullRequestNumbers: openPullRequestNumbers(
|
|
314
|
+
fencedPullRequests.map((entry) =>
|
|
315
|
+
normalizePullRequest(
|
|
316
|
+
entry,
|
|
317
|
+
plan.repository,
|
|
318
|
+
new Date(plan.observedAt),
|
|
319
|
+
DEFAULT_STALE_DAYS,
|
|
320
|
+
),
|
|
321
|
+
),
|
|
322
|
+
plan.repository,
|
|
323
|
+
action.name,
|
|
324
|
+
),
|
|
325
|
+
};
|
|
326
|
+
const fencedValidation = revalidateHousekeeperBranchAction(
|
|
327
|
+
action,
|
|
328
|
+
fenced,
|
|
329
|
+
plan.policy,
|
|
330
|
+
);
|
|
331
|
+
if (!fencedValidation.ok) {
|
|
332
|
+
return rejectedOutcome(action, fencedValidation.reasonCodes, {
|
|
333
|
+
currentHeadOid: fencedValidation.currentHeadOid,
|
|
334
|
+
currentTargetHeadOid: fencedValidation.currentTargetHeadOid,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
await client.deleteBranch(plan.repository, action.name, {
|
|
338
|
+
expectedHeadOid: action.expectedHeadOid,
|
|
339
|
+
});
|
|
340
|
+
return {
|
|
341
|
+
action: actionIdentity(action),
|
|
342
|
+
status: "deleted",
|
|
343
|
+
headOid: action.expectedHeadOid,
|
|
344
|
+
targetHeadOid: action.expectedTargetHeadOid,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function pullRequestRejection(action, current, stale) {
|
|
349
|
+
const reasons = [];
|
|
350
|
+
if (current.headOid !== action.expectedHeadOid) {
|
|
351
|
+
reasons.push(HOUSEKEEPER_REASON_CODES.HEAD_ADVANCED);
|
|
352
|
+
}
|
|
353
|
+
if (!["open", "draft"].includes(current.state)) {
|
|
354
|
+
reasons.push("pull-request.not-active");
|
|
355
|
+
}
|
|
356
|
+
if (!stale) reasons.push("pull-request.not-stale");
|
|
357
|
+
return reasons;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function applyPullRequestAction(
|
|
361
|
+
client,
|
|
362
|
+
plan,
|
|
363
|
+
action,
|
|
364
|
+
appliedAt,
|
|
365
|
+
staleDays,
|
|
366
|
+
) {
|
|
367
|
+
const read = async () =>
|
|
368
|
+
normalizePullRequest(
|
|
369
|
+
await client.getPullRequest(plan.repository, action.number),
|
|
370
|
+
plan.repository,
|
|
371
|
+
appliedAt,
|
|
372
|
+
staleDays,
|
|
373
|
+
);
|
|
374
|
+
let current = await read();
|
|
375
|
+
let reasons = pullRequestRejection(action, current, current.stale);
|
|
376
|
+
if (reasons.length > 0) return rejectedOutcome(action, reasons);
|
|
377
|
+
if (action.kind === "report-pull-request") {
|
|
378
|
+
return {
|
|
379
|
+
action: actionIdentity(action),
|
|
380
|
+
status: "reported",
|
|
381
|
+
headOid: current.headOid,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
const label = String(plan.policy.pullRequests.label || "");
|
|
385
|
+
if (!label)
|
|
386
|
+
return rejectedOutcome(action, [
|
|
387
|
+
HOUSEKEEPER_REASON_CODES.PR_LABEL_ELIGIBLE,
|
|
388
|
+
]);
|
|
389
|
+
current = await read();
|
|
390
|
+
reasons = pullRequestRejection(action, current, current.stale);
|
|
391
|
+
if (reasons.length > 0) return rejectedOutcome(action, reasons);
|
|
392
|
+
if (current.labels.includes(label)) {
|
|
393
|
+
return {
|
|
394
|
+
action: actionIdentity(action),
|
|
395
|
+
status: "already-labeled",
|
|
396
|
+
label,
|
|
397
|
+
headOid: current.headOid,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
await client.addLabels(plan.repository, action.number, [label]);
|
|
401
|
+
return {
|
|
402
|
+
action: actionIdentity(action),
|
|
403
|
+
status: "labeled",
|
|
404
|
+
label,
|
|
405
|
+
headOid: current.headOid,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export async function applyGitHubHousekeeperPlan({
|
|
410
|
+
client,
|
|
411
|
+
plan,
|
|
412
|
+
dryRun = true,
|
|
413
|
+
priorReceipt,
|
|
414
|
+
appliedAt = new Date().toISOString(),
|
|
415
|
+
staleDays = DEFAULT_STALE_DAYS,
|
|
416
|
+
maxActions = DEFAULT_MAX_ACTIONS,
|
|
417
|
+
}) {
|
|
418
|
+
const applied = normalizeDate(appliedAt, "appliedAt");
|
|
419
|
+
const actionLimit = normalizePositiveInteger(
|
|
420
|
+
maxActions,
|
|
421
|
+
DEFAULT_MAX_ACTIONS,
|
|
422
|
+
"maxActions",
|
|
423
|
+
);
|
|
424
|
+
const staleWindow = normalizePositiveInteger(
|
|
425
|
+
staleDays,
|
|
426
|
+
DEFAULT_STALE_DAYS,
|
|
427
|
+
"staleDays",
|
|
428
|
+
);
|
|
429
|
+
const replay = classifyHousekeeperReplay(plan, priorReceipt);
|
|
430
|
+
if (replay.alreadyApplied) {
|
|
431
|
+
return createEngineeringHousekeeperReceipt({
|
|
432
|
+
plan,
|
|
433
|
+
appliedAt: applied.toISOString(),
|
|
434
|
+
outcomes: [
|
|
435
|
+
rootOutcome({
|
|
436
|
+
action: "replay",
|
|
437
|
+
status: "no-op",
|
|
438
|
+
reasonCodes: replay.reasonCodes,
|
|
439
|
+
priorReceiptRoot: priorReceipt.receiptRoot,
|
|
440
|
+
}),
|
|
441
|
+
],
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
const outcomes = [];
|
|
445
|
+
for (const [index, action] of plan.actions.entries()) {
|
|
446
|
+
if (index >= actionLimit) {
|
|
447
|
+
outcomes.push({
|
|
448
|
+
action: actionIdentity(action),
|
|
449
|
+
status: "limit-skipped",
|
|
450
|
+
});
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (dryRun) {
|
|
454
|
+
outcomes.push({ action: actionIdentity(action), status: "dry-run" });
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
try {
|
|
458
|
+
outcomes.push(
|
|
459
|
+
action.kind === "delete-branch"
|
|
460
|
+
? await applyBranchAction(client, plan, action)
|
|
461
|
+
: await applyPullRequestAction(
|
|
462
|
+
client,
|
|
463
|
+
plan,
|
|
464
|
+
action,
|
|
465
|
+
applied,
|
|
466
|
+
staleWindow,
|
|
467
|
+
),
|
|
468
|
+
);
|
|
469
|
+
} catch (error) {
|
|
470
|
+
outcomes.push(
|
|
471
|
+
providerFailureOutcome(
|
|
472
|
+
action,
|
|
473
|
+
providerError(error, actionIdentity(action)),
|
|
474
|
+
),
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return createEngineeringHousekeeperReceipt({
|
|
479
|
+
plan,
|
|
480
|
+
outcomes: outcomes.map(rootOutcome),
|
|
481
|
+
appliedAt: applied.toISOString(),
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export async function runGitHubHousekeeper(options) {
|
|
486
|
+
const plan = await collectGitHubHousekeeperInventory(options);
|
|
487
|
+
const receipt = await applyGitHubHousekeeperPlan({ ...options, plan });
|
|
488
|
+
return { plan, receipt };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export function formatGitHubHousekeeperPlan(plan) {
|
|
492
|
+
const lines = [
|
|
493
|
+
`Engineering Housekeeper plan ${plan.planRoot}`,
|
|
494
|
+
`Repository: ${plan.repository}`,
|
|
495
|
+
`Target: ${plan.target.name}@${plan.target.headOid}`,
|
|
496
|
+
`Observed: ${plan.observedAt}`,
|
|
497
|
+
`Actions: ${plan.actions.length}`,
|
|
498
|
+
];
|
|
499
|
+
for (const action of plan.actions) lines.push(`- ${actionIdentity(action)}`);
|
|
500
|
+
return `${lines.join("\n")}\n`;
|
|
501
|
+
}
|