@aspruyt/xfg 1.1.0 → 1.3.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 +47 -9
- package/dist/repo-detector.d.ts +9 -2
- package/dist/repo-detector.js +84 -2
- package/dist/strategies/azure-pr-strategy.js +12 -9
- package/dist/strategies/gitlab-pr-strategy.d.ts +27 -0
- package/dist/strategies/gitlab-pr-strategy.js +276 -0
- package/dist/strategies/index.d.ts +1 -0
- package/dist/strategies/index.js +6 -1
- package/package.json +8 -5
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/@aspruyt/xfg)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
|
-
A CLI tool that syncs JSON, JSON5, YAML, or text configuration files across multiple GitHub
|
|
8
|
+
A CLI tool that syncs JSON, JSON5, YAML, or text configuration files across multiple GitHub, Azure DevOps, and GitLab repositories by creating pull requests (or merge requests for GitLab). Output format is automatically detected from the target filename extension (`.json` → JSON, `.json5` → JSON5, `.yaml`/`.yml` → YAML, other → text).
|
|
9
9
|
|
|
10
10
|
## Table of Contents
|
|
11
11
|
|
|
@@ -72,7 +72,7 @@ xfg --config ./config.yaml
|
|
|
72
72
|
- **Override Mode** - Skip merging entirely for specific repos
|
|
73
73
|
- **Empty Files** - Create files with no content (e.g., `.prettierignore`)
|
|
74
74
|
- **YAML Comments** - Add header comments and schema directives to YAML files
|
|
75
|
-
- **GitHub
|
|
75
|
+
- **Multi-Platform** - Works with GitHub, Azure DevOps, and GitLab (including self-hosted)
|
|
76
76
|
- **Auto-Merge PRs** - Automatically merge PRs when checks pass, or force merge with admin privileges
|
|
77
77
|
- **Dry-Run Mode** - Preview changes without creating PRs
|
|
78
78
|
- **Error Resilience** - Continues processing if individual repos fail
|
|
@@ -118,6 +118,20 @@ az login
|
|
|
118
118
|
az devops configure --defaults organization=https://dev.azure.com/YOUR_ORG project=YOUR_PROJECT
|
|
119
119
|
```
|
|
120
120
|
|
|
121
|
+
### GitLab Authentication
|
|
122
|
+
|
|
123
|
+
Before using with GitLab repositories, authenticate with the GitLab CLI:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
glab auth login
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
For self-hosted GitLab instances:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
glab auth login --hostname gitlab.example.com
|
|
133
|
+
```
|
|
134
|
+
|
|
121
135
|
## Usage
|
|
122
136
|
|
|
123
137
|
```bash
|
|
@@ -679,11 +693,11 @@ repos:
|
|
|
679
693
|
|
|
680
694
|
**Merge Modes:**
|
|
681
695
|
|
|
682
|
-
| Mode | GitHub Behavior | Azure DevOps Behavior |
|
|
683
|
-
| -------- | ---------------------------------------------------- | -------------------------------------- |
|
|
684
|
-
| `manual` | Leave PR open for review | Leave PR open for review |
|
|
685
|
-
| `auto` | Enable auto-merge (requires repo setup, **default**) | Enable auto-complete (**default**) |
|
|
686
|
-
| `force` | Merge with `--admin` (bypass checks) | Bypass policies with `--bypass-policy` |
|
|
696
|
+
| Mode | GitHub Behavior | Azure DevOps Behavior | GitLab Behavior |
|
|
697
|
+
| -------- | ---------------------------------------------------- | -------------------------------------- | ------------------------------------------ |
|
|
698
|
+
| `manual` | Leave PR open for review | Leave PR open for review | Leave MR open for review |
|
|
699
|
+
| `auto` | Enable auto-merge (requires repo setup, **default**) | Enable auto-complete (**default**) | Merge when pipeline succeeds (**default**) |
|
|
700
|
+
| `force` | Merge with `--admin` (bypass checks) | Bypass policies with `--bypass-policy` | Merge immediately |
|
|
687
701
|
|
|
688
702
|
**GitHub Auto-Merge Note:** The `auto` mode requires auto-merge to be enabled in the repository settings. If not enabled, the tool will warn and leave the PR open for manual review. Enable it with:
|
|
689
703
|
|
|
@@ -713,6 +727,14 @@ xfg --config ./config.yaml --merge force
|
|
|
713
727
|
- SSH: `git@ssh.dev.azure.com:v3/organization/project/repo`
|
|
714
728
|
- HTTPS: `https://dev.azure.com/organization/project/_git/repo`
|
|
715
729
|
|
|
730
|
+
### GitLab
|
|
731
|
+
|
|
732
|
+
- SSH: `git@gitlab.com:owner/repo.git`
|
|
733
|
+
- HTTPS: `https://gitlab.com/owner/repo.git`
|
|
734
|
+
- Nested groups: `git@gitlab.com:org/group/subgroup/repo.git`
|
|
735
|
+
- Self-hosted SSH: `git@gitlab.example.com:owner/repo.git`
|
|
736
|
+
- Self-hosted HTTPS: `https://gitlab.example.com/owner/repo.git`
|
|
737
|
+
|
|
716
738
|
## How It Works
|
|
717
739
|
|
|
718
740
|
```mermaid
|
|
@@ -737,11 +759,13 @@ flowchart TB
|
|
|
737
759
|
end
|
|
738
760
|
|
|
739
761
|
subgraph Platform["PR Creation"]
|
|
740
|
-
COMMIT --> PR_DETECT{
|
|
762
|
+
COMMIT --> PR_DETECT{Platform?}
|
|
741
763
|
PR_DETECT -->|GitHub| GH_PR[Create PR via gh CLI]
|
|
742
764
|
PR_DETECT -->|Azure DevOps| AZ_PR[Create PR via az CLI]
|
|
743
|
-
|
|
765
|
+
PR_DETECT -->|GitLab| GL_PR[Create MR via glab CLI]
|
|
766
|
+
GH_PR --> PR_CREATED[PR/MR Created]
|
|
744
767
|
AZ_PR --> PR_CREATED
|
|
768
|
+
GL_PR --> PR_CREATED
|
|
745
769
|
end
|
|
746
770
|
|
|
747
771
|
subgraph AutoMerge["Auto-Merge (default)"]
|
|
@@ -890,11 +914,25 @@ az login
|
|
|
890
914
|
az devops configure --defaults organization=https://dev.azure.com/YOUR_ORG
|
|
891
915
|
```
|
|
892
916
|
|
|
917
|
+
**GitLab:**
|
|
918
|
+
|
|
919
|
+
```bash
|
|
920
|
+
# Check authentication status
|
|
921
|
+
glab auth status
|
|
922
|
+
|
|
923
|
+
# Re-authenticate if needed
|
|
924
|
+
glab auth login
|
|
925
|
+
|
|
926
|
+
# For self-hosted instances
|
|
927
|
+
glab auth login --hostname gitlab.example.com
|
|
928
|
+
```
|
|
929
|
+
|
|
893
930
|
### Permission Denied
|
|
894
931
|
|
|
895
932
|
- Ensure your token has write access to the target repositories
|
|
896
933
|
- For GitHub, the token needs `repo` scope
|
|
897
934
|
- For Azure DevOps, ensure the user/service account has "Contribute to pull requests" permission
|
|
935
|
+
- For GitLab, ensure the user has at least "Developer" role on the project
|
|
898
936
|
|
|
899
937
|
### Branch Already Exists
|
|
900
938
|
|
package/dist/repo-detector.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type RepoType = "github" | "azure-devops";
|
|
1
|
+
export type RepoType = "github" | "azure-devops" | "gitlab";
|
|
2
2
|
interface BaseRepoInfo {
|
|
3
3
|
gitUrl: string;
|
|
4
4
|
repo: string;
|
|
@@ -13,9 +13,16 @@ export interface AzureDevOpsRepoInfo extends BaseRepoInfo {
|
|
|
13
13
|
organization: string;
|
|
14
14
|
project: string;
|
|
15
15
|
}
|
|
16
|
-
export
|
|
16
|
+
export interface GitLabRepoInfo extends BaseRepoInfo {
|
|
17
|
+
type: "gitlab";
|
|
18
|
+
owner: string;
|
|
19
|
+
namespace: string;
|
|
20
|
+
host: string;
|
|
21
|
+
}
|
|
22
|
+
export type RepoInfo = GitHubRepoInfo | AzureDevOpsRepoInfo | GitLabRepoInfo;
|
|
17
23
|
export declare function isGitHubRepo(info: RepoInfo): info is GitHubRepoInfo;
|
|
18
24
|
export declare function isAzureDevOpsRepo(info: RepoInfo): info is AzureDevOpsRepoInfo;
|
|
25
|
+
export declare function isGitLabRepo(info: RepoInfo): info is GitLabRepoInfo;
|
|
19
26
|
export declare function detectRepoType(gitUrl: string): RepoType;
|
|
20
27
|
export declare function parseGitUrl(gitUrl: string): RepoInfo;
|
|
21
28
|
export declare function getRepoDisplayName(repoInfo: RepoInfo): string;
|
package/dist/repo-detector.js
CHANGED
|
@@ -5,6 +5,9 @@ export function isGitHubRepo(info) {
|
|
|
5
5
|
export function isAzureDevOpsRepo(info) {
|
|
6
6
|
return info.type === "azure-devops";
|
|
7
7
|
}
|
|
8
|
+
export function isGitLabRepo(info) {
|
|
9
|
+
return info.type === "gitlab";
|
|
10
|
+
}
|
|
8
11
|
/**
|
|
9
12
|
* Valid URL patterns for supported repository types.
|
|
10
13
|
*/
|
|
@@ -13,8 +16,33 @@ const AZURE_DEVOPS_URL_PATTERNS = [
|
|
|
13
16
|
/^git@ssh\.dev\.azure\.com:/,
|
|
14
17
|
/^https?:\/\/dev\.azure\.com\//,
|
|
15
18
|
];
|
|
19
|
+
const GITLAB_SAAS_URL_PATTERNS = [
|
|
20
|
+
/^git@gitlab\.com:/,
|
|
21
|
+
/^https?:\/\/gitlab\.com\//,
|
|
22
|
+
];
|
|
23
|
+
/**
|
|
24
|
+
* Check if a URL looks like a GitLab-style URL (used for self-hosted detection).
|
|
25
|
+
* This is a fallback for URLs that don't match known platforms.
|
|
26
|
+
*/
|
|
27
|
+
function isGitLabStyleUrl(gitUrl) {
|
|
28
|
+
// SSH: git@hostname:path/to/repo.git
|
|
29
|
+
const sshMatch = gitUrl.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
|
|
30
|
+
if (sshMatch) {
|
|
31
|
+
const path = sshMatch[2];
|
|
32
|
+
// Must have at least one slash (owner/repo or namespace/repo)
|
|
33
|
+
return path.includes("/");
|
|
34
|
+
}
|
|
35
|
+
// HTTPS: https://hostname/path/to/repo.git
|
|
36
|
+
const httpsMatch = gitUrl.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?$/);
|
|
37
|
+
if (httpsMatch) {
|
|
38
|
+
const path = httpsMatch[2];
|
|
39
|
+
// Must have at least one slash (owner/repo or namespace/repo)
|
|
40
|
+
return path.includes("/");
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
16
44
|
export function detectRepoType(gitUrl) {
|
|
17
|
-
// Check for Azure DevOps formats
|
|
45
|
+
// Check for Azure DevOps formats first (most specific patterns)
|
|
18
46
|
for (const pattern of AZURE_DEVOPS_URL_PATTERNS) {
|
|
19
47
|
if (pattern.test(gitUrl)) {
|
|
20
48
|
return "azure-devops";
|
|
@@ -26,14 +54,27 @@ export function detectRepoType(gitUrl) {
|
|
|
26
54
|
return "github";
|
|
27
55
|
}
|
|
28
56
|
}
|
|
57
|
+
// Check for GitLab SaaS formats
|
|
58
|
+
for (const pattern of GITLAB_SAAS_URL_PATTERNS) {
|
|
59
|
+
if (pattern.test(gitUrl)) {
|
|
60
|
+
return "gitlab";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// For unrecognized URLs, try GitLab-style parsing as fallback (self-hosted)
|
|
64
|
+
if (isGitLabStyleUrl(gitUrl)) {
|
|
65
|
+
return "gitlab";
|
|
66
|
+
}
|
|
29
67
|
// Throw for unrecognized URL formats
|
|
30
|
-
throw new Error(`Unrecognized git URL format: ${gitUrl}. Supported formats: GitHub (git@github.com: or https://github.com/)
|
|
68
|
+
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/)`);
|
|
31
69
|
}
|
|
32
70
|
export function parseGitUrl(gitUrl) {
|
|
33
71
|
const type = detectRepoType(gitUrl);
|
|
34
72
|
if (type === "azure-devops") {
|
|
35
73
|
return parseAzureDevOpsUrl(gitUrl);
|
|
36
74
|
}
|
|
75
|
+
if (type === "gitlab") {
|
|
76
|
+
return parseGitLabUrl(gitUrl);
|
|
77
|
+
}
|
|
37
78
|
return parseGitHubUrl(gitUrl);
|
|
38
79
|
}
|
|
39
80
|
function parseGitHubUrl(gitUrl) {
|
|
@@ -90,9 +131,50 @@ function parseAzureDevOpsUrl(gitUrl) {
|
|
|
90
131
|
}
|
|
91
132
|
throw new Error(`Unable to parse Azure DevOps URL: ${gitUrl}`);
|
|
92
133
|
}
|
|
134
|
+
function parseGitLabUrl(gitUrl) {
|
|
135
|
+
// Handle SSH format: git@gitlab.com:owner/repo.git or git@gitlab.com:org/group/repo.git
|
|
136
|
+
// Also handles self-hosted: git@gitlab.example.com:owner/repo.git
|
|
137
|
+
const sshMatch = gitUrl.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
|
|
138
|
+
if (sshMatch) {
|
|
139
|
+
const host = sshMatch[1];
|
|
140
|
+
const fullPath = sshMatch[2];
|
|
141
|
+
return parseGitLabPath(gitUrl, host, fullPath);
|
|
142
|
+
}
|
|
143
|
+
// Handle HTTPS format: https://gitlab.com/owner/repo.git or https://gitlab.com/org/group/repo.git
|
|
144
|
+
// Also handles self-hosted: https://gitlab.example.com/owner/repo.git
|
|
145
|
+
const httpsMatch = gitUrl.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?$/);
|
|
146
|
+
if (httpsMatch) {
|
|
147
|
+
const host = httpsMatch[1];
|
|
148
|
+
const fullPath = httpsMatch[2];
|
|
149
|
+
return parseGitLabPath(gitUrl, host, fullPath);
|
|
150
|
+
}
|
|
151
|
+
throw new Error(`Unable to parse GitLab URL: ${gitUrl}`);
|
|
152
|
+
}
|
|
153
|
+
function parseGitLabPath(gitUrl, host, fullPath) {
|
|
154
|
+
// Split path into segments: org/group/subgroup/repo -> [org, group, subgroup, repo]
|
|
155
|
+
const segments = fullPath.split("/");
|
|
156
|
+
if (segments.length < 2) {
|
|
157
|
+
throw new Error(`Unable to parse GitLab URL: ${gitUrl}`);
|
|
158
|
+
}
|
|
159
|
+
// Last segment is repo, everything else is namespace
|
|
160
|
+
const repo = segments[segments.length - 1];
|
|
161
|
+
const namespace = segments.slice(0, -1).join("/");
|
|
162
|
+
const owner = segments[0]; // First segment for display
|
|
163
|
+
return {
|
|
164
|
+
type: "gitlab",
|
|
165
|
+
gitUrl,
|
|
166
|
+
repo,
|
|
167
|
+
owner,
|
|
168
|
+
namespace,
|
|
169
|
+
host,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
93
172
|
export function getRepoDisplayName(repoInfo) {
|
|
94
173
|
if (repoInfo.type === "azure-devops") {
|
|
95
174
|
return `${repoInfo.organization}/${repoInfo.project}/${repoInfo.repo}`;
|
|
96
175
|
}
|
|
176
|
+
if (repoInfo.type === "gitlab") {
|
|
177
|
+
return `${repoInfo.namespace}/${repoInfo.repo}`;
|
|
178
|
+
}
|
|
97
179
|
return `${repoInfo.owner}/${repoInfo.repo}`;
|
|
98
180
|
}
|
|
@@ -14,7 +14,7 @@ export class AzurePRStrategy extends BasePRStrategy {
|
|
|
14
14
|
return `https://dev.azure.com/${encodeURIComponent(repoInfo.organization)}`;
|
|
15
15
|
}
|
|
16
16
|
buildPRUrl(repoInfo, prId) {
|
|
17
|
-
return `https://dev.azure.com/${encodeURIComponent(repoInfo.organization)}/${encodeURIComponent(repoInfo.project)}/_git/${encodeURIComponent(repoInfo.repo)}/pullrequest/${prId}`;
|
|
17
|
+
return `https://dev.azure.com/${encodeURIComponent(repoInfo.organization)}/${encodeURIComponent(repoInfo.project)}/_git/${encodeURIComponent(repoInfo.repo)}/pullrequest/${prId.trim()}`;
|
|
18
18
|
}
|
|
19
19
|
async checkExistingPR(options) {
|
|
20
20
|
const { repoInfo, branchName, baseBranch, workDir, retries = 3 } = options;
|
|
@@ -68,7 +68,7 @@ export class AzurePRStrategy extends BasePRStrategy {
|
|
|
68
68
|
return false;
|
|
69
69
|
}
|
|
70
70
|
// Abandon the PR (Azure DevOps equivalent of closing)
|
|
71
|
-
const abandonCommand = `az repos pr update --id ${escapeShellArg(prInfo.prId)} --status abandoned --org ${escapeShellArg(orgUrl)}
|
|
71
|
+
const abandonCommand = `az repos pr update --id ${escapeShellArg(prInfo.prId)} --status abandoned --org ${escapeShellArg(orgUrl)}`;
|
|
72
72
|
try {
|
|
73
73
|
await withRetry(() => this.executor.exec(abandonCommand, workDir), {
|
|
74
74
|
retries,
|
|
@@ -79,12 +79,15 @@ export class AzurePRStrategy extends BasePRStrategy {
|
|
|
79
79
|
logger.info(`Warning: Failed to abandon PR #${prInfo.prId}: ${message}`);
|
|
80
80
|
return false;
|
|
81
81
|
}
|
|
82
|
-
// Delete the source branch
|
|
83
|
-
const deleteBranchCommand = `az repos ref delete --name refs/heads/${escapeShellArg(branchName)} --repository ${escapeShellArg(azureRepoInfo.repo)} --org ${escapeShellArg(orgUrl)} --project ${escapeShellArg(azureRepoInfo.project)}`;
|
|
82
|
+
// Delete the source branch - need to get object_id first
|
|
84
83
|
try {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
});
|
|
84
|
+
// Get the branch's object_id
|
|
85
|
+
const getRefCommand = `az repos ref list --repository ${escapeShellArg(azureRepoInfo.repo)} --org ${escapeShellArg(orgUrl)} --project ${escapeShellArg(azureRepoInfo.project)} --filter heads/${escapeShellArg(branchName)} --query "[0].objectId" -o tsv`;
|
|
86
|
+
const objectId = await withRetry(() => this.executor.exec(getRefCommand, workDir), { retries });
|
|
87
|
+
if (objectId) {
|
|
88
|
+
const deleteBranchCommand = `az repos ref delete --name refs/heads/${escapeShellArg(branchName)} --repository ${escapeShellArg(azureRepoInfo.repo)} --org ${escapeShellArg(orgUrl)} --project ${escapeShellArg(azureRepoInfo.project)} --object-id ${escapeShellArg(objectId)}`;
|
|
89
|
+
await withRetry(() => this.executor.exec(deleteBranchCommand, workDir), { retries });
|
|
90
|
+
}
|
|
88
91
|
}
|
|
89
92
|
catch (error) {
|
|
90
93
|
// Branch deletion failure is not critical - PR is already abandoned
|
|
@@ -168,7 +171,7 @@ export class AzurePRStrategy extends BasePRStrategy {
|
|
|
168
171
|
: "";
|
|
169
172
|
if (config.mode === "auto") {
|
|
170
173
|
// Enable auto-complete (no pre-check needed - always available in Azure DevOps)
|
|
171
|
-
const command = `az repos pr update --id ${escapeShellArg(prInfo.prId)} --auto-complete true ${squashFlag} ${deleteBranchFlag} --org ${escapeShellArg(orgUrl)}
|
|
174
|
+
const command = `az repos pr update --id ${escapeShellArg(prInfo.prId)} --auto-complete true ${squashFlag} ${deleteBranchFlag} --org ${escapeShellArg(orgUrl)}`.trim();
|
|
172
175
|
try {
|
|
173
176
|
await withRetry(() => this.executor.exec(command, workDir), {
|
|
174
177
|
retries,
|
|
@@ -192,7 +195,7 @@ export class AzurePRStrategy extends BasePRStrategy {
|
|
|
192
195
|
if (config.mode === "force") {
|
|
193
196
|
// Bypass policies and complete the PR
|
|
194
197
|
const bypassReason = config.bypassReason ?? "Automated config sync via xfg";
|
|
195
|
-
const command = `az repos pr update --id ${escapeShellArg(prInfo.prId)} --bypass-policy true --bypass-policy-reason ${escapeShellArg(bypassReason)} --status completed ${squashFlag} ${deleteBranchFlag} --org ${escapeShellArg(orgUrl)}
|
|
198
|
+
const command = `az repos pr update --id ${escapeShellArg(prInfo.prId)} --bypass-policy true --bypass-policy-reason ${escapeShellArg(bypassReason)} --status completed ${squashFlag} ${deleteBranchFlag} --org ${escapeShellArg(orgUrl)}`.trim();
|
|
196
199
|
try {
|
|
197
200
|
await withRetry(() => this.executor.exec(command, workDir), {
|
|
198
201
|
retries,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { PRResult } from "../pr-creator.js";
|
|
2
|
+
import { BasePRStrategy, PRStrategyOptions, CloseExistingPROptions, MergeOptions, MergeResult } from "./pr-strategy.js";
|
|
3
|
+
import { CommandExecutor } from "../command-executor.js";
|
|
4
|
+
export declare class GitLabPRStrategy extends BasePRStrategy {
|
|
5
|
+
constructor(executor?: CommandExecutor);
|
|
6
|
+
/**
|
|
7
|
+
* Build the repo flag for glab commands.
|
|
8
|
+
* Format: namespace/repo (supports nested groups)
|
|
9
|
+
*/
|
|
10
|
+
private getRepoFlag;
|
|
11
|
+
/**
|
|
12
|
+
* Build the MR URL from repo info and MR IID.
|
|
13
|
+
*/
|
|
14
|
+
private buildMRUrl;
|
|
15
|
+
/**
|
|
16
|
+
* Parse MR URL to extract components.
|
|
17
|
+
*/
|
|
18
|
+
private parseMRUrl;
|
|
19
|
+
/**
|
|
20
|
+
* Build merge strategy flags for glab mr merge command.
|
|
21
|
+
*/
|
|
22
|
+
private getMergeStrategyFlag;
|
|
23
|
+
checkExistingPR(options: PRStrategyOptions): Promise<string | null>;
|
|
24
|
+
closeExistingPR(options: CloseExistingPROptions): Promise<boolean>;
|
|
25
|
+
create(options: PRStrategyOptions): Promise<PRResult>;
|
|
26
|
+
merge(options: MergeOptions): Promise<MergeResult>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { existsSync, writeFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { escapeShellArg } from "../shell-utils.js";
|
|
4
|
+
import { isGitLabRepo } from "../repo-detector.js";
|
|
5
|
+
import { BasePRStrategy, } from "./pr-strategy.js";
|
|
6
|
+
import { logger } from "../logger.js";
|
|
7
|
+
import { withRetry, isPermanentError } from "../retry-utils.js";
|
|
8
|
+
export class GitLabPRStrategy extends BasePRStrategy {
|
|
9
|
+
constructor(executor) {
|
|
10
|
+
super(executor);
|
|
11
|
+
this.bodyFilePath = ".mr-description.md";
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Build the repo flag for glab commands.
|
|
15
|
+
* Format: namespace/repo (supports nested groups)
|
|
16
|
+
*/
|
|
17
|
+
getRepoFlag(repoInfo) {
|
|
18
|
+
return `${repoInfo.namespace}/${repoInfo.repo}`;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Build the MR URL from repo info and MR IID.
|
|
22
|
+
*/
|
|
23
|
+
buildMRUrl(repoInfo, mrIid) {
|
|
24
|
+
return `https://${repoInfo.host}/${repoInfo.namespace}/${repoInfo.repo}/-/merge_requests/${mrIid}`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Parse MR URL to extract components.
|
|
28
|
+
*/
|
|
29
|
+
parseMRUrl(mrUrl) {
|
|
30
|
+
// URL format: https://gitlab.com/namespace/repo/-/merge_requests/123
|
|
31
|
+
// Nested: https://gitlab.com/org/group/subgroup/repo/-/merge_requests/123
|
|
32
|
+
// Use specific path segment pattern to avoid ReDoS (polynomial regex)
|
|
33
|
+
// Pattern: protocol://host/path-segments/-/merge_requests/id
|
|
34
|
+
const match = mrUrl.match(/https?:\/\/([^/]+)\/((?:[^/]+\/)*[^/]+)\/-\/merge_requests\/(\d+)/);
|
|
35
|
+
if (!match)
|
|
36
|
+
return null;
|
|
37
|
+
const host = match[1];
|
|
38
|
+
const fullPath = match[2];
|
|
39
|
+
const mrIid = match[3];
|
|
40
|
+
// Split path to get namespace and repo
|
|
41
|
+
const segments = fullPath.split("/");
|
|
42
|
+
if (segments.length < 2)
|
|
43
|
+
return null;
|
|
44
|
+
const repo = segments[segments.length - 1];
|
|
45
|
+
const namespace = segments.slice(0, -1).join("/");
|
|
46
|
+
return { host, namespace, repo, mrIid };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Build merge strategy flags for glab mr merge command.
|
|
50
|
+
*/
|
|
51
|
+
getMergeStrategyFlag(strategy) {
|
|
52
|
+
switch (strategy) {
|
|
53
|
+
case "squash":
|
|
54
|
+
return "--squash";
|
|
55
|
+
case "rebase":
|
|
56
|
+
return "--rebase";
|
|
57
|
+
case "merge":
|
|
58
|
+
default:
|
|
59
|
+
return "";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async checkExistingPR(options) {
|
|
63
|
+
const { repoInfo, branchName, workDir, retries = 3 } = options;
|
|
64
|
+
if (!isGitLabRepo(repoInfo)) {
|
|
65
|
+
throw new Error("Expected GitLab repository");
|
|
66
|
+
}
|
|
67
|
+
const repoFlag = this.getRepoFlag(repoInfo);
|
|
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`;
|
|
70
|
+
try {
|
|
71
|
+
const result = await withRetry(() => this.executor.exec(command, workDir), { retries });
|
|
72
|
+
if (!result || result.trim() === "" || result.trim() === "[]") {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
// Parse JSON to get MR IID
|
|
76
|
+
const mrs = JSON.parse(result);
|
|
77
|
+
if (Array.isArray(mrs) && mrs.length > 0 && mrs[0].iid) {
|
|
78
|
+
return this.buildMRUrl(repoInfo, String(mrs[0].iid));
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (error instanceof Error) {
|
|
84
|
+
// Throw on permanent errors (auth failures, etc.)
|
|
85
|
+
if (isPermanentError(error)) {
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
// Log unexpected errors for debugging
|
|
89
|
+
const stderr = error.stderr ?? "";
|
|
90
|
+
if (stderr && !stderr.includes("no merge requests")) {
|
|
91
|
+
logger.info(`Debug: GitLab MR check failed - ${stderr.trim()}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async closeExistingPR(options) {
|
|
98
|
+
const { repoInfo, branchName, baseBranch, workDir, retries = 3 } = options;
|
|
99
|
+
if (!isGitLabRepo(repoInfo)) {
|
|
100
|
+
throw new Error("Expected GitLab repository");
|
|
101
|
+
}
|
|
102
|
+
// First check if there's an existing MR
|
|
103
|
+
const existingUrl = await this.checkExistingPR({
|
|
104
|
+
repoInfo,
|
|
105
|
+
branchName,
|
|
106
|
+
baseBranch,
|
|
107
|
+
workDir,
|
|
108
|
+
retries,
|
|
109
|
+
title: "", // Not used for check
|
|
110
|
+
body: "", // Not used for check
|
|
111
|
+
});
|
|
112
|
+
if (!existingUrl) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
// Extract MR IID from URL
|
|
116
|
+
const mrInfo = this.parseMRUrl(existingUrl);
|
|
117
|
+
if (!mrInfo) {
|
|
118
|
+
throw new Error(`Could not extract MR IID from URL: ${existingUrl}`);
|
|
119
|
+
}
|
|
120
|
+
const repoFlag = this.getRepoFlag(repoInfo);
|
|
121
|
+
// Close the MR
|
|
122
|
+
const closeCommand = `glab mr close ${escapeShellArg(mrInfo.mrIid)} -R ${escapeShellArg(repoFlag)}`;
|
|
123
|
+
try {
|
|
124
|
+
await withRetry(() => this.executor.exec(closeCommand, workDir), {
|
|
125
|
+
retries,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
130
|
+
logger.info(`Warning: Failed to close existing MR !${mrInfo.mrIid}: ${message}`);
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
// Delete the source branch via git
|
|
134
|
+
const deleteBranchCommand = `git push origin --delete ${escapeShellArg(branchName)}`;
|
|
135
|
+
try {
|
|
136
|
+
await withRetry(() => this.executor.exec(deleteBranchCommand, workDir), {
|
|
137
|
+
retries,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
// Branch deletion failure is not critical
|
|
142
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
143
|
+
logger.info(`Warning: Failed to delete branch ${branchName}: ${message}`);
|
|
144
|
+
}
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
async create(options) {
|
|
148
|
+
const { repoInfo, title, body, branchName, baseBranch, workDir, retries = 3, } = options;
|
|
149
|
+
if (!isGitLabRepo(repoInfo)) {
|
|
150
|
+
throw new Error("Expected GitLab repository");
|
|
151
|
+
}
|
|
152
|
+
const repoFlag = this.getRepoFlag(repoInfo);
|
|
153
|
+
// Write description to temp file to avoid shell escaping issues
|
|
154
|
+
const descFile = join(workDir, this.bodyFilePath);
|
|
155
|
+
writeFileSync(descFile, body, "utf-8");
|
|
156
|
+
// glab mr create with description from file
|
|
157
|
+
const command = `glab mr create --source-branch ${escapeShellArg(branchName)} --target-branch ${escapeShellArg(baseBranch)} --title ${escapeShellArg(title)} --description "$(cat ${escapeShellArg(descFile)})" --yes -R ${escapeShellArg(repoFlag)}`;
|
|
158
|
+
try {
|
|
159
|
+
const result = await withRetry(() => this.executor.exec(command, workDir), { retries });
|
|
160
|
+
// Extract MR URL from output
|
|
161
|
+
// glab typically outputs the URL directly
|
|
162
|
+
const urlMatch = result.match(/https:\/\/[^\s]+\/-\/merge_requests\/\d+/);
|
|
163
|
+
if (urlMatch) {
|
|
164
|
+
return {
|
|
165
|
+
url: urlMatch[0],
|
|
166
|
+
success: true,
|
|
167
|
+
message: "MR created successfully",
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
// Fallback: extract MR number and build URL
|
|
171
|
+
const mrMatch = result.match(/!(\d+)/);
|
|
172
|
+
if (mrMatch) {
|
|
173
|
+
return {
|
|
174
|
+
url: this.buildMRUrl(repoInfo, mrMatch[1]),
|
|
175
|
+
success: true,
|
|
176
|
+
message: "MR created successfully",
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
throw new Error(`Could not parse MR URL from output: ${result}`);
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
// Clean up temp file - log warning on failure instead of throwing
|
|
183
|
+
try {
|
|
184
|
+
if (existsSync(descFile)) {
|
|
185
|
+
unlinkSync(descFile);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
catch (cleanupError) {
|
|
189
|
+
logger.info(`Warning: Failed to clean up temp file ${descFile}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
async merge(options) {
|
|
194
|
+
const { prUrl, config, workDir, retries = 3 } = options;
|
|
195
|
+
// Manual mode: do nothing
|
|
196
|
+
if (config.mode === "manual") {
|
|
197
|
+
return {
|
|
198
|
+
success: true,
|
|
199
|
+
message: "MR left open for manual review",
|
|
200
|
+
merged: false,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
// Parse MR URL to extract details
|
|
204
|
+
const mrInfo = this.parseMRUrl(prUrl);
|
|
205
|
+
if (!mrInfo) {
|
|
206
|
+
return {
|
|
207
|
+
success: false,
|
|
208
|
+
message: `Invalid GitLab MR URL: ${prUrl}`,
|
|
209
|
+
merged: false,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
const repoFlag = `${mrInfo.namespace}/${mrInfo.repo}`;
|
|
213
|
+
const strategyFlag = this.getMergeStrategyFlag(config.strategy);
|
|
214
|
+
const deleteBranchFlag = config.deleteBranch
|
|
215
|
+
? "--remove-source-branch"
|
|
216
|
+
: "";
|
|
217
|
+
if (config.mode === "auto") {
|
|
218
|
+
// Enable auto-merge when pipeline succeeds
|
|
219
|
+
// glab mr merge <id> --when-pipeline-succeeds [--squash] [--remove-source-branch]
|
|
220
|
+
const flagParts = [
|
|
221
|
+
"--when-pipeline-succeeds",
|
|
222
|
+
strategyFlag,
|
|
223
|
+
deleteBranchFlag,
|
|
224
|
+
].filter(Boolean);
|
|
225
|
+
const command = `glab mr merge ${escapeShellArg(mrInfo.mrIid)} ${flagParts.join(" ")} -R ${escapeShellArg(repoFlag)} -y`;
|
|
226
|
+
try {
|
|
227
|
+
await withRetry(() => this.executor.exec(command.trim(), workDir), {
|
|
228
|
+
retries,
|
|
229
|
+
});
|
|
230
|
+
return {
|
|
231
|
+
success: true,
|
|
232
|
+
message: "Auto-merge enabled. MR will merge when pipeline succeeds.",
|
|
233
|
+
merged: false,
|
|
234
|
+
autoMergeEnabled: true,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
239
|
+
return {
|
|
240
|
+
success: false,
|
|
241
|
+
message: `Failed to enable auto-merge: ${message}`,
|
|
242
|
+
merged: false,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (config.mode === "force") {
|
|
247
|
+
// Force merge immediately
|
|
248
|
+
// glab mr merge <id> --yes [--squash] [--remove-source-branch]
|
|
249
|
+
const flagParts = [strategyFlag, deleteBranchFlag].filter(Boolean);
|
|
250
|
+
const command = `glab mr merge ${escapeShellArg(mrInfo.mrIid)} ${flagParts.join(" ")} -R ${escapeShellArg(repoFlag)} -y`;
|
|
251
|
+
try {
|
|
252
|
+
await withRetry(() => this.executor.exec(command.trim(), workDir), {
|
|
253
|
+
retries,
|
|
254
|
+
});
|
|
255
|
+
return {
|
|
256
|
+
success: true,
|
|
257
|
+
message: "MR merged successfully.",
|
|
258
|
+
merged: true,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
263
|
+
return {
|
|
264
|
+
success: false,
|
|
265
|
+
message: `Failed to force merge: ${message}`,
|
|
266
|
+
merged: false,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
success: false,
|
|
272
|
+
message: `Unknown merge mode: ${config.mode}`,
|
|
273
|
+
merged: false,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -5,6 +5,7 @@ export type { PRStrategy, PRStrategyOptions, CloseExistingPROptions, PRMergeConf
|
|
|
5
5
|
export { BasePRStrategy, PRWorkflowExecutor } from "./pr-strategy.js";
|
|
6
6
|
export { GitHubPRStrategy } from "./github-pr-strategy.js";
|
|
7
7
|
export { AzurePRStrategy } from "./azure-pr-strategy.js";
|
|
8
|
+
export { GitLabPRStrategy } from "./gitlab-pr-strategy.js";
|
|
8
9
|
/**
|
|
9
10
|
* Factory function to get the appropriate PR strategy for a repository.
|
|
10
11
|
* @param repoInfo - Repository information
|
package/dist/strategies/index.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { isGitHubRepo, isAzureDevOpsRepo } from "../repo-detector.js";
|
|
1
|
+
import { isGitHubRepo, isAzureDevOpsRepo, isGitLabRepo, } from "../repo-detector.js";
|
|
2
2
|
import { GitHubPRStrategy } from "./github-pr-strategy.js";
|
|
3
3
|
import { AzurePRStrategy } from "./azure-pr-strategy.js";
|
|
4
|
+
import { GitLabPRStrategy } from "./gitlab-pr-strategy.js";
|
|
4
5
|
export { BasePRStrategy, PRWorkflowExecutor } from "./pr-strategy.js";
|
|
5
6
|
export { GitHubPRStrategy } from "./github-pr-strategy.js";
|
|
6
7
|
export { AzurePRStrategy } from "./azure-pr-strategy.js";
|
|
8
|
+
export { GitLabPRStrategy } from "./gitlab-pr-strategy.js";
|
|
7
9
|
/**
|
|
8
10
|
* Factory function to get the appropriate PR strategy for a repository.
|
|
9
11
|
* @param repoInfo - Repository information
|
|
@@ -16,6 +18,9 @@ export function getPRStrategy(repoInfo, executor) {
|
|
|
16
18
|
if (isAzureDevOpsRepo(repoInfo)) {
|
|
17
19
|
return new AzurePRStrategy(executor);
|
|
18
20
|
}
|
|
21
|
+
if (isGitLabRepo(repoInfo)) {
|
|
22
|
+
return new GitLabPRStrategy(executor);
|
|
23
|
+
}
|
|
19
24
|
// Type exhaustiveness check - should never reach here
|
|
20
25
|
const _exhaustive = repoInfo;
|
|
21
26
|
throw new Error(`Unknown repository type: ${JSON.stringify(_exhaustive)}`);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aspruyt/xfg",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "CLI tool to sync JSON or YAML configuration files across multiple GitHub
|
|
3
|
+
"version": "1.3.0",
|
|
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",
|
|
7
7
|
"bin": {
|
|
@@ -26,8 +26,9 @@
|
|
|
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/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
|
-
"test:integration": "npm run build && node --import tsx --test src/integration.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",
|
|
30
|
+
"test:integration:github": "npm run build && node --import tsx --test src/integration-github.test.ts",
|
|
31
|
+
"test:integration:ado": "npm run build && node --import tsx --test src/integration-ado.test.ts",
|
|
31
32
|
"prepublishOnly": "npm run build"
|
|
32
33
|
},
|
|
33
34
|
"keywords": [
|
|
@@ -39,7 +40,9 @@
|
|
|
39
40
|
"cli",
|
|
40
41
|
"github",
|
|
41
42
|
"azure-devops",
|
|
42
|
-
"
|
|
43
|
+
"gitlab",
|
|
44
|
+
"pull-request",
|
|
45
|
+
"merge-request"
|
|
43
46
|
],
|
|
44
47
|
"author": "Anthony Spruyt",
|
|
45
48
|
"license": "MIT",
|