@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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/PR.md +15 -0
  3. package/README.md +991 -0
  4. package/dist/command-executor.d.ts +25 -0
  5. package/dist/command-executor.js +32 -0
  6. package/dist/config-formatter.d.ts +17 -0
  7. package/dist/config-formatter.js +100 -0
  8. package/dist/config-normalizer.d.ts +6 -0
  9. package/dist/config-normalizer.js +136 -0
  10. package/dist/config-validator.d.ts +6 -0
  11. package/dist/config-validator.js +173 -0
  12. package/dist/config.d.ts +54 -0
  13. package/dist/config.js +27 -0
  14. package/dist/env.d.ts +39 -0
  15. package/dist/env.js +144 -0
  16. package/dist/file-reference-resolver.d.ts +20 -0
  17. package/dist/file-reference-resolver.js +135 -0
  18. package/dist/git-ops.d.ts +75 -0
  19. package/dist/git-ops.js +229 -0
  20. package/dist/index.d.ts +20 -0
  21. package/dist/index.js +167 -0
  22. package/dist/logger.d.ts +21 -0
  23. package/dist/logger.js +46 -0
  24. package/dist/merge.d.ts +47 -0
  25. package/dist/merge.js +196 -0
  26. package/dist/pr-creator.d.ts +40 -0
  27. package/dist/pr-creator.js +129 -0
  28. package/dist/repo-detector.d.ts +22 -0
  29. package/dist/repo-detector.js +98 -0
  30. package/dist/repository-processor.d.ts +47 -0
  31. package/dist/repository-processor.js +245 -0
  32. package/dist/retry-utils.d.ts +53 -0
  33. package/dist/retry-utils.js +143 -0
  34. package/dist/shell-utils.d.ts +8 -0
  35. package/dist/shell-utils.js +12 -0
  36. package/dist/strategies/azure-pr-strategy.d.ts +16 -0
  37. package/dist/strategies/azure-pr-strategy.js +221 -0
  38. package/dist/strategies/github-pr-strategy.d.ts +17 -0
  39. package/dist/strategies/github-pr-strategy.js +215 -0
  40. package/dist/strategies/index.d.ts +13 -0
  41. package/dist/strategies/index.js +22 -0
  42. package/dist/strategies/pr-strategy.d.ts +112 -0
  43. package/dist/strategies/pr-strategy.js +60 -0
  44. package/dist/workspace-utils.d.ts +5 -0
  45. package/dist/workspace-utils.js +10 -0
  46. 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,5 @@
1
+ /**
2
+ * Generates a unique workspace directory name to avoid collisions
3
+ * when multiple CLI instances run concurrently.
4
+ */
5
+ export declare function generateWorkspaceName(index: number): string;
@@ -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
+ }