@aspruyt/xfg 3.7.2 → 3.7.4

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/dist/index.js CHANGED
@@ -15,12 +15,14 @@ import { sanitizeBranchName, validateBranchName } from "./git-ops.js";
15
15
  import { logger } from "./logger.js";
16
16
  import { generateWorkspaceName } from "./workspace-utils.js";
17
17
  import { RepositoryProcessor, } from "./repository-processor.js";
18
- import { writeSummary } from "./github-summary.js";
19
18
  import { buildRepoResult, buildErrorResult } from "./summary-utils.js";
20
19
  import { RulesetProcessor, } from "./ruleset-processor.js";
21
20
  import { getManagedRulesets } from "./manifest.js";
22
21
  import { isGitHubRepo } from "./repo-detector.js";
23
22
  import { RepoSettingsProcessor, } from "./repo-settings-processor.js";
23
+ import { printPlan } from "./plan-formatter.js";
24
+ import { writePlanSummary } from "./plan-summary.js";
25
+ import { rulesetResultToResources, syncResultToResources, repoSettingsResultToResources, } from "./resource-converters.js";
24
26
  /**
25
27
  * Default factory that creates a real RepositoryProcessor.
26
28
  */
@@ -115,6 +117,8 @@ export async function runSync(options, processorFactory = defaultProcessorFactor
115
117
  console.log(`Branch: ${branchName}\n`);
116
118
  const processor = processorFactory();
117
119
  const results = [];
120
+ // Build plan for Terraform-style output
121
+ const plan = { resources: [], errors: [] };
118
122
  for (let i = 0; i < config.repos.length; i++) {
119
123
  const repoConfig = config.repos[i];
120
124
  // Apply CLI merge overrides to repo config
@@ -136,6 +140,10 @@ export async function runSync(options, processorFactory = defaultProcessorFactor
136
140
  catch (error) {
137
141
  logger.error(current, repoConfig.git, String(error));
138
142
  results.push(buildErrorResult(repoConfig.git, error));
143
+ plan.errors.push({
144
+ repo: repoConfig.git,
145
+ message: error instanceof Error ? error.message : String(error),
146
+ });
139
147
  continue;
140
148
  }
141
149
  const repoName = getRepoDisplayName(repoInfo);
@@ -162,27 +170,27 @@ export async function runSync(options, processorFactory = defaultProcessorFactor
162
170
  else {
163
171
  logger.error(current, repoName, result.message);
164
172
  }
173
+ // Collect resources for plan output
174
+ plan.resources.push(...syncResultToResources(repoName, repoConfig, result));
165
175
  }
166
176
  catch (error) {
167
177
  logger.error(current, repoName, String(error));
168
178
  results.push(buildErrorResult(repoName, error));
179
+ plan.errors.push({
180
+ repo: repoName,
181
+ message: error instanceof Error ? error.message : String(error),
182
+ });
169
183
  }
170
184
  }
171
- logger.summary();
172
- // Write GitHub Actions job summary if running in GitHub Actions
173
- const succeeded = results.filter((r) => r.status === "succeeded").length;
174
- const skipped = results.filter((r) => r.status === "skipped").length;
175
- const failed = results.filter((r) => r.status === "failed").length;
176
- writeSummary({
185
+ // Print Terraform-style plan summary
186
+ console.log("");
187
+ printPlan(plan);
188
+ // Write GitHub Actions job summary
189
+ writePlanSummary(plan, {
177
190
  title: "Config Sync Summary",
178
- dryRun: options.dryRun,
179
- total: config.repos.length,
180
- succeeded,
181
- skipped,
182
- failed,
183
- results,
191
+ dryRun: options.dryRun ?? false,
184
192
  });
185
- if (logger.hasFailures()) {
193
+ if (plan.errors && plan.errors.length > 0) {
186
194
  process.exit(1);
187
195
  }
188
196
  }
@@ -228,14 +236,8 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
228
236
  const processor = processorFactory();
229
237
  const repoProcessor = repoProcessorFactory();
230
238
  const results = [];
231
- let successCount = 0;
232
- let failCount = 0;
233
- let skipCount = 0;
234
- // Tracking for multi-repo summary in dry-run mode
235
- let totalCreates = 0;
236
- let totalUpdates = 0;
237
- let totalDeletes = 0;
238
- let reposWithChanges = 0;
239
+ // Build plan for Terraform-style output
240
+ const plan = { resources: [], errors: [] };
239
241
  for (let i = 0; i < reposWithRulesets.length; i++) {
240
242
  const repoConfig = reposWithRulesets[i];
241
243
  let repoInfo;
@@ -247,14 +249,28 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
247
249
  catch (error) {
248
250
  logger.error(i + 1, repoConfig.git, String(error));
249
251
  results.push(buildErrorResult(repoConfig.git, error));
250
- failCount++;
252
+ plan.errors.push({
253
+ repo: repoConfig.git,
254
+ message: error instanceof Error ? error.message : String(error),
255
+ });
251
256
  continue;
252
257
  }
253
258
  const repoName = getRepoDisplayName(repoInfo);
254
259
  // Skip non-GitHub repos
255
260
  if (!isGitHubRepo(repoInfo)) {
256
261
  logger.skip(i + 1, repoName, "GitHub Rulesets only supported for GitHub repos");
257
- skipCount++;
262
+ // Mark all rulesets from this repo as skipped
263
+ if (repoConfig.settings?.rulesets) {
264
+ for (const rulesetName of Object.keys(repoConfig.settings.rulesets)) {
265
+ plan.resources.push({
266
+ type: "ruleset",
267
+ repo: repoName,
268
+ name: rulesetName,
269
+ action: "skipped",
270
+ skipReason: "GitHub Rulesets only supported for GitHub repos",
271
+ });
272
+ }
273
+ }
258
274
  continue;
259
275
  }
260
276
  // Note: For settings command, we don't clone repos - we work with the API directly.
@@ -269,34 +285,11 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
269
285
  managedRulesets,
270
286
  noDelete: options.noDelete,
271
287
  });
272
- // Display plan output for dry-run mode
273
- if (options.dryRun && result.planOutput) {
274
- if (result.planOutput.lines.length > 0) {
275
- logger.rulesetPlan(repoName, result.planOutput.lines, {
276
- creates: result.planOutput.creates,
277
- updates: result.planOutput.updates,
278
- deletes: result.planOutput.deletes,
279
- unchanged: result.planOutput.unchanged,
280
- });
281
- }
282
- // Accumulate totals for multi-repo summary
283
- totalCreates += result.planOutput.creates;
284
- totalUpdates += result.planOutput.updates;
285
- totalDeletes += result.planOutput.deletes;
286
- if (result.planOutput.creates +
287
- result.planOutput.updates +
288
- result.planOutput.deletes >
289
- 0) {
290
- reposWithChanges++;
291
- }
292
- }
293
288
  if (result.skipped) {
294
289
  logger.skip(i + 1, repoName, result.message);
295
- skipCount++;
296
290
  }
297
291
  else if (result.success) {
298
292
  logger.success(i + 1, repoName, result.message);
299
- successCount++;
300
293
  // Update manifest with ruleset tracking if there are rulesets to track
301
294
  if (result.manifestUpdate &&
302
295
  result.manifestUpdate.rulesets.length > 0) {
@@ -316,7 +309,6 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
316
309
  }
317
310
  else {
318
311
  logger.error(i + 1, repoName, result.message);
319
- failCount++;
320
312
  }
321
313
  results.push({
322
314
  repoName,
@@ -328,11 +320,16 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
328
320
  message: result.message,
329
321
  rulesetPlanDetails: result.planOutput?.entries,
330
322
  });
323
+ // Collect resources for plan output
324
+ plan.resources.push(...rulesetResultToResources(repoName, result));
331
325
  }
332
326
  catch (error) {
333
327
  logger.error(i + 1, repoName, String(error));
334
328
  results.push(buildErrorResult(repoName, error));
335
- failCount++;
329
+ plan.errors.push({
330
+ repo: repoName,
331
+ message: error instanceof Error ? error.message : String(error),
332
+ });
336
333
  }
337
334
  }
338
335
  // Process repo settings
@@ -349,7 +346,10 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
349
346
  }
350
347
  catch (error) {
351
348
  console.error(`Failed to parse ${repoConfig.git}: ${error}`);
352
- failCount++;
349
+ plan.errors.push({
350
+ repo: repoConfig.git,
351
+ message: error instanceof Error ? error.message : String(error),
352
+ });
353
353
  continue;
354
354
  }
355
355
  const repoName = getRepoDisplayName(repoInfo);
@@ -374,11 +374,9 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
374
374
  }
