@aspruyt/xfg 3.4.0 → 3.5.1

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/README.md CHANGED
@@ -65,6 +65,11 @@ files:
65
65
  tabWidth: 2
66
66
 
67
67
  settings:
68
+ repo:
69
+ allowSquashMerge: true
70
+ deleteBranchOnMerge: true
71
+ vulnerabilityAlerts: true
72
+
68
73
  rulesets:
69
74
  main-protection:
70
75
  target: branch
@@ -84,7 +89,7 @@ repos:
84
89
  - git@github.com:your-org/backend-api.git
85
90
  ```
86
91
 
87
- **Result:** PRs are created with `.prettierrc.json` files, and repos get branch protection rules.
92
+ **Result:** PRs are created with `.prettierrc.json` files, and repos get standardized merge options, security settings, and branch protection rules.
88
93
 
89
94
  ## Documentation
90
95
 
@@ -91,6 +91,12 @@ export function mergeSettings(root, perRepo) {
91
91
  if (deleteOrphaned !== undefined) {
92
92
  result.deleteOrphaned = deleteOrphaned;
93
93
  }
94
+ // Merge repo settings: per-repo overrides root (shallow merge)
95
+ const rootRepo = root?.repo;
96
+ const perRepoRepo = perRepo?.repo;
97
+ if (rootRepo || perRepoRepo) {
98
+ result.repo = { ...rootRepo, ...perRepoRepo };
99
+ }
94
100
  return Object.keys(result).length > 0 ? result : undefined;
95
101
  }
96
102
  /**
@@ -231,6 +237,9 @@ export function normalizeConfig(raw) {
231
237
  normalizedRootSettings.rulesets = filteredRulesets;
232
238
  }
233
239
  }
240
+ if (raw.settings.repo) {
241
+ normalizedRootSettings.repo = raw.settings.repo;
242
+ }
234
243
  if (raw.settings.deleteOrphaned !== undefined) {
235
244
  normalizedRootSettings.deleteOrphaned = raw.settings.deleteOrphaned;
236
245
  }
@@ -279,6 +279,74 @@ function getGitDisplayName(git) {
279
279
  return git;
280
280
  }
281
281
  // =============================================================================
282
+ // Repo Settings Validation
283
+ // =============================================================================
284
+ const VALID_VISIBILITY = ["public", "private", "internal"];
285
+ const VALID_SQUASH_MERGE_COMMIT_TITLE = ["PR_TITLE", "COMMIT_OR_PR_TITLE"];
286
+ const VALID_SQUASH_MERGE_COMMIT_MESSAGE = [
287
+ "PR_BODY",
288
+ "COMMIT_MESSAGES",
289
+ "BLANK",
290
+ ];
291
+ const VALID_MERGE_COMMIT_TITLE = ["PR_TITLE", "MERGE_MESSAGE"];
292
+ const VALID_MERGE_COMMIT_MESSAGE = ["PR_BODY", "PR_TITLE", "BLANK"];
293
+ /**
294
+ * Validates GitHub repository settings.
295
+ */
296
+ function validateRepoSettings(repo, context) {
297
+ if (typeof repo !== "object" || repo === null || Array.isArray(repo)) {
298
+ throw new Error(`${context}: repo must be an object`);
299
+ }
300
+ const r = repo;
301
+ // Validate boolean fields
302
+ const booleanFields = [
303
+ "hasIssues",
304
+ "hasProjects",
305
+ "hasWiki",
306
+ "hasDiscussions",
307
+ "isTemplate",
308
+ "allowForking",
309
+ "archived",
310
+ "allowSquashMerge",
311
+ "allowMergeCommit",
312
+ "allowRebaseMerge",
313
+ "allowAutoMerge",
314
+ "deleteBranchOnMerge",
315
+ "allowUpdateBranch",
316
+ "vulnerabilityAlerts",
317
+ "automatedSecurityFixes",
318
+ "secretScanning",
319
+ "secretScanningPushProtection",
320
+ "privateVulnerabilityReporting",
321
+ ];
322
+ for (const field of booleanFields) {
323
+ if (r[field] !== undefined && typeof r[field] !== "boolean") {
324
+ throw new Error(`${context}: ${field} must be a boolean`);
325
+ }
326
+ }
327
+ // Validate enum fields
328
+ if (r.visibility !== undefined &&
329
+ !VALID_VISIBILITY.includes(r.visibility)) {
330
+ throw new Error(`${context}: visibility must be one of: ${VALID_VISIBILITY.join(", ")}`);
331
+ }
332
+ if (r.squashMergeCommitTitle !== undefined &&
333
+ !VALID_SQUASH_MERGE_COMMIT_TITLE.includes(r.squashMergeCommitTitle)) {
334
+ throw new Error(`${context}: squashMergeCommitTitle must be one of: ${VALID_SQUASH_MERGE_COMMIT_TITLE.join(", ")}`);
335
+ }
336
+ if (r.squashMergeCommitMessage !== undefined &&
337
+ !VALID_SQUASH_MERGE_COMMIT_MESSAGE.includes(r.squashMergeCommitMessage)) {
338
+ throw new Error(`${context}: squashMergeCommitMessage must be one of: ${VALID_SQUASH_MERGE_COMMIT_MESSAGE.join(", ")}`);
339
+ }
340
+ if (r.mergeCommitTitle !== undefined &&
341
+ !VALID_MERGE_COMMIT_TITLE.includes(r.mergeCommitTitle)) {
342
+ throw new Error(`${context}: mergeCommitTitle must be one of: ${VALID_MERGE_COMMIT_TITLE.join(", ")}`);
343
+ }
344
+ if (r.mergeCommitMessage !== undefined &&
345
+ !VALID_MERGE_COMMIT_MESSAGE.includes(r.mergeCommitMessage)) {
346
+ throw new Error(`${context}: mergeCommitMessage must be one of: ${VALID_MERGE_COMMIT_MESSAGE.join(", ")}`);
347
+ }
348
+ }
349
+ // =============================================================================
282
350
  // Ruleset Validation
283
351
  // =============================================================================
284
352
  const VALID_RULESET_TARGETS = ["branch", "tag"];
@@ -515,6 +583,10 @@ export function validateSettings(settings, context, rootRulesetNames) {
515
583
  if (s.deleteOrphaned !== undefined && typeof s.deleteOrphaned !== "boolean") {
516
584
  throw new Error(`${context}: settings.deleteOrphaned must be a boolean`);
517
585
  }
586
+ // Validate repo settings
587
+ if (s.repo !== undefined) {
588
+ validateRepoSettings(s.repo, context);
589
+ }
518
590
  }
519
591
  // =============================================================================
520
592
  // Command-Specific Validators
@@ -545,8 +617,10 @@ export function hasActionableSettings(settings) {
545
617
  if (settings.rulesets && Object.keys(settings.rulesets).length > 0) {
546
618
  return true;
547
619
  }
548
- // Future: check for repoConfig, creation, etc.
549
- // if (settings.repoConfig) return true;
620
+ // Check for repo settings
621
+ if (settings.repo && Object.keys(settings.repo).length > 0) {
622
+ return true;
623
+ }
550
624
  return false;
551
625
  }
552
626
  /**
package/dist/config.d.ts CHANGED
@@ -210,9 +210,51 @@ export interface Ruleset {
210
210
  /** Rules to enforce */
211
211
  rules?: RulesetRule[];
212
212
  }
213
+ /** Squash merge commit title format */
214
+ export type SquashMergeCommitTitle = "PR_TITLE" | "COMMIT_OR_PR_TITLE";
215
+ /** Squash merge commit message format */
216
+ export type SquashMergeCommitMessage = "PR_BODY" | "COMMIT_MESSAGES" | "BLANK";
217
+ /** Merge commit title format */
218
+ export type MergeCommitTitle = "PR_TITLE" | "MERGE_MESSAGE";
219
+ /** Merge commit message format */
220
+ export type MergeCommitMessage = "PR_BODY" | "PR_TITLE" | "BLANK";
221
+ /** Repository visibility */
222
+ export type RepoVisibility = "public" | "private" | "internal";
223
+ /**
224
+ * GitHub repository settings configuration.
225
+ * All properties are optional - only specified properties are applied.
226
+ * @see https://docs.github.com/en/rest/repos/repos#update-a-repository
227
+ */
228
+ export interface GitHubRepoSettings {
229
+ hasIssues?: boolean;
230
+ hasProjects?: boolean;
231
+ hasWiki?: boolean;
232
+ hasDiscussions?: boolean;
233
+ isTemplate?: boolean;
234
+ allowForking?: boolean;
235
+ visibility?: RepoVisibility;
236
+ archived?: boolean;
237
+ allowSquashMerge?: boolean;
238
+ allowMergeCommit?: boolean;
239
+ allowRebaseMerge?: boolean;
240
+ allowAutoMerge?: boolean;
241
+ deleteBranchOnMerge?: boolean;
242
+ allowUpdateBranch?: boolean;
243
+ squashMergeCommitTitle?: SquashMergeCommitTitle;
244
+ squashMergeCommitMessage?: SquashMergeCommitMessage;
245
+ mergeCommitTitle?: MergeCommitTitle;
246
+ mergeCommitMessage?: MergeCommitMessage;
247
+ vulnerabilityAlerts?: boolean;
248
+ automatedSecurityFixes?: boolean;
249
+ secretScanning?: boolean;
250
+ secretScanningPushProtection?: boolean;
251
+ privateVulnerabilityReporting?: boolean;
252
+ }
213
253
  export interface RepoSettings {
214
254
  /** GitHub rulesets keyed by name */
215
255
  rulesets?: Record<string, Ruleset>;
256
+ /** GitHub repository settings */
257
+ repo?: GitHubRepoSettings;
216
258
  deleteOrphaned?: boolean;
217
259
  }
218
260
  export type ContentValue = Record<string, unknown> | string | string[];
@@ -242,6 +284,7 @@ export interface RawRepoSettings {
242
284
  rulesets?: Record<string, Ruleset | false> & {
243
285
  inherit?: boolean;
244
286
  };
287
+ repo?: GitHubRepoSettings;
245
288
  deleteOrphaned?: boolean;
246
289
  }
247
290
  export interface RawRepoConfig {
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ import { RepoConfig } from "./config.js";
6
6
  import { RepoInfo } from "./repo-detector.js";
7
7
  import { ProcessorOptions } from "./repository-processor.js";
8
8
  import { RulesetProcessorOptions, RulesetProcessorResult } from "./ruleset-processor.js";
9
+ import { IRepoSettingsProcessor } from "./repo-settings-processor.js";
9
10
  /**
10
11
  * Processor interface for dependency injection in tests.
11
12
  */
@@ -38,6 +39,14 @@ export type RulesetProcessorFactory = () => IRulesetProcessor;
38
39
  * Default factory that creates a real RulesetProcessor.
39
40
  */
40
41
  export declare const defaultRulesetProcessorFactory: RulesetProcessorFactory;
42
+ /**
43
+ * Repo settings processor factory function type.
44
+ */
45
+ export type RepoSettingsProcessorFactory = () => IRepoSettingsProcessor;
46
+ /**
47
+ * Default factory that creates a real RepoSettingsProcessor.
48
+ */
49
+ export declare const defaultRepoSettingsProcessorFactory: RepoSettingsProcessorFactory;
41
50
  interface SharedOptions {
42
51
  config: string;
43
52
  dryRun?: boolean;
@@ -53,5 +62,5 @@ interface SyncOptions extends SharedOptions {
53
62
  }
54
63
  type SettingsOptions = SharedOptions;
55
64
  export declare function runSync(options: SyncOptions, processorFactory?: ProcessorFactory): Promise<void>;
56
- export declare function runSettings(options: SettingsOptions, processorFactory?: RulesetProcessorFactory, repoProcessorFactory?: ProcessorFactory): Promise<void>;
65
+ export declare function runSettings(options: SettingsOptions, processorFactory?: RulesetProcessorFactory, repoProcessorFactory?: ProcessorFactory, repoSettingsProcessorFactory?: RepoSettingsProcessorFactory): Promise<void>;
57
66
  export { program };
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import { buildRepoResult, buildErrorResult } from "./summary-utils.js";
20
20
  import { RulesetProcessor, } from "./ruleset-processor.js";
21
21
  import { getManagedRulesets } from "./manifest.js";
22
22
  import { isGitHubRepo } from "./repo-detector.js";
23
+ import { RepoSettingsProcessor, } from "./repo-settings-processor.js";
23
24
  /**
24
25
  * Default factory that creates a real RepositoryProcessor.
25
26
  */
@@ -28,6 +29,10 @@ export const defaultProcessorFactory = () => new RepositoryProcessor();
28
29
  * Default factory that creates a real RulesetProcessor.
29
30
  */
30
31
  export const defaultRulesetProcessorFactory = () => new RulesetProcessor();
32
+ /**
33
+ * Default factory that creates a real RepoSettingsProcessor.
34
+ */
35
+ export const defaultRepoSettingsProcessorFactory = () => new RepoSettingsProcessor();
31
36
  /**
32
37
  * Adds shared options to a command.
33
38
  */
@@ -182,7 +187,7 @@ export async function runSync(options, processorFactory = defaultProcessorFactor
182
187
  // =============================================================================
183
188
  // Settings Command
184
189
  // =============================================================================
185
- export async function runSettings(options, processorFactory = defaultRulesetProcessorFactory, repoProcessorFactory = defaultProcessorFactory) {
190
+ export async function runSettings(options, processorFactory = defaultRulesetProcessorFactory, repoProcessorFactory = defaultProcessorFactory, repoSettingsProcessorFactory = defaultRepoSettingsProcessorFactory) {
186
191
  const configPath = resolve(options.config);
187
192
  if (!existsSync(configPath)) {
188
193
  console.error(`Config file not found: ${configPath}`);
@@ -204,12 +209,20 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
204
209
  const config = normalizeConfig(rawConfig);
205
210
  // Check if any repos have rulesets configured or have managed rulesets to clean up
206
211
  const reposWithRulesets = config.repos.filter((r) => r.settings?.rulesets && Object.keys(r.settings.rulesets).length > 0);
207
- if (reposWithRulesets.length === 0) {
208
- console.log("No rulesets configured. Add settings.rulesets to your config to manage GitHub Rulesets.");
212
+ // Check if any repos have repo settings configured
213
+ const reposWithRepoSettings = config.repos.filter((r) => r.settings?.repo && Object.keys(r.settings.repo).length > 0);
214
+ if (reposWithRulesets.length === 0 && reposWithRepoSettings.length === 0) {
215
+ console.log("No settings configured. Add settings.rulesets or settings.repo to your config.");
209
216
  return;
210
217
  }
211
- console.log(`Found ${reposWithRulesets.length} repositories with rulesets\n`);
212
- logger.setTotal(reposWithRulesets.length);
218
+ if (reposWithRulesets.length > 0) {
219
+ console.log(`Found ${reposWithRulesets.length} repositories with rulesets`);
220
+ }
221
+ if (reposWithRepoSettings.length > 0) {
222
+ console.log(`Found ${reposWithRepoSettings.length} repositories with repo settings`);
223
+ }
224
+ console.log("");
225
+ logger.setTotal(reposWithRulesets.length + reposWithRepoSettings.length);
213
226
  const processor = processorFactory();
214
227
  const repoProcessor = repoProcessorFactory();
215
228
  const results = [];
@@ -319,6 +332,58 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
319
332
  failCount++;
320
333
  }
321
334
  }
335
+ // Process repo settings
336
+ if (reposWithRepoSettings.length > 0) {
337
+ const repoSettingsProcessor = repoSettingsProcessorFactory();
338
+ console.log(`\nProcessing repo settings for ${reposWithRepoSettings.length} repositories\n`);
339
+ for (let i = 0; i < reposWithRepoSettings.length; i++) {
340
+ const repoConfig = reposWithRepoSettings[i];
341
+ let repoInfo;
342
+ try {
343
+ repoInfo = parseGitUrl(repoConfig.git, {
344
+ githubHosts: config.githubHosts,
345
+ });
346
+ }
347
+ catch (error) {
348
+ console.error(`Failed to parse ${repoConfig.git}: ${error}`);
349
+ failCount++;
350
+ continue;
351
+ }
352
+ const repoName = getRepoDisplayName(repoInfo);
353
+ try {
354
+ const result = await repoSettingsProcessor.process(repoConfig, repoInfo, {
355
+ dryRun: options.dryRun,
356
+ });
357
+ if (result.planOutput && result.planOutput.lines.length > 0) {
358
+ console.log(`\n ${chalk.bold(repoName)}:`);
359
+ console.log(" Repo Settings:");
360
+ for (const line of result.planOutput.lines) {
361
+ console.log(line);
362
+ }
363
+ if (result.warnings && result.warnings.length > 0) {
364
+ for (const warning of result.warnings) {
365
+ console.log(chalk.yellow(` ⚠️ Warning: ${warning}`));
366
+ }
367
+ }
368
+ }
369
+ if (result.skipped) {
370
+ // Silent skip for repos without repo settings
371
+ }
372
+ else if (result.success) {
373
+ console.log(chalk.green(` ✓ ${repoName}: ${result.message}`));
374
+ successCount++;
375
+ }
376
+ else {
377
+ console.log(chalk.red(` ✗ ${repoName}: ${result.message}`));
378
+ failCount++;
379
+ }
380
+ }
381
+ catch (error) {
382
+ console.error(` ✗ ${repoName}: ${error}`);
383
+ failCount++;
384
+ }
385
+ }
386
+ }
322
387
  // Multi-repo summary for dry-run mode
323
388
  if (options.dryRun && reposWithChanges > 0) {
324
389
  console.log("");
@@ -337,7 +402,7 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
337
402
  console.log(`Completed: ${successCount} succeeded, ${skipCount} skipped, ${failCount} failed`);
338
403
  // Write GitHub Actions job summary if available
339
404
  writeSummary({
340
- total: reposWithRulesets.length,
405
+ total: reposWithRulesets.length + reposWithRepoSettings.length,
341
406
  succeeded: successCount,
342
407
  skipped: skipCount,
343
408
  failed: failCount,
@@ -0,0 +1,18 @@
1
+ import type { GitHubRepoSettings } from "./config.js";
2
+ import type { CurrentRepoSettings } from "./strategies/repo-settings-strategy.js";
3
+ export type RepoSettingsAction = "add" | "change" | "unchanged";
4
+ export interface RepoSettingsChange {
5
+ property: keyof GitHubRepoSettings;
6
+ action: RepoSettingsAction;
7
+ oldValue?: unknown;
8
+ newValue?: unknown;
9
+ }
10
+ /**
11
+ * Compares current repository settings with desired settings.
12
+ * Only compares properties that are explicitly set in desired.
13
+ */
14
+ export declare function diffRepoSettings(current: CurrentRepoSettings, desired: GitHubRepoSettings): RepoSettingsChange[];
15
+ /**
16
+ * Checks if there are any changes to apply.
17
+ */
18
+ export declare function hasChanges(changes: RepoSettingsChange[]): boolean;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Maps config property names (camelCase) to GitHub API property names (snake_case).
3
+ */
4
+ const PROPERTY_MAPPING = {
5
+ hasIssues: "has_issues",
6
+ hasProjects: "has_projects",
7
+ hasWiki: "has_wiki",
8
+ hasDiscussions: "has_discussions",
9
+ isTemplate: "is_template",
10
+ allowForking: "allow_forking",
11
+ visibility: "visibility",
12
+ archived: "archived",
13
+ allowSquashMerge: "allow_squash_merge",
14
+ allowMergeCommit: "allow_merge_commit",
15
+ allowRebaseMerge: "allow_rebase_merge",
16
+ allowAutoMerge: "allow_auto_merge",
17
+ deleteBranchOnMerge: "delete_branch_on_merge",
18
+ allowUpdateBranch: "allow_update_branch",
19
+ squashMergeCommitTitle: "squash_merge_commit_title",
20
+ squashMergeCommitMessage: "squash_merge_commit_message",
21
+ mergeCommitTitle: "merge_commit_title",
22
+ mergeCommitMessage: "merge_commit_message",
23
+ vulnerabilityAlerts: "_vulnerability_alerts",
24
+ automatedSecurityFixes: "_automated_security_fixes",
25
+ secretScanning: "_secret_scanning",
26
+ secretScanningPushProtection: "_secret_scanning_push_protection",
27
+ privateVulnerabilityReporting: "_private_vulnerability_reporting",
28
+ };
29
+ /**
30
+ * Gets the current value for a property from GitHub API response.
31
+ */
32
+ function getCurrentValue(current, property) {
33
+ const apiKey = PROPERTY_MAPPING[property];
34
+ // Handle security_and_analysis nested properties
35
+ if (apiKey === "_secret_scanning") {
36
+ return current.security_and_analysis?.secret_scanning?.status === "enabled";
37
+ }
38
+ if (apiKey === "_secret_scanning_push_protection") {
39
+ return (current.security_and_analysis?.secret_scanning_push_protection?.status ===
40
+ "enabled");
41
+ }
42
+ // These require separate API calls to check, return undefined
43
+ if (apiKey.startsWith("_")) {
44
+ return undefined;
45
+ }
46
+ return current[apiKey];
47
+ }
48
+ /**
49
+ * Compares current repository settings with desired settings.
50
+ * Only compares properties that are explicitly set in desired.
51
+ */
52
+ export function diffRepoSettings(current, desired) {
53
+ const changes = [];
54
+ for (const [key, desiredValue] of Object.entries(desired)) {
55
+ if (desiredValue === undefined)
56
+ continue;
57
+ const property = key;
58
+ const currentValue = getCurrentValue(current, property);
59
+ if (currentValue === undefined) {
60
+ // Property not currently set or unknown
61
+ changes.push({
62
+ property,
63
+ action: "add",
64
+ newValue: desiredValue,
65
+ });
66
+ }
67
+ else if (currentValue !== desiredValue) {
68
+ changes.push({
69
+ property,
70
+ action: "change",
71
+ oldValue: currentValue,
72
+ newValue: desiredValue,
73
+ });
74
+ }
75
+ // unchanged properties are not included
76
+ }
77
+ return changes;
78
+ }
79
+ /**
80
+ * Checks if there are any changes to apply.
81
+ */
82
+ export function hasChanges(changes) {
83
+ return changes.some((c) => c.action !== "unchanged");
84
+ }
@@ -0,0 +1,15 @@
1
+ import type { RepoSettingsChange } from "./repo-settings-diff.js";
2
+ export interface RepoSettingsPlanResult {
3
+ lines: string[];
4
+ adds: number;
5
+ changes: number;
6
+ warnings: string[];
7
+ }
8
+ /**
9
+ * Formats repo settings changes as Terraform-style plan output.
10
+ */
11
+ export declare function formatRepoSettingsPlan(changes: RepoSettingsChange[]): RepoSettingsPlanResult;
12
+ /**
13
+ * Formats warnings for display.
14
+ */
15
+ export declare function formatWarnings(warnings: string[]): string[];
@@ -0,0 +1,66 @@
1
+ import chalk from "chalk";
2
+ /**
3
+ * Format a value for display.
4
+ */
5
+ function formatValue(val) {
6
+ if (val === null)
7
+ return "null";
8
+ if (val === undefined)
9
+ return "undefined";
10
+ if (typeof val === "string")
11
+ return `"${val}"`;
12
+ if (typeof val === "boolean")
13
+ return val ? "true" : "false";
14
+ return String(val);
15
+ }
16
+ /**
17
+ * Get warning message for a property change.
18
+ */
19
+ function getWarning(change) {
20
+ if (change.property === "visibility") {
21
+ return `visibility change (${change.oldValue} → ${change.newValue}) may expose or hide repository`;
22
+ }
23
+ if (change.property === "archived" && change.newValue === true) {
24
+ return "archiving makes repository read-only";
25
+ }
26
+ if ((change.property === "hasIssues" ||
27
+ change.property === "hasWiki" ||
28
+ change.property === "hasProjects") &&
29
+ change.newValue === false) {
30
+ return `disabling ${change.property} may hide existing content`;
31
+ }
32
+ return undefined;
33
+ }
34
+ /**
35
+ * Formats repo settings changes as Terraform-style plan output.
36
+ */
37
+ export function formatRepoSettingsPlan(changes) {
38
+ const lines = [];
39
+ const warnings = [];
40
+ let adds = 0;
41
+ let changesCount = 0;
42
+ if (changes.length === 0) {
43
+ return { lines, adds, changes: 0, warnings };
44
+ }
45
+ for (const change of changes) {
46
+ const warning = getWarning(change);
47
+ if (warning) {
48
+ warnings.push(warning);
49
+ }
50
+ if (change.action === "add") {
51
+ lines.push(chalk.green(` + ${change.property}: ${formatValue(change.newValue)}`));
52
+ adds++;
53
+ }
54
+ else if (change.action === "change") {
55
+ lines.push(chalk.yellow(` ~ ${change.property}: ${formatValue(change.oldValue)} → ${formatValue(change.newValue)}`));
56
+ changesCount++;
57
+ }
58
+ }
59
+ return { lines, adds, changes: changesCount, warnings };
60
+ }
61
+ /**
62
+ * Formats warnings for display.
63
+ */
64
+ export function formatWarnings(warnings) {
65
+ return warnings.map((w) => chalk.yellow(` ⚠️ Warning: ${w}`));
66
+ }
@@ -0,0 +1,30 @@
1
+ import type { RepoConfig } from "./config.js";
2
+ import type { RepoInfo } from "./repo-detector.js";
3
+ import type { IRepoSettingsStrategy } from "./strategies/repo-settings-strategy.js";
4
+ import { RepoSettingsPlanResult } from "./repo-settings-plan-formatter.js";
5
+ export interface IRepoSettingsProcessor {
6
+ process(repoConfig: RepoConfig, repoInfo: RepoInfo, options: RepoSettingsProcessorOptions): Promise<RepoSettingsProcessorResult>;
7
+ }
8
+ export interface RepoSettingsProcessorOptions {
9
+ dryRun?: boolean;
10
+ token?: string;
11
+ }
12
+ export interface RepoSettingsProcessorResult {
13
+ success: boolean;
14
+ repoName: string;
15
+ message: string;
16
+ skipped?: boolean;
17
+ dryRun?: boolean;
18
+ changes?: {
19
+ adds: number;
20
+ changes: number;
21
+ };
22
+ warnings?: string[];
23
+ planOutput?: RepoSettingsPlanResult;
24
+ }
25
+ export declare class RepoSettingsProcessor implements IRepoSettingsProcessor {
26
+ private readonly strategy;
27
+ constructor(strategy?: IRepoSettingsStrategy);
28
+ process(repoConfig: RepoConfig, repoInfo: RepoInfo, options: RepoSettingsProcessorOptions): Promise<RepoSettingsProcessorResult>;
29
+ private applyChanges;
30
+ }
@@ -0,0 +1,96 @@
1
+ import { isGitHubRepo, getRepoDisplayName } from "./repo-detector.js";
2
+ import { GitHubRepoSettingsStrategy } from "./strategies/github-repo-settings-strategy.js";
3
+ import { diffRepoSettings, hasChanges } from "./repo-settings-diff.js";
4
+ import { formatRepoSettingsPlan, } from "./repo-settings-plan-formatter.js";
5
+ export class RepoSettingsProcessor {
6
+ strategy;
7
+ constructor(strategy) {
8
+ this.strategy = strategy ?? new GitHubRepoSettingsStrategy();
9
+ }
10
+ async process(repoConfig, repoInfo, options) {
11
+ const repoName = getRepoDisplayName(repoInfo);
12
+ const { dryRun, token } = options;
13
+ // Check if this is a GitHub repo
14
+ if (!isGitHubRepo(repoInfo)) {
15
+ return {
16
+ success: true,
17
+ repoName,
18
+ message: `Skipped: ${repoName} is not a GitHub repository`,
19
+ skipped: true,
20
+ };
21
+ }
22
+ const githubRepo = repoInfo;
23
+ const desiredSettings = repoConfig.settings?.repo;
24
+ // If no repo settings configured, skip
25
+ if (!desiredSettings || Object.keys(desiredSettings).length === 0) {
26
+ return {
27
+ success: true,
28
+ repoName,
29
+ message: "No repo settings configured",
30
+ skipped: true,
31
+ };
32
+ }
33
+ try {
34
+ const strategyOptions = { token, host: githubRepo.host };
35
+ // Fetch current settings
36
+ const currentSettings = await this.strategy.getSettings(githubRepo, strategyOptions);
37
+ // Compute diff
38
+ const changes = diffRepoSettings(currentSettings, desiredSettings);
39
+ if (!hasChanges(changes)) {
40
+ return {
41
+ success: true,
42
+ repoName,
43
+ message: "No changes needed",
44
+ changes: { adds: 0, changes: 0 },
45
+ };
46
+ }
47
+ // Format plan output
48
+ const planOutput = formatRepoSettingsPlan(changes);
49
+ // Dry run mode - report planned changes without applying
50
+ if (dryRun) {
51
+ return {
52
+ success: true,
53
+ repoName,
54
+ message: `[DRY RUN] ${planOutput.adds} to add, ${planOutput.changes} to change`,
55
+ dryRun: true,
56
+ changes: { adds: planOutput.adds, changes: planOutput.changes },
57
+ warnings: planOutput.warnings,
58
+ planOutput,
59
+ };
60
+ }
61
+ // Apply changes
62
+ await this.applyChanges(githubRepo, desiredSettings, strategyOptions);
63
+ return {
64
+ success: true,
65
+ repoName,
66
+ message: `Applied: ${planOutput.adds} added, ${planOutput.changes} changed`,
67
+ changes: { adds: planOutput.adds, changes: planOutput.changes },
68
+ warnings: planOutput.warnings,
69
+ };
70
+ }
71
+ catch (error) {
72
+ const message = error instanceof Error ? error.message : String(error);
73
+ return {
74
+ success: false,
75
+ repoName,
76
+ message: `Failed: ${message}`,
77
+ };
78
+ }
79
+ }
80
+ async applyChanges(repoInfo, settings, options) {
81
+ // Extract settings that need separate API calls
82
+ const { vulnerabilityAlerts, automatedSecurityFixes, ...mainSettings } = settings;
83
+ // Update main settings via PATCH /repos
84
+ if (Object.keys(mainSettings).length > 0) {
85
+ await this.strategy.updateSettings(repoInfo, mainSettings, options);
86
+ }
87
+ // Handle vulnerability alerts (separate endpoint)
88
+ if (vulnerabilityAlerts !== undefined) {
89
+ await this.strategy.setVulnerabilityAlerts(repoInfo, vulnerabilityAlerts, options);
90
+ }
91
+ // Handle automated security fixes (separate endpoint)
92
+ if (automatedSecurityFixes !== undefined) {
93
+ await this.strategy.setAutomatedSecurityFixes(repoInfo, automatedSecurityFixes, options);
94
+ }
95
+ }
96
+ }
@@ -0,0 +1,20 @@
1
+ import { ICommandExecutor } from "../command-executor.js";
2
+ import { RepoInfo } from "../repo-detector.js";
3
+ import type { GitHubRepoSettings } from "../config.js";
4
+ import type { IRepoSettingsStrategy, RepoSettingsStrategyOptions, CurrentRepoSettings } from "./repo-settings-strategy.js";
5
+ /**
6
+ * GitHub Repository Settings Strategy.
7
+ * Manages repository settings via GitHub REST API using `gh api` CLI.
8
+ * Note: Uses exec via ICommandExecutor for gh CLI integration, consistent
9
+ * with other strategies in this codebase. Inputs are escaped via escapeShellArg.
10
+ */
11
+ export declare class GitHubRepoSettingsStrategy implements IRepoSettingsStrategy {
12
+ private executor;
13
+ constructor(executor?: ICommandExecutor);
14
+ getSettings(repoInfo: RepoInfo, options?: RepoSettingsStrategyOptions): Promise<CurrentRepoSettings>;
15
+ updateSettings(repoInfo: RepoInfo, settings: GitHubRepoSettings, options?: RepoSettingsStrategyOptions): Promise<void>;
16
+ setVulnerabilityAlerts(repoInfo: RepoInfo, enable: boolean, options?: RepoSettingsStrategyOptions): Promise<void>;
17
+ setAutomatedSecurityFixes(repoInfo: RepoInfo, enable: boolean, options?: RepoSettingsStrategyOptions): Promise<void>;
18
+ private validateGitHub;
19
+ private ghApi;
20
+ }
@@ -0,0 +1,131 @@
1
+ import { defaultExecutor } from "../command-executor.js";
2
+ import { isGitHubRepo } from "../repo-detector.js";
3
+ import { escapeShellArg } from "../shell-utils.js";
4
+ /**
5
+ * Converts camelCase to snake_case.
6
+ */
7
+ function camelToSnake(str) {
8
+ return str.replace(/([A-Z])/g, "_$1").toLowerCase();
9
+ }
10
+ /**
11
+ * Converts GitHubRepoSettings (camelCase) to GitHub API format (snake_case).
12
+ */
13
+ function configToGitHubPayload(settings) {
14
+ const payload = {};
15
+ // Map config properties to API properties
16
+ const directMappings = [
17
+ "hasIssues",
18
+ "hasProjects",
19
+ "hasWiki",
20
+ "hasDiscussions",
21
+ "isTemplate",
22
+ "allowForking",
23
+ "visibility",
24
+ "archived",
25
+ "allowSquashMerge",
26
+ "allowMergeCommit",
27
+ "allowRebaseMerge",
28
+ "allowAutoMerge",
29
+ "deleteBranchOnMerge",
30
+ "allowUpdateBranch",
31
+ "squashMergeCommitTitle",
32
+ "squashMergeCommitMessage",
33
+ "mergeCommitTitle",
34
+ "mergeCommitMessage",
35
+ ];
36
+ for (const key of directMappings) {
37
+ if (settings[key] !== undefined) {
38
+ payload[camelToSnake(key)] = settings[key];
39
+ }
40
+ }
41
+ // Handle security_and_analysis for secret scanning
42
+ if (settings.secretScanning !== undefined ||
43
+ settings.secretScanningPushProtection !== undefined) {
44
+ payload.security_and_analysis = {
45
+ ...(settings.secretScanning !== undefined && {
46
+ secret_scanning: {
47
+ status: settings.secretScanning ? "enabled" : "disabled",
48
+ },
49
+ }),
50
+ ...(settings.secretScanningPushProtection !== undefined && {
51
+ secret_scanning_push_protection: {
52
+ status: settings.secretScanningPushProtection
53
+ ? "enabled"
54
+ : "disabled",
55
+ },
56
+ }),
57
+ };
58
+ }
59
+ return payload;
60
+ }
61
+ /**
62
+ * GitHub Repository Settings Strategy.
63
+ * Manages repository settings via GitHub REST API using `gh api` CLI.
64
+ * Note: Uses exec via ICommandExecutor for gh CLI integration, consistent
65
+ * with other strategies in this codebase. Inputs are escaped via escapeShellArg.
66
+ */
67
+ export class GitHubRepoSettingsStrategy {
68
+ executor;
69
+ constructor(executor) {
70
+ this.executor = executor ?? defaultExecutor;
71
+ }
72
+ async getSettings(repoInfo, options) {
73
+ this.validateGitHub(repoInfo);
74
+ const github = repoInfo;
75
+ const endpoint = `/repos/${github.owner}/${github.repo}`;
76
+ const result = await this.ghApi("GET", endpoint, undefined, options);
77
+ return JSON.parse(result);
78
+ }
79
+ async updateSettings(repoInfo, settings, options) {
80
+ this.validateGitHub(repoInfo);
81
+ const github = repoInfo;
82
+ const payload = configToGitHubPayload(settings);
83
+ // Skip if no settings to update
84
+ if (Object.keys(payload).length === 0) {
85
+ return;
86
+ }
87
+ const endpoint = `/repos/${github.owner}/${github.repo}`;
88
+ await this.ghApi("PATCH", endpoint, payload, options);
89
+ }
90
+ async setVulnerabilityAlerts(repoInfo, enable, options) {
91
+ this.validateGitHub(repoInfo);
92
+ const github = repoInfo;
93
+ const endpoint = `/repos/${github.owner}/${github.repo}/vulnerability-alerts`;
94
+ const method = enable ? "PUT" : "DELETE";
95
+ await this.ghApi(method, endpoint, undefined, options);
96
+ }
97
+ async setAutomatedSecurityFixes(repoInfo, enable, options) {
98
+ this.validateGitHub(repoInfo);
99
+ const github = repoInfo;
100
+ const endpoint = `/repos/${github.owner}/${github.repo}/automated-security-fixes`;
101
+ const method = enable ? "PUT" : "DELETE";
102
+ await this.ghApi(method, endpoint, undefined, options);
103
+ }
104
+ validateGitHub(repoInfo) {
105
+ if (!isGitHubRepo(repoInfo)) {
106
+ throw new Error(`GitHub Repo Settings strategy requires GitHub repositories. Got: ${repoInfo.type}`);
107
+ }
108
+ }
109
+ async ghApi(method, endpoint, payload, options) {
110
+ const args = ["gh", "api"];
111
+ if (method !== "GET") {
112
+ args.push("-X", method);
113
+ }
114
+ if (options?.host && options.host !== "github.com") {
115
+ args.push("--hostname", escapeShellArg(options.host));
116
+ }
117
+ args.push(escapeShellArg(endpoint));
118
+ const baseCommand = args.join(" ");
119
+ const tokenPrefix = options?.token
120
+ ? `GH_TOKEN=${escapeShellArg(options.token)} `
121
+ : "";
122
+ if (payload &&
123
+ (method === "POST" || method === "PUT" || method === "PATCH")) {
124
+ const payloadJson = JSON.stringify(payload);
125
+ const command = `echo ${escapeShellArg(payloadJson)} | ${tokenPrefix}${baseCommand} --input -`;
126
+ return await this.executor.exec(command, process.cwd());
127
+ }
128
+ const command = `${tokenPrefix}${baseCommand}`;
129
+ return await this.executor.exec(command, process.cwd());
130
+ }
131
+ }
@@ -0,0 +1,62 @@
1
+ import type { RepoInfo } from "../repo-detector.js";
2
+ import type { GitHubRepoSettings } from "../config.js";
3
+ export interface RepoSettingsStrategyOptions {
4
+ token?: string;
5
+ host?: string;
6
+ }
7
+ /**
8
+ * Current repository settings from GitHub API (snake_case).
9
+ */
10
+ export interface CurrentRepoSettings {
11
+ has_issues?: boolean;
12
+ has_projects?: boolean;
13
+ has_wiki?: boolean;
14
+ has_discussions?: boolean;
15
+ is_template?: boolean;
16
+ allow_forking?: boolean;
17
+ visibility?: string;
18
+ archived?: boolean;
19
+ allow_squash_merge?: boolean;
20
+ allow_merge_commit?: boolean;
21
+ allow_rebase_merge?: boolean;
22
+ allow_auto_merge?: boolean;
23
+ delete_branch_on_merge?: boolean;
24
+ allow_update_branch?: boolean;
25
+ squash_merge_commit_title?: string;
26
+ squash_merge_commit_message?: string;
27
+ merge_commit_title?: string;
28
+ merge_commit_message?: string;
29
+ security_and_analysis?: {
30
+ secret_scanning?: {
31
+ status: string;
32
+ };
33
+ secret_scanning_push_protection?: {
34
+ status: string;
35
+ };
36
+ secret_scanning_validity_checks?: {
37
+ status: string;
38
+ };
39
+ };
40
+ }
41
+ export interface IRepoSettingsStrategy {
42
+ /**
43
+ * Gets current repository settings.
44
+ */
45
+ getSettings(repoInfo: RepoInfo, options?: RepoSettingsStrategyOptions): Promise<CurrentRepoSettings>;
46
+ /**
47
+ * Updates repository settings.
48
+ */
49
+ updateSettings(repoInfo: RepoInfo, settings: GitHubRepoSettings, options?: RepoSettingsStrategyOptions): Promise<void>;
50
+ /**
51
+ * Enables or disables vulnerability alerts.
52
+ */
53
+ setVulnerabilityAlerts(repoInfo: RepoInfo, enable: boolean, options?: RepoSettingsStrategyOptions): Promise<void>;
54
+ /**
55
+ * Enables or disables automated security fixes.
56
+ */
57
+ setAutomatedSecurityFixes(repoInfo: RepoInfo, enable: boolean, options?: RepoSettingsStrategyOptions): Promise<void>;
58
+ }
59
+ /**
60
+ * Type guard to check if an object implements IRepoSettingsStrategy.
61
+ */
62
+ export declare function isRepoSettingsStrategy(obj: unknown): obj is IRepoSettingsStrategy;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Type guard to check if an object implements IRepoSettingsStrategy.
3
+ */
4
+ export function isRepoSettingsStrategy(obj) {
5
+ if (typeof obj !== "object" || obj === null) {
6
+ return false;
7
+ }
8
+ const strategy = obj;
9
+ return (typeof strategy.getSettings === "function" &&
10
+ typeof strategy.updateSettings === "function" &&
11
+ typeof strategy.setVulnerabilityAlerts === "function" &&
12
+ typeof strategy.setAutomatedSecurityFixes === "function");
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/xfg",
3
- "version": "3.4.0",
3
+ "version": "3.5.1",
4
4
  "description": "CLI tool for repository-as-code",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -32,7 +32,8 @@
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",
34
34
  "test:integration:github-app": "npm run build && node --import tsx --test test/integration/github-app.test.ts",
35
- "test:integration:github-settings": "npm run build && node --import tsx --test test/integration/github-settings.test.ts",
35
+ "test:integration:github-rulesets": "npm run build && node --import tsx --test test/integration/github-rulesets.test.ts",
36
+ "test:integration:github-repo-settings": "npm run build && node --import tsx --test test/integration/github-repo-settings.test.ts",
36
37
  "prepublishOnly": "npm run build"
37
38
  },
38
39
  "keywords": [