@aspruyt/xfg 1.0.3 → 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/dist/logger.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import chalk from "chalk";
2
+ import { formatStatusBadge } from "./diff-utils.js";
2
3
  export class Logger {
3
4
  stats = {
4
5
  total: 0,
@@ -31,6 +32,35 @@ export class Logger {
31
32
  console.log(chalk.red(`[${current}/${this.stats.total}] ✗`) +
32
33
  ` ${repoName}: ${error}`);
33
34
  }
35
+ /**
36
+ * Display a file diff with status badge.
37
+ * Used in dry-run mode to show what would change.
38
+ */
39
+ fileDiff(fileName, status, diffLines) {
40
+ const badge = formatStatusBadge(status);
41
+ console.log(` ${badge} ${fileName}`);
42
+ // Only show diff lines for NEW or MODIFIED files
43
+ if (status !== "UNCHANGED" && diffLines.length > 0) {
44
+ for (const line of diffLines) {
45
+ console.log(` ${line}`);
46
+ }
47
+ }
48
+ }
49
+ /**
50
+ * Display summary statistics for dry-run diff.
51
+ */
52
+ diffSummary(newCount, modifiedCount, unchangedCount) {
53
+ const parts = [];
54
+ if (newCount > 0)
55
+ parts.push(chalk.green(`${newCount} new`));
56
+ if (modifiedCount > 0)
57
+ parts.push(chalk.yellow(`${modifiedCount} modified`));
58
+ if (unchangedCount > 0)
59
+ parts.push(chalk.gray(`${unchangedCount} unchanged`));
60
+ if (parts.length > 0) {
61
+ console.log(chalk.gray(` Summary: ${parts.join(", ")}`));
62
+ }
63
+ }
34
64
  summary() {
35
65
  console.log("");
36
66
  console.log(chalk.bold("Summary:"));
@@ -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
  }
@@ -7,6 +7,7 @@ import { createPR, mergePR } from "./pr-creator.js";
7
7
  import { logger } from "./logger.js";
8
8
  import { getPRStrategy } from "./strategies/index.js";
9
9
  import { defaultExecutor } from "./command-executor.js";
10
+ import { getFileStatus, generateDiff, createDiffStats, incrementDiffStats, } from "./diff-utils.js";
10
11
  /**
11
12
  * Determines if a file should be marked as executable.
12
13
  * .sh files are auto-executable unless explicit executable: false is set.
@@ -69,7 +70,19 @@ export class RepositoryProcessor {
69
70
  this.log.info(`Creating branch: ${branchName}`);
70
71
  await this.gitOps.createBranch(branchName);
71
72
  // Step 5: Write all config files and track changes
73
+ //
74
+ // DESIGN NOTE: Change detection differs between dry-run and normal mode:
75
+ // - Dry-run: Uses wouldChange() for read-only content comparison (no side effects)
76
+ // - Normal: Uses git status after writing (source of truth for what git will commit)
77
+ //
78
+ // This is intentional. git status is more accurate because it respects .gitattributes
79
+ // (line ending normalization, filters) and detects executable bit changes. However,
80
+ // it requires actually writing files, which defeats dry-run's purpose.
81
+ //
82
+ // For config files (JSON/YAML), these approaches produce identical results in practice.
83
+ // Edge cases (repos with unusual git attributes on config files) are essentially nonexistent.
72
84
  const changedFiles = [];
85
+ const diffStats = createDiffStats();
73
86
  for (const file of repoConfig.files) {
74
87
  const filePath = join(workDir, file.fileName);
75
88
  const fileExistsLocal = existsSync(filePath);
@@ -93,10 +106,18 @@ export class RepositoryProcessor {
93
106
  ? "update"
94
107
  : "create";
95
108
  if (dryRun) {
96
- // In dry-run, check if file would change without writing
97
- if (this.gitOps.wouldChange(file.fileName, fileContent)) {
109
+ // In dry-run, check if file would change and show diff
110
+ const existingContent = this.gitOps.getFileContent(file.fileName);
111
+ const changed = this.gitOps.wouldChange(file.fileName, fileContent);
112
+ const status = getFileStatus(existingContent !== null, changed);
113
+ // Track stats
114
+ incrementDiffStats(diffStats, status);
115
+ if (changed) {
98
116
  changedFiles.push({ fileName: file.fileName, action });
99
117
  }
118
+ // Generate and display diff
119
+ const diffLines = generateDiff(existingContent, fileContent, file.fileName);
120
+ this.log.fileDiff(file.fileName, status, diffLines);
100
121
  }
101
122
  else {
102
123
  // Write the file
@@ -115,6 +136,10 @@ export class RepositoryProcessor {
115
136
  await this.gitOps.setExecutable(file.fileName);
116
137
  }
117
138
  }
139
+ // Show diff summary in dry-run mode
140
+ if (dryRun) {
141
+ this.log.diffSummary(diffStats.newCount, diffStats.modifiedCount, diffStats.unchangedCount);
142
+ }
118
143
  // Step 6: Check for changes (exclude skipped files)
119
144
  let hasChanges;
120
145
  if (dryRun) {
@@ -124,15 +149,21 @@ export class RepositoryProcessor {
124
149
  hasChanges = await this.gitOps.hasChanges();
125
150
  // If there are changes, determine which files changed
126
151
  if (hasChanges) {
127
- // Rebuild the changed files list by checking git status
128
- // Skip files that were already marked as skipped (createOnly)
152
+ // Get the actual list of changed files from git status
153
+ const gitChangedFiles = new Set(await this.gitOps.getChangedFiles());
154
+ // Preserve skipped files (createOnly)
129
155
  const skippedFiles = new Set(changedFiles
130
156
  .filter((f) => f.action === "skip")
131
157
  .map((f) => f.fileName));
158
+ // Only add files that actually changed according to git
132
159
  for (const file of repoConfig.files) {
133
160
  if (skippedFiles.has(file.fileName)) {
134
161
  continue; // Already tracked as skipped
135
162
  }
163
+ // Only include files that git reports as changed
164
+ if (!gitChangedFiles.has(file.fileName)) {
165
+ continue; // File didn't actually change
166
+ }
136
167
  const filePath = join(workDir, file.fileName);
137
168
  const action = existsSync(filePath)
138
169
  ? "update"
@@ -6,6 +6,10 @@
6
6
  * @returns The escaped string wrapped in single quotes
7
7
  */
8
8
  export function escapeShellArg(arg) {
9
+ // Defense-in-depth: reject null bytes even if upstream validation should catch them
10
+ if (arg.includes("\0")) {
11
+ throw new Error("Shell argument contains null byte");
12
+ }
9
13
  // Use single quotes and escape any single quotes within
10
14
  // 'string' -> quote ends, escaped quote, quote starts again
11
15
  return `'${arg.replace(/'/g, "'\\''")}'`;
@@ -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;
@@ -52,8 +52,7 @@ export class GitHubPRStrategy extends BasePRStrategy {
52
52
  // Extract PR number from URL
53
53
  const prNumber = existingUrl.match(/\/pull\/(\d+)/)?.[1];
54
54
  if (!prNumber) {
55
- logger.info(`Warning: Could not extract PR number from URL: ${existingUrl}`);
56
- return false;
55
+ throw new Error(`Could not extract PR number from URL: ${existingUrl}`);
57
56
  }
58
57
  // Close the PR and delete the branch
59
58
  const command = `gh pr close ${escapeShellArg(prNumber)} --repo ${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)} --delete-branch`;
@@ -78,10 +77,13 @@ export class GitHubPRStrategy extends BasePRStrategy {
78
77
  const command = `gh pr create --title ${escapeShellArg(title)} --body-file ${escapeShellArg(bodyFile)} --base ${escapeShellArg(baseBranch)} --head ${escapeShellArg(branchName)}`;
79
78
  try {
80
79
  const result = await withRetry(() => this.executor.exec(command, workDir), { retries });
81
- // Extract URL from output
82
- const urlMatch = result.match(/https:\/\/github\.com\/[^\s]+/);
80
+ // Extract URL from output - use strict regex for valid PR URLs only
81
+ const urlMatch = result.match(/https:\/\/github\.com\/[\w-]+\/[\w.-]+\/pull\/\d+/);
82
+ if (!urlMatch) {
83
+ throw new Error(`Could not parse PR URL from output: ${result}`);
84
+ }
83
85
  return {
84
- url: urlMatch?.[0] ?? result,
86
+ url: urlMatch[0],
85
87
  success: true,
86
88
  message: "PR created successfully",
87
89
  };
@@ -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)}`);