@aspruyt/xfg 2.0.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Anthony
3
+ Copyright (c) 2026 Anthony Spruyt
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/PR.md CHANGED
@@ -1,3 +1,5 @@
1
+ <!-- markdownlint-disable MD041 -->
2
+
1
3
  ## Summary
2
4
 
3
5
  Automated sync of configuration files to ${xfg:repo.fullName}.
package/dist/git-ops.js CHANGED
@@ -283,7 +283,7 @@ export function validateBranchName(branchName) {
283
283
  throw new Error('Branch name cannot start with "." or "-"');
284
284
  }
285
285
  // Git disallows: space, ~, ^, :, ?, *, [, \, and consecutive dots (..)
286
- if (/[\s~^:?*\[\\]/.test(branchName) || branchName.includes("..")) {
286
+ if (/[\s~^:?*[\\]/.test(branchName) || branchName.includes("..")) {
287
287
  throw new Error("Branch name contains invalid characters");
288
288
  }
289
289
  if (branchName.endsWith("/") ||
@@ -0,0 +1,25 @@
1
+ export type MergeOutcome = "manual" | "auto" | "force" | "direct";
2
+ export interface FileChanges {
3
+ added: number;
4
+ modified: number;
5
+ deleted: number;
6
+ unchanged: number;
7
+ }
8
+ export interface RepoResult {
9
+ repoName: string;
10
+ status: "succeeded" | "skipped" | "failed";
11
+ message: string;
12
+ prUrl?: string;
13
+ mergeOutcome?: MergeOutcome;
14
+ fileChanges?: FileChanges;
15
+ }
16
+ export interface SummaryData {
17
+ total: number;
18
+ succeeded: number;
19
+ skipped: number;
20
+ failed: number;
21
+ results: RepoResult[];
22
+ }
23
+ export declare function formatSummary(data: SummaryData): string;
24
+ export declare function isGitHubActions(): boolean;
25
+ export declare function writeSummary(data: SummaryData): void;
@@ -0,0 +1,83 @@
1
+ import { appendFileSync } from "node:fs";
2
+ function escapeMarkdown(text) {
3
+ // Escape backslashes first, then pipes (order matters to prevent double-escaping)
4
+ return text.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
5
+ }
6
+ function formatFileChanges(changes) {
7
+ if (!changes)
8
+ return "-";
9
+ return `+${changes.added} ~${changes.modified} -${changes.deleted}`;
10
+ }
11
+ function formatStatus(result) {
12
+ if (result.status === "skipped")
13
+ return "⏭️ Skipped";
14
+ if (result.status === "failed")
15
+ return "❌ Failed";
16
+ // Succeeded - format based on merge outcome
17
+ switch (result.mergeOutcome) {
18
+ case "manual":
19
+ return "✅ Open";
20
+ case "auto":
21
+ return "✅ Auto-merge";
22
+ case "force":
23
+ return "✅ Merged";
24
+ case "direct":
25
+ return "✅ Pushed";
26
+ default:
27
+ return "✅ Succeeded";
28
+ }
29
+ }
30
+ function formatResult(result) {
31
+ if (result.prUrl) {
32
+ // Extract PR number from URL
33
+ const prMatch = result.prUrl.match(/\/pull\/(\d+)/);
34
+ const prNum = prMatch ? prMatch[1] : "PR";
35
+ return `[PR #${prNum}](${result.prUrl})`;
36
+ }
37
+ if (result.mergeOutcome === "direct") {
38
+ return "Direct to main";
39
+ }
40
+ return escapeMarkdown(result.message);
41
+ }
42
+ export function formatSummary(data) {
43
+ const lines = [];
44
+ // Header
45
+ lines.push("## Config Sync Summary");
46
+ lines.push("");
47
+ // Stats table
48
+ lines.push("| Status | Count |");
49
+ lines.push("|--------|-------|");
50
+ lines.push(`| ✅ Succeeded | ${data.succeeded} |`);
51
+ lines.push(`| ⏭️ Skipped | ${data.skipped} |`);
52
+ lines.push(`| ❌ Failed | ${data.failed} |`);
53
+ lines.push(`| **Total** | **${data.total}** |`);
54
+ // Repo details table (only if there are results)
55
+ if (data.results.length > 0) {
56
+ lines.push("");
57
+ lines.push("<details>");
58
+ lines.push("<summary>Repository Details</summary>");
59
+ lines.push("");
60
+ lines.push("| Repository | Status | Changes | Result |");
61
+ lines.push("|------------|--------|---------|--------|");
62
+ for (const result of data.results) {
63
+ const repo = result.repoName;
64
+ const status = formatStatus(result);
65
+ const changes = formatFileChanges(result.fileChanges);
66
+ const resultText = formatResult(result);
67
+ lines.push(`| ${repo} | ${status} | ${changes} | ${resultText} |`);
68
+ }
69
+ lines.push("");
70
+ lines.push("</details>");
71
+ }
72
+ return lines.join("\n");
73
+ }
74
+ export function isGitHubActions() {
75
+ return !!process.env.GITHUB_STEP_SUMMARY;
76
+ }
77
+ export function writeSummary(data) {
78
+ const summaryPath = process.env.GITHUB_STEP_SUMMARY;
79
+ if (!summaryPath)
80
+ return;
81
+ const markdown = formatSummary(data);
82
+ appendFileSync(summaryPath, markdown + "\n");
83
+ }
package/dist/index.js CHANGED
@@ -13,6 +13,8 @@ import { sanitizeBranchName, validateBranchName } from "./git-ops.js";
13
13
  import { logger } from "./logger.js";
14
14
  import { generateWorkspaceName } from "./workspace-utils.js";
15
15
  import { RepositoryProcessor, } from "./repository-processor.js";
16
+ import { writeSummary } from "./github-summary.js";
17
+ import { buildRepoResult, buildErrorResult } from "./summary-utils.js";
16
18
  /**
17
19
  * Default factory that creates a real RepositoryProcessor.
18
20
  */
@@ -102,6 +104,7 @@ async function main() {
102
104
  console.log(`Target files: ${formatFileNames(fileNames)}`);
103
105
  console.log(`Branch: ${branchName}\n`);
104
106
  const processor = defaultProcessorFactory();
107
+ const results = [];
105
108
  for (let i = 0; i < config.repos.length; i++) {
106
109
  const repoConfig = config.repos[i];
107
110
  // Apply CLI merge overrides to repo config
@@ -121,8 +124,8 @@ async function main() {
121
124
  });
122
125
  }
123
126
  catch (error) {
124
- const message = error instanceof Error ? error.message : String(error);
125
- logger.error(current, repoConfig.git, message);
127
+ logger.error(current, repoConfig.git, String(error));
128
+ results.push(buildErrorResult(repoConfig.git, error));
126
129
  continue;
127
130
  }
128
131
  const repoName = getRepoDisplayName(repoInfo);
@@ -138,31 +141,35 @@ async function main() {
138
141
  prTemplate: config.prTemplate,
139
142
  noDelete: options.noDelete,
140
143
  });
144
+ const repoResult = buildRepoResult(repoName, repoConfig, result);
145
+ results.push(repoResult);
141
146
  if (result.skipped) {
142
147
  logger.skip(current, repoName, result.message);
143
148
  }
144
149
  else if (result.success) {
145
- let message = result.prUrl ? `PR: ${result.prUrl}` : result.message;
146
- if (result.mergeResult) {
147
- if (result.mergeResult.merged) {
148
- message += " (merged)";
149
- }
150
- else if (result.mergeResult.autoMergeEnabled) {
151
- message += " (auto-merge enabled)";
152
- }
153
- }
154
- logger.success(current, repoName, message);
150
+ logger.success(current, repoName, repoResult.message);
155
151
  }
156
152
  else {
157
153
  logger.error(current, repoName, result.message);
158
154
  }
159
155
  }
160
156
  catch (error) {
161
- const message = error instanceof Error ? error.message : String(error);
162
- logger.error(current, repoName, message);
157
+ logger.error(current, repoName, String(error));
158
+ results.push(buildErrorResult(repoName, error));
163
159
  }
164
160
  }
165
161
  logger.summary();
162
+ // Write GitHub Actions job summary if running in GitHub Actions
163
+ const succeeded = results.filter((r) => r.status === "succeeded").length;
164
+ const skipped = results.filter((r) => r.status === "skipped").length;
165
+ const failed = results.filter((r) => r.status === "failed").length;
166
+ writeSummary({
167
+ total: config.repos.length,
168
+ succeeded,
169
+ skipped,
170
+ failed,
171
+ results,
172
+ });
166
173
  if (logger.hasFailures()) {
167
174
  process.exit(1);
168
175
  }
package/dist/logger.js CHANGED
@@ -29,8 +29,7 @@ export class Logger {
29
29
  }
30
30
  error(current, repoName, error) {
31
31
  this.stats.failed++;
32
- console.log(chalk.red(`[${current}/${this.stats.total}] ✗`) +
33
- ` ${repoName}: ${error}`);
32
+ console.log(chalk.red(`[${current}/${this.stats.total}] ✗`) + ` ${repoName}: ${error}`);
34
33
  }
35
34
  /**
36
35
  * Display a file diff with status badge.
@@ -3,6 +3,7 @@ import { RepoInfo } from "./repo-detector.js";
3
3
  import { GitOps, GitOpsOptions } from "./git-ops.js";
4
4
  import { ILogger } from "./logger.js";
5
5
  import { CommandExecutor } from "./command-executor.js";
6
+ import { DiffStats } from "./diff-utils.js";
6
7
  export interface ProcessorOptions {
7
8
  branchName: string;
8
9
  workDir: string;
@@ -34,6 +35,7 @@ export interface ProcessorResult {
34
35
  autoMergeEnabled?: boolean;
35
36
  message: string;
36
37
  };
38
+ diffStats?: DiffStats;
37
39
  }
38
40
  export declare class RepositoryProcessor {
39
41
  private gitOps;
@@ -268,6 +268,7 @@ export class RepositoryProcessor {
268
268
  repoName,
269
269
  message: "No changes detected",
270
270
  skipped: true,
271
+ diffStats,
271
272
  };
272
273
  }
273
274
  // Step 7: Commit
@@ -281,6 +282,7 @@ export class RepositoryProcessor {
281
282
  repoName,
282
283
  message: "No changes detected after staging",
283
284
  skipped: true,
285
+ diffStats,
284
286
  };
285
287
  }
286
288
  this.log.info(`Committed: ${commitMessage}`);
@@ -316,6 +318,7 @@ export class RepositoryProcessor {
316
318
  success: true,
317
319
  repoName,
318
320
  message: `Pushed directly to ${baseBranch}`,
321
+ diffStats,
319
322
  };
320
323
  }
321
324
  // Step 9: Create PR (non-direct modes only)
@@ -368,6 +371,7 @@ export class RepositoryProcessor {
368
371
  message: prResult.message,
369
372
  prUrl: prResult.url,
370
373
  mergeResult,
374
+ diffStats,
371
375
  };
372
376
  }
373
377
  finally {
@@ -0,0 +1,20 @@
1
+ import { ProcessorResult } from "./repository-processor.js";
2
+ import { RepoConfig } from "./config.js";
3
+ import { MergeOutcome, FileChanges, RepoResult } from "./github-summary.js";
4
+ import { DiffStats } from "./diff-utils.js";
5
+ /**
6
+ * Determine merge outcome from repo config and processor result
7
+ */
8
+ export declare function getMergeOutcome(repoConfig: RepoConfig, result: ProcessorResult): MergeOutcome | undefined;
9
+ /**
10
+ * Convert DiffStats to FileChanges for summary output
11
+ */
12
+ export declare function toFileChanges(diffStats?: DiffStats): FileChanges | undefined;
13
+ /**
14
+ * Build a RepoResult from a ProcessorResult for the summary
15
+ */
16
+ export declare function buildRepoResult(repoName: string, repoConfig: RepoConfig, result: ProcessorResult): RepoResult;
17
+ /**
18
+ * Build a RepoResult for an error case
19
+ */
20
+ export declare function buildErrorResult(repoName: string, error: unknown): RepoResult;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Determine merge outcome from repo config and processor result
3
+ */
4
+ export function getMergeOutcome(repoConfig, result) {
5
+ if (!result.success || result.skipped)
6
+ return undefined;
7
+ const mergeMode = repoConfig.prOptions?.merge ?? "auto";
8
+ if (mergeMode === "direct")
9
+ return "direct";
10
+ if (result.mergeResult?.merged)
11
+ return "force";
12
+ if (result.mergeResult?.autoMergeEnabled)
13
+ return "auto";
14
+ if (result.prUrl)
15
+ return "manual";
16
+ return undefined;
17
+ }
18
+ /**
19
+ * Convert DiffStats to FileChanges for summary output
20
+ */
21
+ export function toFileChanges(diffStats) {
22
+ if (!diffStats)
23
+ return undefined;
24
+ return {
25
+ added: diffStats.newCount,
26
+ modified: diffStats.modifiedCount,
27
+ deleted: diffStats.deletedCount,
28
+ unchanged: diffStats.unchangedCount,
29
+ };
30
+ }
31
+ /**
32
+ * Build a RepoResult from a ProcessorResult for the summary
33
+ */
34
+ export function buildRepoResult(repoName, repoConfig, result) {
35
+ if (result.skipped) {
36
+ return {
37
+ repoName,
38
+ status: "skipped",
39
+ message: result.message,
40
+ fileChanges: toFileChanges(result.diffStats),
41
+ };
42
+ }
43
+ if (result.success) {
44
+ let message = result.prUrl ? `PR: ${result.prUrl}` : result.message;
45
+ if (result.mergeResult) {
46
+ if (result.mergeResult.merged) {
47
+ message += " (merged)";
48
+ }
49
+ else if (result.mergeResult.autoMergeEnabled) {
50
+ message += " (auto-merge enabled)";
51
+ }
52
+ }
53
+ return {
54
+ repoName,
55
+ status: "succeeded",
56
+ message,
57
+ prUrl: result.prUrl,
58
+ mergeOutcome: getMergeOutcome(repoConfig, result),
59
+ fileChanges: toFileChanges(result.diffStats),
60
+ };
61
+ }
62
+ return {
63
+ repoName,
64
+ status: "failed",
65
+ message: result.message,
66
+ };
67
+ }
68
+ /**
69
+ * Build a RepoResult for an error case
70
+ */
71
+ export function buildErrorResult(repoName, error) {
72
+ const message = error instanceof Error ? error.message : String(error);
73
+ return {
74
+ repoName,
75
+ status: "failed",
76
+ message,
77
+ };
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/xfg",
3
- "version": "2.0.2",
3
+ "version": "2.1.0",
4
4
  "description": "CLI tool to sync JSON, JSON5, YAML, or text configuration files across multiple Git repositories via pull requests or direct push",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",