375
375
  else if (result.success) {
376
376
  console.log(chalk.green(` ✓ ${repoName}: ${result.message}`));
377
- successCount++;
378
377
  }
379
378
  else {
380
379
  console.log(chalk.red(` ✗ ${repoName}: ${result.message}`));
381
- failCount++;
382
380
  }
383
381
  // Merge repo settings plan details into existing result or push new
384
382
  if (!result.skipped) {
@@ -395,40 +393,27 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
395
393
  });
396
394
  }
397
395
  }
396
+ // Collect resources for plan output
397
+ plan.resources.push(...repoSettingsResultToResources(repoName, result));
398
398
  }
399
399
  catch (error) {
400
400
  console.error(` ✗ ${repoName}: ${error}`);
401
- failCount++;
401
+ plan.errors.push({
402
+ repo: repoName,
403
+ message: error instanceof Error ? error.message : String(error),
404
+ });
402
405
  }
403
406
  }
404
407
  }
405
- // Multi-repo summary for dry-run mode
406
- if (options.dryRun && reposWithChanges > 0) {
407
- console.log("");
408
- console.log(chalk.gray("─".repeat(40)));
409
- const totalParts = [];
410
- if (totalCreates > 0)
411
- totalParts.push(chalk.green(`${totalCreates} to create`));
412
- if (totalUpdates > 0)
413
- totalParts.push(chalk.yellow(`${totalUpdates} to update`));
414
- if (totalDeletes > 0)
415
- totalParts.push(chalk.red(`${totalDeletes} to delete`));
416
- console.log(chalk.bold(`Total: ${totalParts.join(", ")} across ${reposWithChanges} ${reposWithChanges === 1 ? "repository" : "repositories"}`));
417
- }
418
- // Summary
419
- console.log("\n" + "=".repeat(50));
420
- console.log(`Completed: ${successCount} succeeded, ${skipCount} skipped, ${failCount} failed`);
421
- // Write GitHub Actions job summary if available
422
- writeSummary({
408
+ // Print Terraform-style plan summary
409
+ console.log("");
410
+ printPlan(plan);
411
+ // Write GitHub Actions job summary
412
+ writePlanSummary(plan, {
423
413
  title: "Repository Settings Summary",
424
- dryRun: options.dryRun,
425
- total: reposWithRulesets.length + reposWithRepoSettings.length,
426
- succeeded: successCount,
427
- skipped: skipCount,
428
- failed: failCount,
429
- results,
414
+ dryRun: options.dryRun ?? false,
430
415
  });
431
- if (failCount > 0) {
416
+ if (plan.errors && plan.errors.length > 0) {
432
417
  process.exit(1);
433
418
  }
434
419
  }
package/dist/logger.d.ts CHANGED
@@ -1,22 +1,13 @@
1
1
  import { FileStatus } from "./diff-utils.js";
2
- export interface RulesetPlanCounts {
3
- creates: number;
4
- updates: number;
5
- deletes: number;
6
- unchanged: number;
7
- }
8
2
  export interface ILogger {
9
3
  info(message: string): void;
10
4
  fileDiff(fileName: string, status: FileStatus, diffLines: string[]): void;
11
5
  diffSummary(newCount: number, modifiedCount: number, unchangedCount: number, deletedCount?: number): void;
12
- rulesetPlan(repoName: string, planLines: string[], counts: RulesetPlanCounts): void;
13
6
  setTotal(total: number): void;
14
7
  progress(current: number, repoName: string, message: string): void;
15
8
  success(current: number, repoName: string, message: string): void;
16
9
  skip(current: number, repoName: string, reason: string): void;
17
10
  error(current: number, repoName: string, error: string): void;
18
- summary(): void;
19
- hasFailures(): boolean;
20
11
  }
21
12
  export interface LoggerStats {
22
13
  total: number;
@@ -41,11 +32,5 @@ export declare class Logger implements ILogger {
41
32
  * Display summary statistics for dry-run diff.
42
33
  */
43
34
  diffSummary(newCount: number, modifiedCount: number, unchangedCount: number, deletedCount?: number): void;
44
- /**
45
- * Display ruleset plan output for dry-run mode.
46
- */
47
- rulesetPlan(repoName: string, planLines: string[], counts: RulesetPlanCounts): void;
48
- summary(): void;
49
- hasFailures(): boolean;
50
35
  }
51
36
  export declare const logger: Logger;
package/dist/logger.js CHANGED
@@ -62,39 +62,5 @@ export class Logger {
62
62
  console.log(chalk.gray(` Summary: ${parts.join(", ")}`));
63
63
  }
64
64
  }
65
- /**
66
- * Display ruleset plan output for dry-run mode.
67
- */
68
- rulesetPlan(repoName, planLines, counts) {
69
- console.log("");
70
- console.log(chalk.bold(`Repository: ${repoName}`));
71
- for (const line of planLines) {
72
- console.log(line);
73
- }
74
- // Summary line
75
- const parts = [];
76
- if (counts.creates > 0)
77
- parts.push(chalk.green(`${counts.creates} to create`));
78
- if (counts.updates > 0)
79
- parts.push(chalk.yellow(`${counts.updates} to update`));
80
- if (counts.deletes > 0)
81
- parts.push(chalk.red(`${counts.deletes} to delete`));
82
- const unchangedPart = counts.unchanged > 0
83
- ? chalk.gray(` (${counts.unchanged} unchanged)`)
84
- : "";
85
- const summaryLine = parts.length > 0 ? parts.join(", ") + unchangedPart : "No changes";
86
- console.log(chalk.gray(`Plan: ${summaryLine}`));
87
- }
88
- summary() {
89
- console.log("");
90
- console.log(chalk.bold("Summary:"));
91
- console.log(` Total: ${this.stats.total}`);
92
- console.log(chalk.green(` Succeeded: ${this.stats.succeeded}`));
93
- console.log(chalk.yellow(` Skipped: ${this.stats.skipped}`));
94
- console.log(chalk.red(` Failed: ${this.stats.failed}`));
95
- }
96
- hasFailures() {
97
- return this.stats.failed > 0;
98
- }
99
65
  }
100
66
  export const logger = new Logger();
@@ -0,0 +1,39 @@
1
+ export type ResourceType = "file" | "ruleset" | "setting";
2
+ export type ResourceAction = "create" | "update" | "delete" | "unchanged" | "skipped";
3
+ export interface Resource {
4
+ type: ResourceType;
5
+ repo: string;
6
+ name: string;
7
+ action: ResourceAction;
8
+ details?: ResourceDetails;
9
+ skipReason?: string;
10
+ }
11
+ export interface ResourceDetails {
12
+ diff?: string[];
13
+ properties?: PropertyChange[];
14
+ }
15
+ export interface PropertyChange {
16
+ path: string;
17
+ action: "add" | "change" | "remove";
18
+ oldValue?: unknown;
19
+ newValue?: unknown;
20
+ }
21
+ export declare function formatResourceId(resource: Resource): string;
22
+ export declare function formatResourceLine(resource: Resource): string;
23
+ export interface PlanCounts {
24
+ create: number;
25
+ update: number;
26
+ delete: number;
27
+ skipped?: number;
28
+ }
29
+ export declare function formatPlanSummary(counts: PlanCounts): string;
30
+ export interface Plan {
31
+ resources: Resource[];
32
+ errors?: RepoError[];
33
+ }
34
+ export interface RepoError {
35
+ repo: string;
36
+ message: string;
37
+ }
38
+ export declare function formatPlan(plan: Plan): string[];
39
+ export declare function printPlan(plan: Plan): void;
@@ -0,0 +1,84 @@
1
+ import chalk from "chalk";
2
+ export function formatResourceId(resource) {
3
+ return `${resource.type} "${resource.repo}/${resource.name}"`;
4
+ }
5
+ export function formatResourceLine(resource) {
6
+ const id = formatResourceId(resource);
7
+ switch (resource.action) {
8
+ case "create":
9
+ return chalk.green(`+ ${id}`);
10
+ case "update":
11
+ return chalk.yellow(`~ ${id}`);
12
+ case "delete":
13
+ return chalk.red(`- ${id}`);
14
+ case "skipped":
15
+ return chalk.gray(`⊘ ${id}`);
16
+ case "unchanged":
17
+ return chalk.gray(` ${id}`);
18
+ }
19
+ }
20
+ export function formatPlanSummary(counts) {
21
+ const parts = [];
22
+ if (counts.create > 0) {
23
+ parts.push(chalk.green(`${counts.create} to create`));
24
+ }
25
+ if (counts.update > 0) {
26
+ parts.push(chalk.yellow(`${counts.update} to change`));
27
+ }
28
+ if (counts.delete > 0) {
29
+ parts.push(chalk.red(`${counts.delete} to destroy`));
30
+ }
31
+ if (parts.length === 0 && (!counts.skipped || counts.skipped === 0)) {
32
+ return "No changes. Your repositories match the configuration.";
33
+ }
34
+ let summary = parts.length > 0 ? `Plan: ${parts.join(", ")}` : "Plan:";
35
+ if (counts.skipped && counts.skipped > 0) {
36
+ summary += chalk.gray(` (${counts.skipped} skipped)`);
37
+ }
38
+ return summary;
39
+ }
40
+ export function formatPlan(plan) {
41
+ const lines = [];
42
+ // Filter to only changed resources
43
+ const changedResources = plan.resources.filter((r) => r.action !== "unchanged");
44
+ // Format each resource
45
+ for (const resource of changedResources) {
46
+ lines.push(formatResourceLine(resource));
47
+ // Add details if present (indented)
48
+ if (resource.details?.diff) {
49
+ for (const diffLine of resource.details.diff) {
50
+ lines.push(` ${diffLine}`);
51
+ }
52
+ }
53
+ }
54
+ // Add errors
55
+ if (plan.errors && plan.errors.length > 0) {
56
+ for (const error of plan.errors) {
57
+ lines.push(chalk.red(`✗ ${error.repo}`));
58
+ lines.push(chalk.red(` Error: ${error.message}`));
59
+ }
60
+ }
61
+ // Add blank line before summary
62
+ if (lines.length > 0) {
63
+ lines.push("");
64
+ }
65
+ // Count actions
66
+ const counts = {
67
+ create: plan.resources.filter((r) => r.action === "create").length,
68
+ update: plan.resources.filter((r) => r.action === "update").length,
69
+ delete: plan.resources.filter((r) => r.action === "delete").length,
70
+ skipped: plan.resources.filter((r) => r.action === "skipped").length,
71
+ };
72
+ lines.push(formatPlanSummary(counts));
73
+ // Add error count if any
74
+ if (plan.errors && plan.errors.length > 0) {
75
+ lines.push(chalk.red(`${plan.errors.length} ${plan.errors.length === 1 ? "repository" : "repositories"} failed.`));
76
+ }
77
+ return lines;
78
+ }
79
+ export function printPlan(plan) {
80
+ const lines = formatPlan(plan);
81
+ for (const line of lines) {
82
+ console.log(line);
83
+ }
84
+ }
@@ -0,0 +1,8 @@
1
+ import type { Plan, Resource, PlanCounts, RepoError } from "./plan-formatter.js";
2
+ export type { Plan, Resource, PlanCounts, RepoError };
3
+ export interface PlanMarkdownOptions {
4
+ title: string;
5
+ dryRun: boolean;
6
+ }
7
+ export declare function formatPlanMarkdown(plan: Plan, options: PlanMarkdownOptions): string;
8
+ export declare function writePlanSummary(plan: Plan, options: PlanMarkdownOptions): void;
@@ -0,0 +1,110 @@
1
+ import { appendFileSync } from "node:fs";
2
+ function getActionSymbol(action) {
3
+ switch (action) {
4
+ case "create":
5
+ return "+";
6
+ case "update":
7
+ return "~";
8
+ case "delete":
9
+ return "-";
10
+ case "skipped":
11
+ return "⊘";
12
+ default:
13
+ return "";
14
+ }
15
+ }
16
+ function formatResourceIdPlain(resource) {
17
+ return `${resource.type} "${resource.repo}/${resource.name}"`;
18
+ }
19
+ function countActions(resources) {
20
+ return {
21
+ create: resources.filter((r) => r.action === "create").length,
22
+ update: resources.filter((r) => r.action === "update").length,
23
+ delete: resources.filter((r) => r.action === "delete").length,
24
+ skipped: resources.filter((r) => r.action === "skipped").length,
25
+ };
26
+ }
27
+ function formatPlanSummaryPlain(counts) {
28
+ const parts = [];
29
+ if (counts.create > 0)
30
+ parts.push(`${counts.create} to create`);
31
+ if (counts.update > 0)
32
+ parts.push(`${counts.update} to change`);
33
+ if (counts.delete > 0)
34
+ parts.push(`${counts.delete} to destroy`);
35
+ if (parts.length === 0) {
36
+ return "No changes";
37
+ }
38
+ return parts.join(", ");
39
+ }
40
+ export function formatPlanMarkdown(plan, options) {
41
+ const lines = [];
42
+ const counts = countActions(plan.resources);
43
+ const changedResources = plan.resources.filter((r) => r.action !== "unchanged");
44
+ // Title
45
+ const titleSuffix = options.dryRun ? " (Dry Run)" : "";
46
+ lines.push(`## ${options.title}${titleSuffix}`);
47
+ lines.push("");
48
+ // Dry-run warning
49
+ if (options.dryRun) {
50
+ lines.push("> [!WARNING]");
51
+ lines.push("> This was a dry run — no changes were applied");
52
+ lines.push("");
53
+ }
54
+ // Plan summary as heading
55
+ const summaryText = formatPlanSummaryPlain(counts);
56
+ lines.push(`### Plan: ${summaryText}`);
57
+ lines.push("");
58
+ // Resource table (if any changes)
59
+ if (changedResources.length > 0) {
60
+ lines.push("<details open>");
61
+ lines.push("<summary><strong>Resources</strong></summary>");
62
+ lines.push("");
63
+ lines.push("| Resource | Action |");
64
+ lines.push("|----------|--------|");
65
+ for (const resource of changedResources) {
66
+ const symbol = getActionSymbol(resource.action);
67
+ const id = formatResourceIdPlain(resource);
68
+ lines.push(`| \`${symbol} ${id}\` | ${resource.action} |`);
69
+ }
70
+ lines.push("");
71
+ lines.push("</details>");
72
+ }
73
+ // Add diff details for resources that have them
74
+ const resourcesWithDiffs = changedResources.filter((r) => r.details?.diff && r.details.diff.length > 0);
75
+ for (const resource of resourcesWithDiffs) {
76
+ lines.push("");
77
+ lines.push("<details>");
78
+ lines.push(`<summary><strong>Diff: ${formatResourceIdPlain(resource)}</strong></summary>`);
79
+ lines.push("");
80
+ lines.push("```diff");
81
+ for (const diffLine of resource.details.diff) {
82
+ lines.push(diffLine);
83
+ }
84
+ lines.push("```");
85
+ lines.push("");
86
+ lines.push("</details>");
87
+ }
88
+ // Error section
89
+ if (plan.errors && plan.errors.length > 0) {
90
+ lines.push("");
91
+ lines.push("<details open>");
92
+ lines.push("<summary><strong>Errors</strong></summary>");
93
+ lines.push("");
94
+ lines.push("| Repository | Error |");
95
+ lines.push("|------------|-------|");
96
+ for (const error of plan.errors) {
97
+ lines.push(`| ${error.repo} | ${error.message} |`);
98
+ }
99
+ lines.push("");
100
+ lines.push("</details>");
101
+ }
102
+ return lines.join("\n");
103
+ }
104
+ export function writePlanSummary(plan, options) {
105
+ const summaryPath = process.env.GITHUB_STEP_SUMMARY;
106
+ if (!summaryPath)
107
+ return;
108
+ const markdown = formatPlanMarkdown(plan, options);
109
+ appendFileSync(summaryPath, "\n" + markdown + "\n");
110
+ }
@@ -22,11 +22,11 @@ const PROPERTY_MAPPING = {
22
22
  mergeCommitMessage: "merge_commit_message",
23
23
  webCommitSignoffRequired: "web_commit_signoff_required",
24
24
  defaultBranch: "default_branch",
25
- vulnerabilityAlerts: "_vulnerability_alerts",
26
- automatedSecurityFixes: "_automated_security_fixes",
25
+ vulnerabilityAlerts: "vulnerability_alerts",
26
+ automatedSecurityFixes: "automated_security_fixes",
27
27
  secretScanning: "_secret_scanning",
28
28
  secretScanningPushProtection: "_secret_scanning_push_protection",
29
- privateVulnerabilityReporting: "_private_vulnerability_reporting",
29
+ privateVulnerabilityReporting: "private_vulnerability_reporting",
30
30
  };
31
31
  /**
32
32
  * Gets the current value for a property from GitHub API response.
@@ -91,16 +91,22 @@ export class RepoSettingsProcessor {
91
91
  }
92
92
  async applyChanges(repoInfo, settings, options) {
93
93
  // Extract settings that need separate API calls
94
- const { vulnerabilityAlerts, automatedSecurityFixes, ...mainSettings } = settings;
94
+ const { vulnerabilityAlerts, automatedSecurityFixes, privateVulnerabilityReporting, ...mainSettings } = settings;
95
95
  // Update main settings via PATCH /repos
96
96
  if (Object.keys(mainSettings).length > 0) {
97
97
  await this.strategy.updateSettings(repoInfo, mainSettings, options);
98
98
  }
99
99
  // Handle vulnerability alerts (separate endpoint)
100
+ // Must be done before automated security fixes
100
101
  if (vulnerabilityAlerts !== undefined) {
101
102
  await this.strategy.setVulnerabilityAlerts(repoInfo, vulnerabilityAlerts, options);
102
103
  }
104
+ // Handle private vulnerability reporting (separate endpoint)
105
+ if (privateVulnerabilityReporting !== undefined) {
106
+ await this.strategy.setPrivateVulnerabilityReporting(repoInfo, privateVulnerabilityReporting, options);
107
+ }
103
108
  // Handle automated security fixes (separate endpoint)
109
+ // Done last to ensure vulnerability alerts have been fully processed
104
110
  if (automatedSecurityFixes !== undefined) {
105
111
  await this.strategy.setAutomatedSecurityFixes(repoInfo, automatedSecurityFixes, options);
106
112
  }
@@ -0,0 +1,25 @@
1
+ import type { Resource } from "./plan-formatter.js";
2
+ import type { RulesetProcessorResult } from "./ruleset-processor.js";
3
+ import type { ProcessorResult } from "./repository-processor.js";
4
+ import type { RepoConfig } from "./config.js";
5
+ /**
6
+ * Convert RulesetProcessorResult planOutput entries to Resource objects.
7
+ */
8
+ export declare function rulesetResultToResources(repoName: string, result: RulesetProcessorResult): Resource[];
9
+ /**
10
+ * Convert sync ProcessorResult diffStats to Resource objects.
11
+ * Since we don't have per-file details, we represent each file from config
12
+ * with the aggregate action based on diffStats.
13
+ */
14
+ export declare function syncResultToResources(repoName: string, repoConfig: Pick<RepoConfig, "files">, result: ProcessorResult): Resource[];
15
+ /**
16
+ * Convert repo settings processor planOutput entries to Resource objects.
17
+ */
18
+ export declare function repoSettingsResultToResources(repoName: string, result: {
19
+ planOutput?: {
20
+ entries?: Array<{
21
+ property: string;
22
+ action: string;
23
+ }>;
24
+ };
25
+ }): Resource[];
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Convert RulesetProcessorResult planOutput entries to Resource objects.
3
+ */
4
+ export function rulesetResultToResources(repoName, result) {
5
+ const resources = [];
6
+ if (result.planOutput?.entries) {
7
+ for (const entry of result.planOutput.entries) {
8
+ let action;
9
+ switch (entry.action) {
10
+ case "create":
11
+ action = "create";
12
+ break;
13
+ case "update":
14
+ action = "update";
15
+ break;
16
+ case "delete":
17
+ action = "delete";
18
+ break;
19
+ default:
20
+ action = "unchanged";
21
+ }
22
+ resources.push({
23
+ type: "ruleset",
24
+ repo: repoName,
25
+ name: entry.name,
26
+ action,
27
+ });
28
+ }
29
+ }
30
+ return resources;
31
+ }
32
+ /**
33
+ * Convert sync ProcessorResult diffStats to Resource objects.
34
+ * Since we don't have per-file details, we represent each file from config
35
+ * with the aggregate action based on diffStats.
36
+ */
37
+ export function syncResultToResources(repoName, repoConfig, result) {
38
+ const resources = [];
39
+ if (result.skipped) {
40
+ // Mark all files as unchanged when skipped
41
+ for (const file of repoConfig.files) {
42
+ resources.push({
43
+ type: "file",
44
+ repo: repoName,
45
+ name: file.fileName,
46
+ action: "unchanged",
47
+ });
48
+ }
49
+ return resources;
50
+ }
51
+ if (!result.diffStats) {
52
+ return resources;
53
+ }
54
+ // With aggregate stats, we can show repo-level summary
55
+ // For now, create one resource per file in config with best-effort action
56
+ // Note: This is approximate since we don't have per-file tracking
57
+ const { newCount, modifiedCount, deletedCount } = result.diffStats;
58
+ for (const file of repoConfig.files) {
59
+ // Determine action based on aggregate stats - this is a simplification
60
+ let action = "unchanged";
61
+ if (newCount > 0) {
62
+ action = "create";
63
+ }
64
+ else if (modifiedCount > 0) {
65
+ action = "update";
66
+ }
67
+ else if (deletedCount > 0) {
68
+ action = "delete";
69
+ }
70
+ resources.push({
71
+ type: "file",
72
+ repo: repoName,
73
+ name: file.fileName,
74
+ action,
75
+ });
76
+ }
77
+ return resources;
78
+ }
79
+ /**
80
+ * Convert repo settings processor planOutput entries to Resource objects.
81
+ */
82
+ export function repoSettingsResultToResources(repoName, result) {
83
+ const resources = [];
84
+ if (result.planOutput?.entries) {
85
+ for (const entry of result.planOutput.entries) {
86
+ resources.push({
87
+ type: "setting",
88
+ repo: repoName,
89
+ name: entry.property,
90
+ action: entry.action === "add" ? "create" : "update",
91
+ });
92
+ }
93
+ }
94
+ return resources;
95
+ }
@@ -15,6 +15,10 @@ export declare class GitHubRepoSettingsStrategy implements IRepoSettingsStrategy
15
15
  updateSettings(repoInfo: RepoInfo, settings: GitHubRepoSettings, options?: RepoSettingsStrategyOptions): Promise<void>;
16
16
  setVulnerabilityAlerts(repoInfo: RepoInfo, enable: boolean, options?: RepoSettingsStrategyOptions): Promise<void>;
17
17
  setAutomatedSecurityFixes(repoInfo: RepoInfo, enable: boolean, options?: RepoSettingsStrategyOptions): Promise<void>;
18
+ setPrivateVulnerabilityReporting(repoInfo: RepoInfo, enable: boolean, options?: RepoSettingsStrategyOptions): Promise<void>;
19
+ private getVulnerabilityAlerts;
20
+ private getAutomatedSecurityFixes;
21
+ private getPrivateVulnerabilityReporting;
18
22
  private validateGitHub;
19
23
  private ghApi;
20
24
  }
