@aspruyt/xfg 1.3.0 → 1.4.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/PR.md CHANGED
@@ -1,10 +1,10 @@
1
1
  ## Summary
2
2
 
3
- Automated sync of `{{FILE_NAME}}` configuration file.
3
+ Automated sync of configuration files.
4
4
 
5
5
  ## Changes
6
6
 
7
- - {{ACTION}} `{{FILE_NAME}}` in repository root
7
+ {{FILE_CHANGES}}
8
8
 
9
9
  ## Source
10
10
 
package/README.md CHANGED
@@ -182,11 +182,12 @@ repos: # List of repositories
182
182
 
183
183
  ### Root-Level Fields
184
184
 
185
- | Field | Description | Required |
186
- | ----------- | ---------------------------------------------------- | -------- |
187
- | `files` | Map of target filenames to configs | Yes |
188
- | `repos` | Array of repository configurations | Yes |
189
- | `prOptions` | Global PR merge options (can be overridden per-repo) | No |
185
+ | Field | Description | Required |
186
+ | ------------ | ------------------------------------------------------------- | -------- |
187
+ | `files` | Map of target filenames to configs | Yes |
188
+ | `repos` | Array of repository configurations | Yes |
189
+ | `prOptions` | Global PR merge options (can be overridden per-repo) | No |
190
+ | `prTemplate` | Custom PR body template (inline or `@path/to/file` reference) | No |
190
191
 
191
192
  ### Per-File Fields
192
193
 
@@ -216,6 +217,33 @@ repos: # List of repositories
216
217
  | `deleteBranch` | Delete source branch after merge | `true` |
217
218
  | `bypassReason` | Reason for bypassing policies (Azure DevOps only, required for `force`) | - |
218
219
 
220
+ ### PR Template Customization
221
+
222
+ Customize the PR body with a template. Use the `{{FILE_CHANGES}}` placeholder for the list of changed files:
223
+
224
+ ```yaml
225
+ # Inline template
226
+ prTemplate: |
227
+ ## Configuration Update
228
+
229
+ This PR synchronizes the following files:
230
+
231
+ {{FILE_CHANGES}}
232
+
233
+ Please review and merge.
234
+
235
+ # Or reference an external file
236
+ prTemplate: "@templates/pr-body.md"
237
+ ```
238
+
239
+ **Available Placeholders:**
240
+
241
+ | Placeholder | Description | Example Output |
242
+ | ------------------ | ----------------------------------- | -------------------------------------------------------- |
243
+ | `{{FILE_CHANGES}}` | Bulleted list of files with actions | `- Created \`config.json\`\n- Updated \`settings.yaml\`` |
244
+
245
+ **Default Template:** If not specified, uses the built-in template with Summary, Changes, and Source sections.
246
+
219
247
  ### Per-Repo File Override Fields
220
248
 
221
249
  | Field | Description | Required |
@@ -135,5 +135,6 @@ export function normalizeConfig(raw) {
135
135
  }
136
136
  return {
137
137
  repos: expandedRepos,
138
+ prTemplate: raw.prTemplate,
138
139
  };
139
140
  }
@@ -108,7 +108,8 @@ export function validateRawConfig(config) {
108
108
  continue;
109
109
  }
110
110
  if (fileOverride.override && !fileOverride.content) {
111
- throw new Error(`Repo ${getGitDisplayName(repo.git)} has override: true for file '${fileName}' but no content defined`);
111
+ throw new Error(`Repo ${getGitDisplayName(repo.git)} has override: true for file '${fileName}' but no content defined. ` +
112
+ `Use content: "" for an empty text file override, or content: {} for an empty JSON/YAML override.`);
112
113
  }
113
114
  // Validate content type
