@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 +1 -1
- package/dist/config-normalizer.js +1 -0
- package/dist/config-validator.js +18 -0
- package/dist/config.d.ts +2 -0
- package/dist/index.js +3 -1
- package/dist/repo-detector.d.ts +6 -2
- package/dist/repo-detector.js +38 -10
- package/dist/strategies/github-pr-strategy.js +49 -9
- package/package.json +1 -1
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
|
package/dist/config-validator.js
CHANGED
|
@@ -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);
|
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}`);
|
|
@@ -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.`,
|
package/package.json
CHANGED