@aspruyt/xfg 3.8.2 → 3.9.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.
Files changed (35) hide show
  1. package/dist/cli/settings-command.d.ts +2 -1
  2. package/dist/cli/settings-command.js +82 -7
  3. package/dist/cli/sync-command.d.ts +2 -1
  4. package/dist/cli/sync-command.js +77 -3
  5. package/dist/config/normalizer.js +2 -0
  6. package/dist/config/types.d.ts +9 -0
  7. package/dist/config/validator.js +62 -0
  8. package/dist/lifecycle/ado-migration-source.d.ts +15 -0
  9. package/dist/lifecycle/ado-migration-source.js +37 -0
  10. package/dist/lifecycle/github-lifecycle-provider.d.ts +56 -0
  11. package/dist/lifecycle/github-lifecycle-provider.js +314 -0
  12. package/dist/lifecycle/index.d.ts +7 -0
  13. package/dist/lifecycle/index.js +6 -0
  14. package/dist/lifecycle/lifecycle-formatter.d.ts +14 -0
  15. package/dist/lifecycle/lifecycle-formatter.js +34 -0
  16. package/dist/lifecycle/lifecycle-helpers.d.ts +27 -0
  17. package/dist/lifecycle/lifecycle-helpers.js +47 -0
  18. package/dist/lifecycle/repo-lifecycle-factory.d.ts +14 -0
  19. package/dist/lifecycle/repo-lifecycle-factory.js +57 -0
  20. package/dist/lifecycle/repo-lifecycle-manager.d.ts +20 -0
  21. package/dist/lifecycle/repo-lifecycle-manager.js +139 -0
  22. package/dist/lifecycle/types.d.ts +104 -0
  23. package/dist/lifecycle/types.js +1 -0
  24. package/dist/output/lifecycle-report.d.ts +37 -0
  25. package/dist/output/lifecycle-report.js +152 -0
  26. package/dist/output/settings-report.js +36 -28
  27. package/dist/output/sync-report.js +15 -8
  28. package/dist/settings/repo-settings/diff.js +1 -0
  29. package/dist/settings/repo-settings/github-repo-settings-strategy.js +1 -0
  30. package/dist/shared/logger.d.ts +2 -0
  31. package/dist/shared/logger.js +5 -0
  32. package/dist/sync/manifest-strategy.js +3 -1
  33. package/dist/vcs/authenticated-git-ops.js +1 -1
  34. package/dist/vcs/git-ops.js +1 -1
  35. package/package.json +4 -2
