@aspruyt/xfg 3.9.0 → 3.9.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.
@@ -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);
@@ -9,8 +9,9 @@ import { logger } from "../shared/logger.js";
9
9
  import { generateWorkspaceName } from "../shared/workspace-utils.js";
10
10
  import { defaultProcessorFactory } from "./types.js";
11
11
  import { buildSyncReport } from "./sync-report-builder.js";
12
- import { formatSyncReportCLI, writeSyncReportSummary, } from "../output/sync-report.js";
13
- import { buildLifecycleReport, formatLifecycleReportCLI, writeLifecycleReportSummary, hasLifecycleChanges, } from "../output/lifecycle-report.js";
12
+ import { formatSyncReportCLI } from "../output/sync-report.js";
13
+ import { buildLifecycleReport, formatLifecycleReportCLI, hasLifecycleChanges, } from "../output/lifecycle-report.js";
14
+ import { writeUnifiedSummary } from "../output/unified-summary.js";
14
15
  import { RepoLifecycleManager, runLifecycleCheck, toCreateRepoSettings, } from "../lifecycle/index.js";
15
16
  /**
16
17
  * Get unique file names from all repos in the config
@@ -238,14 +239,18 @@ export async function runSync(options, processorFactory = defaultProcessorFactor
238
239
  console.log(line);
239
240
  }
240
241
  }
241
- writeLifecycleReportSummary(lifecycleReport, options.dryRun ?? false);
242
242
  // Build and display sync report
243
243
  const report = buildSyncReport(reportResults);
244
244
  console.log("");
245
245
  for (const line of formatSyncReportCLI(report)) {
246
246
  console.log(line);
247
247
  }
248
- writeSyncReportSummary(report, options.dryRun ?? false);
248
+ // Write unified summary to GITHUB_STEP_SUMMARY
249
+ writeUnifiedSummary({
250
+ lifecycle: lifecycleReport,
251
+ sync: report,
252
+ dryRun: options.dryRun ?? false,
253
+ });
249
254
  // Exit with error if any failures
250
255
  const hasErrors = reportResults.some((r) => r.error);
251
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
@@ -1,12 +1,6 @@
1
1
  // src/output/lifecycle-report.ts
2
2
  import { appendFileSync } from "node:fs";
3
3
  import chalk from "chalk";
4
- function escapeHtml(text) {
5
- return text
6
- .replace(/&/g, "&")
7
- .replace(/</g, "&lt;")
8
- .replace(/>/g, "&gt;");
9
- }
10
4
  // =============================================================================
11
5
  // Builder
12
6
  // =============================================================================
@@ -103,35 +97,35 @@ export function formatLifecycleReportMarkdown(report, dryRun) {
103
97
  lines.push("> This was a dry run — no changes were applied");
104
98
  lines.push("");
105
99
  }
106
- // Colored diff output using HTML <pre> with inline styles
100
+ // Diff block
107
101
  const diffLines = [];
108
102
  for (const action of report.actions) {
109
103
  if (action.action === "existed")
110
104
  continue;
111
105
  switch (action.action) {
112
106
  case "created":
113
- diffLines.push(`<span style="color:#3fb950">+ CREATE ${escapeHtml(action.repoName)}</span>`);
107
+ diffLines.push(`+ CREATE ${action.repoName}`);
114
108
  break;
115
109
  case "forked":
116
- diffLines.push(`<span style="color:#3fb950">+ FORK ${escapeHtml(action.upstream ?? "upstream")} -&gt; ${escapeHtml(action.repoName)}</span>`);
110
+ diffLines.push(`+ FORK ${action.upstream ?? "upstream"} -> ${action.repoName}`);
117
111
  break;
118
112
  case "migrated":
119
- diffLines.push(`<span style="color:#3fb950">+ MIGRATE ${escapeHtml(action.source ?? "source")} -&gt; ${escapeHtml(action.repoName)}</span>`);
113
+ diffLines.push(`+ MIGRATE ${action.source ?? "source"} -> ${action.repoName}`);
120
114
  break;
121
115
  }
122
116
  if (action.settings) {
123
117
  if (action.settings.visibility) {
124
- diffLines.push(`<span style="color:#3fb950"> visibility: ${escapeHtml(action.settings.visibility)}</span>`);
118
+ diffLines.push(` visibility: ${action.settings.visibility}`);
125
119
  }
126
120
  if (action.settings.description) {
127
- diffLines.push(`<span style="color:#3fb950"> description: "${escapeHtml(action.settings.description)}"</span>`);
121
+ diffLines.push(` description: "${action.settings.description}"`);
128
122
  }
129
123
  }
130
124
  }
131
125
  if (diffLines.length > 0) {
132
- lines.push("<pre>");
126
+ lines.push("```diff");
133
127
  lines.push(...diffLines);
134
- lines.push("</pre>");
128
+ lines.push("```");
135
129
  lines.push("");
136
130
  }
137
131
  // Summary
@@ -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;
@@ -1,12 +1,6 @@
1
1
  import { appendFileSync } from "node:fs";
2
2
  import chalk from "chalk";
3
3
  import { formatPropertyTree, } from "../settings/rulesets/formatter.js";
4
- function escapeHtml(text) {
5
- return text
6
- .replace(/&/g, "&amp;")
7
- .replace(/</g, "&lt;")
8
- .replace(/>/g, "&gt;");
9
- }
10
4
  // =============================================================================
11
5
  // Helpers
12
6
  // =============================================================================
@@ -159,7 +153,7 @@ export function formatSettingsReportCLI(report) {
159
153
  // =============================================================================
160
154
  // Markdown Formatter
161
155
  // =============================================================================
162
- function formatValuePlain(val) {
156
+ export function formatValuePlain(val) {
163
157
  if (val === null)
164
158
  return "null";
165
159
  if (val === undefined)
@@ -170,7 +164,7 @@ function formatValuePlain(val) {
170
164
  return val ? "true" : "false";
171
165
  return String(val);
172
166
  }
173
- function formatRulesetConfigPlain(config) {
167
+ export function formatRulesetConfigPlain(config) {
174
168
  const lines = [];
175
169
  function renderObject(obj, depth) {
176
170
  for (const [k, v] of Object.entries(obj)) {
@@ -222,8 +216,8 @@ function formatRulesetConfigPlain(config) {
222
216
  export function formatSettingsReportMarkdown(report, dryRun) {
223
217
  const lines = [];
224
218
  // Title
225
- const titleSuffix = dryRun ? " (Dry Run)" : "";
226
- lines.push(`## Repository Settings Summary${titleSuffix}`);
219
+ const title = dryRun ? "## xfg Plan" : "## xfg Apply";
220
+ lines.push(title);
227
221
  lines.push("");
228
222
  // Dry-run warning
229
223
  if (dryRun) {
@@ -239,57 +233,55 @@ export function formatSettingsReportMarkdown(report, dryRun) {
239
233
  !repo.error) {
240
234
  continue;
241
235
  }
242
- diffLines.push(`<span style="color:#d29922">~ ${escapeHtml(repo.repoName)}</span>`);
236
+ diffLines.push(`@@ ${repo.repoName} @@`);
243
237
  for (const setting of repo.settings) {
244
238
  // Skip settings where both values are undefined
245
239
  if (setting.oldValue === undefined && setting.newValue === undefined) {
246
240
  continue;
247
241
  }
248
242
  if (setting.action === "add") {
249
- diffLines.push(`<span style="color:#3fb950"> + ${escapeHtml(setting.name)}: ${escapeHtml(formatValuePlain(setting.newValue))}</span>`);
243
+ diffLines.push(`+ ${setting.name}: ${formatValuePlain(setting.newValue)}`);
250
244
  }
251
245
  else {
252
- diffLines.push(`<span style="color:#d29922"> ~ ${escapeHtml(setting.name)}: ${escapeHtml(formatValuePlain(setting.oldValue))} → ${escapeHtml(formatValuePlain(setting.newValue))}</span>`);
246
+ diffLines.push(`! ${setting.name}: ${formatValuePlain(setting.oldValue)} → ${formatValuePlain(setting.newValue)}`);
253
247
  }
254
248
  }
255
249
  for (const ruleset of repo.rulesets) {
256
250
  if (ruleset.action === "create") {
257
- diffLines.push(`<span style="color:#3fb950"> + ruleset "${escapeHtml(ruleset.name)}"</span>`);
251
+ diffLines.push(`+ ruleset "${ruleset.name}"`);
258
252
  if (ruleset.config) {
259
- for (const line of formatRulesetConfigPlain(ruleset.config)) {
260
- diffLines.push(`<span style="color:#3fb950">${escapeHtml(line)}</span>`);
261
- }
253
+ diffLines.push(...formatRulesetConfigPlain(ruleset.config));
262
254
  }
263
255
  }
264
256
  else if (ruleset.action === "update") {
265
- diffLines.push(`<span style="color:#d29922"> ~ ruleset "${escapeHtml(ruleset.name)}"</span>`);
257
+ diffLines.push(`! ruleset "${ruleset.name}"`);
266
258
  if (ruleset.propertyDiffs && ruleset.propertyDiffs.length > 0) {
267
259
  for (const diff of ruleset.propertyDiffs) {
268
260
  const path = diff.path.join(".");
269
261
  if (diff.action === "add") {
270
- diffLines.push(`<span style="color:#3fb950"> + ${escapeHtml(path)}: ${escapeHtml(formatValuePlain(diff.newValue))}</span>`);
262
+ diffLines.push(`+ ${path}: ${formatValuePlain(diff.newValue)}`);
271
263
  }
272
264
  else if (diff.action === "change") {
273
- diffLines.push(`<span style="color:#d29922"> ~ ${escapeHtml(path)}: ${escapeHtml(formatValuePlain(diff.oldValue))} → ${escapeHtml(formatValuePlain(diff.newValue))}</span>`);
265
+ diffLines.push(`! ${path}: ${formatValuePlain(diff.oldValue)} → ${formatValuePlain(diff.newValue)}`);
274
266
  }
275
267
  else if (diff.action === "remove") {
276
- diffLines.push(`<span style="color:#f85149"> - ${escapeHtml(path)}</span>`);
268
+ diffLines.push(`- ${path}`);
277
269
  }
278
270
  }
279
271
  }
280
272
  }
281
273
  else if (ruleset.action === "delete") {
282
- diffLines.push(`<span style="color:#f85149"> - ruleset "${escapeHtml(ruleset.name)}"</span>`);
274
+ diffLines.push(`- ruleset "${ruleset.name}"`);
283
275
  }
284
276
  }
285
277
  if (repo.error) {
286
- diffLines.push(`<span style="color:#f85149"> ! Error: ${escapeHtml(repo.error)}</span>`);
278
+ diffLines.push(`- Error: ${repo.error}`);
287
279
  }
288
280
  }
289
281
  if (diffLines.length > 0) {
290
- lines.push("<pre>");
282
+ lines.push("```diff");
291
283
  lines.push(...diffLines);
292
- lines.push("</pre>");
284
+ lines.push("```");
293
285
  lines.push("");
294
286
  }
295
287
  // Summary
@@ -1,12 +1,6 @@
1
1
  // src/output/sync-report.ts
2
2
  import { appendFileSync } from "node:fs";
3
3
  import chalk from "chalk";
4
- function escapeHtml(text) {
5
- return text
6
- .replace(/&/g, "&amp;")
7
- .replace(/</g, "&lt;")
8
- .replace(/>/g, "&gt;");
9
- }
10
4
  function formatSummary(totals) {
11
5
  const total = totals.files.create + totals.files.update + totals.files.delete;
12
6
  if (total === 0) {
@@ -55,8 +49,8 @@ export function formatSyncReportCLI(report) {
55
49
  export function formatSyncReportMarkdown(report, dryRun) {
56
50
  const lines = [];
57
51
  // Title
58
- const titleSuffix = dryRun ? " (Dry Run)" : "";
59
- lines.push(`## Config Sync Summary${titleSuffix}`);
52
+ const title = dryRun ? "## xfg Plan" : "## xfg Apply";
53
+ lines.push(title);
60
54
  lines.push("");
61
55
  // Dry-run warning
62
56
  if (dryRun) {
@@ -64,33 +58,32 @@ export function formatSyncReportMarkdown(report, dryRun) {
64
58
  lines.push("> This was a dry run — no changes were applied");
65
59
  lines.push("");
66
60
  }
67
- // Colored diff output using HTML <pre> with inline styles
68
- // Colors: green (#3fb950) for creates, yellow (#d29922) for changes, red (#f85149) for deletes
61
+ // Diff block
69
62
  const diffLines = [];
70
63
  for (const repo of report.repos) {
71
64
  if (repo.files.length === 0 && !repo.error) {
72
65
  continue;
73
66
  }
74
- diffLines.push(`<span style="color:#d29922">~ ${escapeHtml(repo.repoName)}</span>`);
67
+ diffLines.push(`@@ ${repo.repoName} @@`);
75
68
  for (const file of repo.files) {
76
69
  if (file.action === "create") {
77
- diffLines.push(`<span style="color:#3fb950"> + ${escapeHtml(file.path)}</span>`);
70
+ diffLines.push(`+ ${file.path}`);
78
71
  }
79
72
  else if (file.action === "update") {
80
- diffLines.push(`<span style="color:#d29922"> ~ ${escapeHtml(file.path)}</span>`);
73
+ diffLines.push(`! ${file.path}`);
81
74
  }
82
75
  else if (file.action === "delete") {
83
- diffLines.push(`<span style="color:#f85149"> - ${escapeHtml(file.path)}</span>`);
76
+ diffLines.push(`- ${file.path}`);
84
77
  }
85
78
  }
86
79
  if (repo.error) {
87
- diffLines.push(`<span style="color:#f85149"> ! Error: ${escapeHtml(repo.error)}</span>`);
80
+ diffLines.push(`- Error: ${repo.error}`);
88
81
  }
89
82
  }
90
83
  if (diffLines.length > 0) {
91
- lines.push("<pre>");
84
+ lines.push("```diff");
92
85
  lines.push(...diffLines);
93
- lines.push("</pre>");
86
+ lines.push("```");
94
87
  lines.push("");
95
88
  }
96
89
  // Summary
@@ -0,0 +1,11 @@
1
+ import type { LifecycleReport } from "./lifecycle-report.js";
2
+ import type { SyncReport } from "./sync-report.js";
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;
@@ -0,0 +1,245 @@
1
+ // src/output/unified-summary.ts
2
+ import { appendFileSync } from "node:fs";
3
+ import { hasLifecycleChanges } from "./lifecycle-report.js";
4
+ import { formatValuePlain, formatRulesetConfigPlain, } from "./settings-report.js";
5
+ // =============================================================================
6
+ // Helpers
7
+ // =============================================================================
8
+ function formatCombinedSummary(input) {
9
+ const parts = [];
10
+ // Lifecycle totals
11
+ if (input.lifecycle) {
12
+ const t = input.lifecycle.totals;
13
+ const repoTotal = t.created + t.forked + t.migrated;
14
+ if (repoTotal > 0) {
15
+ const repoParts = [];
16
+ if (t.created > 0)
17
+ repoParts.push(`${t.created} to create`);
18
+ if (t.forked > 0)
19
+ repoParts.push(`${t.forked} to fork`);
20
+ if (t.migrated > 0)
21
+ repoParts.push(`${t.migrated} to migrate`);
22
+ const repoWord = repoTotal === 1 ? "repo" : "repos";
23
+ parts.push(`${repoTotal} ${repoWord} (${repoParts.join(", ")})`);
24
+ }
25
+ }
26
+ // Sync totals
27
+ if (input.sync) {
28
+ const t = input.sync.totals;
29
+ const fileTotal = t.files.create + t.files.update + t.files.delete;
30
+ if (fileTotal > 0) {
31
+ const fileParts = [];
32
+ if (t.files.create > 0)
33
+ fileParts.push(`${t.files.create} to create`);
34
+ if (t.files.update > 0)
35
+ fileParts.push(`${t.files.update} to update`);
36
+ if (t.files.delete > 0)
37
+ fileParts.push(`${t.files.delete} to delete`);
38
+ const fileWord = fileTotal === 1 ? "file" : "files";
39
+ parts.push(`${fileTotal} ${fileWord} (${fileParts.join(", ")})`);
40
+ }
41
+ }
42
+ // Settings totals
43
+ if (input.settings) {
44
+ const t = input.settings.totals;
45
+ const settingsTotal = t.settings.add + t.settings.change;
46
+ if (settingsTotal > 0) {
47
+ const settingWord = settingsTotal === 1 ? "setting" : "settings";
48
+ const actions = [];
49
+ if (t.settings.add > 0)
50
+ actions.push(`${t.settings.add} to add`);
51
+ if (t.settings.change > 0)
52
+ actions.push(`${t.settings.change} to change`);
53
+ parts.push(`${settingsTotal} ${settingWord} (${actions.join(", ")})`);
54
+ }
55
+ const rulesetsTotal = t.rulesets.create + t.rulesets.update + t.rulesets.delete;
56
+ if (rulesetsTotal > 0) {
57
+ const rulesetWord = rulesetsTotal === 1 ? "ruleset" : "rulesets";
58
+ const actions = [];
59
+ if (t.rulesets.create > 0)
60
+ actions.push(`${t.rulesets.create} to create`);
61
+ if (t.rulesets.update > 0)
62
+ actions.push(`${t.rulesets.update} to update`);
63
+ if (t.rulesets.delete > 0)
64
+ actions.push(`${t.rulesets.delete} to delete`);
65
+ parts.push(`${rulesetsTotal} ${rulesetWord} (${actions.join(", ")})`);
66
+ }
67
+ }
68
+ if (parts.length === 0) {
69
+ return "No changes";
70
+ }
71
+ return `Plan: ${parts.join(", ")}`;
72
+ }
73
+ function hasAnyChanges(input) {
74
+ if (input.lifecycle && hasLifecycleChanges(input.lifecycle))
75
+ return true;
76
+ if (input.sync?.repos.some((r) => r.files.length > 0 || r.error))
77
+ return true;
78
+ if (input.settings?.repos.some((r) => r.settings.length > 0 || r.rulesets.length > 0 || r.error))
79
+ return true;
80
+ return false;
81
+ }
82
+ // =============================================================================
83
+ // Diff line builders
84
+ // =============================================================================
85
+ function renderLifecycleLines(lcAction, diffLines) {
86
+ if (lcAction.action === "existed")
87
+ return;
88
+ switch (lcAction.action) {
89
+ case "created":
90
+ diffLines.push(`+ CREATE`);
91
+ break;
92
+ case "forked":
93
+ diffLines.push(`+ FORK ${lcAction.upstream ?? "upstream"} -> ${lcAction.repoName}`);
94
+ break;
95
+ case "migrated":
96
+ diffLines.push(`+ MIGRATE ${lcAction.source ?? "source"} -> ${lcAction.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
+ function renderSyncLines(syncRepo, diffLines) {
109
+ for (const file of syncRepo.files) {
110
+ if (file.action === "create") {
111
+ diffLines.push(`+ ${file.path}`);
112
+ }
113
+ else if (file.action === "update") {
114
+ diffLines.push(`! ${file.path}`);
115
+ }
116
+ else if (file.action === "delete") {
117
+ diffLines.push(`- ${file.path}`);
118
+ }
119
+ }
120
+ if (syncRepo.error) {
121
+ diffLines.push(`- Error: ${syncRepo.error}`);
122
+ }
123
+ }
124
+ function renderSettingsLines(settingsRepo, diffLines) {
125
+ for (const setting of settingsRepo.settings) {
126
+ if (setting.oldValue === undefined && setting.newValue === undefined) {
127
+ continue;
128
+ }
129
+ if (setting.action === "add") {
130
+ diffLines.push(`+ ${setting.name}: ${formatValuePlain(setting.newValue)}`);
131
+ }
132
+ else {
133
+ diffLines.push(`! ${setting.name}: ${formatValuePlain(setting.oldValue)} → ${formatValuePlain(setting.newValue)}`);
134
+ }
135
+ }
136
+ for (const ruleset of settingsRepo.rulesets) {
137
+ if (ruleset.action === "create") {
138
+ diffLines.push(`+ ruleset "${ruleset.name}"`);
139
+ if (ruleset.config) {
140
+ diffLines.push(...formatRulesetConfigPlain(ruleset.config));
141
+ }
142
+ }
143
+ else if (ruleset.action === "update") {
144
+ diffLines.push(`! ruleset "${ruleset.name}"`);
145
+ if (ruleset.propertyDiffs && ruleset.propertyDiffs.length > 0) {
146
+ for (const diff of ruleset.propertyDiffs) {
147
+ const path = diff.path.join(".");
148
+ if (diff.action === "add") {
149
+ diffLines.push(`+ ${path}: ${formatValuePlain(diff.newValue)}`);
150
+ }
151
+ else if (diff.action === "change") {
152
+ diffLines.push(`! ${path}: ${formatValuePlain(diff.oldValue)} → ${formatValuePlain(diff.newValue)}`);
153
+ }
154
+ else if (diff.action === "remove") {
155
+ diffLines.push(`- ${path}`);
156
+ }
157
+ }
158
+ }
159
+ }
160
+ else if (ruleset.action === "delete") {
161
+ diffLines.push(`- ruleset "${ruleset.name}"`);
162
+ }
163
+ }
164
+ if (settingsRepo.error) {
165
+ diffLines.push(`- Error: ${settingsRepo.error}`);
166
+ }
167
+ }
168
+ // =============================================================================
169
+ // Markdown Formatter
170
+ // =============================================================================
171
+ export function formatUnifiedSummaryMarkdown(input) {
172
+ if (!hasAnyChanges(input)) {
173
+ return "";
174
+ }
175
+ const lines = [];
176
+ // Title: "xfg Plan" for dry-run, "xfg Apply" otherwise
177
+ const title = input.dryRun ? "## xfg Plan" : "## xfg Apply";
178
+ lines.push(title);
179
+ lines.push("");
180
+ // Dry-run warning
181
+ if (input.dryRun) {
182
+ lines.push("> [!WARNING]");
183
+ lines.push("> This was a dry run — no changes were applied");
184
+ lines.push("");
185
+ }
186
+ // Build lookup maps
187
+ const lifecycleByRepo = new Map((input.lifecycle?.actions ?? []).map((a) => [a.repoName, a]));
188
+ const syncByRepo = new Map((input.sync?.repos ?? []).map((r) => [r.repoName, r]));
189
+ const settingsByRepo = new Map((input.settings?.repos ?? []).map((r) => [r.repoName, r]));
190
+ // Collect all repo names in order
191
+ const allRepos = [];
192
+ const addRepo = (name) => {
193
+ if (!allRepos.includes(name))
194
+ allRepos.push(name);
195
+ };
196
+ for (const a of input.lifecycle?.actions ?? [])
197
+ addRepo(a.repoName);
198
+ for (const r of input.sync?.repos ?? [])
199
+ addRepo(r.repoName);
200
+ for (const r of input.settings?.repos ?? [])
201
+ addRepo(r.repoName);
202
+ // Diff block
203
+ const diffLines = [];
204
+ for (const repoName of allRepos) {
205
+ const lcAction = lifecycleByRepo.get(repoName);
206
+ const syncRepo = syncByRepo.get(repoName);
207
+ const settingsRepo = settingsByRepo.get(repoName);
208
+ const hasLcChange = lcAction && lcAction.action !== "existed";
209
+ const hasSyncChanges = syncRepo && (syncRepo.files.length > 0 || syncRepo.error);
210
+ const hasSettingsChanges = settingsRepo &&
211
+ (settingsRepo.settings.length > 0 ||
212
+ settingsRepo.rulesets.length > 0 ||
213
+ settingsRepo.error);
214
+ if (!hasLcChange && !hasSyncChanges && !hasSettingsChanges)
215
+ continue;
216
+ diffLines.push(`@@ ${repoName} @@`);
217
+ if (lcAction)
218
+ renderLifecycleLines(lcAction, diffLines);
219
+ if (syncRepo)
220
+ renderSyncLines(syncRepo, diffLines);
221
+ if (settingsRepo)
222
+ renderSettingsLines(settingsRepo, diffLines);
223
+ }
224
+ if (diffLines.length > 0) {
225
+ lines.push("```diff");
226
+ lines.push(...diffLines);
227
+ lines.push("```");
228
+ lines.push("");
229
+ }
230
+ // Combined summary
231
+ lines.push(`**${formatCombinedSummary(input)}**`);
232
+ return lines.join("\n");
233
+ }
234
+ // =============================================================================
235
+ // File Writer
236
+ // =============================================================================
237
+ export function writeUnifiedSummary(input) {
238
+ const summaryPath = process.env.GITHUB_STEP_SUMMARY;
239
+ if (!summaryPath)
240
+ return;
241
+ const markdown = formatUnifiedSummaryMarkdown(input);
242
+ if (!markdown)
243
+ return;
244
+ appendFileSync(summaryPath, "\n" + markdown + "\n");
245
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/xfg",
3
- "version": "3.9.0",
3
+ "version": "3.9.2",
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",