@aspruyt/xfg 3.9.1 → 3.9.3

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.
@@ -9,7 +9,8 @@ import { logger } from "../shared/logger.js";
9
9
  import { generateWorkspaceName } from "../shared/workspace-utils.js";
10
10
  import { buildErrorResult } from "../output/summary-utils.js";
11
11
  import { getManagedRulesets } from "../sync/manifest.js";
12
- import { formatSettingsReportCLI, writeSettingsReportSummary, } from "../output/settings-report.js";
12
+ import { formatSettingsReportCLI } from "../output/settings-report.js";
13
+ import { writeUnifiedSummary } from "../output/unified-summary.js";
13
14
  import { buildSettingsReport, } from "./settings-report-builder.js";
14
15
  import { defaultProcessorFactory, defaultRulesetProcessorFactory, defaultRepoSettingsProcessorFactory, } from "./types.js";
15
16
  import { RepoLifecycleManager, runLifecycleCheck, } from "../lifecycle/index.js";
@@ -318,7 +319,7 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
318
319
  for (const line of lines) {
319
320
  console.log(line);
320
321
  }
321
- writeSettingsReportSummary(report, options.dryRun ?? false);
322
+ writeUnifiedSummary({ settings: report, dryRun: options.dryRun ?? false });
322
323
  const hasErrors = report.repos.some((r) => r.error);
