@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 +6 -1
- 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 +71 -6
- 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/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
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
|
}
|
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
|
@@ -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
|
|
208
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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.
|
|
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-
|
|
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": [
|