@aspruyt/xfg 1.0.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/LICENSE +21 -0
- package/PR.md +15 -0
- package/README.md +991 -0
- package/dist/command-executor.d.ts +25 -0
- package/dist/command-executor.js +32 -0
- package/dist/config-formatter.d.ts +17 -0
- package/dist/config-formatter.js +100 -0
- package/dist/config-normalizer.d.ts +6 -0
- package/dist/config-normalizer.js +136 -0
- package/dist/config-validator.d.ts +6 -0
- package/dist/config-validator.js +173 -0
- package/dist/config.d.ts +54 -0
- package/dist/config.js +27 -0
- package/dist/env.d.ts +39 -0
- package/dist/env.js +144 -0
- package/dist/file-reference-resolver.d.ts +20 -0
- package/dist/file-reference-resolver.js +135 -0
- package/dist/git-ops.d.ts +75 -0
- package/dist/git-ops.js +229 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +167 -0
- package/dist/logger.d.ts +21 -0
- package/dist/logger.js +46 -0
- package/dist/merge.d.ts +47 -0
- package/dist/merge.js +196 -0
- package/dist/pr-creator.d.ts +40 -0
- package/dist/pr-creator.js +129 -0
- package/dist/repo-detector.d.ts +22 -0
- package/dist/repo-detector.js +98 -0
- package/dist/repository-processor.d.ts +47 -0
- package/dist/repository-processor.js +245 -0
- package/dist/retry-utils.d.ts +53 -0
- package/dist/retry-utils.js +143 -0
- package/dist/shell-utils.d.ts +8 -0
- package/dist/shell-utils.js +12 -0
- package/dist/strategies/azure-pr-strategy.d.ts +16 -0
- package/dist/strategies/azure-pr-strategy.js +221 -0
- package/dist/strategies/github-pr-strategy.d.ts +17 -0
- package/dist/strategies/github-pr-strategy.js +215 -0
- package/dist/strategies/index.d.ts +13 -0
- package/dist/strategies/index.js +22 -0
- package/dist/strategies/pr-strategy.d.ts +112 -0
- package/dist/strategies/pr-strategy.js +60 -0
- package/dist/workspace-utils.d.ts +5 -0
- package/dist/workspace-utils.js +10 -0
- package/package.json +58 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { existsSync, writeFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { escapeShellArg } from "../shell-utils.js";
|
|
4
|
+
import { isAzureDevOpsRepo } 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 AzurePRStrategy extends BasePRStrategy {
|
|
9
|
+
constructor(executor) {
|
|
10
|
+
super(executor);
|
|
11
|
+
this.bodyFilePath = ".pr-description.md";
|
|
12
|
+
}
|
|
13
|
+
getOrgUrl(repoInfo) {
|
|
14
|
+
return `https://dev.azure.com/${encodeURIComponent(repoInfo.organization)}`;
|
|
15
|
+
}
|
|
16
|
+
buildPRUrl(repoInfo, prId) {
|
|
17
|
+
return `https://dev.azure.com/${encodeURIComponent(repoInfo.organization)}/${encodeURIComponent(repoInfo.project)}/_git/${encodeURIComponent(repoInfo.repo)}/pullrequest/${prId}`;
|
|
18
|
+
}
|
|
19
|
+
async checkExistingPR(options) {
|
|
20
|
+
const { repoInfo, branchName, baseBranch, workDir, retries = 3 } = options;
|
|
21
|
+
if (!isAzureDevOpsRepo(repoInfo)) {
|
|
22
|
+
throw new Error("Expected Azure DevOps repository");
|
|
23
|
+
}
|
|
24
|
+
const azureRepoInfo = repoInfo;
|
|
25
|
+
const orgUrl = this.getOrgUrl(azureRepoInfo);
|
|
26
|
+
const command = `az repos pr list --repository ${escapeShellArg(azureRepoInfo.repo)} --source-branch ${escapeShellArg(branchName)} --target-branch ${escapeShellArg(baseBranch)} --org ${escapeShellArg(orgUrl)} --project ${escapeShellArg(azureRepoInfo.project)} --query "[0].pullRequestId" -o tsv`;
|
|
27
|
+
try {
|
|
28
|
+
const existingPRId = await withRetry(() => this.executor.exec(command, workDir), { retries });
|
|
29
|
+
return existingPRId ? this.buildPRUrl(azureRepoInfo, existingPRId) : null;
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error instanceof Error) {
|
|
33
|
+
if (isPermanentError(error)) {
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
const stderr = error.stderr ?? "";
|
|
37
|
+
if (stderr && !stderr.includes("does not exist")) {
|
|
38
|
+
logger.info(`Debug: Azure PR check failed - ${stderr.trim()}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async closeExistingPR(options) {
|
|
45
|
+
const { repoInfo, branchName, baseBranch, workDir, retries = 3 } = options;
|
|
46
|
+
if (!isAzureDevOpsRepo(repoInfo)) {
|
|
47
|
+
throw new Error("Expected Azure DevOps repository");
|
|
48
|
+
}
|
|
49
|
+
const azureRepoInfo = repoInfo;
|
|
50
|
+
const orgUrl = this.getOrgUrl(azureRepoInfo);
|
|
51
|
+
// First check if there's an existing PR
|
|
52
|
+
const existingUrl = await this.checkExistingPR({
|
|
53
|
+
repoInfo,
|
|
54
|
+
branchName,
|
|
55
|
+
baseBranch,
|
|
56
|
+
workDir,
|
|
57
|
+
retries,
|
|
58
|
+
title: "", // Not used for check
|
|
59
|
+
body: "", // Not used for check
|
|
60
|
+
});
|
|
61
|
+
if (!existingUrl) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
// Extract PR ID from URL
|
|
65
|
+
const prInfo = this.parsePRUrl(existingUrl);
|
|
66
|
+
if (!prInfo) {
|
|
67
|
+
logger.info(`Warning: Could not parse PR URL: ${existingUrl}`);
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
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)} --project ${escapeShellArg(prInfo.project)}`;
|
|
72
|
+
try {
|
|
73
|
+
await withRetry(() => this.executor.exec(abandonCommand, workDir), {
|
|
74
|
+
retries,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
79
|
+
logger.info(`Warning: Failed to abandon PR #${prInfo.prId}: ${message}`);
|
|
80
|
+
return false;
|
|
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)}`;
|
|
84
|
+
try {
|
|
85
|
+
await withRetry(() => this.executor.exec(deleteBranchCommand, workDir), {
|
|
86
|
+
retries,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
// Branch deletion failure is not critical - PR is already abandoned
|
|
91
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
92
|
+
logger.info(`Warning: Failed to delete branch ${branchName}: ${message}`);
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
async create(options) {
|
|
97
|
+
const { repoInfo, title, body, branchName, baseBranch, workDir, retries = 3, } = options;
|
|
98
|
+
if (!isAzureDevOpsRepo(repoInfo)) {
|
|
99
|
+
throw new Error("Expected Azure DevOps repository");
|
|
100
|
+
}
|
|
101
|
+
const azureRepoInfo = repoInfo;
|
|
102
|
+
const orgUrl = this.getOrgUrl(azureRepoInfo);
|
|
103
|
+
// Write description to temp file to avoid shell escaping issues
|
|
104
|
+
const descFile = join(workDir, this.bodyFilePath);
|
|
105
|
+
writeFileSync(descFile, body, "utf-8");
|
|
106
|
+
// Azure CLI @file syntax: escape the full @path to handle special chars in workDir
|
|
107
|
+
const command = `az repos pr create --repository ${escapeShellArg(azureRepoInfo.repo)} --source-branch ${escapeShellArg(branchName)} --target-branch ${escapeShellArg(baseBranch)} --title ${escapeShellArg(title)} --description ${escapeShellArg("@" + descFile)} --org ${escapeShellArg(orgUrl)} --project ${escapeShellArg(azureRepoInfo.project)} --query "pullRequestId" -o tsv`;
|
|
108
|
+
try {
|
|
109
|
+
const prId = await withRetry(() => this.executor.exec(command, workDir), {
|
|
110
|
+
retries,
|
|
111
|
+
});
|
|
112
|
+
return {
|
|
113
|
+
url: this.buildPRUrl(azureRepoInfo, prId),
|
|
114
|
+
success: true,
|
|
115
|
+
message: "PR created successfully",
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
// Clean up temp file - log warning on failure instead of throwing
|
|
120
|
+
try {
|
|
121
|
+
if (existsSync(descFile)) {
|
|
122
|
+
unlinkSync(descFile);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch (cleanupError) {
|
|
126
|
+
logger.info(`Warning: Failed to clean up temp file ${descFile}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Extract PR ID and repo info from Azure DevOps PR URL.
|
|
132
|
+
*/
|
|
133
|
+
parsePRUrl(prUrl) {
|
|
134
|
+
// URL format: https://dev.azure.com/{org}/{project}/_git/{repo}/pullrequest/{prId}
|
|
135
|
+
const match = prUrl.match(/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)\/pullrequest\/(\d+)/);
|
|
136
|
+
if (!match)
|
|
137
|
+
return null;
|
|
138
|
+
return {
|
|
139
|
+
organization: decodeURIComponent(match[1]),
|
|
140
|
+
project: decodeURIComponent(match[2]),
|
|
141
|
+
repo: decodeURIComponent(match[3]),
|
|
142
|
+
prId: match[4],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
async merge(options) {
|
|
146
|
+
const { prUrl, config, workDir, retries = 3 } = options;
|
|
147
|
+
// Manual mode: do nothing
|
|
148
|
+
if (config.mode === "manual") {
|
|
149
|
+
return {
|
|
150
|
+
success: true,
|
|
151
|
+
message: "PR left open for manual review",
|
|
152
|
+
merged: false,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
// Parse PR URL to extract details
|
|
156
|
+
const prInfo = this.parsePRUrl(prUrl);
|
|
157
|
+
if (!prInfo) {
|
|
158
|
+
return {
|
|
159
|
+
success: false,
|
|
160
|
+
message: `Invalid Azure DevOps PR URL: ${prUrl}`,
|
|
161
|
+
merged: false,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const orgUrl = `https://dev.azure.com/${encodeURIComponent(prInfo.organization)}`;
|
|
165
|
+
const squashFlag = config.strategy === "squash" ? "--squash true" : "";
|
|
166
|
+
const deleteBranchFlag = config.deleteBranch
|
|
167
|
+
? "--delete-source-branch true"
|
|
168
|
+
: "";
|
|
169
|
+
if (config.mode === "auto") {
|
|
170
|
+
// 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)} --project ${escapeShellArg(prInfo.project)}`.trim();
|
|
172
|
+
try {
|
|
173
|
+
await withRetry(() => this.executor.exec(command, workDir), {
|
|
174
|
+
retries,
|
|
175
|
+
});
|
|
176
|
+
return {
|
|
177
|
+
success: true,
|
|
178
|
+
message: "Auto-complete enabled. PR will merge when all policies pass.",
|
|
179
|
+
merged: false,
|
|
180
|
+
autoMergeEnabled: true,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
185
|
+
return {
|
|
186
|
+
success: false,
|
|
187
|
+
message: `Failed to enable auto-complete: ${message}`,
|
|
188
|
+
merged: false,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (config.mode === "force") {
|
|
193
|
+
// Bypass policies and complete the PR
|
|
194
|
+
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)} --project ${escapeShellArg(prInfo.project)}`.trim();
|
|
196
|
+
try {
|
|
197
|
+
await withRetry(() => this.executor.exec(command, workDir), {
|
|
198
|
+
retries,
|
|
199
|
+
});
|
|
200
|
+
return {
|
|
201
|
+
success: true,
|
|
202
|
+
message: "PR completed by bypassing policies.",
|
|
203
|
+
merged: true,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
208
|
+
return {
|
|
209
|
+
success: false,
|
|
210
|
+
message: `Failed to bypass policies and complete PR: ${message}`,
|
|
211
|
+
merged: false,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
success: false,
|
|
217
|
+
message: `Unknown merge mode: ${config.mode}`,
|
|
218
|
+
merged: false,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { GitHubRepoInfo } from "../repo-detector.js";
|
|
2
|
+
import { PRResult } from "../pr-creator.js";
|
|
3
|
+
import { BasePRStrategy, PRStrategyOptions, CloseExistingPROptions, MergeOptions, MergeResult } from "./pr-strategy.js";
|
|
4
|
+
export declare class GitHubPRStrategy extends BasePRStrategy {
|
|
5
|
+
checkExistingPR(options: PRStrategyOptions): Promise<string | null>;
|
|
6
|
+
closeExistingPR(options: CloseExistingPROptions): Promise<boolean>;
|
|
7
|
+
create(options: PRStrategyOptions): Promise<PRResult>;
|
|
8
|
+
/**
|
|
9
|
+
* Check if auto-merge is enabled on the repository.
|
|
10
|
+
*/
|
|
11
|
+
checkAutoMergeEnabled(repoInfo: GitHubRepoInfo, workDir: string, retries?: number): Promise<boolean>;
|
|
12
|
+
/**
|
|
13
|
+
* Build merge strategy flag for gh pr merge command.
|
|
14
|
+
*/
|
|
15
|
+
private getMergeStrategyFlag;
|
|
16
|
+
merge(options: MergeOptions): Promise<MergeResult>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { existsSync, writeFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { escapeShellArg } from "../shell-utils.js";
|
|
4
|
+
import { isGitHubRepo } 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 GitHubPRStrategy extends BasePRStrategy {
|
|
9
|
+
async checkExistingPR(options) {
|
|
10
|
+
const { repoInfo, branchName, workDir, retries = 3 } = options;
|
|
11
|
+
if (!isGitHubRepo(repoInfo)) {
|
|
12
|
+
throw new Error("Expected GitHub repository");
|
|
13
|
+
}
|
|
14
|
+
const command = `gh pr list --head ${escapeShellArg(branchName)} --json url --jq '.[0].url'`;
|
|
15
|
+
try {
|
|
16
|
+
const existingPR = await withRetry(() => this.executor.exec(command, workDir), { retries });
|
|
17
|
+
return existingPR || null;
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (error instanceof Error) {
|
|
21
|
+
// Throw on permanent errors (auth failures, etc.)
|
|
22
|
+
if (isPermanentError(error)) {
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
// Log unexpected errors for debugging (expected: empty result means no PR)
|
|
26
|
+
const stderr = error.stderr ?? "";
|
|
27
|
+
if (stderr && !stderr.includes("no pull requests match")) {
|
|
28
|
+
logger.info(`Debug: GitHub PR check failed - ${stderr.trim()}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async closeExistingPR(options) {
|
|
35
|
+
const { repoInfo, branchName, baseBranch, workDir, retries = 3 } = options;
|
|
36
|
+
if (!isGitHubRepo(repoInfo)) {
|
|
37
|
+
throw new Error("Expected GitHub repository");
|
|
38
|
+
}
|
|
39
|
+
// First check if there's an existing PR
|
|
40
|
+
const existingUrl = await this.checkExistingPR({
|
|
41
|
+
repoInfo,
|
|
42
|
+
branchName,
|
|
43
|
+
baseBranch,
|
|
44
|
+
workDir,
|
|
45
|
+
retries,
|
|
46
|
+
title: "", // Not used for check
|
|
47
|
+
body: "", // Not used for check
|
|
48
|
+
});
|
|
49
|
+
if (!existingUrl) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
// Extract PR number from URL
|
|
53
|
+
const prNumber = existingUrl.match(/\/pull\/(\d+)/)?.[1];
|
|
54
|
+
if (!prNumber) {
|
|
55
|
+
logger.info(`Warning: Could not extract PR number from URL: ${existingUrl}`);
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
// Close the PR and delete the branch
|
|
59
|
+
const command = `gh pr close ${escapeShellArg(prNumber)} --repo ${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)} --delete-branch`;
|
|
60
|
+
try {
|
|
61
|
+
await withRetry(() => this.executor.exec(command, workDir), { retries });
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
66
|
+
logger.info(`Warning: Failed to close existing PR #${prNumber}: ${message}`);
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async create(options) {
|
|
71
|
+
const { repoInfo, title, body, branchName, baseBranch, workDir, retries = 3, } = options;
|
|
72
|
+
if (!isGitHubRepo(repoInfo)) {
|
|
73
|
+
throw new Error("Expected GitHub repository");
|
|
74
|
+
}
|
|
75
|
+
// Write body to temp file to avoid shell escaping issues
|
|
76
|
+
const bodyFile = join(workDir, this.bodyFilePath);
|
|
77
|
+
writeFileSync(bodyFile, body, "utf-8");
|
|
78
|
+
const command = `gh pr create --title ${escapeShellArg(title)} --body-file ${escapeShellArg(bodyFile)} --base ${escapeShellArg(baseBranch)} --head ${escapeShellArg(branchName)}`;
|
|
79
|
+
try {
|
|
80
|
+
const result = await withRetry(() => this.executor.exec(command, workDir), { retries });
|
|
81
|
+
// Extract URL from output
|
|
82
|
+
const urlMatch = result.match(/https:\/\/github\.com\/[^\s]+/);
|
|
83
|
+
return {
|
|
84
|
+
url: urlMatch?.[0] ?? result,
|
|
85
|
+
success: true,
|
|
86
|
+
message: "PR created successfully",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
// Clean up temp file - log warning on failure instead of throwing
|
|
91
|
+
try {
|
|
92
|
+
if (existsSync(bodyFile)) {
|
|
93
|
+
unlinkSync(bodyFile);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch (cleanupError) {
|
|
97
|
+
logger.info(`Warning: Failed to clean up temp file ${bodyFile}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Check if auto-merge is enabled on the repository.
|
|
103
|
+
*/
|
|
104
|
+
async checkAutoMergeEnabled(repoInfo, workDir, retries = 3) {
|
|
105
|
+
const command = `gh api repos/${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)} --jq '.allow_auto_merge // false'`;
|
|
106
|
+
try {
|
|
107
|
+
const result = await withRetry(() => this.executor.exec(command, workDir), { retries });
|
|
108
|
+
return result.trim() === "true";
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
// If we can't check, assume auto-merge is not enabled
|
|
112
|
+
logger.info(`Warning: Could not check auto-merge status: ${error instanceof Error ? error.message : String(error)}`);
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Build merge strategy flag for gh pr merge command.
|
|
118
|
+
*/
|
|
119
|
+
getMergeStrategyFlag(strategy) {
|
|
120
|
+
switch (strategy) {
|
|
121
|
+
case "squash":
|
|
122
|
+
return "--squash";
|
|
123
|
+
case "rebase":
|
|
124
|
+
return "--rebase";
|
|
125
|
+
case "merge":
|
|
126
|
+
default:
|
|
127
|
+
return "--merge";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async merge(options) {
|
|
131
|
+
const { prUrl, config, workDir, retries = 3 } = options;
|
|
132
|
+
// Manual mode: do nothing
|
|
133
|
+
if (config.mode === "manual") {
|
|
134
|
+
return {
|
|
135
|
+
success: true,
|
|
136
|
+
message: "PR left open for manual review",
|
|
137
|
+
merged: false,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const strategyFlag = this.getMergeStrategyFlag(config.strategy);
|
|
141
|
+
const deleteBranchFlag = config.deleteBranch ? "--delete-branch" : "";
|
|
142
|
+
if (config.mode === "auto") {
|
|
143
|
+
// Check if auto-merge is enabled on the repo
|
|
144
|
+
// Extract owner/repo from PR URL
|
|
145
|
+
const match = prUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
|
|
146
|
+
if (match) {
|
|
147
|
+
const repoInfo = {
|
|
148
|
+
type: "github",
|
|
149
|
+
gitUrl: prUrl,
|
|
150
|
+
owner: match[1],
|
|
151
|
+
repo: match[2],
|
|
152
|
+
};
|
|
153
|
+
const autoMergeEnabled = await this.checkAutoMergeEnabled(repoInfo, workDir, retries);
|
|
154
|
+
if (!autoMergeEnabled) {
|
|
155
|
+
logger.info(`Warning: Auto-merge not enabled for '${repoInfo.owner}/${repoInfo.repo}'. PR left open for manual review.`);
|
|
156
|
+
logger.info(`To enable: gh repo edit ${repoInfo.owner}/${repoInfo.repo} --enable-auto-merge (requires admin)`);
|
|
157
|
+
return {
|
|
158
|
+
success: true,
|
|
159
|
+
message: `Auto-merge not enabled for repository. PR left open for manual review.`,
|
|
160
|
+
merged: false,
|
|
161
|
+
autoMergeEnabled: false,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// Enable auto-merge
|
|
166
|
+
const command = `gh pr merge ${escapeShellArg(prUrl)} --auto ${strategyFlag} ${deleteBranchFlag}`.trim();
|
|
167
|
+
try {
|
|
168
|
+
await withRetry(() => this.executor.exec(command, workDir), {
|
|
169
|
+
retries,
|
|
170
|
+
});
|
|
171
|
+
return {
|
|
172
|
+
success: true,
|
|
173
|
+
message: "Auto-merge enabled. PR will merge when checks pass.",
|
|
174
|
+
merged: false,
|
|
175
|
+
autoMergeEnabled: true,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
180
|
+
return {
|
|
181
|
+
success: false,
|
|
182
|
+
message: `Failed to enable auto-merge: ${message}`,
|
|
183
|
+
merged: false,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (config.mode === "force") {
|
|
188
|
+
// Force merge using admin privileges
|
|
189
|
+
const command = `gh pr merge ${escapeShellArg(prUrl)} --admin ${strategyFlag} ${deleteBranchFlag}`.trim();
|
|
190
|
+
try {
|
|
191
|
+
await withRetry(() => this.executor.exec(command, workDir), {
|
|
192
|
+
retries,
|
|
193
|
+
});
|
|
194
|
+
return {
|
|
195
|
+
success: true,
|
|
196
|
+
message: "PR merged successfully using admin privileges.",
|
|
197
|
+
merged: true,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
202
|
+
return {
|
|
203
|
+
success: false,
|
|
204
|
+
message: `Failed to force merge: ${message}`,
|
|
205
|
+
merged: false,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
success: false,
|
|
211
|
+
message: `Unknown merge mode: ${config.mode}`,
|
|
212
|
+
merged: false,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { RepoInfo } from "../repo-detector.js";
|
|
2
|
+
import type { PRStrategy } from "./pr-strategy.js";
|
|
3
|
+
import { CommandExecutor } from "../command-executor.js";
|
|
4
|
+
export type { PRStrategy, PRStrategyOptions, CloseExistingPROptions, PRMergeConfig, MergeOptions, MergeResult, } from "./pr-strategy.js";
|
|
5
|
+
export { BasePRStrategy, PRWorkflowExecutor } from "./pr-strategy.js";
|
|
6
|
+
export { GitHubPRStrategy } from "./github-pr-strategy.js";
|
|
7
|
+
export { AzurePRStrategy } from "./azure-pr-strategy.js";
|
|
8
|
+
/**
|
|
9
|
+
* Factory function to get the appropriate PR strategy for a repository.
|
|
10
|
+
* @param repoInfo - Repository information
|
|
11
|
+
* @param executor - Optional command executor for shell commands
|
|
12
|
+
*/
|
|
13
|
+
export declare function getPRStrategy(repoInfo: RepoInfo, executor?: CommandExecutor): PRStrategy;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { isGitHubRepo, isAzureDevOpsRepo } from "../repo-detector.js";
|
|
2
|
+
import { GitHubPRStrategy } from "./github-pr-strategy.js";
|
|
3
|
+
import { AzurePRStrategy } from "./azure-pr-strategy.js";
|
|
4
|
+
export { BasePRStrategy, PRWorkflowExecutor } from "./pr-strategy.js";
|
|
5
|
+
export { GitHubPRStrategy } from "./github-pr-strategy.js";
|
|
6
|
+
export { AzurePRStrategy } from "./azure-pr-strategy.js";
|
|
7
|
+
/**
|
|
8
|
+
* Factory function to get the appropriate PR strategy for a repository.
|
|
9
|
+
* @param repoInfo - Repository information
|
|
10
|
+
* @param executor - Optional command executor for shell commands
|
|
11
|
+
*/
|
|
12
|
+
export function getPRStrategy(repoInfo, executor) {
|
|
13
|
+
if (isGitHubRepo(repoInfo)) {
|
|
14
|
+
return new GitHubPRStrategy(executor);
|
|
15
|
+
}
|
|
16
|
+
if (isAzureDevOpsRepo(repoInfo)) {
|
|
17
|
+
return new AzurePRStrategy(executor);
|
|
18
|
+
}
|
|
19
|
+
// Type exhaustiveness check - should never reach here
|
|
20
|
+
const _exhaustive = repoInfo;
|
|
21
|
+
throw new Error(`Unknown repository type: ${JSON.stringify(_exhaustive)}`);
|
|
22
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { PRResult } from "../pr-creator.js";
|
|
2
|
+
import { RepoInfo } from "../repo-detector.js";
|
|
3
|
+
import { CommandExecutor } from "../command-executor.js";
|
|
4
|
+
import type { MergeMode, MergeStrategy } from "../config.js";
|
|
5
|
+
export interface PRMergeConfig {
|
|
6
|
+
mode: MergeMode;
|
|
7
|
+
strategy?: MergeStrategy;
|
|
8
|
+
deleteBranch?: boolean;
|
|
9
|
+
bypassReason?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface MergeResult {
|
|
12
|
+
success: boolean;
|
|
13
|
+
message: string;
|
|
14
|
+
merged?: boolean;
|
|
15
|
+
autoMergeEnabled?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface PRStrategyOptions {
|
|
18
|
+
repoInfo: RepoInfo;
|
|
19
|
+
title: string;
|
|
20
|
+
body: string;
|
|
21
|
+
branchName: string;
|
|
22
|
+
baseBranch: string;
|
|
23
|
+
workDir: string;
|
|
24
|
+
/** Number of retries for API operations (default: 3) */
|
|
25
|
+
retries?: number;
|
|
26
|
+
}
|
|
27
|
+
export interface MergeOptions {
|
|
28
|
+
prUrl: string;
|
|
29
|
+
config: PRMergeConfig;
|
|
30
|
+
workDir: string;
|
|
31
|
+
retries?: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Options for closing an existing PR.
|
|
35
|
+
*/
|
|
36
|
+
export interface CloseExistingPROptions {
|
|
37
|
+
repoInfo: RepoInfo;
|
|
38
|
+
branchName: string;
|
|
39
|
+
baseBranch: string;
|
|
40
|
+
workDir: string;
|
|
41
|
+
retries?: number;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Interface for PR creation strategies (platform-specific implementations).
|
|
45
|
+
* Strategies focus on platform-specific logic (checkExistingPR, create, merge).
|
|
46
|
+
* Use PRWorkflowExecutor for full workflow orchestration with error handling.
|
|
47
|
+
*/
|
|
48
|
+
export interface PRStrategy {
|
|
49
|
+
/**
|
|
50
|
+
* Check if a PR already exists for the given branch
|
|
51
|
+
* @returns PR URL if exists, null otherwise
|
|
52
|
+
*/
|
|
53
|
+
checkExistingPR(options: PRStrategyOptions): Promise<string | null>;
|
|
54
|
+
/**
|
|
55
|
+
* Close an existing PR and delete its branch.
|
|
56
|
+
* Used for fresh start approach - always create new PR from clean state.
|
|
57
|
+
* @returns true if PR was closed, false if no PR existed
|
|
58
|
+
*/
|
|
59
|
+
closeExistingPR(options: CloseExistingPROptions): Promise<boolean>;
|
|
60
|
+
/**
|
|
61
|
+
* Create a new PR
|
|
62
|
+
* @returns Result with URL and status
|
|
63
|
+
*/
|
|
64
|
+
create(options: PRStrategyOptions): Promise<PRResult>;
|
|
65
|
+
/**
|
|
66
|
+
* Merge or enable auto-merge for a PR
|
|
67
|
+
* @returns Result with merge status
|
|
68
|
+
*/
|
|
69
|
+
merge(options: MergeOptions): Promise<MergeResult>;
|
|
70
|
+
/**
|
|
71
|
+
* Execute the full PR creation workflow
|
|
72
|
+
* @deprecated Use PRWorkflowExecutor.execute() for better SRP
|
|
73
|
+
*/
|
|
74
|
+
execute(options: PRStrategyOptions): Promise<PRResult>;
|
|
75
|
+
}
|
|
76
|
+
export declare abstract class BasePRStrategy implements PRStrategy {
|
|
77
|
+
protected bodyFilePath: string;
|
|
78
|
+
protected executor: CommandExecutor;
|
|
79
|
+
constructor(executor?: CommandExecutor);
|
|
80
|
+
abstract checkExistingPR(options: PRStrategyOptions): Promise<string | null>;
|
|
81
|
+
abstract closeExistingPR(options: CloseExistingPROptions): Promise<boolean>;
|
|
82
|
+
abstract create(options: PRStrategyOptions): Promise<PRResult>;
|
|
83
|
+
abstract merge(options: MergeOptions): Promise<MergeResult>;
|
|
84
|
+
/**
|
|
85
|
+
* Execute the full PR creation workflow:
|
|
86
|
+
* 1. Check for existing PR
|
|
87
|
+
* 2. If exists, return it
|
|
88
|
+
* 3. Otherwise, create new PR
|
|
89
|
+
*
|
|
90
|
+
* @deprecated Use PRWorkflowExecutor.execute() for better SRP
|
|
91
|
+
*/
|
|
92
|
+
execute(options: PRStrategyOptions): Promise<PRResult>;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Orchestrates the PR creation workflow with error handling.
|
|
96
|
+
* Follows Single Responsibility Principle by separating workflow orchestration
|
|
97
|
+
* from platform-specific PR creation logic.
|
|
98
|
+
*
|
|
99
|
+
* Workflow:
|
|
100
|
+
* 1. Check for existing PR on the branch
|
|
101
|
+
* 2. If exists, return existing PR URL
|
|
102
|
+
* 3. Otherwise, create new PR
|
|
103
|
+
* 4. Handle errors and return failure result
|
|
104
|
+
*/
|
|
105
|
+
export declare class PRWorkflowExecutor {
|
|
106
|
+
private readonly strategy;
|
|
107
|
+
constructor(strategy: PRStrategy);
|
|
108
|
+
/**
|
|
109
|
+
* Execute the full PR creation workflow with error handling.
|
|
110
|
+
*/
|
|
111
|
+
execute(options: PRStrategyOptions): Promise<PRResult>;
|
|
112
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { defaultExecutor } from "../command-executor.js";
|
|
2
|
+
export class BasePRStrategy {
|
|
3
|
+
bodyFilePath = ".pr-body.md";
|
|
4
|
+
executor;
|
|
5
|
+
constructor(executor) {
|
|
6
|
+
this.executor = executor ?? defaultExecutor;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Execute the full PR creation workflow:
|
|
10
|
+
* 1. Check for existing PR
|
|
11
|
+
* 2. If exists, return it
|
|
12
|
+
* 3. Otherwise, create new PR
|
|
13
|
+
*
|
|
14
|
+
* @deprecated Use PRWorkflowExecutor.execute() for better SRP
|
|
15
|
+
*/
|
|
16
|
+
async execute(options) {
|
|
17
|
+
const executor = new PRWorkflowExecutor(this);
|
|
18
|
+
return executor.execute(options);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Orchestrates the PR creation workflow with error handling.
|
|
23
|
+
* Follows Single Responsibility Principle by separating workflow orchestration
|
|
24
|
+
* from platform-specific PR creation logic.
|
|
25
|
+
*
|
|
26
|
+
* Workflow:
|
|
27
|
+
* 1. Check for existing PR on the branch
|
|
28
|
+
* 2. If exists, return existing PR URL
|
|
29
|
+
* 3. Otherwise, create new PR
|
|
30
|
+
* 4. Handle errors and return failure result
|
|
31
|
+
*/
|
|
32
|
+
export class PRWorkflowExecutor {
|
|
33
|
+
strategy;
|
|
34
|
+
constructor(strategy) {
|
|
35
|
+
this.strategy = strategy;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Execute the full PR creation workflow with error handling.
|
|
39
|
+
*/
|
|
40
|
+
async execute(options) {
|
|
41
|
+
try {
|
|
42
|
+
const existingUrl = await this.strategy.checkExistingPR(options);
|
|
43
|
+
if (existingUrl) {
|
|
44
|
+
return {
|
|
45
|
+
url: existingUrl,
|
|
46
|
+
success: true,
|
|
47
|
+
message: `PR already exists: ${existingUrl}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return await this.strategy.create(options);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
54
|
+
return {
|
|
55
|
+
success: false,
|
|
56
|
+
message: `Failed to create PR: ${message}`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Generates a unique workspace directory name to avoid collisions
|
|
4
|
+
* when multiple CLI instances run concurrently.
|
|
5
|
+
*/
|
|
6
|
+
export function generateWorkspaceName(index) {
|
|
7
|
+
const timestamp = Date.now();
|
|
8
|
+
const uuid = randomUUID().slice(0, 8);
|
|
9
|
+
return `repo-${timestamp}-${index}-${uuid}`;
|
|
10
|
+
}
|