114
115
  if (fileOverride.content !== undefined) {
package/dist/config.d.ts CHANGED
@@ -34,6 +34,7 @@ export interface RawConfig {
34
34
  files: Record<string, RawFileConfig>;
35
35
  repos: RawRepoConfig[];
36
36
  prOptions?: PRMergeOptions;
37
+ prTemplate?: string;
37
38
  }
38
39
  export interface FileContent {
39
40
  fileName: string;
@@ -50,5 +51,6 @@ export interface RepoConfig {
50
51
  }
51
52
  export interface Config {
52
53
  repos: RepoConfig[];
54
+ prTemplate?: string;
53
55
  }
54
56
  export declare function loadConfig(filePath: string): Config;
@@ -99,6 +99,14 @@ export function resolveFileReferencesInConfig(raw, options) {
99
99
  const { configDir } = options;
100
100
  // Deep clone to avoid mutating input
101
101
  const result = JSON.parse(JSON.stringify(raw));
102
+ // Resolve prTemplate file reference
103
+ if (result.prTemplate && isFileReference(result.prTemplate)) {
104
+ const resolved = resolveFileReference(result.prTemplate, configDir);
105
+ if (typeof resolved !== "string") {
106
+ throw new Error(`prTemplate file reference "${result.prTemplate}" must resolve to a text file, not JSON/YAML`);
107
+ }
108
+ result.prTemplate = resolved;
109
+ }
102
110
  // Resolve root-level file content
103
111
  if (result.files) {
104
112
  for (const [fileName, fileConfig] of Object.entries(result.files)) {
package/dist/index.js CHANGED
@@ -131,6 +131,7 @@ async function main() {
131
131
  workDir,
132
132
  dryRun: options.dryRun,
133
133
  retries: options.retries,
134
+ prTemplate: config.prTemplate,
134
135
  });
135
136
  if (result.skipped) {
136
137
  logger.skip(current, repoName, result.message);
@@ -14,6 +14,8 @@ export interface PROptions {
14
14
  dryRun?: boolean;
15
15
  /** Number of retries for API operations (default: 3) */
16
16
  retries?: number;
17
+ /** Custom PR body template */
18
+ prTemplate?: string;
17
19
  }
18
20
  export interface PRResult {
19
21
  url?: string;
@@ -21,9 +23,9 @@ export interface PRResult {
21
23
  message: string;
22
24
  }
23
25
  /**
24
- * Format PR body for multiple files
26
+ * Format PR body using template with {{FILE_CHANGES}} placeholder
25
27
  */
26
- export declare function formatPRBody(files: FileAction[]): string;
28
+ export declare function formatPRBody(files: FileAction[], customTemplate?: string): string;
27
29
  /**
28
30
  * Generate PR title based on files changed (excludes skipped files)
29
31
  */
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
4
4
  import { getPRStrategy, } from "./strategies/index.js";
5
5
  // Re-export for backwards compatibility and testing
6
6
  export { escapeShellArg } from "./shell-utils.js";
7
- function loadPRTemplate() {
7
+ function loadDefaultTemplate() {
8
8
  // Try to find PR.md in the project root
9
9
  const __filename = fileURLToPath(import.meta.url);
10
10
  const __dirname = dirname(__filename);
@@ -12,17 +12,21 @@ function loadPRTemplate() {
12
12
  if (existsSync(templatePath)) {
13
13
  return readFileSync(templatePath, "utf-8");
14
14
  }
15
- // Fallback template for multi-file support
15
+ // Fallback template
16
16
  return `## Summary
17
+
17
18
  Automated sync of configuration files.
18
19
 
19
20
  ## Changes
21
+
20
22
  {{FILE_CHANGES}}
21
23
 
22
24
  ## Source
25
+
23
26
  Configuration synced using [xfg](https://github.com/anthony-spruyt/xfg).
24
27
 
25
28
  ---
29
+
26
30
  _This PR was automatically generated by [xfg](https://github.com/anthony-spruyt/xfg)_`;
27
31
  }
28
32
  /**
@@ -38,35 +42,12 @@ function formatFileChanges(files) {
38
42
  .join("\n");
39
43
  }
40
44
  /**
41
- * Format PR body for multiple files
45
+ * Format PR body using template with {{FILE_CHANGES}} placeholder
42
46
  */
43
- export function formatPRBody(files) {
44
- const template = loadPRTemplate();
47
+ export function formatPRBody(files, customTemplate) {
48
+ const template = customTemplate ?? loadDefaultTemplate();
45
49
  const fileChanges = formatFileChanges(files);
46
- // Check if template supports multi-file format
47
- if (template.includes("{{FILE_CHANGES}}")) {
48
- return template.replace(/\{\{FILE_CHANGES\}\}/g, fileChanges);
49
- }
50
- // Legacy single-file template - adapt it for multiple files
51
- const changedFiles = files.filter((f) => f.action !== "skip");
52
- if (changedFiles.length === 1) {
53
- const actionText = changedFiles[0].action === "create" ? "Created" : "Updated";
54
- return template
55
- .replace(/\{\{FILE_NAME\}\}/g, changedFiles[0].fileName)
56
- .replace(/\{\{ACTION\}\}/g, actionText);
57
- }
58
- // Multiple files with legacy template - generate custom body
59
- return `## Summary
60
- Automated sync of configuration files.
61
-
62
- ## Changes
63
- ${fileChanges}
64
-
65
- ## Source
66
- Configuration synced using [xfg](https://github.com/anthony-spruyt/xfg).
67
-
68
- ---
69
- _This PR was automatically generated by [xfg](https://github.com/anthony-spruyt/xfg)_`;
50
+ return template.replace(/\{\{FILE_CHANGES\}\}/g, fileChanges);
70
51
  }
71
52
  /**
72
53
  * Generate PR title based on files changed (excludes skipped files)
@@ -83,9 +64,9 @@ export function formatPRTitle(files) {
83
64
  return `chore: sync ${changedFiles.length} config files`;
84
65
  }
85
66
  export async function createPR(options) {
86
- const { repoInfo, branchName, baseBranch, files, workDir, dryRun, retries } = options;
67
+ const { repoInfo, branchName, baseBranch, files, workDir, dryRun, retries, prTemplate, } = options;
87
68
  const title = formatPRTitle(files);
88
- const body = formatPRBody(files);
69
+ const body = formatPRBody(files, prTemplate);
89
70
  if (dryRun) {
90
71
  return {
91
72
  success: true,
@@ -11,6 +11,8 @@ export interface ProcessorOptions {
11
11
  retries?: number;
12
12
  /** Command executor for shell commands (for testing) */
13
13
  executor?: CommandExecutor;
14
+ /** Custom PR body template */
15
+ prTemplate?: string;
14
16
  }
15
17
  /**
16
18
  * Factory function type for creating GitOps instances.
@@ -37,7 +37,7 @@ export class RepositoryProcessor {
37
37
  }
38
38
  async process(repoConfig, repoInfo, options) {
39
39
  const repoName = getRepoDisplayName(repoInfo);
40
- const { branchName, workDir, dryRun, retries } = options;
40
+ const { branchName, workDir, dryRun, retries, prTemplate } = options;
41
41
  const executor = options.executor ?? defaultExecutor;
42
42
  this.gitOps = this.gitOpsFactory({ workDir, dryRun, retries });
43
43
  try {
@@ -207,6 +207,7 @@ export class RepositoryProcessor {
207
207
  workDir,
208
208
  dryRun,
209
209
  retries,
210
+ prTemplate,
210
211
  });
211
212
  // Step 10: Handle merge options if configured
212
213
  const mergeMode = repoConfig.prOptions?.merge ?? "auto";
@@ -66,7 +66,8 @@ export class GitLabPRStrategy extends BasePRStrategy {
66
66
  }
67
67
  const repoFlag = this.getRepoFlag(repoInfo);
68
68
  // Use glab mr list with JSON output for reliable parsing
69
- const command = `glab mr list --source-branch ${escapeShellArg(branchName)} --state opened -R ${escapeShellArg(repoFlag)} -F json`;
69
+ // Note: glab mr list returns open MRs by default (use -c for closed, -M for merged)
70
+ const command = `glab mr list --source-branch ${escapeShellArg(branchName)} -R ${escapeShellArg(repoFlag)} -F json`;
70
71
  try {
71
72
  const result = await withRetry(() => this.executor.exec(command, workDir), { retries });
72
73
  if (!result || result.trim() === "" || result.trim() === "[]") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/xfg",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "CLI tool to sync JSON or YAML configuration files across multiple GitHub, Azure DevOps, and GitLab repositories",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,6 +29,7 @@
29
29
  "test": "node --import tsx --test src/config.test.ts src/merge.test.ts src/env.test.ts src/repo-detector.test.ts src/pr-creator.test.ts src/git-ops.test.ts src/logger.test.ts src/workspace-utils.test.ts src/strategies/pr-strategy.test.ts src/strategies/github-pr-strategy.test.ts src/strategies/azure-pr-strategy.test.ts src/strategies/gitlab-pr-strategy.test.ts src/repository-processor.test.ts src/retry-utils.test.ts src/command-executor.test.ts src/shell-utils.test.ts src/index.test.ts src/config-formatter.test.ts src/config-validator.test.ts src/config-normalizer.test.ts src/diff-utils.test.ts",
30
30
  "test:integration:github": "npm run build && node --import tsx --test src/integration-github.test.ts",
31
31
  "test:integration:ado": "npm run build && node --import tsx --test src/integration-ado.test.ts",
32
+ "test:integration:gitlab": "npm run build && node --import tsx --test src/integration-gitlab.test.ts",
32
33
  "prepublishOnly": "npm run build"
33
34
  },
34
35
  "keywords": [