@kungfu-tech/buildchain 2.14.2-alpha.0 → 2.14.2

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.
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { spawnSync } from "node:child_process";
5
+ import { loadBuildchainConfig } from "../packages/core/buildchain-config.js";
5
6
 
6
7
  const RULESET_PREFIX = "Buildchain dev merge queue";
7
8
  const RULESET_BYPASS_TYPES = new Set(["Integration", "Team", "User"]);
@@ -80,6 +81,19 @@ export function selectMergeQueueMethod(repositorySettings = {}) {
80
81
  return selected;
81
82
  }
82
83
 
84
+ function assertMergeQueueMethod(method, repositorySettings = {}) {
85
+ const normalized = requiredString(method, "merge queue method").toUpperCase();
86
+ const allowed = {
87
+ MERGE: repositorySettings.allow_merge_commit === true,
88
+ SQUASH: repositorySettings.allow_squash_merge === true,
89
+ REBASE: repositorySettings.allow_rebase_merge === true,
90
+ };
91
+ if (!(normalized in allowed) || !allowed[normalized]) {
92
+ throw new Error(`inherited merge queue method '${normalized}' is not enabled by the repository`);
93
+ }
94
+ return normalized;
95
+ }
96
+
83
97
  export function validateMergeGroupWorkflows(workflows = []) {
84
98
  if (workflows.length === 0) {
85
99
  throw new Error("at least one required workflow must be declared with --workflow");
@@ -115,14 +129,20 @@ export function createDevMergeQueuePlan({
115
129
  bypassActors = [],
116
130
  checkResponseTimeoutMinutes = 120,
117
131
  maxEntriesToBuild = 1,
132
+ queueParameters = {},
133
+ allowInheritedWorkflowEvidence = false,
118
134
  } = {}) {
119
135
  const normalizedRepository = requiredString(repository, "repository");
120
136
  const normalizedBranch = requiredString(branch, "branch");
121
137
  if (!/^dev\/v\d+\/v\d+\.\d+$/.test(normalizedBranch)) {
122
138
  throw new Error(`branch must be a Buildchain dev channel, got '${normalizedBranch}'`);
123
139
  }
124
- const workflowChecks = validateMergeGroupWorkflows(workflows);
125
- const mergeMethod = selectMergeQueueMethod(repositorySettings);
140
+ const workflowChecks = workflows.length === 0 && allowInheritedWorkflowEvidence
141
+ ? []
142
+ : validateMergeGroupWorkflows(workflows);
143
+ const mergeMethod = queueParameters.merge_method
144
+ ? assertMergeQueueMethod(queueParameters.merge_method, repositorySettings)
145
+ : selectMergeQueueMethod(repositorySettings);
126
146
  const requiredChecks = protection?.required_status_checks?.checks || [];
127
147
  if (requiredChecks.length === 0) {
128
148
  throw new Error(`protected branch ${normalizedBranch} must declare required status checks before merge queue enablement`);
@@ -143,13 +163,23 @@ export function createDevMergeQueuePlan({
143
163
  rules: [{
144
164
  type: "merge_queue",
145
165
  parameters: {
146
- check_response_timeout_minutes: positiveInteger(checkResponseTimeoutMinutes, "check response timeout", 120),
147
- grouping_strategy: "ALLGREEN",
148
- max_entries_to_build: positiveInteger(maxEntriesToBuild, "max entries to build", 1),
149
- max_entries_to_merge: 1,
166
+ check_response_timeout_minutes: positiveInteger(
167
+ queueParameters.check_response_timeout_minutes || checkResponseTimeoutMinutes,
168
+ "check response timeout",
169
+ 120,
170
+ ),
171
+ grouping_strategy: queueParameters.grouping_strategy || "ALLGREEN",
172
+ max_entries_to_build: positiveInteger(
173
+ queueParameters.max_entries_to_build || maxEntriesToBuild,
174
+ "max entries to build",
175
+ 1,
176
+ ),
177
+ max_entries_to_merge: positiveInteger(queueParameters.max_entries_to_merge, "max entries to merge", 1),
150
178
  merge_method: mergeMethod,
151
- min_entries_to_merge: 1,
152
- min_entries_to_merge_wait_minutes: 0,
179
+ min_entries_to_merge: positiveInteger(queueParameters.min_entries_to_merge, "min entries to merge", 1),
180
+ min_entries_to_merge_wait_minutes: Number.isInteger(queueParameters.min_entries_to_merge_wait_minutes)
181
+ ? queueParameters.min_entries_to_merge_wait_minutes
182
+ : 0,
153
183
  },
154
184
  }],
155
185
  };
@@ -160,6 +190,7 @@ export function createDevMergeQueuePlan({
160
190
  branch: normalizedBranch,
161
191
  ok: true,
162
192
  workflowChecks,
193
+ workflowEvidence: workflowChecks.length > 0 ? "declared-workflows" : "inherited-active-ruleset",
163
194
  before: {
164
195
  strict: protection.required_status_checks?.strict === true,
165
196
  requiredStatusChecks: requiredChecks.map((check) => check.context),
@@ -199,18 +230,23 @@ export async function reconcileDevMergeQueue({
199
230
  bypassTeams = [],
200
231
  checkResponseTimeoutMinutes = 120,
201
232
  maxEntriesToBuild = 1,
233
+ bypassActors,
234
+ queueParameters = {},
235
+ allowInheritedWorkflowEvidence = false,
202
236
  } = {}) {
203
237
  const encodedBranch = encodeURIComponent(requiredString(branch, "branch"));
204
238
  const repositorySettings = await api.request("GET", `repos/${repository}`);
205
239
  const protection = await api.request("GET", `repos/${repository}/branches/${encodedBranch}/protection`);
206
240
  const rulesets = await api.request("GET", `repos/${repository}/rulesets?includes_parents=false&per_page=100`);
207
- const bypassActors = await resolveRulesetBypassActors({
208
- api,
209
- repository,
210
- apps: bypassApps,
211
- users: bypassUsers,
212
- teams: bypassTeams,
213
- });
241
+ const resolvedBypassActors = bypassActors === undefined
242
+ ? await resolveRulesetBypassActors({
243
+ api,
244
+ repository,
245
+ apps: bypassApps,
246
+ users: bypassUsers,
247
+ teams: bypassTeams,
248
+ })
249
+ : normalizeRulesetBypassActors(bypassActors);
214
250
  const plan = createDevMergeQueuePlan({
215
251
  repository,
216
252
  branch,
@@ -218,9 +254,11 @@ export async function reconcileDevMergeQueue({
218
254
  protection,
219
255
  repositorySettings,
220
256
  rulesets,
221
- bypassActors,
257
+ bypassActors: resolvedBypassActors,
222
258
  checkResponseTimeoutMinutes,
223
259
  maxEntriesToBuild,
260
+ queueParameters,
261
+ allowInheritedWorkflowEvidence,
224
262
  });
225
263
  if (!apply) return { ...plan, action: "planned", applied: false };
226
264
  const results = [];
@@ -238,6 +276,172 @@ export async function reconcileDevMergeQueue({
238
276
  };
239
277
  }
240
278
 
279
+ async function loadDetailedRulesets(api, repository) {
280
+ const rulesets = await api.request("GET", `repos/${repository}/rulesets?includes_parents=false&per_page=100`);
281
+ const details = [];
282
+ for (const ruleset of Array.isArray(rulesets) ? rulesets : []) {
283
+ if (ruleset?.target !== "branch" || ruleset?.enforcement === "disabled") continue;
284
+ if (Array.isArray(ruleset.rules) && ruleset.conditions) {
285
+ details.push(ruleset);
286
+ continue;
287
+ }
288
+ details.push(await api.request("GET", `repos/${repository}/rulesets/${ruleset.id}`));
289
+ }
290
+ return details;
291
+ }
292
+
293
+ function findExactMergeQueueRuleset(rulesets, branch) {
294
+ const include = `refs/heads/${branch}`;
295
+ return rulesets.find((ruleset) =>
296
+ ruleset?.enforcement === "active" &&
297
+ ruleset?.conditions?.ref_name?.include?.length === 1 &&
298
+ ruleset.conditions.ref_name.include[0] === include &&
299
+ ruleset?.rules?.some((rule) => rule?.type === "merge_queue"),
300
+ );
301
+ }
302
+
303
+ function mergeQueueRuleParameters(ruleset) {
304
+ return ruleset?.rules?.find((rule) => rule?.type === "merge_queue")?.parameters || {};
305
+ }
306
+
307
+ export async function resolveConfiguredDevMergeQueuePolicy({
308
+ api,
309
+ repository,
310
+ branch,
311
+ cwd = process.cwd(),
312
+ } = {}) {
313
+ const loadedConfig = loadBuildchainConfig(cwd);
314
+ const declared = loadedConfig?.config?.governance?.dev?.mergeQueue;
315
+ const policy = declared || {
316
+ mode: "inherit",
317
+ requiredWorkflows: [],
318
+ checkResponseTimeoutMinutes: 120,
319
+ maxEntriesToBuild: 1,
320
+ bypassApps: [],
321
+ bypassUsers: [],
322
+ bypassTeams: [],
323
+ };
324
+ if (policy.mode === "disabled") {
325
+ return {
326
+ mode: "disabled",
327
+ declared: Boolean(declared),
328
+ enabled: false,
329
+ reason: "merge queue policy is explicitly disabled",
330
+ sourceBranch: null,
331
+ sourceRulesetId: null,
332
+ policy,
333
+ };
334
+ }
335
+
336
+ if (policy.mode === "enabled") {
337
+ return {
338
+ mode: "enabled",
339
+ declared: true,
340
+ enabled: true,
341
+ reason: "merge queue policy is explicitly enabled",
342
+ sourceBranch: null,
343
+ sourceRulesetId: null,
344
+ policy,
345
+ queueParameters: {},
346
+ inheritedBypassActors: undefined,
347
+ };
348
+ }
349
+
350
+ const repositorySettings = await api.request("GET", `repos/${repository}`);
351
+ const sourceBranch = repositorySettings.default_branch;
352
+ if (!sourceBranch || sourceBranch === branch) {
353
+ const targetRuleset = findExactMergeQueueRuleset(await loadDetailedRulesets(api, repository), branch);
354
+ if (!targetRuleset) {
355
+ return {
356
+ mode: "inherit",
357
+ declared: Boolean(declared),
358
+ enabled: false,
359
+ reason: "the active dev branch has no exact merge queue ruleset",
360
+ sourceBranch: sourceBranch || null,
361
+ sourceRulesetId: null,
362
+ policy,
363
+ };
364
+ }
365
+ return {
366
+ mode: "inherit",
367
+ declared: Boolean(declared),
368
+ enabled: true,
369
+ reason: "the active dev branch merge queue policy is inherited",
370
+ sourceBranch: branch,
371
+ sourceRulesetId: targetRuleset.id,
372
+ policy,
373
+ queueParameters: mergeQueueRuleParameters(targetRuleset),
374
+ inheritedBypassActors: targetRuleset.bypass_actors || [],
375
+ };
376
+ }
377
+ if (!/^dev\/v\d+\/v\d+\.\d+$/.test(sourceBranch)) {
378
+ return {
379
+ mode: "inherit",
380
+ declared: Boolean(declared),
381
+ enabled: false,
382
+ reason: `repository default branch '${sourceBranch}' is not a Buildchain dev channel`,
383
+ sourceBranch,
384
+ sourceRulesetId: null,
385
+ policy,
386
+ };
387
+ }
388
+ const sourceRuleset = findExactMergeQueueRuleset(await loadDetailedRulesets(api, repository), sourceBranch);
389
+ return {
390
+ mode: "inherit",
391
+ declared: Boolean(declared),
392
+ enabled: Boolean(sourceRuleset),
393
+ reason: sourceRuleset
394
+ ? "the active dev branch merge queue policy is inherited"
395
+ : "the active dev branch has no exact merge queue ruleset",
396
+ sourceBranch,
397
+ sourceRulesetId: sourceRuleset?.id || null,
398
+ policy,
399
+ queueParameters: mergeQueueRuleParameters(sourceRuleset),
400
+ inheritedBypassActors: sourceRuleset?.bypass_actors || [],
401
+ };
402
+ }
403
+
404
+ export async function reconcileConfiguredDevMergeQueue({
405
+ api,
406
+ repository,
407
+ branch,
408
+ cwd = process.cwd(),
409
+ apply = false,
410
+ } = {}) {
411
+ const resolution = await resolveConfiguredDevMergeQueuePolicy({ api, repository, branch, cwd });
412
+ if (!resolution.enabled) {
413
+ return {
414
+ schemaVersion: 1,
415
+ contract: "kungfu-buildchain-dev-merge-queue-policy",
416
+ repository,
417
+ branch,
418
+ ok: true,
419
+ action: "not-required",
420
+ applied: false,
421
+ policyResolution: resolution,
422
+ operations: [],
423
+ };
424
+ }
425
+ const workflows = loadWorkflowSources(cwd, resolution.policy.requiredWorkflows);
426
+ const result = await reconcileDevMergeQueue({
427
+ api,
428
+ repository,
429
+ branch,
430
+ workflows,
431
+ apply,
432
+ bypassApps: resolution.policy.bypassApps,
433
+ bypassUsers: resolution.policy.bypassUsers,
434
+ bypassTeams: resolution.policy.bypassTeams,
435
+ bypassActors: resolution.mode === "inherit" ? resolution.inheritedBypassActors : undefined,
436
+ checkResponseTimeoutMinutes: resolution.policy.checkResponseTimeoutMinutes,
437
+ maxEntriesToBuild: resolution.policy.maxEntriesToBuild,
438
+ queueParameters: resolution.mode === "inherit" ? resolution.queueParameters : {},
439
+ allowInheritedWorkflowEvidence:
440
+ resolution.mode === "inherit" && resolution.policy.requiredWorkflows.length === 0,
441
+ });
442
+ return { ...result, policyResolution: resolution };
443
+ }
444
+
241
445
  function createGhApi() {
242
446
  return {
243
447
  request(method, endpoint, body) {
@@ -297,6 +501,17 @@ async function main(args = process.argv.slice(2)) {
297
501
  const cwd = path.resolve(readFlag(args, "cwd", process.cwd()));
298
502
  const repository = readFlag(args, "repository", process.env.GITHUB_REPOSITORY || "");
299
503
  const branch = readFlag(args, "branch", "");
504
+ if (args.includes("--from-config")) {
505
+ const result = await reconcileConfiguredDevMergeQueue({
506
+ api: createGhApi(),
507
+ repository,
508
+ branch,
509
+ cwd,
510
+ apply: args.includes("--apply"),
511
+ });
512
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
513
+ return;
514
+ }
300
515
  const workflows = loadWorkflowSources(cwd, readRepeatedFlag(args, "workflow"));
301
516
  const result = await reconcileDevMergeQueue({
302
517
  api: createGhApi(),