@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/README.md +97 -53
- package/dist/config-normalizer.js +4 -1
- package/dist/config-validator.js +3 -3
- package/dist/diff-utils.d.ts +31 -0
- package/dist/diff-utils.js +223 -0
- package/dist/file-reference-resolver.js +6 -4
- package/dist/git-ops.d.ts +11 -0
- package/dist/git-ops.js +30 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.js +30 -0
- package/dist/repo-detector.d.ts +9 -2
- package/dist/repo-detector.js +84 -2
- package/dist/repository-processor.js +35 -4
- package/dist/shell-utils.js +4 -0
- package/dist/strategies/azure-pr-strategy.js +1 -1
- package/dist/strategies/github-pr-strategy.js +7 -5
- 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 +6 -4
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:"));
|
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
|
}
|
|
@@ -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
|
|
97
|
-
|
|
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
|
-
//
|
|
128
|
-
|
|
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"
|
package/dist/shell-utils.js
CHANGED
|
@@ -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
|
-
|
|
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\/[
|
|
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
|
|
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
|
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)}`);
|