@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
|
@@ -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,27 @@
|
|
|
1
|
+
import type { RulesetChange } from "./ruleset-diff.js";
|
|
2
|
+
export type DiffAction = "add" | "change" | "remove";
|
|
3
|
+
export interface PropertyDiff {
|
|
4
|
+
path: string[];
|
|
5
|
+
action: DiffAction;
|
|
6
|
+
oldValue?: unknown;
|
|
7
|
+
newValue?: unknown;
|
|
8
|
+
}
|
|
9
|
+
export interface RulesetPlanResult {
|
|
10
|
+
lines: string[];
|
|
11
|
+
creates: number;
|
|
12
|
+
updates: number;
|
|
13
|
+
deletes: number;
|
|
14
|
+
unchanged: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Recursively compute property-level diffs between two objects.
|
|
18
|
+
*/
|
|
19
|
+
export declare function computePropertyDiffs(current: Record<string, unknown>, desired: Record<string, unknown>, parentPath?: string[]): PropertyDiff[];
|
|
20
|
+
/**
|
|
21
|
+
* Format property diffs as an indented tree structure.
|
|
22
|
+
*/
|
|
23
|
+
export declare function formatPropertyTree(diffs: PropertyDiff[]): string[];
|
|
24
|
+
/**
|
|
25
|
+
* Format ruleset changes as a Terraform-style plan.
|
|
26
|
+
*/
|
|
27
|
+
export declare function formatRulesetPlan(changes: RulesetChange[]): RulesetPlanResult;
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// src/ruleset-plan-formatter.ts
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
// =============================================================================
|
|
4
|
+
// Property Diff Algorithm
|
|
5
|
+
// =============================================================================
|
|
6
|
+
/**
|
|
7
|
+
* Recursively compute property-level diffs between two objects.
|
|
8
|
+
*/
|
|
9
|
+
export function computePropertyDiffs(current, desired, parentPath = []) {
|
|
10
|
+
const diffs = [];
|
|
11
|
+
const allKeys = new Set([...Object.keys(current), ...Object.keys(desired)]);
|
|
12
|
+
for (const key of allKeys) {
|
|
13
|
+
const path = [...parentPath, key];
|
|
14
|
+
const currentVal = current[key];
|
|
15
|
+
const desiredVal = desired[key];
|
|
16
|
+
if (!(key in current)) {
|
|
17
|
+
// Added property
|
|
18
|
+
diffs.push({ path, action: "add", newValue: desiredVal });
|
|
19
|
+
}
|
|
20
|
+
else if (!(key in desired)) {
|
|
21
|
+
// Removed property
|
|
22
|
+
diffs.push({ path, action: "remove", oldValue: currentVal });
|
|
23
|
+
}
|
|
24
|
+
else if (!deepEqual(currentVal, desiredVal)) {
|
|
25
|
+
// Changed property
|
|
26
|
+
if (isObject(currentVal) && isObject(desiredVal)) {
|
|
27
|
+
// Recurse into nested objects
|
|
28
|
+
diffs.push(...computePropertyDiffs(currentVal, desiredVal, path));
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
diffs.push({
|
|
32
|
+
path,
|
|
33
|
+
action: "change",
|
|
34
|
+
oldValue: currentVal,
|
|
35
|
+
newValue: desiredVal,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Unchanged properties are not included
|
|
40
|
+
}
|
|
41
|
+
return diffs;
|
|
42
|
+
}
|
|
43
|
+
// =============================================================================
|
|
44
|
+
// Helpers
|
|
45
|
+
// =============================================================================
|
|
46
|
+
function isObject(val) {
|
|
47
|
+
return val !== null && typeof val === "object" && !Array.isArray(val);
|
|
48
|
+
}
|
|
49
|
+
function deepEqual(a, b) {
|
|
50
|
+
if (a === b)
|
|
51
|
+
return true;
|
|
52
|
+
if (a === null || b === null || a === undefined || b === undefined)
|
|
53
|
+
return a === b;
|
|
54
|
+
if (typeof a !== typeof b)
|
|
55
|
+
return false;
|
|
56
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
57
|
+
if (a.length !== b.length)
|
|
58
|
+
return false;
|
|
59
|
+
return a.every((val, i) => deepEqual(val, b[i]));
|
|
60
|
+
}
|
|
61
|
+
if (isObject(a) && isObject(b)) {
|
|
62
|
+
const keysA = Object.keys(a);
|
|
63
|
+
const keysB = Object.keys(b);
|
|
64
|
+
if (keysA.length !== keysB.length)
|
|
65
|
+
return false;
|
|
66
|
+
return keysA.every((key) => deepEqual(a[key], b[key]));
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Build a tree structure from flat property diffs.
|
|
72
|
+
*/
|
|
73
|
+
function buildTree(diffs) {
|
|
74
|
+
const root = { name: "", children: new Map() };
|
|
75
|
+
for (const diff of diffs) {
|
|
76
|
+
let current = root;
|
|
77
|
+
for (let i = 0; i < diff.path.length; i++) {
|
|
78
|
+
const segment = diff.path[i];
|
|
79
|
+
const isLast = i === diff.path.length - 1;
|
|
80
|
+
if (!current.children.has(segment)) {
|
|
81
|
+
current.children.set(segment, {
|
|
82
|
+
name: segment,
|
|
83
|
+
children: new Map(),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const child = current.children.get(segment);
|
|
87
|
+
if (isLast) {
|
|
88
|
+
child.action = diff.action;
|
|
89
|
+
child.oldValue = diff.oldValue;
|
|
90
|
+
child.newValue = diff.newValue;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
// Intermediate node - mark as change if any child changes
|
|
94
|
+
if (!child.action) {
|
|
95
|
+
child.action = "change";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
current = child;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return root;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Format a value for display.
|
|
105
|
+
*/
|
|
106
|
+
function formatValue(val) {
|
|
107
|
+
if (val === null)
|
|
108
|
+
return "null";
|
|
109
|
+
if (val === undefined)
|
|
110
|
+
return "undefined";
|
|
111
|
+
if (typeof val === "string")
|
|
112
|
+
return `"${val}"`;
|
|
113
|
+
if (Array.isArray(val)) {
|
|
114
|
+
if (val.length <= 3) {
|
|
115
|
+
return `[${val.map(formatValue).join(", ")}]`;
|
|
116
|
+
}
|
|
117
|
+
return `[${val.slice(0, 3).map(formatValue).join(", ")}, ... (${val.length - 3} more)]`;
|
|
118
|
+
}
|
|
119
|
+
if (typeof val === "object") {
|
|
120
|
+
return "{...}";
|
|
121
|
+
}
|
|
122
|
+
return String(val);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Get the symbol and color for an action.
|
|
126
|
+
*/
|
|
127
|
+
function getActionStyle(action) {
|
|
128
|
+
switch (action) {
|
|
129
|
+
case "add":
|
|
130
|
+
return { symbol: "+", color: chalk.green };
|
|
131
|
+
case "remove":
|
|
132
|
+
return { symbol: "-", color: chalk.red };
|
|
133
|
+
case "change":
|
|
134
|
+
return { symbol: "~", color: chalk.yellow };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Recursively render tree nodes to formatted lines.
|
|
139
|
+
*/
|
|
140
|
+
function renderTree(node, indent = 0) {
|
|
141
|
+
const lines = [];
|
|
142
|
+
const indentStr = " ".repeat(indent);
|
|
143
|
+
for (const [, child] of node.children) {
|
|
144
|
+
const style = child.action
|
|
145
|
+
? getActionStyle(child.action)
|
|
146
|
+
: { symbol: " ", color: chalk.gray };
|
|
147
|
+
const hasChildren = child.children.size > 0;
|
|
148
|
+
if (hasChildren) {
|
|
149
|
+
// Intermediate node
|
|
150
|
+
lines.push(style.color(`${indentStr}${style.symbol} ${child.name}:`));
|
|
151
|
+
lines.push(...renderTree(child, indent + 1));
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
// Leaf node with value
|
|
155
|
+
let valuePart = "";
|
|
156
|
+
if (child.action === "change") {
|
|
157
|
+
valuePart = `: ${formatValue(child.oldValue)} → ${formatValue(child.newValue)}`;
|
|
158
|
+
}
|
|
159
|
+
else if (child.action === "add") {
|
|
160
|
+
valuePart = `: ${formatValue(child.newValue)}`;
|
|
161
|
+
}
|
|
162
|
+
else if (child.action === "remove") {
|
|
163
|
+
valuePart = ` (was: ${formatValue(child.oldValue)})`;
|
|
164
|
+
}
|
|
165
|
+
lines.push(style.color(`${indentStr}${style.symbol} ${child.name}${valuePart}`));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return lines;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Format property diffs as an indented tree structure.
|
|
172
|
+
*/
|
|
173
|
+
export function formatPropertyTree(diffs) {
|
|
174
|
+
if (diffs.length === 0) {
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
const tree = buildTree(diffs);
|
|
178
|
+
return renderTree(tree);
|
|
179
|
+
}
|
|
180
|
+
// =============================================================================
|
|
181
|
+
// Ruleset Plan Formatter
|
|
182
|
+
// =============================================================================
|
|
183
|
+
/**
|
|
184
|
+
* Normalize a GitHubRuleset or Ruleset for comparison.
|
|
185
|
+
* Converts to snake_case and removes metadata fields.
|
|
186
|
+
*/
|
|
187
|
+
function normalizeForDiff(obj) {
|
|
188
|
+
const result = {};
|
|
189
|
+
const ignoreFields = new Set(["id", "name", "source_type", "source"]);
|
|
190
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
191
|
+
if (ignoreFields.has(key) || value === undefined)
|
|
192
|
+
continue;
|
|
193
|
+
// Convert camelCase to snake_case for consistency
|
|
194
|
+
const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase();
|
|
195
|
+
result[snakeKey] = normalizeNestedValue(value);
|
|
196
|
+
}
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
function normalizeNestedValue(value) {
|
|
200
|
+
if (value === null || value === undefined)
|
|
201
|
+
return value;
|
|
202
|
+
if (Array.isArray(value))
|
|
203
|
+
return value.map(normalizeNestedValue);
|
|
204
|
+
if (typeof value === "object") {
|
|
205
|
+
const obj = value;
|
|
206
|
+
const result = {};
|
|
207
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
208
|
+
const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase();
|
|
209
|
+
result[snakeKey] = normalizeNestedValue(val);
|
|
210
|
+
}
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
return value;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Format a full ruleset config as tree lines (for create action).
|
|
217
|
+
*/
|
|
218
|
+
function formatFullConfig(ruleset, indent = 2) {
|
|
219
|
+
const lines = [];
|
|
220
|
+
const style = getActionStyle("add");
|
|
221
|
+
function renderValue(key, value, currentIndent) {
|
|
222
|
+
const pad = " ".repeat(currentIndent);
|
|
223
|
+
if (value === null || value === undefined)
|
|
224
|
+
return;
|
|
225
|
+
if (Array.isArray(value)) {
|
|
226
|
+
if (value.length === 0) {
|
|
227
|
+
lines.push(style.color(`${pad}+ ${key}: []`));
|
|
228
|
+
}
|
|
229
|
+
else if (value.every((v) => typeof v !== "object")) {
|
|
230
|
+
lines.push(style.color(`${pad}+ ${key}: ${formatValue(value)}`));
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
lines.push(style.color(`${pad}+ ${key}:`));
|
|
234
|
+
for (const item of value) {
|
|
235
|
+
if (typeof item === "object" && item !== null) {
|
|
236
|
+
lines.push(style.color(`${pad} - ${JSON.stringify(item)}`));
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
lines.push(style.color(`${pad} - ${formatValue(item)}`));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
else if (typeof value === "object") {
|
|
245
|
+
lines.push(style.color(`${pad}+ ${key}:`));
|
|
246
|
+
for (const [k, v] of Object.entries(value)) {
|
|
247
|
+
renderValue(k, v, currentIndent + 1);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
lines.push(style.color(`${pad}+ ${key}: ${formatValue(value)}`));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
for (const [key, value] of Object.entries(ruleset)) {
|
|
255
|
+
renderValue(key, value, indent);
|
|
256
|
+
}
|
|
257
|
+
return lines;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Format ruleset changes as a Terraform-style plan.
|
|
261
|
+
*/
|
|
262
|
+
export function formatRulesetPlan(changes) {
|
|
263
|
+
const lines = [];
|
|
264
|
+
let creates = 0;
|
|
265
|
+
let updates = 0;
|
|
266
|
+
let deletes = 0;
|
|
267
|
+
let unchanged = 0;
|
|
268
|
+
// Group by action type
|
|
269
|
+
const createChanges = changes.filter((c) => c.action === "create");
|
|
270
|
+
const updateChanges = changes.filter((c) => c.action === "update");
|
|
271
|
+
const deleteChanges = changes.filter((c) => c.action === "delete");
|
|
272
|
+
const unchangedChanges = changes.filter((c) => c.action === "unchanged");
|
|
273
|
+
creates = createChanges.length;
|
|
274
|
+
updates = updateChanges.length;
|
|
275
|
+
deletes = deleteChanges.length;
|
|
276
|
+
unchanged = unchangedChanges.length;
|
|
277
|
+
// Format creates
|
|
278
|
+
if (createChanges.length > 0) {
|
|
279
|
+
lines.push(chalk.bold(" Create:"));
|
|
280
|
+
for (const change of createChanges) {
|
|
281
|
+
lines.push(chalk.green(` + ruleset "${change.name}"`));
|
|
282
|
+
if (change.desired) {
|
|
283
|
+
lines.push(...formatFullConfig(change.desired, 2));
|
|
284
|
+
}
|
|
285
|
+
lines.push(""); // Blank line between rulesets
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// Format updates
|
|
289
|
+
if (updateChanges.length > 0) {
|
|
290
|
+
lines.push(chalk.bold(" Update:"));
|
|
291
|
+
for (const change of updateChanges) {
|
|
292
|
+
lines.push(chalk.yellow(` ~ ruleset "${change.name}"`));
|
|
293
|
+
if (change.current && change.desired) {
|
|
294
|
+
const currentNorm = normalizeForDiff(change.current);
|
|
295
|
+
const desiredNorm = normalizeForDiff(change.desired);
|
|
296
|
+
const diffs = computePropertyDiffs(currentNorm, desiredNorm);
|
|
297
|
+
const treeLines = formatPropertyTree(diffs);
|
|
298
|
+
for (const line of treeLines) {
|
|
299
|
+
lines.push(` ${line}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
lines.push(""); // Blank line between rulesets
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// Format deletes
|
|
306
|
+
if (deleteChanges.length > 0) {
|
|
307
|
+
lines.push(chalk.bold(" Delete:"));
|
|
308
|
+
for (const change of deleteChanges) {
|
|
309
|
+
lines.push(chalk.red(` - ruleset "${change.name}"`));
|
|
310
|
+
}
|
|
311
|
+
lines.push(""); // Blank line after deletes
|
|
312
|
+
}
|
|
313
|
+
return { lines, creates, updates, deletes, unchanged };
|
|
314
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { RepoConfig } from "./config.js";
|
|
2
2
|
import type { RepoInfo } from "./repo-detector.js";
|
|
3
3
|
import { GitHubRulesetStrategy } from "./strategies/github-ruleset-strategy.js";
|
|
4
|
+
import { RulesetPlanResult } from "./ruleset-plan-formatter.js";
|
|
4
5
|
export interface IRulesetProcessor {
|
|
5
6
|
process(repoConfig: RepoConfig, repoInfo: RepoInfo, options: RulesetProcessorOptions): Promise<RulesetProcessorResult>;
|
|
6
7
|
}
|
|
@@ -26,6 +27,7 @@ export interface RulesetProcessorResult {
|
|
|
26
27
|
manifestUpdate?: {
|
|
27
28
|
rulesets: string[];
|
|
28
29
|
};
|
|
30
|
+
planOutput?: RulesetPlanResult;
|
|
29
31
|
}
|
|
30
32
|
/**
|
|
31
33
|
* Processes ruleset configuration for a repository.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isGitHubRepo, getRepoDisplayName } from "./repo-detector.js";
|
|
2
2
|
import { GitHubRulesetStrategy } from "./strategies/github-ruleset-strategy.js";
|
|
3
3
|
import { diffRulesets } from "./ruleset-diff.js";
|
|
4
|
+
import { formatRulesetPlan, } from "./ruleset-plan-formatter.js";
|
|
4
5
|
// =============================================================================
|
|
5
6
|
// Processor Implementation
|
|
6
7
|
// =============================================================================
|
|
@@ -60,12 +61,14 @@ export class RulesetProcessor {
|
|
|
60
61
|
// Dry run mode - report planned changes without applying
|
|
61
62
|
if (dryRun) {
|
|
62
63
|
const summary = this.formatChangeSummary(changeCounts);
|
|
64
|
+
const planOutput = formatRulesetPlan(changes);
|
|
63
65
|
return {
|
|
64
66
|
success: true,
|
|
65
67
|
repoName,
|
|
66
68
|
message: `[DRY RUN] ${summary}`,
|
|
67
69
|
dryRun: true,
|
|
68
70
|
changes: changeCounts,
|
|
71
|
+
planOutput,
|
|
69
72
|
manifestUpdate: this.computeManifestUpdate(desiredRulesets, deleteOrphaned),
|
|
70
73
|
};
|
|
71
74
|
}
|
|
@@ -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;
|