@aspruyt/xfg 1.1.0 → 1.2.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
@@ -5,7 +5,7 @@
5
5
  [![npm downloads](https://img.shields.io/npm/dw/@aspruyt/xfg.svg)](https://www.npmjs.com/package/@aspruyt/xfg)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
7
 
8
- A CLI tool that syncs JSON, JSON5, YAML, or text configuration files across multiple GitHub and Azure DevOps repositories by creating pull requests. Output format is automatically detected from the target filename extension (`.json` → JSON, `.json5` → JSON5, `.yaml`/`.yml` → YAML, other → text).
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 & Azure DevOps** - Works with both platforms
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{GitHub or<br/>Azure DevOps?}
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
- GH_PR --> PR_CREATED[PR Created]
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
 
@@ -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 type RepoInfo = GitHubRepoInfo | AzureDevOpsRepoInfo;
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;
@@ -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/) and Azure DevOps (git@ssh.dev.azure.com: or https://dev.azure.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;
@@ -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
@@ -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.1.0",
4
- "description": "CLI tool to sync JSON or YAML configuration files across multiple GitHub and Azure DevOps repositories",
3
+ "version": "1.2.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,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/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",
30
30
  "test:integration": "npm run build && node --import tsx --test src/integration.test.ts",
31
31
  "prepublishOnly": "npm run build"
32
32
  },
@@ -39,7 +39,9 @@
39
39
  "cli",
40
40
  "github",
41
41
  "azure-devops",
42
- "pull-request"
42
+ "gitlab",
43
+ "pull-request",
44
+ "merge-request"
43
45
  ],
44
46
  "author": "Anthony Spruyt",
45
47
  "license": "MIT",