@aspruyt/xfg 1.5.1 → 1.7.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 +2 -2
- package/README.md +2 -1
- package/dist/config-normalizer.js +9 -0
- package/dist/config-validator.js +50 -0
- package/dist/config.d.ts +8 -0
- package/dist/env.js +3 -1
- package/dist/index.js +3 -1
- package/dist/pr-creator.d.ts +9 -2
- package/dist/pr-creator.js +26 -6
- package/dist/repo-detector.d.ts +6 -2
- package/dist/repo-detector.js +38 -10
- package/dist/repository-processor.js +11 -1
- package/dist/strategies/github-pr-strategy.js +49 -9
- package/dist/xfg-template.d.ts +43 -0
- package/dist/xfg-template.js +158 -0
- package/package.json +2 -2
package/PR.md
CHANGED
package/README.md
CHANGED
|
@@ -51,11 +51,12 @@ xfg --config ./config.yaml
|
|
|
51
51
|
- **Content Inheritance** - Define base config once, override per-repo as needed
|
|
52
52
|
- **Multi-Repo Targeting** - Apply same config to multiple repos with array syntax
|
|
53
53
|
- **Environment Variables** - Use `${VAR}` syntax for dynamic values
|
|
54
|
+
- **Templating** - Use `${xfg:repo.name}` syntax for dynamic repo-specific content
|
|
54
55
|
- **Merge Strategies** - Control how arrays merge (replace, append, prepend)
|
|
55
56
|
- **Override Mode** - Skip merging entirely for specific repos
|
|
56
57
|
- **Empty Files** - Create files with no content (e.g., `.prettierignore`)
|
|
57
58
|
- **YAML Comments** - Add header comments and schema directives to YAML files
|
|
58
|
-
- **Multi-Platform** - Works with GitHub, Azure DevOps, and GitLab (including self-hosted)
|
|
59
|
+
- **Multi-Platform** - Works with GitHub (including GitHub Enterprise Server), Azure DevOps, and GitLab (including self-hosted)
|
|
59
60
|
- **Auto-Merge PRs** - Automatically merge PRs when checks pass, or force merge with admin privileges
|
|
60
61
|
- **Dry-Run Mode** - Preview changes without creating PRs
|
|
61
62
|
- **Error Resilience** - Continues processing if individual repos fail
|
|
@@ -115,6 +115,12 @@ export function normalizeConfig(raw) {
|
|
|
115
115
|
const executable = repoOverride?.executable ?? fileConfig.executable;
|
|
116
116
|
const header = normalizeHeader(repoOverride?.header ?? fileConfig.header);
|
|
117
117
|
const schemaUrl = repoOverride?.schemaUrl ?? fileConfig.schemaUrl;
|
|
118
|
+
// Template: per-repo overrides root level
|
|
119
|
+
const template = repoOverride?.template ?? fileConfig.template;
|
|
120
|
+
// Vars: merge root + per-repo (per-repo takes precedence)
|
|
121
|
+
const vars = fileConfig.vars || repoOverride?.vars
|
|
122
|
+
? { ...fileConfig.vars, ...repoOverride?.vars }
|
|
123
|
+
: undefined;
|
|
118
124
|
files.push({
|
|
119
125
|
fileName,
|
|
120
126
|
content: mergedContent,
|
|
@@ -122,6 +128,8 @@ export function normalizeConfig(raw) {
|
|
|
122
128
|
executable,
|
|
123
129
|
header,
|
|
124
130
|
schemaUrl,
|
|
131
|
+
template,
|
|
132
|
+
vars,
|
|
125
133
|
});
|
|
126
134
|
}
|
|
127
135
|
// Merge PR options: per-repo overrides global
|
|
@@ -136,5 +144,6 @@ export function normalizeConfig(raw) {
|
|
|
136
144
|
return {
|
|
137
145
|
repos: expandedRepos,
|
|
138
146
|
prTemplate: raw.prTemplate,
|
|
147
|
+
githubHosts: raw.githubHosts,
|
|
139
148
|
};
|
|
140
149
|
}
|
package/dist/config-validator.js
CHANGED
|
@@ -79,10 +79,44 @@ export function validateRawConfig(config) {
|
|
|
79
79
|
typeof fileConfig.schemaUrl !== "string") {
|
|
80
80
|
throw new Error(`File '${fileName}' schemaUrl must be a string`);
|
|
81
81
|
}
|
|
82
|
+
if (fileConfig.template !== undefined &&
|
|
83
|
+
typeof fileConfig.template !== "boolean") {
|
|
84
|
+
throw new Error(`File '${fileName}' template must be a boolean`);
|
|
85
|
+
}
|
|
86
|
+
if (fileConfig.vars !== undefined) {
|
|
87
|
+
if (typeof fileConfig.vars !== "object" ||
|
|
88
|
+
fileConfig.vars === null ||
|
|
89
|
+
Array.isArray(fileConfig.vars)) {
|
|
90
|
+
throw new Error(`File '${fileName}' vars must be an object with string values`);
|
|
91
|
+
}
|
|
92
|
+
for (const [key, value] of Object.entries(fileConfig.vars)) {
|
|
93
|
+
if (typeof value !== "string") {
|
|
94
|
+
throw new Error(`File '${fileName}' vars.${key} must be a string`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
82
98
|
}
|
|
83
99
|
if (!config.repos || !Array.isArray(config.repos)) {
|
|
84
100
|
throw new Error("Config missing required field: repos (must be an array)");
|
|
85
101
|
}
|
|
102
|
+
// Validate githubHosts if provided
|
|
103
|
+
if (config.githubHosts !== undefined) {
|
|
104
|
+
if (!Array.isArray(config.githubHosts) ||
|
|
105
|
+
!config.githubHosts.every((h) => typeof h === "string")) {
|
|
106
|
+
throw new Error("githubHosts must be an array of strings");
|
|
107
|
+
}
|
|
108
|
+
for (const host of config.githubHosts) {
|
|
109
|
+
if (!host) {
|
|
110
|
+
throw new Error("githubHosts entries must be non-empty hostnames");
|
|
111
|
+
}
|
|
112
|
+
if (host.includes("://")) {
|
|
113
|
+
throw new Error(`githubHosts entries must be hostnames only, not URLs. Got: ${host}`);
|
|
114
|
+
}
|
|
115
|
+
if (host.includes("/")) {
|
|
116
|
+
throw new Error(`githubHosts entries must be hostnames only, not paths. Got: ${host}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
86
120
|
// Validate each repo
|
|
87
121
|
for (let i = 0; i < config.repos.length; i++) {
|
|
88
122
|
const repo = config.repos[i];
|
|
@@ -146,6 +180,22 @@ export function validateRawConfig(config) {
|
|
|
146
180
|
typeof fileOverride.schemaUrl !== "string") {
|
|
147
181
|
throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' schemaUrl must be a string`);
|
|
148
182
|
}
|
|
183
|
+
if (fileOverride.template !== undefined &&
|
|
184
|
+
typeof fileOverride.template !== "boolean") {
|
|
185
|
+
throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' template must be a boolean`);
|
|
186
|
+
}
|
|
187
|
+
if (fileOverride.vars !== undefined) {
|
|
188
|
+
if (typeof fileOverride.vars !== "object" ||
|
|
189
|
+
fileOverride.vars === null ||
|
|
190
|
+
Array.isArray(fileOverride.vars)) {
|
|
191
|
+
throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' vars must be an object with string values`);
|
|
192
|
+
}
|
|
193
|
+
for (const [key, value] of Object.entries(fileOverride.vars)) {
|
|
194
|
+
if (typeof value !== "string") {
|
|
195
|
+
throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' vars.${key} must be a string`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
149
199
|
}
|
|
150
200
|
}
|
|
151
201
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ export interface RawFileConfig {
|
|
|
16
16
|
executable?: boolean;
|
|
17
17
|
header?: string | string[];
|
|
18
18
|
schemaUrl?: string;
|
|
19
|
+
template?: boolean;
|
|
20
|
+
vars?: Record<string, string>;
|
|
19
21
|
}
|
|
20
22
|
export interface RawRepoFileOverride {
|
|
21
23
|
content?: ContentValue;
|
|
@@ -24,6 +26,8 @@ export interface RawRepoFileOverride {
|
|
|
24
26
|
executable?: boolean;
|
|
25
27
|
header?: string | string[];
|
|
26
28
|
schemaUrl?: string;
|
|
29
|
+
template?: boolean;
|
|
30
|
+
vars?: Record<string, string>;
|
|
27
31
|
}
|
|
28
32
|
export interface RawRepoConfig {
|
|
29
33
|
git: string | string[];
|
|
@@ -35,6 +39,7 @@ export interface RawConfig {
|
|
|
35
39
|
repos: RawRepoConfig[];
|
|
36
40
|
prOptions?: PRMergeOptions;
|
|
37
41
|
prTemplate?: string;
|
|
42
|
+
githubHosts?: string[];
|
|
38
43
|
}
|
|
39
44
|
export interface FileContent {
|
|
40
45
|
fileName: string;
|
|
@@ -43,6 +48,8 @@ export interface FileContent {
|
|
|
43
48
|
executable?: boolean;
|
|
44
49
|
header?: string[];
|
|
45
50
|
schemaUrl?: string;
|
|
51
|
+
template?: boolean;
|
|
52
|
+
vars?: Record<string, string>;
|
|
46
53
|
}
|
|
47
54
|
export interface RepoConfig {
|
|
48
55
|
git: string;
|
|
@@ -52,5 +59,6 @@ export interface RepoConfig {
|
|
|
52
59
|
export interface Config {
|
|
53
60
|
repos: RepoConfig[];
|
|
54
61
|
prTemplate?: string;
|
|
62
|
+
githubHosts?: string[];
|
|
55
63
|
}
|
|
56
64
|
export declare function loadConfig(filePath: string): Config;
|
package/dist/env.js
CHANGED
|
@@ -23,8 +23,10 @@ const ENV_VAR_REGEX = /\$\{([^}:]+)(?::([?-])([^}]*))?\}/g;
|
|
|
23
23
|
* Regex to match escaped environment variable placeholders.
|
|
24
24
|
* $${...} outputs literal ${...} without interpolation.
|
|
25
25
|
* Example: $${VAR} -> ${VAR}, $${VAR:-default} -> ${VAR:-default}
|
|
26
|
+
*
|
|
27
|
+
* Note: Does NOT match $${xfg:...} patterns - those are handled by xfg templating.
|
|
26
28
|
*/
|
|
27
|
-
const ESCAPED_VAR_REGEX = /\$\$\{([^}]+)\}/g;
|
|
29
|
+
const ESCAPED_VAR_REGEX = /\$\$\{((?!xfg:)[^}]+)\}/g;
|
|
28
30
|
/**
|
|
29
31
|
* Placeholder prefix for temporarily storing escaped sequences.
|
|
30
32
|
* Uses null bytes which won't appear in normal content.
|
package/dist/index.js
CHANGED
|
@@ -115,7 +115,9 @@ async function main() {
|
|
|
115
115
|
const current = i + 1;
|
|
116
116
|
let repoInfo;
|
|
117
117
|
try {
|
|
118
|
-
repoInfo = parseGitUrl(repoConfig.git
|
|
118
|
+
repoInfo = parseGitUrl(repoConfig.git, {
|
|
119
|
+
githubHosts: config.githubHosts,
|
|
120
|
+
});
|
|
119
121
|
}
|
|
120
122
|
catch (error) {
|
|
121
123
|
const message = error instanceof Error ? error.message : String(error);
|
package/dist/pr-creator.d.ts
CHANGED
|
@@ -23,9 +23,16 @@ export interface PRResult {
|
|
|
23
23
|
message: string;
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
|
-
* Format PR body using template with {
|
|
26
|
+
* Format PR body using template with ${xfg:...} variables.
|
|
27
|
+
*
|
|
28
|
+
* Available PR-specific variables:
|
|
29
|
+
* - ${xfg:pr.fileChanges} - formatted list of changed files
|
|
30
|
+
* - ${xfg:pr.fileCount} - number of changed files
|
|
31
|
+
* - ${xfg:pr.title} - the PR title
|
|
32
|
+
*
|
|
33
|
+
* Plus all standard repo variables (repo.name, repo.owner, etc.)
|
|
27
34
|
*/
|
|
28
|
-
export declare function formatPRBody(files: FileAction[], customTemplate?: string): string;
|
|
35
|
+
export declare function formatPRBody(files: FileAction[], repoInfo: RepoInfo, customTemplate?: string): string;
|
|
29
36
|
/**
|
|
30
37
|
* Generate PR title based on files changed (excludes skipped files)
|
|
31
38
|
*/
|
package/dist/pr-creator.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync, existsSync } from "node:fs";
|
|
|
2
2
|
import { join, dirname } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { getPRStrategy, } from "./strategies/index.js";
|
|
5
|
+
import { interpolateXfgContent } from "./xfg-template.js";
|
|
5
6
|
// Re-export for backwards compatibility and testing
|
|
6
7
|
export { escapeShellArg } from "./shell-utils.js";
|
|
7
8
|
function loadDefaultTemplate() {
|
|
@@ -15,11 +16,11 @@ function loadDefaultTemplate() {
|
|
|
15
16
|
// Fallback template
|
|
16
17
|
return `## Summary
|
|
17
18
|
|
|
18
|
-
Automated sync of configuration files.
|
|
19
|
+
Automated sync of configuration files to \${xfg:repo.fullName}.
|
|
19
20
|
|
|
20
21
|
## Changes
|
|
21
22
|
|
|
22
|
-
{
|
|
23
|
+
\${xfg:pr.fileChanges}
|
|
23
24
|
|
|
24
25
|
## Source
|
|
25
26
|
|
|
@@ -42,12 +43,31 @@ function formatFileChanges(files) {
|
|
|
42
43
|
.join("\n");
|
|
43
44
|
}
|
|
44
45
|
/**
|
|
45
|
-
* Format PR body using template with {
|
|
46
|
+
* Format PR body using template with ${xfg:...} variables.
|
|
47
|
+
*
|
|
48
|
+
* Available PR-specific variables:
|
|
49
|
+
* - ${xfg:pr.fileChanges} - formatted list of changed files
|
|
50
|
+
* - ${xfg:pr.fileCount} - number of changed files
|
|
51
|
+
* - ${xfg:pr.title} - the PR title
|
|
52
|
+
*
|
|
53
|
+
* Plus all standard repo variables (repo.name, repo.owner, etc.)
|
|
46
54
|
*/
|
|
47
|
-
export function formatPRBody(files, customTemplate) {
|
|
55
|
+
export function formatPRBody(files, repoInfo, customTemplate) {
|
|
48
56
|
const template = customTemplate ?? loadDefaultTemplate();
|
|
49
57
|
const fileChanges = formatFileChanges(files);
|
|
50
|
-
|
|
58
|
+
const changedFiles = files.filter((f) => f.action !== "skip");
|
|
59
|
+
const title = formatPRTitle(files);
|
|
60
|
+
// Create context with PR-specific variables
|
|
61
|
+
const result = interpolateXfgContent(template, {
|
|
62
|
+
repoInfo,
|
|
63
|
+
fileName: "PR.md",
|
|
64
|
+
vars: {
|
|
65
|
+
"pr.fileChanges": fileChanges,
|
|
66
|
+
"pr.fileCount": String(changedFiles.length),
|
|
67
|
+
"pr.title": title,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
return result;
|
|
51
71
|
}
|
|
52
72
|
/**
|
|
53
73
|
* Generate PR title based on files changed (excludes skipped files)
|
|
@@ -66,7 +86,7 @@ export function formatPRTitle(files) {
|
|
|
66
86
|
export async function createPR(options) {
|
|
67
87
|
const { repoInfo, branchName, baseBranch, files, workDir, dryRun, retries, prTemplate, } = options;
|
|
68
88
|
const title = formatPRTitle(files);
|
|
69
|
-
const body = formatPRBody(files, prTemplate);
|
|
89
|
+
const body = formatPRBody(files, repoInfo, prTemplate);
|
|
70
90
|
if (dryRun) {
|
|
71
91
|
return {
|
|
72
92
|
success: true,
|
package/dist/repo-detector.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export type RepoType = "github" | "azure-devops" | "gitlab";
|
|
2
|
+
export interface RepoDetectorContext {
|
|
3
|
+
githubHosts?: string[];
|
|
4
|
+
}
|
|
2
5
|
interface BaseRepoInfo {
|
|
3
6
|
gitUrl: string;
|
|
4
7
|
repo: string;
|
|
@@ -6,6 +9,7 @@ interface BaseRepoInfo {
|
|
|
6
9
|
export interface GitHubRepoInfo extends BaseRepoInfo {
|
|
7
10
|
type: "github";
|
|
8
11
|
owner: string;
|
|
12
|
+
host: string;
|
|
9
13
|
}
|
|
10
14
|
export interface AzureDevOpsRepoInfo extends BaseRepoInfo {
|
|
11
15
|
type: "azure-devops";
|
|
@@ -23,7 +27,7 @@ export type RepoInfo = GitHubRepoInfo | AzureDevOpsRepoInfo | GitLabRepoInfo;
|
|
|
23
27
|
export declare function isGitHubRepo(info: RepoInfo): info is GitHubRepoInfo;
|
|
24
28
|
export declare function isAzureDevOpsRepo(info: RepoInfo): info is AzureDevOpsRepoInfo;
|
|
25
29
|
export declare function isGitLabRepo(info: RepoInfo): info is GitLabRepoInfo;
|
|
26
|
-
export declare function detectRepoType(gitUrl: string): RepoType;
|
|
27
|
-
export declare function parseGitUrl(gitUrl: string): RepoInfo;
|
|
30
|
+
export declare function detectRepoType(gitUrl: string, context?: RepoDetectorContext): RepoType;
|
|
31
|
+
export declare function parseGitUrl(gitUrl: string, context?: RepoDetectorContext): RepoInfo;
|
|
28
32
|
export declare function getRepoDisplayName(repoInfo: RepoInfo): string;
|
|
29
33
|
export {};
|
package/dist/repo-detector.js
CHANGED
|
@@ -8,6 +8,22 @@ export function isAzureDevOpsRepo(info) {
|
|
|
8
8
|
export function isGitLabRepo(info) {
|
|
9
9
|
return info.type === "gitlab";
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Extract hostname from a git URL.
|
|
13
|
+
*/
|
|
14
|
+
function extractHostFromUrl(gitUrl) {
|
|
15
|
+
// SSH: git@hostname:path
|
|
16
|
+
const sshMatch = gitUrl.match(/^git@([^:]+):/);
|
|
17
|
+
if (sshMatch) {
|
|
18
|
+
return sshMatch[1];
|
|
19
|
+
}
|
|
20
|
+
// HTTPS: https://hostname/path
|
|
21
|
+
const httpsMatch = gitUrl.match(/^https?:\/\/([^/]+)/);
|
|
22
|
+
if (httpsMatch) {
|
|
23
|
+
return httpsMatch[1];
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
11
27
|
/**
|
|
12
28
|
* Valid URL patterns for supported repository types.
|
|
13
29
|
*/
|
|
@@ -41,8 +57,16 @@ function isGitLabStyleUrl(gitUrl) {
|
|
|
41
57
|
}
|
|
42
58
|
return false;
|
|
43
59
|
}
|
|
44
|
-
export function detectRepoType(gitUrl) {
|
|
45
|
-
// Check for
|
|
60
|
+
export function detectRepoType(gitUrl, context) {
|
|
61
|
+
// Check for GitHub Enterprise hosts first (if configured)
|
|
62
|
+
if (context?.githubHosts?.length) {
|
|
63
|
+
const host = extractHostFromUrl(gitUrl)?.toLowerCase();
|
|
64
|
+
const normalizedHosts = context.githubHosts.map((h) => h.toLowerCase());
|
|
65
|
+
if (host && normalizedHosts.includes(host)) {
|
|
66
|
+
return "github";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Check for Azure DevOps formats (most specific patterns)
|
|
46
70
|
for (const pattern of AZURE_DEVOPS_URL_PATTERNS) {
|
|
47
71
|
if (pattern.test(gitUrl)) {
|
|
48
72
|
return "azure-devops";
|
|
@@ -67,37 +91,41 @@ export function detectRepoType(gitUrl) {
|
|
|
67
91
|
// Throw for unrecognized URL formats
|
|
68
92
|
throw new Error(`Unrecognized git URL format: ${gitUrl}. Supported formats: GitHub (git@github.com: or https://github.com/), Azure DevOps (git@ssh.dev.azure.com: or https://dev.azure.com/), and GitLab (git@gitlab.com: or https://gitlab.com/)`);
|
|
69
93
|
}
|
|
70
|
-
export function parseGitUrl(gitUrl) {
|
|
71
|
-
const type = detectRepoType(gitUrl);
|
|
94
|
+
export function parseGitUrl(gitUrl, context) {
|
|
95
|
+
const type = detectRepoType(gitUrl, context);
|
|
72
96
|
if (type === "azure-devops") {
|
|
73
97
|
return parseAzureDevOpsUrl(gitUrl);
|
|
74
98
|
}
|
|
75
99
|
if (type === "gitlab") {
|
|
76
100
|
return parseGitLabUrl(gitUrl);
|
|
77
101
|
}
|
|
78
|
-
|
|
102
|
+
// For GitHub, extract the host from the URL
|
|
103
|
+
const host = extractHostFromUrl(gitUrl) ?? "github.com";
|
|
104
|
+
return parseGitHubUrl(gitUrl, host);
|
|
79
105
|
}
|
|
80
|
-
function parseGitHubUrl(gitUrl) {
|
|
81
|
-
// Handle SSH format: git@
|
|
106
|
+
function parseGitHubUrl(gitUrl, host) {
|
|
107
|
+
// Handle SSH format: git@hostname:owner/repo.git
|
|
82
108
|
// Use (.+?) with end anchor to handle repo names with dots (e.g., my.repo.git)
|
|
83
|
-
const sshMatch = gitUrl.match(
|
|
109
|
+
const sshMatch = gitUrl.match(/^git@[^:]+:([^/]+)\/(.+?)(?:\.git)?$/);
|
|
84
110
|
if (sshMatch) {
|
|
85
111
|
return {
|
|
86
112
|
type: "github",
|
|
87
113
|
gitUrl,
|
|
88
114
|
owner: sshMatch[1],
|
|
89
115
|
repo: sshMatch[2],
|
|
116
|
+
host,
|
|
90
117
|
};
|
|
91
118
|
}
|
|
92
|
-
// Handle HTTPS format: https://
|
|
119
|
+
// Handle HTTPS format: https://hostname/owner/repo.git
|
|
93
120
|
// Use (.+?) with end anchor to handle repo names with dots
|
|
94
|
-
const httpsMatch = gitUrl.match(
|
|
121
|
+
const httpsMatch = gitUrl.match(/^https?:\/\/[^/]+\/([^/]+)\/(.+?)(?:\.git)?$/);
|
|
95
122
|
if (httpsMatch) {
|
|
96
123
|
return {
|
|
97
124
|
type: "github",
|
|
98
125
|
gitUrl,
|
|
99
126
|
owner: httpsMatch[1],
|
|
100
127
|
repo: httpsMatch[2],
|
|
128
|
+
host,
|
|
101
129
|
};
|
|
102
130
|
}
|
|
103
131
|
throw new Error(`Unable to parse GitHub URL: ${gitUrl}`);
|
|
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { convertContentToString, } from "./config.js";
|
|
4
4
|
import { getRepoDisplayName } from "./repo-detector.js";
|
|
5
|
+
import { interpolateXfgContent } from "./xfg-template.js";
|
|
5
6
|
import { GitOps } from "./git-ops.js";
|
|
6
7
|
import { createPR, mergePR } from "./pr-creator.js";
|
|
7
8
|
import { logger } from "./logger.js";
|
|
@@ -97,7 +98,16 @@ export class RepositoryProcessor {
|
|
|
97
98
|
}
|
|
98
99
|
}
|
|
99
100
|
this.log.info(`Writing ${file.fileName}...`);
|
|
100
|
-
|
|
101
|
+
// Apply xfg templating if enabled
|
|
102
|
+
let contentToWrite = file.content;
|
|
103
|
+
if (file.template && contentToWrite !== null) {
|
|
104
|
+
contentToWrite = interpolateXfgContent(contentToWrite, {
|
|
105
|
+
repoInfo,
|
|
106
|
+
fileName: file.fileName,
|
|
107
|
+
vars: file.vars,
|
|
108
|
+
}, { strict: true });
|
|
109
|
+
}
|
|
110
|
+
const fileContent = convertContentToString(contentToWrite, file.fileName, {
|
|
101
111
|
header: file.header,
|
|
102
112
|
schemaUrl: file.schemaUrl,
|
|
103
113
|
});
|
|
@@ -5,13 +5,47 @@ import { isGitHubRepo } from "../repo-detector.js";
|
|
|
5
5
|
import { BasePRStrategy, } from "./pr-strategy.js";
|
|
6
6
|
import { logger } from "../logger.js";
|
|
7
7
|
import { withRetry, isPermanentError } from "../retry-utils.js";
|
|
8
|
+
/**
|
|
9
|
+
* Get the repo flag value for gh CLI commands.
|
|
10
|
+
* Returns HOST/OWNER/REPO for GHE, OWNER/REPO for github.com.
|
|
11
|
+
*/
|
|
12
|
+
function getRepoFlag(repoInfo) {
|
|
13
|
+
if (repoInfo.host && repoInfo.host !== "github.com") {
|
|
14
|
+
return `${repoInfo.host}/${repoInfo.owner}/${repoInfo.repo}`;
|
|
15
|
+
}
|
|
16
|
+
return `${repoInfo.owner}/${repoInfo.repo}`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Get the hostname flag for gh api commands.
|
|
20
|
+
* Returns "--hostname HOST" for GHE, empty string for github.com.
|
|
21
|
+
*/
|
|
22
|
+
function getHostnameFlag(repoInfo) {
|
|
23
|
+
if (repoInfo.host && repoInfo.host !== "github.com") {
|
|
24
|
+
return `--hostname ${escapeShellArg(repoInfo.host)}`;
|
|
25
|
+
}
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Escape special regex characters in a string.
|
|
30
|
+
*/
|
|
31
|
+
function escapeRegExp(str) {
|
|
32
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Build regex to match PR URLs for the given host.
|
|
36
|
+
*/
|
|
37
|
+
function buildPRUrlRegex(host) {
|
|
38
|
+
const escapedHost = escapeRegExp(host);
|
|
39
|
+
return new RegExp(`https://${escapedHost}/[\\w-]+/[\\w.-]+/pull/\\d+`);
|
|
40
|
+
}
|
|
8
41
|
export class GitHubPRStrategy extends BasePRStrategy {
|
|
9
42
|
async checkExistingPR(options) {
|
|
10
43
|
const { repoInfo, branchName, workDir, retries = 3 } = options;
|
|
11
44
|
if (!isGitHubRepo(repoInfo)) {
|
|
12
45
|
throw new Error("Expected GitHub repository");
|
|
13
46
|
}
|
|
14
|
-
const
|
|
47
|
+
const repoFlag = getRepoFlag(repoInfo);
|
|
48
|
+
const command = `gh pr list --repo ${escapeShellArg(repoFlag)} --head ${escapeShellArg(branchName)} --json url --jq '.[0].url'`;
|
|
15
49
|
try {
|
|
16
50
|
const existingPR = await withRetry(() => this.executor.exec(command, workDir), { retries });
|
|
17
51
|
return existingPR || null;
|
|
@@ -55,7 +89,8 @@ export class GitHubPRStrategy extends BasePRStrategy {
|
|
|
55
89
|
throw new Error(`Could not extract PR number from URL: ${existingUrl}`);
|
|
56
90
|
}
|
|
57
91
|
// Close the PR and delete the branch
|
|
58
|
-
const
|
|
92
|
+
const repoFlag = getRepoFlag(repoInfo);
|
|
93
|
+
const command = `gh pr close ${escapeShellArg(prNumber)} --repo ${escapeShellArg(repoFlag)} --delete-branch`;
|
|
59
94
|
try {
|
|
60
95
|
await withRetry(() => this.executor.exec(command, workDir), { retries });
|
|
61
96
|
return true;
|
|
@@ -78,7 +113,9 @@ export class GitHubPRStrategy extends BasePRStrategy {
|
|
|
78
113
|
try {
|
|
79
114
|
const result = await withRetry(() => this.executor.exec(command, workDir), { retries });
|
|
80
115
|
// Extract URL from output - use strict regex for valid PR URLs only
|
|
81
|
-
const
|
|
116
|
+
const host = repoInfo.host || "github.com";
|
|
117
|
+
const urlRegex = buildPRUrlRegex(host);
|
|
118
|
+
const urlMatch = result.match(urlRegex);
|
|
82
119
|
if (!urlMatch) {
|
|
83
120
|
throw new Error(`Could not parse PR URL from output: ${result}`);
|
|
84
121
|
}
|
|
@@ -104,7 +141,9 @@ export class GitHubPRStrategy extends BasePRStrategy {
|
|
|
104
141
|
* Check if auto-merge is enabled on the repository.
|
|
105
142
|
*/
|
|
106
143
|
async checkAutoMergeEnabled(repoInfo, workDir, retries = 3) {
|
|
107
|
-
const
|
|
144
|
+
const hostnameFlag = getHostnameFlag(repoInfo);
|
|
145
|
+
const hostnamePart = hostnameFlag ? `${hostnameFlag} ` : "";
|
|
146
|
+
const command = `gh api ${hostnamePart}repos/${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)} --jq '.allow_auto_merge // false'`;
|
|
108
147
|
try {
|
|
109
148
|
const result = await withRetry(() => this.executor.exec(command, workDir), { retries });
|
|
110
149
|
return result.trim() === "true";
|
|
@@ -143,19 +182,20 @@ export class GitHubPRStrategy extends BasePRStrategy {
|
|
|
143
182
|
const deleteBranchFlag = config.deleteBranch ? "--delete-branch" : "";
|
|
144
183
|
if (config.mode === "auto") {
|
|
145
184
|
// Check if auto-merge is enabled on the repo
|
|
146
|
-
// Extract owner/repo from PR URL
|
|
147
|
-
const match = prUrl.match(/
|
|
185
|
+
// Extract host/owner/repo from PR URL (supports both github.com and GHE)
|
|
186
|
+
const match = prUrl.match(/https:\/\/([^/]+)\/([^/]+)\/([^/]+)/);
|
|
148
187
|
if (match) {
|
|
149
188
|
const repoInfo = {
|
|
150
189
|
type: "github",
|
|
151
190
|
gitUrl: prUrl,
|
|
152
|
-
owner: match[
|
|
153
|
-
repo: match[
|
|
191
|
+
owner: match[2],
|
|
192
|
+
repo: match[3],
|
|
193
|
+
host: match[1],
|
|
154
194
|
};
|
|
155
195
|
const autoMergeEnabled = await this.checkAutoMergeEnabled(repoInfo, workDir, retries);
|
|
156
196
|
if (!autoMergeEnabled) {
|
|
157
197
|
logger.info(`Warning: Auto-merge not enabled for '${repoInfo.owner}/${repoInfo.repo}'. PR left open for manual review.`);
|
|
158
|
-
logger.info(`To enable: gh repo edit ${repoInfo
|
|
198
|
+
logger.info(`To enable: gh repo edit ${getRepoFlag(repoInfo)} --enable-auto-merge (requires admin)`);
|
|
159
199
|
return {
|
|
160
200
|
success: true,
|
|
161
201
|
message: `Auto-merge not enabled for repository. PR left open for manual review.`,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XFG template variable interpolation utilities.
|
|
3
|
+
* Supports ${xfg:variable} syntax for repo-specific content.
|
|
4
|
+
* Use $${xfg:variable} to escape and output literal ${xfg:variable}.
|
|
5
|
+
*/
|
|
6
|
+
import type { RepoInfo } from "./repo-detector.js";
|
|
7
|
+
import type { ContentValue } from "./config.js";
|
|
8
|
+
export interface XfgTemplateContext {
|
|
9
|
+
/** Repository information from URL parsing */
|
|
10
|
+
repoInfo: RepoInfo;
|
|
11
|
+
/** Current file being processed */
|
|
12
|
+
fileName: string;
|
|
13
|
+
/** Custom variables defined in config */
|
|
14
|
+
vars?: Record<string, string>;
|
|
15
|
+
}
|
|
16
|
+
export interface XfgInterpolationOptions {
|
|
17
|
+
/**
|
|
18
|
+
* If true (default), throws an error when a variable is missing.
|
|
19
|
+
* If false, leaves the placeholder as-is.
|
|
20
|
+
*/
|
|
21
|
+
strict: boolean;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Interpolate xfg template variables in content.
|
|
25
|
+
*
|
|
26
|
+
* Supports these syntaxes:
|
|
27
|
+
* - ${xfg:repo.name} - Repository name
|
|
28
|
+
* - ${xfg:repo.owner} - Repository owner
|
|
29
|
+
* - ${xfg:repo.fullName} - Full repository name (owner/repo)
|
|
30
|
+
* - ${xfg:repo.url} - Git URL
|
|
31
|
+
* - ${xfg:repo.platform} - Platform type (github, azure-devops, gitlab)
|
|
32
|
+
* - ${xfg:repo.host} - Host domain
|
|
33
|
+
* - ${xfg:file.name} - Current file name
|
|
34
|
+
* - ${xfg:date} - Current date (YYYY-MM-DD)
|
|
35
|
+
* - ${xfg:customVar} - Custom variable from vars config
|
|
36
|
+
* - $${xfg:var} - Escape: outputs literal ${xfg:var}
|
|
37
|
+
*
|
|
38
|
+
* @param content - The content to process (object, string, or string[])
|
|
39
|
+
* @param ctx - Template context with repo info and custom vars
|
|
40
|
+
* @param options - Interpolation options (default: strict mode)
|
|
41
|
+
* @returns Content with interpolated values
|
|
42
|
+
*/
|
|
43
|
+
export declare function interpolateXfgContent(content: ContentValue, ctx: XfgTemplateContext, options?: XfgInterpolationOptions): ContentValue;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XFG template variable interpolation utilities.
|
|
3
|
+
* Supports ${xfg:variable} syntax for repo-specific content.
|
|
4
|
+
* Use $${xfg:variable} to escape and output literal ${xfg:variable}.
|
|
5
|
+
*/
|
|
6
|
+
const DEFAULT_OPTIONS = {
|
|
7
|
+
strict: true,
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Regex to match xfg template variable placeholders.
|
|
11
|
+
* Captures the variable name including dot notation.
|
|
12
|
+
* Variable names can only contain: a-z, A-Z, 0-9, dots, and underscores.
|
|
13
|
+
*
|
|
14
|
+
* Examples:
|
|
15
|
+
* - ${xfg:repo.name} -> varName=repo.name
|
|
16
|
+
* - ${xfg:myVar} -> varName=myVar
|
|
17
|
+
*/
|
|
18
|
+
const XFG_VAR_REGEX = /\$\{xfg:([a-zA-Z0-9._]+)\}/g;
|
|
19
|
+
/**
|
|
20
|
+
* Regex to match escaped xfg template variable placeholders.
|
|
21
|
+
* $${xfg:...} outputs literal ${xfg:...} without interpolation.
|
|
22
|
+
* Variable names can only contain: a-z, A-Z, 0-9, dots, and underscores.
|
|
23
|
+
*/
|
|
24
|
+
const ESCAPED_XFG_VAR_REGEX = /\$\$\{xfg:([a-zA-Z0-9._]+)\}/g;
|
|
25
|
+
/**
|
|
26
|
+
* Placeholder prefix for temporarily storing escaped sequences.
|
|
27
|
+
* Uses null bytes which won't appear in normal content.
|
|
28
|
+
*/
|
|
29
|
+
const ESCAPE_PLACEHOLDER = "\x00ESCAPED_XFG_VAR\x00";
|
|
30
|
+
/**
|
|
31
|
+
* Get the value of a built-in xfg variable.
|
|
32
|
+
* Returns undefined if the variable is not recognized.
|
|
33
|
+
*/
|
|
34
|
+
function getBuiltinVar(varName, ctx) {
|
|
35
|
+
const { repoInfo, fileName } = ctx;
|
|
36
|
+
switch (varName) {
|
|
37
|
+
case "repo.name":
|
|
38
|
+
return repoInfo.repo;
|
|
39
|
+
case "repo.owner":
|
|
40
|
+
return repoInfo.owner;
|
|
41
|
+
case "repo.fullName":
|
|
42
|
+
if (repoInfo.type === "azure-devops") {
|
|
43
|
+
return `${repoInfo.organization}/${repoInfo.project}/${repoInfo.repo}`;
|
|
44
|
+
}
|
|
45
|
+
if (repoInfo.type === "gitlab") {
|
|
46
|
+
return `${repoInfo.namespace}/${repoInfo.repo}`;
|
|
47
|
+
}
|
|
48
|
+
return `${repoInfo.owner}/${repoInfo.repo}`;
|
|
49
|
+
case "repo.url":
|
|
50
|
+
return repoInfo.gitUrl;
|
|
51
|
+
case "repo.platform":
|
|
52
|
+
return repoInfo.type;
|
|
53
|
+
case "repo.host":
|
|
54
|
+
if (repoInfo.type === "github" || repoInfo.type === "gitlab") {
|
|
55
|
+
return repoInfo.host;
|
|
56
|
+
}
|
|
57
|
+
// Azure DevOps doesn't have a host field, use dev.azure.com
|
|
58
|
+
return "dev.azure.com";
|
|
59
|
+
case "file.name":
|
|
60
|
+
return fileName;
|
|
61
|
+
case "date":
|
|
62
|
+
return new Date().toISOString().split("T")[0];
|
|
63
|
+
default:
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Check if a value is a plain object (not null, not array).
|
|
69
|
+
*/
|
|
70
|
+
function isPlainObject(val) {
|
|
71
|
+
return typeof val === "object" && val !== null && !Array.isArray(val);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Process a single string value, replacing xfg template variable placeholders.
|
|
75
|
+
* Supports escaping with $${xfg:var} syntax to output literal ${xfg:var}.
|
|
76
|
+
*/
|
|
77
|
+
function processString(value, ctx, options) {
|
|
78
|
+
// Phase 1: Replace escaped $${xfg:...} with placeholders
|
|
79
|
+
const escapedContent = [];
|
|
80
|
+
let processed = value.replace(ESCAPED_XFG_VAR_REGEX, (_match, content) => {
|
|
81
|
+
const index = escapedContent.length;
|
|
82
|
+
escapedContent.push(content);
|
|
83
|
+
return `${ESCAPE_PLACEHOLDER}${index}\x00`;
|
|
84
|
+
});
|
|
85
|
+
// Phase 2: Interpolate remaining ${xfg:...}
|
|
86
|
+
processed = processed.replace(XFG_VAR_REGEX, (match, varName) => {
|
|
87
|
+
// First check custom vars
|
|
88
|
+
if (ctx.vars && varName in ctx.vars) {
|
|
89
|
+
return ctx.vars[varName];
|
|
90
|
+
}
|
|
91
|
+
// Then check built-in vars
|
|
92
|
+
const builtinValue = getBuiltinVar(varName, ctx);
|
|
93
|
+
if (builtinValue !== undefined) {
|
|
94
|
+
return builtinValue;
|
|
95
|
+
}
|
|
96
|
+
// Unknown variable
|
|
97
|
+
if (options.strict) {
|
|
98
|
+
throw new Error(`Unknown xfg template variable: ${varName}`);
|
|
99
|
+
}
|
|
100
|
+
// Non-strict mode - leave placeholder as-is
|
|
101
|
+
return match;
|
|
102
|
+
});
|
|
103
|
+
// Phase 3: Restore escaped sequences as literal ${xfg:...}
|
|
104
|
+
processed = processed.replace(new RegExp(`${ESCAPE_PLACEHOLDER}(\\d+)\x00`, "g"), (_match, indexStr) => {
|
|
105
|
+
const index = parseInt(indexStr, 10);
|
|
106
|
+
return `\${xfg:${escapedContent[index]}}`;
|
|
107
|
+
});
|
|
108
|
+
return processed;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Recursively process a value, interpolating xfg template variables in strings.
|
|
112
|
+
*/
|
|
113
|
+
function processValue(value, ctx, options) {
|
|
114
|
+
if (typeof value === "string") {
|
|
115
|
+
return processString(value, ctx, options);
|
|
116
|
+
}
|
|
117
|
+
if (Array.isArray(value)) {
|
|
118
|
+
return value.map((item) => processValue(item, ctx, options));
|
|
119
|
+
}
|
|
120
|
+
if (isPlainObject(value)) {
|
|
121
|
+
const result = {};
|
|
122
|
+
for (const [key, val] of Object.entries(value)) {
|
|
123
|
+
result[key] = processValue(val, ctx, options);
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
// For numbers, booleans, null - return as-is
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Interpolate xfg template variables in content.
|
|
132
|
+
*
|
|
133
|
+
* Supports these syntaxes:
|
|
134
|
+
* - ${xfg:repo.name} - Repository name
|
|
135
|
+
* - ${xfg:repo.owner} - Repository owner
|
|
136
|
+
* - ${xfg:repo.fullName} - Full repository name (owner/repo)
|
|
137
|
+
* - ${xfg:repo.url} - Git URL
|
|
138
|
+
* - ${xfg:repo.platform} - Platform type (github, azure-devops, gitlab)
|
|
139
|
+
* - ${xfg:repo.host} - Host domain
|
|
140
|
+
* - ${xfg:file.name} - Current file name
|
|
141
|
+
* - ${xfg:date} - Current date (YYYY-MM-DD)
|
|
142
|
+
* - ${xfg:customVar} - Custom variable from vars config
|
|
143
|
+
* - $${xfg:var} - Escape: outputs literal ${xfg:var}
|
|
144
|
+
*
|
|
145
|
+
* @param content - The content to process (object, string, or string[])
|
|
146
|
+
* @param ctx - Template context with repo info and custom vars
|
|
147
|
+
* @param options - Interpolation options (default: strict mode)
|
|
148
|
+
* @returns Content with interpolated values
|
|
149
|
+
*/
|
|
150
|
+
export function interpolateXfgContent(content, ctx, options = DEFAULT_OPTIONS) {
|
|
151
|
+
if (typeof content === "string") {
|
|
152
|
+
return processString(content, ctx, options);
|
|
153
|
+
}
|
|
154
|
+
if (Array.isArray(content)) {
|
|
155
|
+
return content.map((line) => processString(line, ctx, options));
|
|
156
|
+
}
|
|
157
|
+
return processValue(content, ctx, options);
|
|
158
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aspruyt/xfg",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "CLI tool to sync JSON, JSON5, YAML, or text configuration files across multiple Git repositories by creating pull requests",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"build": "tsc",
|
|
27
27
|
"start": "node dist/index.js",
|
|
28
28
|
"dev": "ts-node src/index.ts",
|
|
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",
|
|
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 src/xfg-template.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
32
|
"test:integration:gitlab": "npm run build && node --import tsx --test src/integration-gitlab.test.ts",
|