@@ -0,0 +1,139 @@
1
+ import { join } from "node:path";
2
+ import { rm } from "node:fs/promises";
3
+ import { parseGitUrl } from "../shared/repo-detector.js";
4
+ import { logger } from "../shared/logger.js";
5
+ import { RepoLifecycleFactory } from "./repo-lifecycle-factory.js";
6
+ /**
7
+ * Orchestrates repo lifecycle operations before sync.
8
+ */
9
+ export class RepoLifecycleManager {
10
+ factory;
11
+ constructor(factory, retries) {
12
+ this.factory = factory ?? new RepoLifecycleFactory(undefined, retries);
13
+ }
14
+ async ensureRepo(repoConfig, repoInfo, options, settings) {
15
+ let provider;
16
+ try {
17
+ provider = this.factory.getProvider(repoInfo.type);
18
+ }
19
+ catch (error) {
20
+ // If user explicitly configured lifecycle (upstream/source), propagate the error
21
+ if (repoConfig.upstream || repoConfig.source) {
22
+ throw error;
23
+ }
24
+ // Platform doesn't support lifecycle operations yet - skip silently
25
+ return { repoInfo, action: "existed" };
26
+ }
27
+ const { token } = options;
28
+ // Check if repo exists
29
+ const exists = await provider.exists(repoInfo, token);
30
+ if (exists) {
31
+ // Repo exists - nothing to do (ignore upstream/source)
32
+ return {
33
+ repoInfo,
34
+ action: "existed",
35
+ };
36
+ }
37
+ // Repo doesn't exist - determine what action to take
38
+ if (repoConfig.source) {
39
+ // Migration mode
40
+ return this.migrate(repoConfig, repoInfo, options, settings);
41
+ }
42
+ if (repoConfig.upstream) {
43
+ // Fork mode
44
+ return this.fork(repoConfig, repoInfo, provider, options, settings);
45
+ }
46
+ // Create mode (no upstream or source)
47
+ return this.create(repoInfo, provider, options, settings);
48
+ }
49
+ async create(repoInfo, provider, options, settings) {
50
+ if (options.dryRun) {
51
+ return {
52
+ repoInfo,
53
+ action: "created",
54
+ skipped: true,
55
+ };
56
+ }
57
+ await provider.create(repoInfo, settings, options.token);
58
+ await this.waitForRepoReady(provider, repoInfo, options.token);
59
+ return {
60
+ repoInfo,
61
+ action: "created",
62
+ };
63
+ }
64
+ async fork(repoConfig, repoInfo, provider, options, settings) {
65
+ if (options.dryRun) {
66
+ return {
67
+ repoInfo,
68
+ action: "forked",
69
+ skipped: true,
70
+ };
71
+ }
72
+ if (!provider.fork) {
73
+ throw new Error(`Platform '${repoInfo.type}' does not support forking`);
74
+ }
75
+ // Parse upstream URL to get repo info
76
+ const upstreamInfo = parseGitUrl(repoConfig.upstream, {
77
+ githubHosts: options.githubHosts,
78
+ });
79
+ await provider.fork(upstreamInfo, repoInfo, settings, options.token);
80
+ await this.waitForRepoReady(provider, repoInfo, options.token);
81
+ return {
82
+ repoInfo,
83
+ action: "forked",
84
+ };
85
+ }
86
+ async migrate(repoConfig, repoInfo, options, settings) {
87
+ if (options.dryRun) {
88
+ return {
89
+ repoInfo,
90
+ action: "migrated",
91
+ skipped: true,
92
+ };
93
+ }
94
+ // Parse source URL to get platform and repo info
95
+ const sourceInfo = parseGitUrl(repoConfig.source, {
96
+ githubHosts: options.githubHosts,
97
+ });
98
+ const source = this.factory.getMigrationSource(sourceInfo.type);
99
+ // Clone source repo to temp directory
100
+ const sourceDir = join(options.workDir, "migration-source");
101
+ try {
102
+ await source.cloneForMigration(sourceInfo, sourceDir);
103
+ // Create target and push content
104
+ const provider = this.factory.getProvider(repoInfo.type);
105
+ await provider.receiveMigration(repoInfo, sourceDir, settings, options.token);
106
+ await this.waitForRepoReady(provider, repoInfo, options.token);
107
+ return {
108
+ repoInfo,
109
+ action: "migrated",
110
+ };
111
+ }
112
+ finally {
113
+ // Clean up migration source directory
114
+ try {
115
+ await rm(sourceDir, { recursive: true, force: true });
116
+ }
117
+ catch (cleanupError) {
118
+ // Log cleanup errors at debug level for troubleshooting
119
+ logger.debug(`Failed to clean up migration source directory ${sourceDir}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
120
+ }
121
+ }
122
+ }
123
+ /**
124
+ * Polls provider.exists() until the repo is visible, with timeout.
125
+ * GitHub's API may return success from create/fork before the git
126
+ * backend has fully propagated, causing subsequent clone to 403.
127
+ */
128
+ async waitForRepoReady(provider, repoInfo, token, timeoutMs = 15000, pollMs = 1000) {
129
+ const start = Date.now();
130
+ while (Date.now() - start < timeoutMs) {
131
+ if (await provider.exists(repoInfo, token)) {
132
+ return;
133
+ }
134
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
135
+ }
136
+ // Timed out — proceed anyway and let downstream operations handle it
137
+ logger.info(`Repo ${repoInfo.owner}/${repoInfo.repo} not yet visible after ${timeoutMs}ms, proceeding`);
138
+ }
139
+ }
@@ -0,0 +1,104 @@
1
+ import type { RepoInfo } from "../shared/repo-detector.js";
2
+ import type { RepoConfig } from "../config/types.js";
3
+ /**
4
+ * Supported platforms for lifecycle operations.
5
+ */
6
+ export type LifecyclePlatform = "github" | "azure-devops" | "gitlab";
7
+ /**
8
+ * Result of a lifecycle operation.
9
+ */
10
+ export interface LifecycleResult {
11
+ /** The repo info (may be updated) */
12
+ repoInfo: RepoInfo;
13
+ /** What action was taken */
14
+ action: "existed" | "created" | "forked" | "migrated";
15
+ /** True if skipped due to dry-run */
16
+ skipped?: boolean;
17
+ }
18
+ /**
19
+ * Options for lifecycle operations.
20
+ */
21
+ export interface LifecycleOptions {
22
+ /** Dry-run mode - don't make changes */
23
+ dryRun: boolean;
24
+ /** Working directory for git operations */
25
+ workDir: string;
26
+ /** GitHub Enterprise hostnames for URL detection */
27
+ githubHosts?: string[];
28
+ /** Auth token (GitHub App installation token or PAT) for gh CLI commands */
29
+ token?: string;
30
+ }
31
+ /**
32
+ * Repo settings to apply when creating a new repo.
33
+ * Subset of GitHubRepoSettings that makes sense for creation.
34
+ */
35
+ export interface CreateRepoSettings {
36
+ visibility?: "public" | "private" | "internal";
37
+ description?: string;
38
+ hasIssues?: boolean;
39
+ hasWiki?: boolean;
40
+ }
41
+ /**
42
+ * Provider for platform-specific lifecycle operations.
43
+ * Implementations handle create/fork/receive for a specific platform.
44
+ */
45
+ export interface IRepoLifecycleProvider {
46
+ /** Platform this provider handles */
47
+ readonly platform: LifecyclePlatform;
48
+ /**
49
+ * Check if a repository exists on this platform.
50
+ * @throws Error on network/auth failures (NOT for "repo not found")
51
+ */
52
+ exists(repoInfo: RepoInfo, token?: string): Promise<boolean>;
53
+ /**
54
+ * Create an empty repository.
55
+ */
56
+ create(repoInfo: RepoInfo, settings?: CreateRepoSettings, token?: string): Promise<void>;
57
+ /**
58
+ * Fork from an upstream repository.
59
+ * Optional - not all platforms support forking.
60
+ */
61
+ fork?(upstream: RepoInfo, target: RepoInfo, settings?: CreateRepoSettings, token?: string): Promise<void>;
62
+ /**
63
+ * Receive migrated content (repo already created, push content).
64
+ */
65
+ receiveMigration(repoInfo: RepoInfo, sourceDir: string, settings?: CreateRepoSettings, token?: string): Promise<void>;
66
+ }
67
+ /**
68
+ * Source for migration operations.
69
+ * Implementations handle cloning from a source platform.
70
+ */
71
+ export interface IMigrationSource {
72
+ /** Platform this source handles */
73
+ readonly platform: LifecyclePlatform;
74
+ /**
75
+ * Clone repository with all refs for migration.
76
+ * Uses --mirror to get all branches/tags.
77
+ */
78
+ cloneForMigration(repoInfo: RepoInfo, workDir: string): Promise<void>;
79
+ }
80
+ /**
81
+ * Factory for getting providers by platform.
82
+ */
83
+ export interface IRepoLifecycleFactory {
84
+ /**
85
+ * Get lifecycle provider for a platform.
86
+ * @throws Error if platform not supported as target
87
+ */
88
+ getProvider(platform: LifecyclePlatform): IRepoLifecycleProvider;
89
+ /**
90
+ * Get migration source for a platform.
91
+ * @throws Error if platform not supported as source
92
+ */
93
+ getMigrationSource(platform: LifecyclePlatform): IMigrationSource;
94
+ }
95
+ /**
96
+ * Manager that orchestrates lifecycle operations before sync.
97
+ */
98
+ export interface IRepoLifecycleManager {
99
+ /**
100
+ * Ensure repository exists, creating/forking/migrating if needed.
101
+ * Call this before sync/settings operations.
102
+ */
103
+ ensureRepo(repoConfig: RepoConfig, repoInfo: RepoInfo, options: LifecycleOptions, settings?: CreateRepoSettings): Promise<LifecycleResult>;
104
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,37 @@
1
+ export interface LifecycleReport {
2
+ actions: LifecycleAction[];
3
+ totals: {
4
+ created: number;
5
+ forked: number;
6
+ migrated: number;
7
+ existed: number;
8
+ };
9
+ }
10
+ export interface LifecycleAction {
11
+ repoName: string;
12
+ action: "existed" | "created" | "forked" | "migrated";
13
+ upstream?: string;
14
+ source?: string;
15
+ settings?: {
16
+ visibility?: string;
17
+ description?: string;
18
+ };
19
+ }
20
+ export interface LifecycleReportInput {
21
+ repoName: string;
22
+ action: "existed" | "created" | "forked" | "migrated";
23
+ upstream?: string;
24
+ source?: string;
25
+ settings?: {
26
+ visibility?: string;
27
+ description?: string;
28
+ };
29
+ }
30
+ export declare function buildLifecycleReport(results: LifecycleReportInput[]): LifecycleReport;
31
+ /**
32
+ * Returns true if the report has any non-"existed" actions worth displaying.
33
+ */
34
+ export declare function hasLifecycleChanges(report: LifecycleReport): boolean;
35
+ export declare function formatLifecycleReportCLI(report: LifecycleReport): string[];
36
+ export declare function formatLifecycleReportMarkdown(report: LifecycleReport, dryRun: boolean): string;
37
+ export declare function writeLifecycleReportSummary(report: LifecycleReport, dryRun: boolean): void;
@@ -0,0 +1,152 @@
1
+ // src/output/lifecycle-report.ts
2
+ import { appendFileSync } from "node:fs";
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
+ // =============================================================================
11
+ // Builder
12
+ // =============================================================================
13
+ export function buildLifecycleReport(results) {
14
+ const actions = [];
15
+ const totals = { created: 0, forked: 0, migrated: 0, existed: 0 };
16
+ for (const result of results) {
17
+ actions.push({
18
+ repoName: result.repoName,
19
+ action: result.action,
20
+ upstream: result.upstream,
21
+ source: result.source,
22
+ settings: result.settings,
23
+ });
24
+ totals[result.action]++;
25
+ }
26
+ return { actions, totals };
27
+ }
28
+ // =============================================================================
29
+ // Helpers
30
+ // =============================================================================
31
+ function formatSummary(totals) {
32
+ const total = totals.created + totals.forked + totals.migrated;
33
+ if (total === 0) {
34
+ return "No changes";
35
+ }
36
+ const parts = [];
37
+ if (totals.created > 0)
38
+ parts.push(`${totals.created} to create`);
39
+ if (totals.forked > 0)
40
+ parts.push(`${totals.forked} to fork`);
41
+ if (totals.migrated > 0)
42
+ parts.push(`${totals.migrated} to migrate`);
43
+ const repoWord = total === 1 ? "repo" : "repos";
44
+ return `Plan: ${total} ${repoWord} (${parts.join(", ")})`;
45
+ }
46
+ /**
47
+ * Returns true if the report has any non-"existed" actions worth displaying.
48
+ */
49
+ export function hasLifecycleChanges(report) {
50
+ return report.actions.some((a) => a.action !== "existed");
51
+ }
52
+ // =============================================================================
53
+ // CLI Formatter
54
+ // =============================================================================
55
+ export function formatLifecycleReportCLI(report) {
56
+ if (!hasLifecycleChanges(report)) {
57
+ return [];
58
+ }
59
+ const lines = [];
60
+ for (const action of report.actions) {
61
+ if (action.action === "existed")
62
+ continue;
63
+ switch (action.action) {
64
+ case "created":
65
+ lines.push(chalk.green(`+ CREATE ${action.repoName}`));
66
+ break;
67
+ case "forked":
68
+ lines.push(chalk.green(`+ FORK ${action.upstream ?? "upstream"} -> ${action.repoName}`));
69
+ break;
70
+ case "migrated":
71
+ lines.push(chalk.green(`+ MIGRATE ${action.source ?? "source"} -> ${action.repoName}`));
72
+ break;
73
+ }
74
+ if (action.settings) {
75
+ if (action.settings.visibility) {
76
+ lines.push(` visibility: ${action.settings.visibility}`);
77
+ }
78
+ if (action.settings.description) {
79
+ lines.push(` description: "${action.settings.description}"`);
80
+ }
81
+ }
82
+ }
83
+ lines.push("");
84
+ // Summary
85
+ lines.push(formatSummary(report.totals));
86
+ return lines;
87
+ }
88
+ // =============================================================================
89
+ // Markdown Formatter
90
+ // =============================================================================
91
+ export function formatLifecycleReportMarkdown(report, dryRun) {
92
+ if (!hasLifecycleChanges(report)) {
93
+ return "";
94
+ }
95
+ const lines = [];
96
+ // Title
97
+ const titleSuffix = dryRun ? " (Dry Run)" : "";
98
+ lines.push(`## Lifecycle Summary${titleSuffix}`);
99
+ lines.push("");
100
+ // Dry-run warning
101
+ if (dryRun) {
102
+ lines.push("> [!WARNING]");
103
+ lines.push("> This was a dry run — no changes were applied");
104
+ lines.push("");
105
+ }
106
+ // Colored diff output using HTML <pre> with inline styles
107
+ const diffLines = [];
108
+ for (const action of report.actions) {
109
+ if (action.action === "existed")
110
+ continue;
111
+ switch (action.action) {
112
+ case "created":
113
+ diffLines.push(`<span style="color:#3fb950">+ CREATE ${escapeHtml(action.repoName)}</span>`);
114
+ break;
115
+ case "forked":
116
+ diffLines.push(`<span style="color:#3fb950">+ FORK ${escapeHtml(action.upstream ?? "upstream")} -&gt; ${escapeHtml(action.repoName)}</span>`);
117
+ break;
118
+ case "migrated":
119
+ diffLines.push(`<span style="color:#3fb950">+ MIGRATE ${escapeHtml(action.source ?? "source")} -&gt; ${escapeHtml(action.repoName)}</span>`);
120
+ break;
121
+ }
122
+ if (action.settings) {
123
+ if (action.settings.visibility) {
124
+ diffLines.push(`<span style="color:#3fb950"> visibility: ${escapeHtml(action.settings.visibility)}</span>`);
125
+ }
126
+ if (action.settings.description) {
127
+ diffLines.push(`<span style="color:#3fb950"> description: "${escapeHtml(action.settings.description)}"</span>`);
128
+ }
129
+ }
130
+ }
131
+ if (diffLines.length > 0) {
132
+ lines.push("<pre>");
133
+ lines.push(...diffLines);
134
+ lines.push("</pre>");
135
+ lines.push("");
136
+ }
137
+ // Summary
138
+ lines.push(`**${formatSummary(report.totals)}**`);
139
+ return lines.join("\n");
140
+ }
141
+ // =============================================================================
142
+ // File Writer
143
+ // =============================================================================
144
+ export function writeLifecycleReportSummary(report, dryRun) {
145
+ const summaryPath = process.env.GITHUB_STEP_SUMMARY;
146
+ if (!summaryPath)
147
+ return;
148
+ const markdown = formatLifecycleReportMarkdown(report, dryRun);
149
+ if (!markdown)
150
+ return;
151
+ appendFileSync(summaryPath, "\n" + markdown + "\n");
152
+ }
@@ -1,6 +1,12 @@
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
+ }
4
10
  // =============================================================================
5
11
  // Helpers
6
12
  // =============================================================================
@@ -164,52 +170,52 @@ function formatValuePlain(val) {
164
170
  return val ? "true" : "false";
165
171
  return String(val);
166
172
  }
167
- function formatRulesetConfigPlain(config, indent) {
173
+ function formatRulesetConfigPlain(config) {
168
174
  const lines = [];
169
- function renderObject(obj, currentIndent) {
175
+ function renderObject(obj, depth) {
170
176
  for (const [k, v] of Object.entries(obj)) {
171
- renderValue(k, v, currentIndent);
177
+ renderValue(k, v, depth);
172
178
  }
173
179
  }
174
- function renderValue(key, value, currentIndent) {
175
- const pad = " ".repeat(currentIndent);
180
+ function renderValue(key, value, depth) {
181
+ const indent = " ".repeat(depth);
176
182
  if (value === null || value === undefined)
177
183
  return;
178
184
  if (Array.isArray(value)) {
179
185
  if (value.length === 0) {
180
- lines.push(`${pad}+ ${key}: []`);
186
+ lines.push(`+${indent} ${key}: []`);
181
187
  }
182
188
  else if (value.every((v) => typeof v !== "object")) {
183
- lines.push(`${pad}+ ${key}: [${value.map((v) => (typeof v === "string" ? `"${v}"` : String(v))).join(", ")}]`);
189
+ lines.push(`+${indent} ${key}: [${value.map((v) => (typeof v === "string" ? `"${v}"` : String(v))).join(", ")}]`);
184
190
  }
185
191
  else {
186
- lines.push(`${pad}+ ${key}:`);
192
+ lines.push(`+${indent} ${key}:`);
187
193
  for (let i = 0; i < value.length; i++) {
188
194
  const item = value[i];
189
195
  if (typeof item === "object" && item !== null) {
190
196
  const obj = item;
191
197
  const typeLabel = "type" in obj ? ` (${obj.type})` : "";
192
- lines.push(`${pad} + [${i}]${typeLabel}:`);
193
- renderObject(obj, currentIndent + 2);
198
+ lines.push(`+${indent} [${i}]${typeLabel}:`);
199
+ renderObject(obj, depth + 2);
194
200
  }
195
201
  else {
196
- lines.push(`${pad} + ${formatValuePlain(item)}`);
202
+ lines.push(`+${indent} ${formatValuePlain(item)}`);
197
203
  }
198
204
  }
199
205
  }
200
206
  }
201
207
  else if (typeof value === "object") {
202
- lines.push(`${pad}+ ${key}:`);
203
- renderObject(value, currentIndent + 1);
208
+ lines.push(`+${indent} ${key}:`);
209
+ renderObject(value, depth + 1);
204
210
  }
205
211
  else {
206
- lines.push(`${pad}+ ${key}: ${formatValuePlain(value)}`);
212
+ lines.push(`+${indent} ${key}: ${formatValuePlain(value)}`);
207
213
  }
208
214
  }
209
215
  for (const [key, value] of Object.entries(config)) {
210
216
  if (key === "name")
211
217
  continue;
212
- renderValue(key, value, indent);
218
+ renderValue(key, value, 1);
213
219
  }
214
220
  return lines;
215
221
  }
@@ -233,55 +239,57 @@ export function formatSettingsReportMarkdown(report, dryRun) {
233
239
  !repo.error) {
234
240
  continue;
235
241
  }
236
- diffLines.push(`~ ${repo.repoName}`);
242
+ diffLines.push(`<span style="color:#d29922">~ ${escapeHtml(repo.repoName)}</span>`);
237
243
  for (const setting of repo.settings) {
238
244
  // Skip settings where both values are undefined
239
245
  if (setting.oldValue === undefined && setting.newValue === undefined) {
240
246
  continue;
241
247
  }
242
248
  if (setting.action === "add") {
243
- diffLines.push(` + ${setting.name}: ${formatValuePlain(setting.newValue)}`);
249
+ diffLines.push(`<span style="color:#3fb950"> + ${escapeHtml(setting.name)}: ${escapeHtml(formatValuePlain(setting.newValue))}</span>`);
244
250
  }
245
251
  else {
246
- diffLines.push(` ~ ${setting.name}: ${formatValuePlain(setting.oldValue)} → ${formatValuePlain(setting.newValue)}`);
252
+ diffLines.push(`<span style="color:#d29922"> ~ ${escapeHtml(setting.name)}: ${escapeHtml(formatValuePlain(setting.oldValue))} → ${escapeHtml(formatValuePlain(setting.newValue))}</span>`);
247
253
  }
248
254
  }
249
255
  for (const ruleset of repo.rulesets) {
250
256
  if (ruleset.action === "create") {
251
- diffLines.push(` + ruleset "${ruleset.name}"`);
257
+ diffLines.push(`<span style="color:#3fb950"> + ruleset "${escapeHtml(ruleset.name)}"</span>`);
252
258
  if (ruleset.config) {
253
- diffLines.push(...formatRulesetConfigPlain(ruleset.config, 2));
259
+ for (const line of formatRulesetConfigPlain(ruleset.config)) {
260
+ diffLines.push(`<span style="color:#3fb950">${escapeHtml(line)}</span>`);
261
+ }
254
262
  }
255
263
  }
256
264
  else if (ruleset.action === "update") {
257
- diffLines.push(` ~ ruleset "${ruleset.name}"`);
265
+ diffLines.push(`<span style="color:#d29922"> ~ ruleset "${escapeHtml(ruleset.name)}"</span>`);
258
266
  if (ruleset.propertyDiffs && ruleset.propertyDiffs.length > 0) {
259
267
  for (const diff of ruleset.propertyDiffs) {
260
268
  const path = diff.path.join(".");
261
269
  if (diff.action === "add") {
262
- diffLines.push(` + ${path}: ${formatValuePlain(diff.newValue)}`);
270
+ diffLines.push(`<span style="color:#3fb950"> + ${escapeHtml(path)}: ${escapeHtml(formatValuePlain(diff.newValue))}</span>`);
263
271
  }
264
272
  else if (diff.action === "change") {
265
- diffLines.push(` ~ ${path}: ${formatValuePlain(diff.oldValue)} → ${formatValuePlain(diff.newValue)}`);
273
+ diffLines.push(`<span style="color:#d29922"> ~ ${escapeHtml(path)}: ${escapeHtml(formatValuePlain(diff.oldValue))} → ${escapeHtml(formatValuePlain(diff.newValue))}</span>`);
266
274
  }
267
275
  else if (diff.action === "remove") {
268
- diffLines.push(` - ${path}`);
276
+ diffLines.push(`<span style="color:#f85149"> - ${escapeHtml(path)}</span>`);
269
277
  }
270
278
  }
271
279
  }
272
280
  }
273
281
  else if (ruleset.action === "delete") {
274
- diffLines.push(` - ruleset "${ruleset.name}"`);
282
+ diffLines.push(`<span style="color:#f85149"> - ruleset "${escapeHtml(ruleset.name)}"</span>`);
275
283
  }
276
284
  }
277
285
  if (repo.error) {
278
- diffLines.push(` ! Error: ${repo.error}`);
286
+ diffLines.push(`<span style="color:#f85149"> ! Error: ${escapeHtml(repo.error)}</span>`);
279
287
  }
280
288
  }
281
289
  if (diffLines.length > 0) {
282
- lines.push("```diff");
290
+ lines.push("<pre>");
283
291
  lines.push(...diffLines);
284
- lines.push("```");
292
+ lines.push("</pre>");
285
293
  lines.push("");
286
294
  }
287
295
  // Summary
@@ -1,6 +1,12 @@
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
+ }
4
10
  function formatSummary(totals) {
5
11
  const total = totals.files.create + totals.files.update + totals.files.delete;
6
12
  if (total === 0) {
@@ -58,32 +64,33 @@ export function formatSyncReportMarkdown(report, dryRun) {
58
64
  lines.push("> This was a dry run — no changes were applied");
59
65
  lines.push("");
60
66
  }
61
- // Diff block
67
+ // Colored diff output using HTML <pre> with inline styles
68
+ // Colors: green (#3fb950) for creates, yellow (#d29922) for changes, red (#f85149) for deletes
62
69
  const diffLines = [];
63
70
  for (const repo of report.repos) {
64
71
  if (repo.files.length === 0 && !repo.error) {
65
72
  continue;
66
73
  }
67
- diffLines.push(`~ ${repo.repoName}`);
74
+ diffLines.push(`<span style="color:#d29922">~ ${escapeHtml(repo.repoName)}</span>`);
68
75
  for (const file of repo.files) {
69
76
  if (file.action === "create") {
70
- diffLines.push(` + ${file.path}`);
77
+ diffLines.push(`<span style="color:#3fb950"> + ${escapeHtml(file.path)}</span>`);
71
78
  }
72
79
  else if (file.action === "update") {
73
- diffLines.push(` ~ ${file.path}`);
80
+ diffLines.push(`<span style="color:#d29922"> ~ ${escapeHtml(file.path)}</span>`);
74
81
  }
75
82
  else if (file.action === "delete") {
76
- diffLines.push(` - ${file.path}`);
83
+ diffLines.push(`<span style="color:#f85149"> - ${escapeHtml(file.path)}</span>`);
77
84
  }
78
85
  }
79
86
  if (repo.error) {
80
- diffLines.push(` ! Error: ${repo.error}`);
87
+ diffLines.push(`<span style="color:#f85149"> ! Error: ${escapeHtml(repo.error)}</span>`);
81
88
  }
82
89
  }
83
90
  if (diffLines.length > 0) {
84
- lines.push("```diff");
91
+ lines.push("<pre>");
85
92
  lines.push(...diffLines);
86
- lines.push("```");
93
+ lines.push("</pre>");
87
94
  lines.push("");
88
95
  }
89
96
  // Summary
@@ -2,6 +2,7 @@
2
2
  * Maps config property names (camelCase) to GitHub API property names (snake_case).
3
3
  */
4
4
  const PROPERTY_MAPPING = {
5
+ description: "description",
5
6
  hasIssues: "has_issues",
6
7
  hasProjects: "has_projects",
7
8
  hasWiki: "has_wiki",
@@ -14,6 +14,7 @@ function configToGitHubPayload(settings) {
14
14
  const payload = {};
15
15
  // Map config properties to API properties
16
16
  const directMappings = [
17
+ "description",
17
18
  "hasIssues",
18
19
  "hasProjects",
19
20
  "hasWiki",