@aspruyt/xfg 1.5.1 → 1.6.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/README.md CHANGED
@@ -55,7 +55,7 @@ xfg --config ./config.yaml
55
55
  - **Override Mode** - Skip merging entirely for specific repos
56
56
  - **Empty Files** - Create files with no content (e.g., `.prettierignore`)
57
57
  - **YAML Comments** - Add header comments and schema directives to YAML files
58
- - **Multi-Platform** - Works with GitHub, Azure DevOps, and GitLab (including self-hosted)
58
+ - **Multi-Platform** - Works with GitHub (including GitHub Enterprise Server), Azure DevOps, and GitLab (including self-hosted)
59
59
  - **Auto-Merge PRs** - Automatically merge PRs when checks pass, or force merge with admin privileges
60
60
  - **Dry-Run Mode** - Preview changes without creating PRs
61
61
  - **Error Resilience** - Continues processing if individual repos fail
@@ -136,5 +136,6 @@ export function normalizeConfig(raw) {
136
136
  return {
137
137
  repos: expandedRepos,
138
138
  prTemplate: raw.prTemplate,
139
+ githubHosts: raw.githubHosts,
139
140
  };
140
141
  }
@@ -83,6 +83,24 @@ export function validateRawConfig(config) {
83
83
  if (!config.repos || !Array.isArray(config.repos)) {
84
84
  throw new Error("Config missing required field: repos (must be an array)");
85
85
  }
86
+ // Validate githubHosts if provided
87
+ if (config.githubHosts !== undefined) {
88
+ if (!Array.isArray(config.githubHosts) ||
89
+ !config.githubHosts.every((h) => typeof h === "string")) {
90
+ throw new Error("githubHosts must be an array of strings");
91
+ }
92
+ for (const host of config.githubHosts) {
93
+ if (!host) {
94
+ throw new Error("githubHosts entries must be non-empty hostnames");
95
+ }
96
+ if (host.includes("://")) {
97
+ throw new Error(`githubHosts entries must be hostnames only, not URLs. Got: ${host}`);
98
+ }
99
+ if (host.includes("/")) {
100
+ throw new Error(`githubHosts entries must be hostnames only, not paths. Got: ${host}`);
101
+ }
102
+ }
103
+ }
86
104
  // Validate each repo
87
105
  for (let i = 0; i < config.repos.length; i++) {
88
106
  const repo = config.repos[i];
package/dist/config.d.ts CHANGED
@@ -35,6 +35,7 @@ export interface RawConfig {
35
35
  repos: RawRepoConfig[];
36
36
  prOptions?: PRMergeOptions;
37
37
  prTemplate?: string;
38
+ githubHosts?: string[];
38
39
  }
39
40
  export interface FileContent {
40
41
  fileName: string;
@@ -52,5 +53,6 @@ export interface RepoConfig {
52
53
  export interface Config {
53
54
  repos: RepoConfig[];
54
55
  prTemplate?: string;
56
+ githubHosts?: string[];
55
57
  }
56
58
  export declare function loadConfig(filePath: string): Config;
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);
@@ -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 {};
@@ -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 Azure DevOps formats first (most specific patterns)
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
- return parseGitHubUrl(gitUrl);
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@github.com:owner/repo.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(/git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/);
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://github.com/owner/repo.git
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(/https?:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/);
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}`);
@@ -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 command = `gh pr list --head ${escapeShellArg(branchName)} --json url --jq '.[0].url'`;
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 command = `gh pr close ${escapeShellArg(prNumber)} --repo ${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)} --delete-branch`;
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 urlMatch = result.match(/https:\/\/github\.com\/[\w-]+\/[\w.-]+\/pull\/\d+/);
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 command = `gh api repos/${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)} --jq '.allow_auto_merge // false'`;
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(/github\.com\/([^/]+)\/([^/]+)/);
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[1],
153
- repo: match[2],
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.owner}/${repoInfo.repo} --enable-auto-merge (requires admin)`);
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.`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/xfg",
3
- "version": "1.5.1",
3
+ "version": "1.6.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",