323
324
  if (hasErrors) {
324
325
  process.exit(1);
@@ -246,7 +246,11 @@ export async function runSync(options, processorFactory = defaultProcessorFactor
246
246
  console.log(line);
247
247
  }
248
248
  // Write unified summary to GITHUB_STEP_SUMMARY
249
- writeUnifiedSummary(lifecycleReport, report, options.dryRun ?? false);
249
+ writeUnifiedSummary({
250
+ lifecycle: lifecycleReport,
251
+ sync: report,
252
+ dryRun: options.dryRun ?? false,
253
+ });
250
254
  // Exit with error if any failures
251
255
  const hasErrors = reportResults.some((r) => r.error);
252
256
  if (hasErrors) {
@@ -1,4 +1,5 @@
1
1
  export { formatSyncReportCLI, formatSyncReportMarkdown, writeSyncReportSummary, type SyncReport, type RepoFileChanges, type FileChange, } from "./sync-report.js";
2
- export { formatSettingsReportCLI, formatSettingsReportMarkdown, writeSettingsReportSummary, type SettingsReport, type RepoChanges, type RulesetChange, type SettingChange, } from "./settings-report.js";
2
+ export { formatSettingsReportCLI, formatSettingsReportMarkdown, writeSettingsReportSummary, formatValuePlain, formatRulesetConfigPlain, type SettingsReport, type RepoChanges, type RulesetChange, type SettingChange, } from "./settings-report.js";
3
+ export { formatUnifiedSummaryMarkdown, writeUnifiedSummary, type UnifiedSummaryInput, } from "./unified-summary.js";
3
4
  export { formatSummary, isGitHubActions, writeSummary, type MergeOutcome, type FileChanges, type RulesetPlanDetail, type RepoSettingsPlanDetail, type RepoResult, type SummaryData, } from "./github-summary.js";
4
5
  export { getMergeOutcome, toFileChanges, buildRepoResult, buildErrorResult, } from "./summary-utils.js";
@@ -1,7 +1,9 @@
1
1
  // Sync report (repo-grouped file changes)
2
2
  export { formatSyncReportCLI, formatSyncReportMarkdown, writeSyncReportSummary, } from "./sync-report.js";
3
3
  // Settings report (repo-grouped settings changes)
4
- export { formatSettingsReportCLI, formatSettingsReportMarkdown, writeSettingsReportSummary, } from "./settings-report.js";
4
+ export { formatSettingsReportCLI, formatSettingsReportMarkdown, writeSettingsReportSummary, formatValuePlain, formatRulesetConfigPlain, } from "./settings-report.js";
5
+ // Unified summary (lifecycle + sync + settings in one diff block)
6
+ export { formatUnifiedSummaryMarkdown, writeUnifiedSummary, } from "./unified-summary.js";
5
7
  // GitHub Actions summary
6
8
  export { formatSummary, isGitHubActions, writeSummary, } from "./github-summary.js";
7
9
  // Summary utilities
@@ -33,5 +33,7 @@ export interface RulesetChange {
33
33
  config?: Ruleset;
34
34
  }
35
35
  export declare function formatSettingsReportCLI(report: SettingsReport): string[];
36
+ export declare function formatValuePlain(val: unknown): string;
37
+ export declare function formatRulesetConfigPlain(config: Ruleset): string[];
36
38
  export declare function formatSettingsReportMarkdown(report: SettingsReport, dryRun: boolean): string;
37
39
  export declare function writeSettingsReportSummary(report: SettingsReport, dryRun: boolean): void;
@@ -153,7 +153,7 @@ export function formatSettingsReportCLI(report) {
153
153
  // =============================================================================
154
154
  // Markdown Formatter
155
155
  // =============================================================================
156
- function formatValuePlain(val) {
156
+ export function formatValuePlain(val) {
157
157
  if (val === null)
158
158
  return "null";
159
159
  if (val === undefined)
@@ -164,7 +164,7 @@ function formatValuePlain(val) {
164
164
  return val ? "true" : "false";
165
165
  return String(val);
166
166
  }
167
- function formatRulesetConfigPlain(config) {
167
+ export function formatRulesetConfigPlain(config) {
168
168
  const lines = [];
169
169
  function renderObject(obj, depth) {
170
170
  for (const [k, v] of Object.entries(obj)) {
@@ -216,8 +216,8 @@ function formatRulesetConfigPlain(config) {
216
216
  export function formatSettingsReportMarkdown(report, dryRun) {
217
217
  const lines = [];
218
218
  // Title
219
- const titleSuffix = dryRun ? " (Dry Run)" : "";
220
- lines.push(`## Repository Settings Summary${titleSuffix}`);
219
+ const title = dryRun ? "## xfg Plan" : "## xfg Apply";
220
+ lines.push(title);
221
221
  lines.push("");
222
222
  // Dry-run warning
223
223
  if (dryRun) {
@@ -49,8 +49,8 @@ export function formatSyncReportCLI(report) {
49
49
  export function formatSyncReportMarkdown(report, dryRun) {
50
50
  const lines = [];
51
51
  // Title
52
- const titleSuffix = dryRun ? " (Dry Run)" : "";
53
- lines.push(`## Config Sync Summary${titleSuffix}`);
52
+ const title = dryRun ? "## xfg Plan" : "## xfg Apply";
53
+ lines.push(title);
54
54
  lines.push("");
55
55
  // Dry-run warning
56
56
  if (dryRun) {
@@ -1,4 +1,11 @@
1
1
  import type { LifecycleReport } from "./lifecycle-report.js";
2
2
  import type { SyncReport } from "./sync-report.js";
3
- export declare function formatUnifiedSummaryMarkdown(lifecycle: LifecycleReport, sync: SyncReport, dryRun: boolean): string;
4
- export declare function writeUnifiedSummary(lifecycle: LifecycleReport, sync: SyncReport, dryRun: boolean): void;
3
+ import type { SettingsReport } from "./settings-report.js";
4
+ export interface UnifiedSummaryInput {
5
+ lifecycle?: LifecycleReport;
6
+ sync?: SyncReport;
7
+ settings?: SettingsReport;
8
+ dryRun: boolean;
9
+ }
10
+ export declare function formatUnifiedSummaryMarkdown(input: UnifiedSummaryInput): string;
11
+ export declare function writeUnifiedSummary(input: UnifiedSummaryInput): void;
@@ -1,127 +1,227 @@
1
1
  // src/output/unified-summary.ts
2
2
  import { appendFileSync } from "node:fs";
3
3
  import { hasLifecycleChanges } from "./lifecycle-report.js";
4
+ import { formatValuePlain, formatRulesetConfigPlain, } from "./settings-report.js";
4
5
  // =============================================================================
5
6
  // Helpers
6
7
  // =============================================================================
7
- function formatCombinedSummary(lifecycleTotals, syncTotals) {
8
+ function formatCombinedSummary(input) {
8
9
  const parts = [];
10
+ const dry = input.dryRun;
9
11
  // Lifecycle totals
10
- const repoTotal = lifecycleTotals.created + lifecycleTotals.forked + lifecycleTotals.migrated;
11
- if (repoTotal > 0) {
12
- const repoParts = [];
13
- if (lifecycleTotals.created > 0)
14
- repoParts.push(`${lifecycleTotals.created} to create`);
15
- if (lifecycleTotals.forked > 0)
16
- repoParts.push(`${lifecycleTotals.forked} to fork`);
17
- if (lifecycleTotals.migrated > 0)
18
- repoParts.push(`${lifecycleTotals.migrated} to migrate`);
19
- const repoWord = repoTotal === 1 ? "repo" : "repos";
20
- parts.push(`${repoTotal} ${repoWord} (${repoParts.join(", ")})`);
12
+ if (input.lifecycle) {
13
+ const t = input.lifecycle.totals;
14
+ const repoTotal = t.created + t.forked + t.migrated;
15
+ if (repoTotal > 0) {
16
+ const repoParts = [];
17
+ if (t.created > 0)
18
+ repoParts.push(`${t.created} ${dry ? "to create" : "created"}`);
19
+ if (t.forked > 0)
20
+ repoParts.push(`${t.forked} ${dry ? "to fork" : "forked"}`);
21
+ if (t.migrated > 0)
22
+ repoParts.push(`${t.migrated} ${dry ? "to migrate" : "migrated"}`);
23
+ const repoWord = repoTotal === 1 ? "repo" : "repos";
24
+ parts.push(`${repoTotal} ${repoWord} (${repoParts.join(", ")})`);
25
+ }
21
26
  }
22
27
  // Sync totals
23
- const fileTotal = syncTotals.files.create + syncTotals.files.update + syncTotals.files.delete;
24
- if (fileTotal > 0) {
25
- const fileParts = [];
26
- if (syncTotals.files.create > 0)
27
- fileParts.push(`${syncTotals.files.create} to create`);
28
- if (syncTotals.files.update > 0)
29
- fileParts.push(`${syncTotals.files.update} to update`);
30
- if (syncTotals.files.delete > 0)
31
- fileParts.push(`${syncTotals.files.delete} to delete`);
32
- const fileWord = fileTotal === 1 ? "file" : "files";
33
- parts.push(`${fileTotal} ${fileWord} (${fileParts.join(", ")})`);
28
+ if (input.sync) {
29
+ const t = input.sync.totals;
30
+ const fileTotal = t.files.create + t.files.update + t.files.delete;
31
+ if (fileTotal > 0) {
32
+ const fileParts = [];
33
+ if (t.files.create > 0)
34
+ fileParts.push(`${t.files.create} ${dry ? "to create" : "created"}`);
35
+ if (t.files.update > 0)
36
+ fileParts.push(`${t.files.update} ${dry ? "to update" : "updated"}`);
37
+ if (t.files.delete > 0)
38
+ fileParts.push(`${t.files.delete} ${dry ? "to delete" : "deleted"}`);
39
+ const fileWord = fileTotal === 1 ? "file" : "files";
40
+ parts.push(`${fileTotal} ${fileWord} (${fileParts.join(", ")})`);
41
+ }
42
+ }
43
+ // Settings totals
44
+ if (input.settings) {
45
+ const t = input.settings.totals;
46
+ const settingsTotal = t.settings.add + t.settings.change;
47
+ if (settingsTotal > 0) {
48
+ const settingWord = settingsTotal === 1 ? "setting" : "settings";
49
+ const actions = [];
50
+ if (t.settings.add > 0)
51
+ actions.push(`${t.settings.add} ${dry ? "to add" : "added"}`);
52
+ if (t.settings.change > 0)
53
+ actions.push(`${t.settings.change} ${dry ? "to change" : "changed"}`);
54
+ parts.push(`${settingsTotal} ${settingWord} (${actions.join(", ")})`);
55
+ }
56
+ const rulesetsTotal = t.rulesets.create + t.rulesets.update + t.rulesets.delete;
57
+ if (rulesetsTotal > 0) {
58
+ const rulesetWord = rulesetsTotal === 1 ? "ruleset" : "rulesets";
59
+ const actions = [];
60
+ if (t.rulesets.create > 0)
61
+ actions.push(`${t.rulesets.create} ${dry ? "to create" : "created"}`);
62
+ if (t.rulesets.update > 0)
63
+ actions.push(`${t.rulesets.update} ${dry ? "to update" : "updated"}`);
64
+ if (t.rulesets.delete > 0)
65
+ actions.push(`${t.rulesets.delete} ${dry ? "to delete" : "deleted"}`);
66
+ parts.push(`${rulesetsTotal} ${rulesetWord} (${actions.join(", ")})`);
67
+ }
34
68
  }
35
69
  if (parts.length === 0) {
36
70
  return "No changes";
37
71
  }
38
- return `Plan: ${parts.join(", ")}`;
72
+ const prefix = dry ? "Plan" : "Applied";
73
+ return `${prefix}: ${parts.join(", ")}`;
74
+ }
75
+ function hasAnyChanges(input) {
76
+ if (input.lifecycle && hasLifecycleChanges(input.lifecycle))
77
+ return true;
78
+ if (input.sync?.repos.some((r) => r.files.length > 0 || r.error))
79
+ return true;
80
+ if (input.settings?.repos.some((r) => r.settings.length > 0 || r.rulesets.length > 0 || r.error))
81
+ return true;
82
+ return false;
83
+ }
84
+ // =============================================================================
85
+ // Diff line builders
86
+ // =============================================================================
87
+ function renderLifecycleLines(lcAction, diffLines) {
88
+ if (lcAction.action === "existed")
89
+ return;
90
+ switch (lcAction.action) {
91
+ case "created":
92
+ diffLines.push(`+ CREATE`);
93
+ break;
94
+ case "forked":
95
+ diffLines.push(`+ FORK ${lcAction.upstream ?? "upstream"} -> ${lcAction.repoName}`);
96
+ break;
97
+ case "migrated":
98
+ diffLines.push(`+ MIGRATE ${lcAction.source ?? "source"} -> ${lcAction.repoName}`);
99
+ break;
100
+ }
101
+ if (lcAction.settings) {
102
+ if (lcAction.settings.visibility) {
103
+ diffLines.push(`+ visibility: ${lcAction.settings.visibility}`);
104
+ }
105
+ if (lcAction.settings.description) {
106
+ diffLines.push(`+ description: "${lcAction.settings.description}"`);
107
+ }
108
+ }
109
+ }
110
+ function renderSyncLines(syncRepo, diffLines) {
111
+ for (const file of syncRepo.files) {
112
+ if (file.action === "create") {
113
+ diffLines.push(`+ ${file.path}`);
114
+ }
115
+ else if (file.action === "update") {
116
+ diffLines.push(`! ${file.path}`);
117
+ }
118
+ else if (file.action === "delete") {
119
+ diffLines.push(`- ${file.path}`);
120
+ }
121
+ }
122
+ if (syncRepo.error) {
123
+ diffLines.push(`- Error: ${syncRepo.error}`);
124
+ }
125
+ }
126
+ function renderSettingsLines(settingsRepo, diffLines) {
127
+ for (const setting of settingsRepo.settings) {
128
+ if (setting.oldValue === undefined && setting.newValue === undefined) {
129
+ continue;
130
+ }
131
+ if (setting.action === "add") {
132
+ diffLines.push(`+ ${setting.name}: ${formatValuePlain(setting.newValue)}`);
133
+ }
134
+ else {
135
+ diffLines.push(`! ${setting.name}: ${formatValuePlain(setting.oldValue)} → ${formatValuePlain(setting.newValue)}`);
136
+ }
137
+ }
138
+ for (const ruleset of settingsRepo.rulesets) {
139
+ if (ruleset.action === "create") {
140
+ diffLines.push(`+ ruleset "${ruleset.name}"`);
141
+ if (ruleset.config) {
142
+ diffLines.push(...formatRulesetConfigPlain(ruleset.config));
143
+ }
144
+ }
145
+ else if (ruleset.action === "update") {
146
+ diffLines.push(`! ruleset "${ruleset.name}"`);
147
+ if (ruleset.propertyDiffs && ruleset.propertyDiffs.length > 0) {
148
+ for (const diff of ruleset.propertyDiffs) {
149
+ const path = diff.path.join(".");
150
+ if (diff.action === "add") {
151
+ diffLines.push(`+ ${path}: ${formatValuePlain(diff.newValue)}`);
152
+ }
153
+ else if (diff.action === "change") {
154
+ diffLines.push(`! ${path}: ${formatValuePlain(diff.oldValue)} → ${formatValuePlain(diff.newValue)}`);
155
+ }
156
+ else if (diff.action === "remove") {
157
+ diffLines.push(`- ${path}`);
158
+ }
159
+ }
160
+ }
161
+ }
162
+ else if (ruleset.action === "delete") {
163
+ diffLines.push(`- ruleset "${ruleset.name}"`);
164
+ }
165
+ }
166
+ if (settingsRepo.error) {
167
+ diffLines.push(`- Error: ${settingsRepo.error}`);
168
+ }
39
169
  }
40
170
  // =============================================================================
41
171
  // Markdown Formatter
42
172
  // =============================================================================
43
- export function formatUnifiedSummaryMarkdown(lifecycle, sync, dryRun) {
44
- const hasLifecycle = hasLifecycleChanges(lifecycle);
45
- const hasSync = sync.repos.some((r) => r.files.length > 0 || r.error);
46
- if (!hasLifecycle && !hasSync) {
173
+ export function formatUnifiedSummaryMarkdown(input) {
174
+ if (!hasAnyChanges(input)) {
47
175
  return "";
48
176
  }
49
177
  const lines = [];
50
- // Title
51
- const titleSuffix = dryRun ? " (Dry Run)" : "";
52
- lines.push(`## xfg Sync Summary${titleSuffix}`);
178
+ // Title: "xfg Plan" for dry-run, "xfg Apply" otherwise
179
+ const title = input.dryRun ? "## xfg Plan" : "## xfg Apply";
180
+ lines.push(title);
53
181
  lines.push("");
54
182
  // Dry-run warning
55
- if (dryRun) {
183
+ if (input.dryRun) {
56
184
  lines.push("> [!WARNING]");
57
185
  lines.push("> This was a dry run — no changes were applied");
58
186
  lines.push("");
59
187
  }
60
- // Build lookup maps by repoName
61
- const lifecycleByRepo = new Map(lifecycle.actions.map((a) => [a.repoName, a]));
62
- const syncByRepo = new Map(sync.repos.map((r) => [r.repoName, r]));
63
- // Collect all repo names in order (lifecycle first, then sync-only)
188
+ // Build lookup maps
189
+ const lifecycleByRepo = new Map((input.lifecycle?.actions ?? []).map((a) => [a.repoName, a]));
190
+ const syncByRepo = new Map((input.sync?.repos ?? []).map((r) => [r.repoName, r]));
191
+ const settingsByRepo = new Map((input.settings?.repos ?? []).map((r) => [r.repoName, r]));
192
+ // Collect all repo names in order
64
193
  const allRepos = [];
65
- for (const action of lifecycle.actions) {
66
- if (!allRepos.includes(action.repoName)) {
67
- allRepos.push(action.repoName);
68
- }
69
- }
70
- for (const repo of sync.repos) {
71
- if (!allRepos.includes(repo.repoName)) {
72
- allRepos.push(repo.repoName);
73
- }
74
- }
194
+ const addRepo = (name) => {
195
+ if (!allRepos.includes(name))
196
+ allRepos.push(name);
197
+ };
198
+ for (const a of input.lifecycle?.actions ?? [])
199
+ addRepo(a.repoName);
200
+ for (const r of input.sync?.repos ?? [])
201
+ addRepo(r.repoName);
202
+ for (const r of input.settings?.repos ?? [])
203
+ addRepo(r.repoName);
75
204
  // Diff block
76
205
  const diffLines = [];
77
206
  for (const repoName of allRepos) {
78
207
  const lcAction = lifecycleByRepo.get(repoName);
79
208
  const syncRepo = syncByRepo.get(repoName);
209
+ const settingsRepo = settingsByRepo.get(repoName);
80
210
  const hasLcChange = lcAction && lcAction.action !== "existed";
81
211
  const hasSyncChanges = syncRepo && (syncRepo.files.length > 0 || syncRepo.error);
82
- if (!hasLcChange && !hasSyncChanges)
212
+ const hasSettingsChanges = settingsRepo &&
213
+ (settingsRepo.settings.length > 0 ||
214
+ settingsRepo.rulesets.length > 0 ||
215
+ settingsRepo.error);
216
+ if (!hasLcChange && !hasSyncChanges && !hasSettingsChanges)
83
217
  continue;
84
- // Repo header
85
218
  diffLines.push(`@@ ${repoName} @@`);
86
- // Lifecycle action
87
- if (lcAction && lcAction.action !== "existed") {
88
- switch (lcAction.action) {
89
- case "created":
90
- diffLines.push(`+ CREATE`);
91
- break;
92
- case "forked":
93
- diffLines.push(`+ FORK ${lcAction.upstream ?? "upstream"} -> ${repoName}`);
94
- break;
95
- case "migrated":
96
- diffLines.push(`+ MIGRATE ${lcAction.source ?? "source"} -> ${repoName}`);
97
- break;
98
- }
99
- if (lcAction.settings) {
100
- if (lcAction.settings.visibility) {
101
- diffLines.push(`+ visibility: ${lcAction.settings.visibility}`);
102
- }
103
- if (lcAction.settings.description) {
104
- diffLines.push(`+ description: "${lcAction.settings.description}"`);
105
- }
106
- }
107
- }
108
- // File changes
109
- if (syncRepo) {
110
- for (const file of syncRepo.files) {
111
- if (file.action === "create") {
112
- diffLines.push(`+ ${file.path}`);
113
- }
114
- else if (file.action === "update") {
115
- diffLines.push(`! ${file.path}`);
116
- }
117
- else if (file.action === "delete") {
118
- diffLines.push(`- ${file.path}`);
119
- }
120
- }
121
- if (syncRepo.error) {
122
- diffLines.push(`- Error: ${syncRepo.error}`);
123
- }
124
- }
219
+ if (lcAction)
220
+ renderLifecycleLines(lcAction, diffLines);
221
+ if (syncRepo)
222
+ renderSyncLines(syncRepo, diffLines);
223
+ if (settingsRepo)
224
+ renderSettingsLines(settingsRepo, diffLines);
125
225
  }
126
226
  if (diffLines.length > 0) {
127
227
  lines.push("```diff");
@@ -130,17 +230,17 @@ export function formatUnifiedSummaryMarkdown(lifecycle, sync, dryRun) {
130
230
  lines.push("");
131
231
  }
132
232
  // Combined summary
133
- lines.push(`**${formatCombinedSummary(lifecycle.totals, sync.totals)}**`);
233
+ lines.push(`**${formatCombinedSummary(input)}**`);
134
234
  return lines.join("\n");
135
235
  }
136
236
  // =============================================================================
137
237
  // File Writer
138
238
  // =============================================================================
139
- export function writeUnifiedSummary(lifecycle, sync, dryRun) {
239
+ export function writeUnifiedSummary(input) {
140
240
  const summaryPath = process.env.GITHUB_STEP_SUMMARY;
141
241
  if (!summaryPath)
142
242
  return;
143
- const markdown = formatUnifiedSummaryMarkdown(lifecycle, sync, dryRun);
243
+ const markdown = formatUnifiedSummaryMarkdown(input);
144
244
  if (!markdown)
145
245
  return;
146
246
  appendFileSync(summaryPath, "\n" + markdown + "\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/xfg",
3
- "version": "3.9.1",
3
+ "version": "3.9.3",
4
4
  "description": "CLI tool for repository-as-code: sync files and manage settings across GitHub, Azure DevOps, and GitLab",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",