@aspruyt/xfg 3.5.0 → 3.5.2
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.d.ts +5 -0
- package/dist/config.js +16 -0
- package/dist/github-summary.d.ts +18 -0
- package/dist/github-summary.js +126 -15
- package/dist/index.js +20 -0
- package/dist/repo-settings-plan-formatter.d.ts +5 -0
- package/dist/repo-settings-plan-formatter.js +5 -2
- package/dist/repo-settings-processor.js +1 -0
- package/dist/ruleset-diff.d.ts +7 -1
- package/dist/ruleset-diff.js +82 -7
- package/dist/ruleset-plan-formatter.d.ts +12 -1
- package/dist/ruleset-plan-formatter.js +209 -17
- package/dist/ruleset-processor.js +2 -1
- package/package.json +1 -1
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
|
|
package/dist/config.d.ts
CHANGED
|
@@ -210,6 +210,11 @@ export interface Ruleset {
|
|
|
210
210
|
/** Rules to enforce */
|
|
211
211
|
rules?: RulesetRule[];
|
|
212
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* Set of snake_case field names that are comparable between config and API.
|
|
215
|
+
* Used as an allowlist — any API response field not in this set is ignored.
|
|
216
|
+
*/
|
|
217
|
+
export declare const RULESET_COMPARABLE_FIELDS: Set<string>;
|
|
213
218
|
/** Squash merge commit title format */
|
|
214
219
|
export type SquashMergeCommitTitle = "PR_TITLE" | "COMMIT_OR_PR_TITLE";
|
|
215
220
|
/** Squash merge commit message format */
|
package/dist/config.js
CHANGED
|
@@ -7,6 +7,22 @@ export { normalizeConfigInternal as normalizeConfig };
|
|
|
7
7
|
import { resolveFileReferencesInConfig } from "./file-reference-resolver.js";
|
|
8
8
|
// Re-export formatter functions for backwards compatibility
|
|
9
9
|
export { convertContentToString } from "./config-formatter.js";
|
|
10
|
+
/**
|
|
11
|
+
* Maps Ruleset config keys (camelCase) to GitHub API keys (snake_case).
|
|
12
|
+
* TypeScript enforces this stays in sync with the Ruleset interface.
|
|
13
|
+
*/
|
|
14
|
+
const RULESET_FIELD_MAP = {
|
|
15
|
+
target: "target",
|
|
16
|
+
enforcement: "enforcement",
|
|
17
|
+
bypassActors: "bypass_actors",
|
|
18
|
+
conditions: "conditions",
|
|
19
|
+
rules: "rules",
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Set of snake_case field names that are comparable between config and API.
|
|
23
|
+
* Used as an allowlist — any API response field not in this set is ignored.
|
|
24
|
+
*/
|
|
25
|
+
export const RULESET_COMPARABLE_FIELDS = new Set(Object.values(RULESET_FIELD_MAP));
|
|
10
26
|
// =============================================================================
|
|
11
27
|
// Public API
|
|
12
28
|
// =============================================================================
|
package/dist/github-summary.d.ts
CHANGED
|
@@ -5,6 +5,20 @@ export interface FileChanges {
|
|
|
5
5
|
deleted: number;
|
|
6
6
|
unchanged: number;
|
|
7
7
|
}
|
|
8
|
+
export interface RulesetPlanDetail {
|
|
9
|
+
name: string;
|
|
10
|
+
action: "create" | "update" | "delete" | "unchanged";
|
|
11
|
+
propertyCount?: number;
|
|
12
|
+
propertyChanges?: {
|
|
13
|
+
added: number;
|
|
14
|
+
changed: number;
|
|
15
|
+
removed: number;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export interface RepoSettingsPlanDetail {
|
|
19
|
+
property: string;
|
|
20
|
+
action: "add" | "change";
|
|
21
|
+
}
|
|
8
22
|
export interface RepoResult {
|
|
9
23
|
repoName: string;
|
|
10
24
|
status: "succeeded" | "skipped" | "failed";
|
|
@@ -12,8 +26,12 @@ export interface RepoResult {
|
|
|
12
26
|
prUrl?: string;
|
|
13
27
|
mergeOutcome?: MergeOutcome;
|
|
14
28
|
fileChanges?: FileChanges;
|
|
29
|
+
rulesetPlanDetails?: RulesetPlanDetail[];
|
|
30
|
+
repoSettingsPlanDetails?: RepoSettingsPlanDetail[];
|
|
15
31
|
}
|
|
16
32
|
export interface SummaryData {
|
|
33
|
+
title: string;
|
|
34
|
+
dryRun?: boolean;
|
|
17
35
|
total: number;
|
|
18
36
|
succeeded: number;
|
|
19
37
|
skipped: number;
|
package/dist/github-summary.js
CHANGED
|
@@ -8,23 +8,38 @@ function formatFileChanges(changes) {
|
|
|
8
8
|
return "-";
|
|
9
9
|
return `+${changes.added} ~${changes.modified} -${changes.deleted}`;
|
|
10
10
|
}
|
|
11
|
-
function
|
|
11
|
+
function formatChangesColumn(result) {
|
|
12
|
+
if (result.fileChanges) {
|
|
13
|
+
return formatFileChanges(result.fileChanges);
|
|
14
|
+
}
|
|
15
|
+
// For settings results, derive changes from plan details
|
|
16
|
+
const parts = [];
|
|
17
|
+
if (result.rulesetPlanDetails && result.rulesetPlanDetails.length > 0) {
|
|
18
|
+
parts.push(formatRulesetPlanSummary(result.rulesetPlanDetails));
|
|
19
|
+
}
|
|
20
|
+
if (result.repoSettingsPlanDetails &&
|
|
21
|
+
result.repoSettingsPlanDetails.length > 0) {
|
|
22
|
+
parts.push(formatSettingsPlanSummary(result.repoSettingsPlanDetails));
|
|
23
|
+
}
|
|
24
|
+
return parts.length > 0 ? parts.join("; ") : "-";
|
|
25
|
+
}
|
|
26
|
+
function formatStatus(result, dryRun) {
|
|
12
27
|
if (result.status === "skipped")
|
|
13
|
-
return "⏭️ Skipped";
|
|
28
|
+
return dryRun ? "⏭️ Would Skip" : "⏭️ Skipped";
|
|
14
29
|
if (result.status === "failed")
|
|
15
|
-
return "❌ Failed";
|
|
30
|
+
return dryRun ? "❌ Would Fail" : "❌ Failed";
|
|
16
31
|
// Succeeded - format based on merge outcome
|
|
17
32
|
switch (result.mergeOutcome) {
|
|
18
33
|
case "manual":
|
|
19
|
-
return "✅ Open";
|
|
34
|
+
return dryRun ? "✅ Would Open" : "✅ Open";
|
|
20
35
|
case "auto":
|
|
21
|
-
return "✅ Auto-merge";
|
|
36
|
+
return dryRun ? "✅ Would Auto-merge" : "✅ Auto-merge";
|
|
22
37
|
case "force":
|
|
23
|
-
return "✅ Merged";
|
|
38
|
+
return dryRun ? "✅ Would Merge" : "✅ Merged";
|
|
24
39
|
case "direct":
|
|
25
|
-
return "✅ Pushed";
|
|
40
|
+
return dryRun ? "✅ Would Push" : "✅ Pushed";
|
|
26
41
|
default:
|
|
27
|
-
return "✅ Succeeded";
|
|
42
|
+
return dryRun ? "✅ Would Succeed" : "✅ Succeeded";
|
|
28
43
|
}
|
|
29
44
|
}
|
|
30
45
|
function formatResult(result) {
|
|
@@ -39,17 +54,83 @@ function formatResult(result) {
|
|
|
39
54
|
}
|
|
40
55
|
return escapeMarkdown(result.message);
|
|
41
56
|
}
|
|
57
|
+
function formatRulesetAction(action) {
|
|
58
|
+
switch (action) {
|
|
59
|
+
case "create":
|
|
60
|
+
return "+ Create";
|
|
61
|
+
case "update":
|
|
62
|
+
return "~ Update";
|
|
63
|
+
case "delete":
|
|
64
|
+
return "- Delete";
|
|
65
|
+
case "unchanged":
|
|
66
|
+
return "= Unchanged";
|
|
67
|
+
default:
|
|
68
|
+
return action;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function formatRulesetProperties(detail) {
|
|
72
|
+
if (detail.propertyChanges) {
|
|
73
|
+
return `+${detail.propertyChanges.added} ~${detail.propertyChanges.changed} -${detail.propertyChanges.removed}`;
|
|
74
|
+
}
|
|
75
|
+
if (detail.propertyCount !== undefined) {
|
|
76
|
+
return `${detail.propertyCount} properties`;
|
|
77
|
+
}
|
|
78
|
+
return "-";
|
|
79
|
+
}
|
|
80
|
+
function formatRulesetPlanSummary(details) {
|
|
81
|
+
const creates = details.filter((d) => d.action === "create").length;
|
|
82
|
+
const updates = details.filter((d) => d.action === "update").length;
|
|
83
|
+
const deletes = details.filter((d) => d.action === "delete").length;
|
|
84
|
+
const parts = [];
|
|
85
|
+
if (creates > 0)
|
|
86
|
+
parts.push(`${creates} to create`);
|
|
87
|
+
if (updates > 0)
|
|
88
|
+
parts.push(`${updates} to update`);
|
|
89
|
+
if (deletes > 0)
|
|
90
|
+
parts.push(`${deletes} to delete`);
|
|
91
|
+
return parts.join(", ") || "no changes";
|
|
92
|
+
}
|
|
93
|
+
function formatSettingsAction(action) {
|
|
94
|
+
switch (action) {
|
|
95
|
+
case "add":
|
|
96
|
+
return "+ Add";
|
|
97
|
+
case "change":
|
|
98
|
+
return "~ Change";
|
|
99
|
+
default:
|
|
100
|
+
return action;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function formatSettingsPlanSummary(details) {
|
|
104
|
+
const adds = details.filter((d) => d.action === "add").length;
|
|
105
|
+
const changes = details.filter((d) => d.action === "change").length;
|
|
106
|
+
const parts = [];
|
|
107
|
+
if (adds > 0)
|
|
108
|
+
parts.push(`${adds} to add`);
|
|
109
|
+
if (changes > 0)
|
|
110
|
+
parts.push(`${changes} to change`);
|
|
111
|
+
return parts.join(", ") || "no changes";
|
|
112
|
+
}
|
|
42
113
|
export function formatSummary(data) {
|
|
43
114
|
const lines = [];
|
|
44
115
|
// Header
|
|
45
|
-
|
|
116
|
+
const titleSuffix = data.dryRun ? " (Dry Run)" : "";
|
|
117
|
+
lines.push(`## ${data.title}${titleSuffix}`);
|
|
46
118
|
lines.push("");
|
|
119
|
+
// Dry-run warning banner
|
|
120
|
+
if (data.dryRun) {
|
|
121
|
+
lines.push("> [!WARNING]");
|
|
122
|
+
lines.push("> This was a dry run — no changes were applied");
|
|
123
|
+
lines.push("");
|
|
124
|
+
}
|
|
47
125
|
// Stats table
|
|
126
|
+
const succeededLabel = data.dryRun ? "✅ Would Succeed" : "✅ Succeeded";
|
|
127
|
+
const skippedLabel = data.dryRun ? "⏭️ Would Skip" : "⏭️ Skipped";
|
|
128
|
+
const failedLabel = data.dryRun ? "❌ Would Fail" : "❌ Failed";
|
|
48
129
|
lines.push("| Status | Count |");
|
|
49
130
|
lines.push("|--------|-------|");
|
|
50
|
-
lines.push(`|
|
|
51
|
-
lines.push(`|
|
|
52
|
-
lines.push(`|
|
|
131
|
+
lines.push(`| ${succeededLabel} | ${data.succeeded} |`);
|
|
132
|
+
lines.push(`| ${skippedLabel} | ${data.skipped} |`);
|
|
133
|
+
lines.push(`| ${failedLabel} | ${data.failed} |`);
|
|
53
134
|
lines.push(`| **Total** | **${data.total}** |`);
|
|
54
135
|
// Repo details table (only if there are results)
|
|
55
136
|
if (data.results.length > 0) {
|
|
@@ -61,11 +142,41 @@ export function formatSummary(data) {
|
|
|
61
142
|
lines.push("|------------|--------|---------|--------|");
|
|
62
143
|
for (const result of data.results) {
|
|
63
144
|
const repo = result.repoName;
|
|
64
|
-
const status = formatStatus(result);
|
|
65
|
-
const changes =
|
|
145
|
+
const status = formatStatus(result, data.dryRun);
|
|
146
|
+
const changes = formatChangesColumn(result);
|
|
66
147
|
const resultText = formatResult(result);
|
|
67
148
|
lines.push(`| ${repo} | ${status} | ${changes} | ${resultText} |`);
|
|
68
149
|
}
|
|
150
|
+
// Plan details nested sections
|
|
151
|
+
for (const result of data.results) {
|
|
152
|
+
if (result.rulesetPlanDetails && result.rulesetPlanDetails.length > 0) {
|
|
153
|
+
lines.push("");
|
|
154
|
+
lines.push("<details>");
|
|
155
|
+
lines.push(`<summary>${result.repoName} — Rulesets: ${formatRulesetPlanSummary(result.rulesetPlanDetails)}</summary>`);
|
|
156
|
+
lines.push("");
|
|
157
|
+
lines.push("| Ruleset | Action | Properties |");
|
|
158
|
+
lines.push("|---------|--------|------------|");
|
|
159
|
+
for (const detail of result.rulesetPlanDetails) {
|
|
160
|
+
lines.push(`| ${detail.name} | ${formatRulesetAction(detail.action)} | ${formatRulesetProperties(detail)} |`);
|
|
161
|
+
}
|
|
162
|
+
lines.push("");
|
|
163
|
+
lines.push("</details>");
|
|
164
|
+
}
|
|
165
|
+
if (result.repoSettingsPlanDetails &&
|
|
166
|
+
result.repoSettingsPlanDetails.length > 0) {
|
|
167
|
+
lines.push("");
|
|
168
|
+
lines.push("<details>");
|
|
169
|
+
lines.push(`<summary>${result.repoName} — Repo Settings: ${formatSettingsPlanSummary(result.repoSettingsPlanDetails)}</summary>`);
|
|
170
|
+
lines.push("");
|
|
171
|
+
lines.push("| Setting | Action |");
|
|
172
|
+
lines.push("|---------|--------|");
|
|
173
|
+
for (const detail of result.repoSettingsPlanDetails) {
|
|
174
|
+
lines.push(`| ${detail.property} | ${formatSettingsAction(detail.action)} |`);
|
|
175
|
+
}
|
|
176
|
+
lines.push("");
|
|
177
|
+
lines.push("</details>");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
69
180
|
lines.push("");
|
|
70
181
|
lines.push("</details>");
|
|
71
182
|
}
|
|
@@ -79,5 +190,5 @@ export function writeSummary(data) {
|
|
|
79
190
|
if (!summaryPath)
|
|
80
191
|
return;
|
|
81
192
|
const markdown = formatSummary(data);
|
|
82
|
-
appendFileSync(summaryPath, markdown + "\n");
|
|
193
|
+
appendFileSync(summaryPath, "\n" + markdown + "\n");
|
|
83
194
|
}
|
package/dist/index.js
CHANGED
|
@@ -174,6 +174,8 @@ export async function runSync(options, processorFactory = defaultProcessorFactor
|
|
|
174
174
|
const skipped = results.filter((r) => r.status === "skipped").length;
|
|
175
175
|
const failed = results.filter((r) => r.status === "failed").length;
|
|
176
176
|
writeSummary({
|
|
177
|
+
title: "Config Sync Summary",
|
|
178
|
+
dryRun: options.dryRun,
|
|
177
179
|
total: config.repos.length,
|
|
178
180
|
succeeded,
|
|
179
181
|
skipped,
|
|
@@ -324,6 +326,7 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
|
|
|
324
326
|
? "succeeded"
|
|
325
327
|
: "failed",
|
|
326
328
|
message: result.message,
|
|
329
|
+
rulesetPlanDetails: result.planOutput?.entries,
|
|
327
330
|
});
|
|
328
331
|
}
|
|
329
332
|
catch (error) {
|
|
@@ -377,6 +380,21 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
|
|
|
377
380
|
console.log(chalk.red(` ✗ ${repoName}: ${result.message}`));
|
|
378
381
|
failCount++;
|
|
379
382
|
}
|
|
383
|
+
// Merge repo settings plan details into existing result or push new
|
|
384
|
+
if (!result.skipped) {
|
|
385
|
+
const existing = results.find((r) => r.repoName === repoName);
|
|
386
|
+
if (existing) {
|
|
387
|
+
existing.repoSettingsPlanDetails = result.planOutput?.entries;
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
results.push({
|
|
391
|
+
repoName,
|
|
392
|
+
status: result.success ? "succeeded" : "failed",
|
|
393
|
+
message: result.message,
|
|
394
|
+
repoSettingsPlanDetails: result.planOutput?.entries,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
380
398
|
}
|
|
381
399
|
catch (error) {
|
|
382
400
|
console.error(` ✗ ${repoName}: ${error}`);
|
|
@@ -402,6 +420,8 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
|
|
|
402
420
|
console.log(`Completed: ${successCount} succeeded, ${skipCount} skipped, ${failCount} failed`);
|
|
403
421
|
// Write GitHub Actions job summary if available
|
|
404
422
|
writeSummary({
|
|
423
|
+
title: "Repository Settings Summary",
|
|
424
|
+
dryRun: options.dryRun,
|
|
405
425
|
total: reposWithRulesets.length + reposWithRepoSettings.length,
|
|
406
426
|
succeeded: successCount,
|
|
407
427
|
skipped: skipCount,
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import type { RepoSettingsChange } from "./repo-settings-diff.js";
|
|
2
|
+
export interface RepoSettingsPlanEntry {
|
|
3
|
+
property: string;
|
|
4
|
+
action: "add" | "change";
|
|
5
|
+
}
|
|
2
6
|
export interface RepoSettingsPlanResult {
|
|
3
7
|
lines: string[];
|
|
4
8
|
adds: number;
|
|
5
9
|
changes: number;
|
|
6
10
|
warnings: string[];
|
|
11
|
+
entries: RepoSettingsPlanEntry[];
|
|
7
12
|
}
|
|
8
13
|
/**
|
|
9
14
|
* Formats repo settings changes as Terraform-style plan output.
|
|
@@ -39,8 +39,9 @@ export function formatRepoSettingsPlan(changes) {
|
|
|
39
39
|
const warnings = [];
|
|
40
40
|
let adds = 0;
|
|
41
41
|
let changesCount = 0;
|
|
42
|
+
const entries = [];
|
|
42
43
|
if (changes.length === 0) {
|
|
43
|
-
return { lines, adds, changes: 0, warnings };
|
|
44
|
+
return { lines, adds, changes: 0, warnings, entries };
|
|
44
45
|
}
|
|
45
46
|
for (const change of changes) {
|
|
46
47
|
const warning = getWarning(change);
|
|
@@ -50,13 +51,15 @@ export function formatRepoSettingsPlan(changes) {
|
|
|
50
51
|
if (change.action === "add") {
|
|
51
52
|
lines.push(chalk.green(` + ${change.property}: ${formatValue(change.newValue)}`));
|
|
52
53
|
adds++;
|
|
54
|
+
entries.push({ property: change.property, action: "add" });
|
|
53
55
|
}
|
|
54
56
|
else if (change.action === "change") {
|
|
55
57
|
lines.push(chalk.yellow(` ~ ${change.property}: ${formatValue(change.oldValue)} → ${formatValue(change.newValue)}`));
|
|
56
58
|
changesCount++;
|
|
59
|
+
entries.push({ property: change.property, action: "change" });
|
|
57
60
|
}
|
|
58
61
|
}
|
|
59
|
-
return { lines, adds, changes: changesCount, warnings };
|
|
62
|
+
return { lines, adds, changes: changesCount, warnings, entries };
|
|
60
63
|
}
|
|
61
64
|
/**
|
|
62
65
|
* Formats warnings for display.
|
|
@@ -66,6 +66,7 @@ export class RepoSettingsProcessor {
|
|
|
66
66
|
message: `Applied: ${planOutput.adds} added, ${planOutput.changes} changed`,
|
|
67
67
|
changes: { adds: planOutput.adds, changes: planOutput.changes },
|
|
68
68
|
warnings: planOutput.warnings,
|
|
69
|
+
planOutput,
|
|
69
70
|
};
|
|
70
71
|
}
|
|
71
72
|
catch (error) {
|
package/dist/ruleset-diff.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Ruleset } from "./config.js";
|
|
2
2
|
import type { GitHubRuleset } from "./strategies/github-ruleset-strategy.js";
|
|
3
3
|
export type RulesetAction = "create" | "update" | "delete" | "unchanged";
|
|
4
4
|
export interface RulesetChange {
|
|
@@ -8,6 +8,12 @@ export interface RulesetChange {
|
|
|
8
8
|
current?: GitHubRuleset;
|
|
9
9
|
desired?: Ruleset;
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Projects `current` onto the shape of `desired`.
|
|
13
|
+
* Only keeps keys/structure present in `desired`, filtering out API noise.
|
|
14
|
+
* For arrays of objects, matches items by `type` field if present, else by index.
|
|
15
|
+
*/
|
|
16
|
+
export declare function projectToDesiredShape(current: unknown, desired: unknown): unknown;
|
|
11
17
|
/**
|
|
12
18
|
* Compares current rulesets (from GitHub) with desired rulesets (from config).
|
|
13
19
|
*
|
package/dist/ruleset-diff.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RULESET_COMPARABLE_FIELDS } from "./config.js";
|
|
1
2
|
// =============================================================================
|
|
2
3
|
// Normalization (for comparison)
|
|
3
4
|
// =============================================================================
|
|
@@ -28,18 +29,13 @@ function normalizeValue(value) {
|
|
|
28
29
|
}
|
|
29
30
|
return value;
|
|
30
31
|
}
|
|
31
|
-
/**
|
|
32
|
-
* Fields to ignore when comparing rulesets (API-only metadata).
|
|
33
|
-
* The "name" field is compared via map key, not content.
|
|
34
|
-
*/
|
|
35
|
-
const IGNORE_FIELDS = new Set(["id", "name", "source_type", "source"]);
|
|
36
32
|
/**
|
|
37
33
|
* Normalizes a GitHub ruleset for comparison.
|
|
38
34
|
*/
|
|
39
35
|
function normalizeGitHubRuleset(ruleset) {
|
|
40
36
|
const normalized = {};
|
|
41
37
|
for (const [key, value] of Object.entries(ruleset)) {
|
|
42
|
-
if (
|
|
38
|
+
if (!RULESET_COMPARABLE_FIELDS.has(key) || value === undefined) {
|
|
43
39
|
continue;
|
|
44
40
|
}
|
|
45
41
|
normalized[key] = normalizeValue(value);
|
|
@@ -97,6 +93,84 @@ function deepEqual(a, b) {
|
|
|
97
93
|
return false;
|
|
98
94
|
}
|
|
99
95
|
// =============================================================================
|
|
96
|
+
// Desired-Side Projection
|
|
97
|
+
// =============================================================================
|
|
98
|
+
/**
|
|
99
|
+
* Projects `current` onto the shape of `desired`.
|
|
100
|
+
* Only keeps keys/structure present in `desired`, filtering out API noise.
|
|
101
|
+
* For arrays of objects, matches items by `type` field if present, else by index.
|
|
102
|
+
*/
|
|
103
|
+
export function projectToDesiredShape(current, desired) {
|
|
104
|
+
// Both must be same general type to project
|
|
105
|
+
if (desired === null || desired === undefined)
|
|
106
|
+
return desired;
|
|
107
|
+
if (current === null || current === undefined)
|
|
108
|
+
return current;
|
|
109
|
+
// Arrays
|
|
110
|
+
if (Array.isArray(desired) && Array.isArray(current)) {
|
|
111
|
+
return projectArrays(current, desired);
|
|
112
|
+
}
|
|
113
|
+
// Objects
|
|
114
|
+
if (isPlainObject(desired) && isPlainObject(current)) {
|
|
115
|
+
return projectObjects(current, desired);
|
|
116
|
+
}
|
|
117
|
+
// Scalars — return current as-is
|
|
118
|
+
return current;
|
|
119
|
+
}
|
|
120
|
+
function isPlainObject(val) {
|
|
121
|
+
return val !== null && typeof val === "object" && !Array.isArray(val);
|
|
122
|
+
}
|
|
123
|
+
function projectObjects(current, desired) {
|
|
124
|
+
const result = {};
|
|
125
|
+
for (const key of Object.keys(desired)) {
|
|
126
|
+
if (key in current) {
|
|
127
|
+
result[key] = projectToDesiredShape(current[key], desired[key]);
|
|
128
|
+
}
|
|
129
|
+
// If key not in current, skip — diff will handle it as an addition
|
|
130
|
+
}
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
function projectArrays(current, desired) {
|
|
134
|
+
// Primitive arrays — return current as-is
|
|
135
|
+
if (desired.length === 0 || !isPlainObject(desired[0])) {
|
|
136
|
+
return current;
|
|
137
|
+
}
|
|
138
|
+
// Arrays of objects — match by `type` field if available
|
|
139
|
+
const hasType = desired.every((item) => isPlainObject(item) && "type" in item);
|
|
140
|
+
if (hasType) {
|
|
141
|
+
return matchByType(current, desired);
|
|
142
|
+
}
|
|
143
|
+
// Fallback: match by index
|
|
144
|
+
return matchByIndex(current, desired);
|
|
145
|
+
}
|
|
146
|
+
function matchByType(current, desired) {
|
|
147
|
+
const currentByType = new Map();
|
|
148
|
+
for (const item of current) {
|
|
149
|
+
if (isPlainObject(item)) {
|
|
150
|
+
const type = item.type;
|
|
151
|
+
if (type)
|
|
152
|
+
currentByType.set(type, item);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const result = [];
|
|
156
|
+
for (const desiredItem of desired) {
|
|
157
|
+
const type = desiredItem.type;
|
|
158
|
+
const currentItem = currentByType.get(type);
|
|
159
|
+
if (currentItem) {
|
|
160
|
+
result.push(projectToDesiredShape(currentItem, desiredItem));
|
|
161
|
+
}
|
|
162
|
+
// If no match in current, skip — diff handles additions
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
function matchByIndex(current, desired) {
|
|
167
|
+
const result = [];
|
|
168
|
+
for (let i = 0; i < Math.min(current.length, desired.length); i++) {
|
|
169
|
+
result.push(projectToDesiredShape(current[i], desired[i]));
|
|
170
|
+
}
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
// =============================================================================
|
|
100
174
|
// Diff Algorithm
|
|
101
175
|
// =============================================================================
|
|
102
176
|
/**
|
|
@@ -126,7 +200,8 @@ export function diffRulesets(current, desired, managedNames) {
|
|
|
126
200
|
// Existing ruleset - check if changed
|
|
127
201
|
const normalizedCurrent = normalizeGitHubRuleset(currentRuleset);
|
|
128
202
|
const normalizedDesired = normalizeConfigRuleset(desiredRuleset);
|
|
129
|
-
|
|
203
|
+
const projectedCurrent = projectToDesiredShape(normalizedCurrent, normalizedDesired);
|
|
204
|
+
if (deepEqual(projectedCurrent, normalizedDesired)) {
|
|
130
205
|
changes.push({
|
|
131
206
|
action: "unchanged",
|
|
132
207
|
name,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type RulesetChange, type RulesetAction } from "./ruleset-diff.js";
|
|
2
2
|
export type DiffAction = "add" | "change" | "remove";
|
|
3
3
|
export interface PropertyDiff {
|
|
4
4
|
path: string[];
|
|
@@ -6,12 +6,23 @@ export interface PropertyDiff {
|
|
|
6
6
|
oldValue?: unknown;
|
|
7
7
|
newValue?: unknown;
|
|
8
8
|
}
|
|
9
|
+
export interface RulesetPlanEntry {
|
|
10
|
+
name: string;
|
|
11
|
+
action: RulesetAction;
|
|
12
|
+
propertyCount?: number;
|
|
13
|
+
propertyChanges?: {
|
|
14
|
+
added: number;
|
|
15
|
+
changed: number;
|
|
16
|
+
removed: number;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
9
19
|
export interface RulesetPlanResult {
|
|
10
20
|
lines: string[];
|
|
11
21
|
creates: number;
|
|
12
22
|
updates: number;
|
|
13
23
|
deletes: number;
|
|
14
24
|
unchanged: number;
|
|
25
|
+
entries: RulesetPlanEntry[];
|
|
15
26
|
}
|
|
16
27
|
/**
|
|
17
28
|
* Recursively compute property-level diffs between two objects.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// src/ruleset-plan-formatter.ts
|
|
2
2
|
import chalk from "chalk";
|
|
3
|
+
import { projectToDesiredShape, } from "./ruleset-diff.js";
|
|
4
|
+
import { RULESET_COMPARABLE_FIELDS } from "./config.js";
|
|
3
5
|
// =============================================================================
|
|
4
6
|
// Property Diff Algorithm
|
|
5
7
|
// =============================================================================
|
|
@@ -27,6 +29,13 @@ export function computePropertyDiffs(current, desired, parentPath = []) {
|
|
|
27
29
|
// Recurse into nested objects
|
|
28
30
|
diffs.push(...computePropertyDiffs(currentVal, desiredVal, path));
|
|
29
31
|
}
|
|
32
|
+
else if (Array.isArray(currentVal) &&
|
|
33
|
+
Array.isArray(desiredVal) &&
|
|
34
|
+
isArrayOfObjects(currentVal) &&
|
|
35
|
+
isArrayOfObjects(desiredVal)) {
|
|
36
|
+
// Recurse into arrays of objects
|
|
37
|
+
diffs.push(...diffObjectArrays(currentVal, desiredVal, path));
|
|
38
|
+
}
|
|
30
39
|
else {
|
|
31
40
|
diffs.push({
|
|
32
41
|
path,
|
|
@@ -67,6 +76,94 @@ function deepEqual(a, b) {
|
|
|
67
76
|
}
|
|
68
77
|
return false;
|
|
69
78
|
}
|
|
79
|
+
function isArrayOfObjects(arr) {
|
|
80
|
+
return arr.length > 0 && arr.every((item) => isObject(item));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Diff two arrays of objects by matching items on `type` field (or by index).
|
|
84
|
+
*/
|
|
85
|
+
function diffObjectArrays(currentArr, desiredArr, parentPath) {
|
|
86
|
+
const diffs = [];
|
|
87
|
+
const hasType = desiredArr.every((item) => isObject(item) && "type" in item);
|
|
88
|
+
if (hasType) {
|
|
89
|
+
// Match by type field
|
|
90
|
+
const currentByType = new Map();
|
|
91
|
+
for (let i = 0; i < currentArr.length; i++) {
|
|
92
|
+
const item = currentArr[i];
|
|
93
|
+
const type = item.type;
|
|
94
|
+
if (type)
|
|
95
|
+
currentByType.set(type, { item, index: i });
|
|
96
|
+
}
|
|
97
|
+
const matchedTypes = new Set();
|
|
98
|
+
for (let i = 0; i < desiredArr.length; i++) {
|
|
99
|
+
const desiredItem = desiredArr[i];
|
|
100
|
+
const type = desiredItem.type;
|
|
101
|
+
const label = `[${i}] (${type})`;
|
|
102
|
+
const currentEntry = currentByType.get(type);
|
|
103
|
+
if (currentEntry) {
|
|
104
|
+
matchedTypes.add(type);
|
|
105
|
+
// Recurse into matched pair
|
|
106
|
+
const itemDiffs = computePropertyDiffs(currentEntry.item, desiredItem, [
|
|
107
|
+
...parentPath,
|
|
108
|
+
label,
|
|
109
|
+
]);
|
|
110
|
+
diffs.push(...itemDiffs);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
// New item in desired
|
|
114
|
+
diffs.push({
|
|
115
|
+
path: [...parentPath, label],
|
|
116
|
+
action: "add",
|
|
117
|
+
newValue: desiredItem,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Items in current but not in desired
|
|
122
|
+
for (const [type, entry] of currentByType) {
|
|
123
|
+
if (!matchedTypes.has(type)) {
|
|
124
|
+
diffs.push({
|
|
125
|
+
path: [...parentPath, `[${entry.index}] (${type})`],
|
|
126
|
+
action: "remove",
|
|
127
|
+
oldValue: entry.item,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
// Fallback: match by index
|
|
134
|
+
const maxLen = Math.max(currentArr.length, desiredArr.length);
|
|
135
|
+
for (let i = 0; i < maxLen; i++) {
|
|
136
|
+
const label = `[${i}]`;
|
|
137
|
+
if (i >= currentArr.length) {
|
|
138
|
+
diffs.push({
|
|
139
|
+
path: [...parentPath, label],
|
|
140
|
+
action: "add",
|
|
141
|
+
newValue: desiredArr[i],
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
else if (i >= desiredArr.length) {
|
|
145
|
+
diffs.push({
|
|
146
|
+
path: [...parentPath, label],
|
|
147
|
+
action: "remove",
|
|
148
|
+
oldValue: currentArr[i],
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
else if (isObject(currentArr[i]) && isObject(desiredArr[i])) {
|
|
152
|
+
const itemDiffs = computePropertyDiffs(currentArr[i], desiredArr[i], [...parentPath, label]);
|
|
153
|
+
diffs.push(...itemDiffs);
|
|
154
|
+
}
|
|
155
|
+
else if (!deepEqual(currentArr[i], desiredArr[i])) {
|
|
156
|
+
diffs.push({
|
|
157
|
+
path: [...parentPath, label],
|
|
158
|
+
action: "change",
|
|
159
|
+
oldValue: currentArr[i],
|
|
160
|
+
newValue: desiredArr[i],
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return diffs;
|
|
166
|
+
}
|
|
70
167
|
/**
|
|
71
168
|
* Build a tree structure from flat property diffs.
|
|
72
169
|
*/
|
|
@@ -101,7 +198,7 @@ function buildTree(diffs) {
|
|
|
101
198
|
return root;
|
|
102
199
|
}
|
|
103
200
|
/**
|
|
104
|
-
* Format a value for display.
|
|
201
|
+
* Format a value for inline display (scalars and simple arrays only).
|
|
105
202
|
*/
|
|
106
203
|
function formatValue(val) {
|
|
107
204
|
if (val === null)
|
|
@@ -111,16 +208,65 @@ function formatValue(val) {
|
|
|
111
208
|
if (typeof val === "string")
|
|
112
209
|
return `"${val}"`;
|
|
113
210
|
if (Array.isArray(val)) {
|
|
114
|
-
if (val.
|
|
211
|
+
if (val.every((v) => typeof v !== "object" || v === null)) {
|
|
115
212
|
return `[${val.map(formatValue).join(", ")}]`;
|
|
116
213
|
}
|
|
117
|
-
|
|
214
|
+
// Arrays of objects are rendered by renderNestedValue
|
|
215
|
+
return `[${val.length} items]`;
|
|
118
216
|
}
|
|
119
217
|
if (typeof val === "object") {
|
|
120
|
-
|
|
218
|
+
// Objects are rendered by renderNestedValue
|
|
219
|
+
return `{${Object.keys(val).length} properties}`;
|
|
121
220
|
}
|
|
122
221
|
return String(val);
|
|
123
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Render a nested value (object or array) as indented tree lines.
|
|
225
|
+
*/
|
|
226
|
+
function renderNestedValue(val, action, indent) {
|
|
227
|
+
const lines = [];
|
|
228
|
+
const style = getActionStyle(action);
|
|
229
|
+
const indentStr = " ".repeat(indent);
|
|
230
|
+
if (Array.isArray(val)) {
|
|
231
|
+
for (let i = 0; i < val.length; i++) {
|
|
232
|
+
const item = val[i];
|
|
233
|
+
if (isObject(item)) {
|
|
234
|
+
const obj = item;
|
|
235
|
+
const typeLabel = "type" in obj ? ` (${obj.type})` : "";
|
|
236
|
+
lines.push(style.color(`${indentStr}${style.symbol} [${i}]${typeLabel}:`));
|
|
237
|
+
lines.push(...renderNestedObject(obj, action, indent + 1));
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
lines.push(style.color(`${indentStr}${style.symbol} [${i}]: ${formatValue(item)}`));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
else if (isObject(val)) {
|
|
245
|
+
lines.push(...renderNestedObject(val, action, indent));
|
|
246
|
+
}
|
|
247
|
+
return lines;
|
|
248
|
+
}
|
|
249
|
+
function renderNestedObject(obj, action, indent) {
|
|
250
|
+
const lines = [];
|
|
251
|
+
const style = getActionStyle(action);
|
|
252
|
+
const indentStr = " ".repeat(indent);
|
|
253
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
254
|
+
if (value === null || value === undefined)
|
|
255
|
+
continue;
|
|
256
|
+
if (Array.isArray(value) && value.some((v) => isObject(v))) {
|
|
257
|
+
lines.push(style.color(`${indentStr}${style.symbol} ${key}:`));
|
|
258
|
+
lines.push(...renderNestedValue(value, action, indent + 1));
|
|
259
|
+
}
|
|
260
|
+
else if (isObject(value)) {
|
|
261
|
+
lines.push(style.color(`${indentStr}${style.symbol} ${key}:`));
|
|
262
|
+
lines.push(...renderNestedObject(value, action, indent + 1));
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
lines.push(style.color(`${indentStr}${style.symbol} ${key}: ${formatValue(value)}`));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return lines;
|
|
269
|
+
}
|
|
124
270
|
/**
|
|
125
271
|
* Get the symbol and color for an action.
|
|
126
272
|
*/
|
|
@@ -152,17 +298,43 @@ function renderTree(node, indent = 0) {
|
|
|
152
298
|
}
|
|
153
299
|
else {
|
|
154
300
|
// Leaf node with value
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
301
|
+
const hasComplexNew = isObject(child.newValue) ||
|
|
302
|
+
(Array.isArray(child.newValue) &&
|
|
303
|
+
child.newValue.some((v) => isObject(v)));
|
|
304
|
+
const hasComplexOld = isObject(child.oldValue) ||
|
|
305
|
+
(Array.isArray(child.oldValue) &&
|
|
306
|
+
child.oldValue.some((v) => isObject(v)));
|
|
307
|
+
if (child.action === "add" && hasComplexNew) {
|
|
308
|
+
lines.push(style.color(`${indentStr}${style.symbol} ${child.name}:`));
|
|
309
|
+
lines.push(...renderNestedValue(child.newValue, child.action, indent + 1));
|
|
158
310
|
}
|
|
159
|
-
else if (child.action === "
|
|
160
|
-
|
|
311
|
+
else if (child.action === "remove" && hasComplexOld) {
|
|
312
|
+
lines.push(style.color(`${indentStr}${style.symbol} ${child.name} (removed):`));
|
|
313
|
+
lines.push(...renderNestedValue(child.oldValue, child.action, indent + 1));
|
|
161
314
|
}
|
|
162
|
-
else if (child.action === "
|
|
163
|
-
|
|
315
|
+
else if (child.action === "change" &&
|
|
316
|
+
(hasComplexNew || hasComplexOld)) {
|
|
317
|
+
lines.push(style.color(`${indentStr}${style.symbol} ${child.name}:`));
|
|
318
|
+
if (hasComplexOld) {
|
|
319
|
+
lines.push(...renderNestedValue(child.oldValue, "remove", indent + 1));
|
|
320
|
+
}
|
|
321
|
+
if (hasComplexNew) {
|
|
322
|
+
lines.push(...renderNestedValue(child.newValue, "add", indent + 1));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
let valuePart = "";
|
|
327
|
+
if (child.action === "change") {
|
|
328
|
+
valuePart = `: ${formatValue(child.oldValue)} → ${formatValue(child.newValue)}`;
|
|
329
|
+
}
|
|
330
|
+
else if (child.action === "add") {
|
|
331
|
+
valuePart = `: ${formatValue(child.newValue)}`;
|
|
332
|
+
}
|
|
333
|
+
else if (child.action === "remove") {
|
|
334
|
+
valuePart = ` (was: ${formatValue(child.oldValue)})`;
|
|
335
|
+
}
|
|
336
|
+
lines.push(style.color(`${indentStr}${style.symbol} ${child.name}${valuePart}`));
|
|
164
337
|
}
|
|
165
|
-
lines.push(style.color(`${indentStr}${style.symbol} ${child.name}${valuePart}`));
|
|
166
338
|
}
|
|
167
339
|
}
|
|
168
340
|
return lines;
|
|
@@ -186,12 +358,11 @@ export function formatPropertyTree(diffs) {
|
|
|
186
358
|
*/
|
|
187
359
|
function normalizeForDiff(obj) {
|
|
188
360
|
const result = {};
|
|
189
|
-
const ignoreFields = new Set(["id", "name", "source_type", "source"]);
|
|
190
361
|
for (const [key, value] of Object.entries(obj)) {
|
|
191
|
-
if (ignoreFields.has(key) || value === undefined)
|
|
192
|
-
continue;
|
|
193
362
|
// Convert camelCase to snake_case for consistency
|
|
194
363
|
const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase();
|
|
364
|
+
if (!RULESET_COMPARABLE_FIELDS.has(snakeKey) || value === undefined)
|
|
365
|
+
continue;
|
|
195
366
|
result[snakeKey] = normalizeNestedValue(value);
|
|
196
367
|
}
|
|
197
368
|
return result;
|
|
@@ -265,6 +436,7 @@ export function formatRulesetPlan(changes) {
|
|
|
265
436
|
let updates = 0;
|
|
266
437
|
let deletes = 0;
|
|
267
438
|
let unchanged = 0;
|
|
439
|
+
const entries = [];
|
|
268
440
|
// Group by action type
|
|
269
441
|
const createChanges = changes.filter((c) => c.action === "create");
|
|
270
442
|
const updateChanges = changes.filter((c) => c.action === "update");
|
|
@@ -282,6 +454,10 @@ export function formatRulesetPlan(changes) {
|
|
|
282
454
|
if (change.desired) {
|
|
283
455
|
lines.push(...formatFullConfig(change.desired, 2));
|
|
284
456
|
}
|
|
457
|
+
const propertyCount = change.desired
|
|
458
|
+
? Object.keys(change.desired).length
|
|
459
|
+
: 0;
|
|
460
|
+
entries.push({ name: change.name, action: "create", propertyCount });
|
|
285
461
|
lines.push(""); // Blank line between rulesets
|
|
286
462
|
}
|
|
287
463
|
}
|
|
@@ -293,11 +469,23 @@ export function formatRulesetPlan(changes) {
|
|
|
293
469
|
if (change.current && change.desired) {
|
|
294
470
|
const currentNorm = normalizeForDiff(change.current);
|
|
295
471
|
const desiredNorm = normalizeForDiff(change.desired);
|
|
296
|
-
const
|
|
472
|
+
const projectedCurrent = projectToDesiredShape(currentNorm, desiredNorm);
|
|
473
|
+
const diffs = computePropertyDiffs(projectedCurrent, desiredNorm);
|
|
297
474
|
const treeLines = formatPropertyTree(diffs);
|
|
298
475
|
for (const line of treeLines) {
|
|
299
476
|
lines.push(` ${line}`);
|
|
300
477
|
}
|
|
478
|
+
const added = diffs.filter((d) => d.action === "add").length;
|
|
479
|
+
const changed = diffs.filter((d) => d.action === "change").length;
|
|
480
|
+
const removed = diffs.filter((d) => d.action === "remove").length;
|
|
481
|
+
entries.push({
|
|
482
|
+
name: change.name,
|
|
483
|
+
action: "update",
|
|
484
|
+
propertyChanges: { added, changed, removed },
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
else {
|
|
488
|
+
entries.push({ name: change.name, action: "update" });
|
|
301
489
|
}
|
|
302
490
|
lines.push(""); // Blank line between rulesets
|
|
303
491
|
}
|
|
@@ -307,8 +495,12 @@ export function formatRulesetPlan(changes) {
|
|
|
307
495
|
lines.push(chalk.bold(" Delete:"));
|
|
308
496
|
for (const change of deleteChanges) {
|
|
309
497
|
lines.push(chalk.red(` - ruleset "${change.name}"`));
|
|
498
|
+
entries.push({ name: change.name, action: "delete" });
|
|
310
499
|
}
|
|
311
500
|
lines.push(""); // Blank line after deletes
|
|
312
501
|
}
|
|
313
|
-
|
|
502
|
+
for (const change of unchangedChanges) {
|
|
503
|
+
entries.push({ name: change.name, action: "unchanged" });
|
|
504
|
+
}
|
|
505
|
+
return { lines, creates, updates, deletes, unchanged, entries };
|
|
314
506
|
}
|
|
@@ -58,10 +58,10 @@ export class RulesetProcessor {
|
|
|
58
58
|
delete: changes.filter((c) => c.action === "delete").length,
|
|
59
59
|
unchanged: changes.filter((c) => c.action === "unchanged").length,
|
|
60
60
|
};
|
|
61
|
+
const planOutput = formatRulesetPlan(changes);
|
|
61
62
|
// Dry run mode - report planned changes without applying
|
|
62
63
|
if (dryRun) {
|
|
63
64
|
const summary = this.formatChangeSummary(changeCounts);
|
|
64
|
-
const planOutput = formatRulesetPlan(changes);
|
|
65
65
|
return {
|
|
66
66
|
success: true,
|
|
67
67
|
repoName,
|
|
@@ -106,6 +106,7 @@ export class RulesetProcessor {
|
|
|
106
106
|
repoName,
|
|
107
107
|
message: appliedCount > 0 ? `Applied: ${summary}` : "No changes needed",
|
|
108
108
|
changes: changeCounts,
|
|
109
|
+
planOutput,
|
|
109
110
|
manifestUpdate: this.computeManifestUpdate(desiredRulesets, deleteOrphaned),
|
|
110
111
|
};
|
|
111
112
|
}
|