@aspruyt/xfg 3.3.2 → 3.5.0

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.
@@ -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
@@ -3,6 +3,7 @@ import { program, Command } from "commander";
3
3
  import { resolve, join, dirname } from "node:path";
4
4
  import { existsSync, readFileSync } from "node:fs";
5
5
  import { fileURLToPath } from "node:url";
6
+ import chalk from "chalk";
6
7
  import { loadRawConfig, normalizeConfig, } from "./config.js";
7
8
  import { validateForSync, validateForSettings } from "./config-validator.js";
8
9
  // Get version from package.json
@@ -19,6 +20,7 @@ import { buildRepoResult, buildErrorResult } from "./summary-utils.js";
19
20
  import { RulesetProcessor, } from "./ruleset-processor.js";
20
21
  import { getManagedRulesets } from "./manifest.js";
21
22
  import { isGitHubRepo } from "./repo-detector.js";
23
+ import { RepoSettingsProcessor, } from "./repo-settings-processor.js";
22
24
  /**
23
25
  * Default factory that creates a real RepositoryProcessor.
24
26
  */
@@ -27,6 +29,10 @@ export const defaultProcessorFactory = () => new RepositoryProcessor();
27
29
  * Default factory that creates a real RulesetProcessor.
28
30
  */
29
31
  export const defaultRulesetProcessorFactory = () => new RulesetProcessor();
32
+ /**
33
+ * Default factory that creates a real RepoSettingsProcessor.
34
+ */
35
+ export const defaultRepoSettingsProcessorFactory = () => new RepoSettingsProcessor();
30
36
  /**
31
37
  * Adds shared options to a command.
32
38
  */
@@ -181,7 +187,7 @@ export async function runSync(options, processorFactory = defaultProcessorFactor
181
187
  // =============================================================================
182
188
  // Settings Command
183
189
  // =============================================================================
184
- export async function runSettings(options, processorFactory = defaultRulesetProcessorFactory, repoProcessorFactory = defaultProcessorFactory) {
190
+ export async function runSettings(options, processorFactory = defaultRulesetProcessorFactory, repoProcessorFactory = defaultProcessorFactory, repoSettingsProcessorFactory = defaultRepoSettingsProcessorFactory) {
185
191
  const configPath = resolve(options.config);
186
192
  if (!existsSync(configPath)) {
187
193
  console.error(`Config file not found: ${configPath}`);
@@ -203,18 +209,31 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
203
209
  const config = normalizeConfig(rawConfig);
204
210
  // Check if any repos have rulesets configured or have managed rulesets to clean up
205
211
  const reposWithRulesets = config.repos.filter((r) => r.settings?.rulesets && Object.keys(r.settings.rulesets).length > 0);
206
- if (reposWithRulesets.length === 0) {
207
- 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.");
208
216
  return;
209
217
  }
210
- console.log(`Found ${reposWithRulesets.length} repositories with rulesets\n`);
211
- 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);
212
226
  const processor = processorFactory();
213
227
  const repoProcessor = repoProcessorFactory();
214
228
  const results = [];
215
229
  let successCount = 0;
216
230
  let failCount = 0;
217
231
  let skipCount = 0;
232
+ // Tracking for multi-repo summary in dry-run mode
233
+ let totalCreates = 0;
234
+ let totalUpdates = 0;
235
+ let totalDeletes = 0;
236
+ let reposWithChanges = 0;
218
237
  for (let i = 0; i < reposWithRulesets.length; i++) {
219
238
  const repoConfig = reposWithRulesets[i];
220
239
  let repoInfo;
@@ -248,6 +267,27 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
248
267
  managedRulesets,
249
268
  noDelete: options.noDelete,
250
269
  });
270
+ // Display plan output for dry-run mode
271
+ if (options.dryRun && result.planOutput) {
272
+ if (result.planOutput.lines.length > 0) {
273
+ logger.rulesetPlan(repoName, result.planOutput.lines, {
274
+ creates: result.planOutput.creates,
275
+ updates: result.planOutput.updates,
276
+ deletes: result.planOutput.deletes,
277
+ unchanged: result.planOutput.unchanged,
278
+ });
279
+ }
280
+ // Accumulate totals for multi-repo summary
281
+ totalCreates += result.planOutput.creates;
282
+ totalUpdates += result.planOutput.updates;
283
+ totalDeletes += result.planOutput.deletes;
284
+ if (result.planOutput.creates +
285
+ result.planOutput.updates +
286
+ result.planOutput.deletes >
287
+ 0) {
288
+ reposWithChanges++;
289
+ }
290
+ }
251
291
  if (result.skipped) {
252
292
  logger.skip(i + 1, repoName, result.message);
253
293
  skipCount++;
@@ -292,12 +332,77 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
292
332
  failCount++;
293
333
  }
294
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
+ }
387
+ // Multi-repo summary for dry-run mode
388
+ if (options.dryRun && reposWithChanges > 0) {
389
+ console.log("");
390
+ console.log(chalk.gray("─".repeat(40)));
391
+ const totalParts = [];
392
+ if (totalCreates > 0)
393
+ totalParts.push(chalk.green(`${totalCreates} to create`));
394
+ if (totalUpdates > 0)
395
+ totalParts.push(chalk.yellow(`${totalUpdates} to update`));
396
+ if (totalDeletes > 0)
397
+ totalParts.push(chalk.red(`${totalDeletes} to delete`));
398
+ console.log(chalk.bold(`Total: ${totalParts.join(", ")} across ${reposWithChanges} ${reposWithChanges === 1 ? "repository" : "repositories"}`));
399
+ }
295
400
  // Summary
296
401
  console.log("\n" + "=".repeat(50));
297
402
  console.log(`Completed: ${successCount} succeeded, ${skipCount} skipped, ${failCount} failed`);
298
403
  // Write GitHub Actions job summary if available
299
404
  writeSummary({
300
- total: reposWithRulesets.length,
405
+ total: reposWithRulesets.length + reposWithRepoSettings.length,
301
406
  succeeded: successCount,
302
407
  skipped: skipCount,
303
408
  failed: failCount,
package/dist/logger.d.ts CHANGED
@@ -1,8 +1,15 @@
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
+ }
2
8
  export interface ILogger {
3
9
  info(message: string): void;
4
10
  fileDiff(fileName: string, status: FileStatus, diffLines: string[]): void;
5
11
  diffSummary(newCount: number, modifiedCount: number, unchangedCount: number, deletedCount?: number): void;
12
+ rulesetPlan(repoName: string, planLines: string[], counts: RulesetPlanCounts): void;
6
13
  setTotal(total: number): void;
7
14
  progress(current: number, repoName: string, message: string): void;
8
15
  success(current: number, repoName: string, message: string): void;
@@ -34,6 +41,10 @@ export declare class Logger implements ILogger {
34
41
  * Display summary statistics for dry-run diff.
35
42
  */
36
43
  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;
37
48
  summary(): void;
38
49
  hasFailures(): boolean;
39
50
  }
package/dist/logger.js CHANGED
@@ -62,6 +62,29 @@ 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
+ }
65
88
  summary() {
66
89
  console.log("");
67
90
  console.log(chalk.bold("Summary:"));
@@ -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
+ }