@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.
- package/dist/config-normalizer.js +9 -0
- package/dist/config-validator.js +76 -2
- package/dist/config.d.ts +43 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +111 -6
- package/dist/logger.d.ts +11 -0
- package/dist/logger.js +23 -0
- package/dist/repo-settings-diff.d.ts +18 -0
- package/dist/repo-settings-diff.js +84 -0
- package/dist/repo-settings-plan-formatter.d.ts +15 -0
- package/dist/repo-settings-plan-formatter.js +66 -0
- package/dist/repo-settings-processor.d.ts +30 -0
- package/dist/repo-settings-processor.js +96 -0
- package/dist/ruleset-plan-formatter.d.ts +27 -0
- package/dist/ruleset-plan-formatter.js +314 -0
- package/dist/ruleset-processor.d.ts +2 -0
- package/dist/ruleset-processor.js +3 -0
- package/dist/strategies/github-repo-settings-strategy.d.ts +20 -0
- package/dist/strategies/github-repo-settings-strategy.js +131 -0
- package/dist/strategies/repo-settings-strategy.d.ts +62 -0
- package/dist/strategies/repo-settings-strategy.js +13 -0
- package/package.json +3 -2
|
@@ -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
|
}
|
package/dist/config-validator.js
CHANGED
|
@@ -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
|
-
//
|
|
549
|
-
|
|
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
|
|
207
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
+
}
|