@@ -76,7 +76,14 @@ export class GitHubRepoSettingsStrategy {
76
76
  const github = repoInfo;
77
77
  const endpoint = `/repos/${github.owner}/${github.repo}`;
78
78
  const result = await this.ghApi("GET", endpoint, undefined, options);
79
- return JSON.parse(result);
79
+ const settings = JSON.parse(result);
80
+ // Fetch security settings from separate endpoints
81
+ settings.vulnerability_alerts = await this.getVulnerabilityAlerts(github, options);
82
+ // Pass vulnerability_alerts state - automated security fixes requires it enabled
83
+ settings.automated_security_fixes = await this.getAutomatedSecurityFixes(github, options, settings.vulnerability_alerts);
84
+ settings.private_vulnerability_reporting =
85
+ await this.getPrivateVulnerabilityReporting(github, options);
86
+ return settings;
80
87
  }
81
88
  async updateSettings(repoInfo, settings, options) {
82
89
  this.validateGitHub(repoInfo);
@@ -103,6 +110,54 @@ export class GitHubRepoSettingsStrategy {
103
110
  const method = enable ? "PUT" : "DELETE";
104
111
  await this.ghApi(method, endpoint, undefined, options);
105
112
  }
113
+ async setPrivateVulnerabilityReporting(repoInfo, enable, options) {
114
+ this.validateGitHub(repoInfo);
115
+ const github = repoInfo;
116
+ const endpoint = `/repos/${github.owner}/${github.repo}/private-vulnerability-reporting`;
117
+ const method = enable ? "PUT" : "DELETE";
118
+ await this.ghApi(method, endpoint, undefined, options);
119
+ }
120
+ async getVulnerabilityAlerts(github, options) {
121
+ const endpoint = `/repos/${github.owner}/${github.repo}/vulnerability-alerts`;
122
+ try {
123
+ await this.ghApi("GET", endpoint, undefined, options);
124
+ return true; // 204 = enabled
125
+ }
126
+ catch (error) {
127
+ const message = error instanceof Error ? error.message : String(error);
128
+ if (message.includes("HTTP 404")) {
129
+ return false; // 404 = disabled
130
+ }
131
+ throw error; // Re-throw other errors
132
+ }
133
+ }
134
+ async getAutomatedSecurityFixes(github, options, _vulnerabilityAlertsEnabled) {
135
+ // Note: GitHub returns JSON with {enabled: boolean} for this endpoint
136
+ const endpoint = `/repos/${github.owner}/${github.repo}/automated-security-fixes`;
137
+ try {
138
+ const result = await this.ghApi("GET", endpoint, undefined, options);
139
+ // Parse JSON response - GitHub returns {"enabled": true/false}
140
+ if (result) {
141
+ const data = JSON.parse(result);
142
+ return data.enabled === true;
143
+ }
144
+ // Empty response (204) means enabled
145
+ return true;
146
+ }
147
+ catch (error) {
148
+ const message = error instanceof Error ? error.message : String(error);
149
+ if (message.includes("HTTP 404")) {
150
+ return false;
151
+ }
152
+ throw error;
153
+ }
154
+ }
155
+ async getPrivateVulnerabilityReporting(github, options) {
156
+ const endpoint = `/repos/${github.owner}/${github.repo}/private-vulnerability-reporting`;
157
+ const result = await this.ghApi("GET", endpoint, undefined, options);
158
+ const data = JSON.parse(result);
159
+ return data.enabled === true;
160
+ }
106
161
  validateGitHub(repoInfo) {
107
162
  if (!isGitHubRepo(repoInfo)) {
108
163
  throw new Error(`GitHub Repo Settings strategy requires GitHub repositories. Got: ${repoInfo.type}`);
@@ -39,6 +39,9 @@ export interface CurrentRepoSettings {
39
39
  status: string;
40
40
  };
41
41
  };
42
+ vulnerability_alerts?: boolean;
43
+ automated_security_fixes?: boolean;
44
+ private_vulnerability_reporting?: boolean;
42
45
  }
43
46
  export interface IRepoSettingsStrategy {
44
47
  /**
@@ -57,6 +60,10 @@ export interface IRepoSettingsStrategy {
57
60
  * Enables or disables automated security fixes.
58
61
  */
59
62
  setAutomatedSecurityFixes(repoInfo: RepoInfo, enable: boolean, options?: RepoSettingsStrategyOptions): Promise<void>;
63
+ /**
64
+ * Enables or disables private vulnerability reporting.
65
+ */
66
+ setPrivateVulnerabilityReporting(repoInfo: RepoInfo, enable: boolean, options?: RepoSettingsStrategyOptions): Promise<void>;
60
67
  }
61
68
  /**
62
69
  * Type guard to check if an object implements IRepoSettingsStrategy.
@@ -9,5 +9,6 @@ export function isRepoSettingsStrategy(obj) {
9
9
  return (typeof strategy.getSettings === "function" &&
10
10
  typeof strategy.updateSettings === "function" &&
11
11
  typeof strategy.setVulnerabilityAlerts === "function" &&
12
- typeof strategy.setAutomatedSecurityFixes === "function");
12
+ typeof strategy.setAutomatedSecurityFixes === "function" &&
13
+ typeof strategy.setPrivateVulnerabilityReporting === "function");
13
14
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/xfg",
3
- "version": "3.7.2",
3
+ "version": "3.7.4",
4
4
  "description": "CLI tool for repository-as-code",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -27,7 +27,7 @@
27
27
  "start": "node dist/cli.js",
28
28
  "dev": "ts-node src/cli.ts",
29
29
  "test": "node --import tsx scripts/run-tests.js",
30
- "test:coverage": "c8 --check-coverage --lines 95 --reporter=text --reporter=lcov --all --src=src --exclude='test/**/*.test.ts' --exclude='scripts/**' npm test",
30
+ "test:coverage": "c8 --check-coverage --lines 95 --reporter=text --reporter=lcov --all --src=src --exclude='test/**/*.test.ts' --exclude='scripts/**' --exclude='src/strategies/commit-strategy.ts' --exclude='src/strategies/ruleset-strategy.ts' --exclude='test/mocks/**' npm test",
31
31
  "test:integration:github": "npm run build && node --import tsx --test test/integration/github.test.ts",
32
32
  "test:integration:ado": "npm run build && node --import tsx --test test/integration/ado.test.ts",
33
33
  "test:integration:gitlab": "npm run build && node --import tsx --test test/integration/gitlab.test.ts",