@aspruyt/json-config-sync 2.0.3 → 2.1.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.
@@ -1,14 +1,33 @@
1
+ // Type guards
2
+ export function isGitHubRepo(info) {
3
+ return info.type === "github";
4
+ }
5
+ export function isAzureDevOpsRepo(info) {
6
+ return info.type === "azure-devops";
7
+ }
8
+ /**
9
+ * Valid URL patterns for supported repository types.
10
+ */
11
+ const GITHUB_URL_PATTERNS = [/^git@github\.com:/, /^https?:\/\/github\.com\//];
12
+ const AZURE_DEVOPS_URL_PATTERNS = [
13
+ /^git@ssh\.dev\.azure\.com:/,
14
+ /^https?:\/\/dev\.azure\.com\//,
15
+ ];
1
16
  export function detectRepoType(gitUrl) {
2
- // Check for Azure DevOps SSH format: git@ssh.dev.azure.com:...
3
- // Use broader pattern to catch malformed Azure URLs
4
- if (/^git@ssh\.dev\.azure\.com:/.test(gitUrl)) {
5
- return "azure-devops";
17
+ // Check for Azure DevOps formats
18
+ for (const pattern of AZURE_DEVOPS_URL_PATTERNS) {
19
+ if (pattern.test(gitUrl)) {
20
+ return "azure-devops";
21
+ }
6
22
  }
7
- // Check for Azure DevOps HTTPS format: https://dev.azure.com/...
8
- if (/^https?:\/\/dev\.azure\.com\//.test(gitUrl)) {
9
- return "azure-devops";
23
+ // Check for GitHub formats
24
+ for (const pattern of GITHUB_URL_PATTERNS) {
25
+ if (pattern.test(gitUrl)) {
26
+ return "github";
27
+ }
10
28
  }
11
- return "github";
29
+ // 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/)`);
12
31
  }
13
32
  export function parseGitUrl(gitUrl) {
14
33
  const type = detectRepoType(gitUrl);
@@ -0,0 +1,36 @@
1
+ import { RepoConfig } from "./config.js";
2
+ import { RepoInfo } from "./repo-detector.js";
3
+ import { GitOps, GitOpsOptions } from "./git-ops.js";
4
+ import { ILogger } from "./logger.js";
5
+ export interface ProcessorOptions {
6
+ fileName: string;
7
+ branchName: string;
8
+ workDir: string;
9
+ dryRun?: boolean;
10
+ /** Number of retries for network operations (default: 3) */
11
+ retries?: number;
12
+ }
13
+ /**
14
+ * Factory function type for creating GitOps instances.
15
+ * Allows dependency injection for testing.
16
+ */
17
+ export type GitOpsFactory = (options: GitOpsOptions) => GitOps;
18
+ export interface ProcessorResult {
19
+ success: boolean;
20
+ repoName: string;
21
+ message: string;
22
+ prUrl?: string;
23
+ skipped?: boolean;
24
+ }
25
+ export declare class RepositoryProcessor {
26
+ private gitOps;
27
+ private readonly gitOpsFactory;
28
+ private readonly log;
29
+ /**
30
+ * Creates a new RepositoryProcessor.
31
+ * @param gitOpsFactory - Optional factory for creating GitOps instances (for testing)
32
+ * @param log - Optional logger instance (for testing)
33
+ */
34
+ constructor(gitOpsFactory?: GitOpsFactory, log?: ILogger);
35
+ process(repoConfig: RepoConfig, repoInfo: RepoInfo, options: ProcessorOptions): Promise<ProcessorResult>;
36
+ }
@@ -0,0 +1,106 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { convertContentToString } from "./config.js";
4
+ import { getRepoDisplayName } from "./repo-detector.js";
5
+ import { GitOps } from "./git-ops.js";
6
+ import { createPR } from "./pr-creator.js";
7
+ import { logger } from "./logger.js";
8
+ export class RepositoryProcessor {
9
+ gitOps = null;
10
+ gitOpsFactory;
11
+ log;
12
+ /**
13
+ * Creates a new RepositoryProcessor.
14
+ * @param gitOpsFactory - Optional factory for creating GitOps instances (for testing)
15
+ * @param log - Optional logger instance (for testing)
16
+ */
17
+ constructor(gitOpsFactory, log) {
18
+ this.gitOpsFactory = gitOpsFactory ?? ((opts) => new GitOps(opts));
19
+ this.log = log ?? logger;
20
+ }
21
+ async process(repoConfig, repoInfo, options) {
22
+ const repoName = getRepoDisplayName(repoInfo);
23
+ const { fileName, branchName, workDir, dryRun, retries } = options;
24
+ this.gitOps = this.gitOpsFactory({ workDir, dryRun, retries });
25
+ try {
26
+ // Step 1: Clean workspace
27
+ this.log.info("Cleaning workspace...");
28
+ this.gitOps.cleanWorkspace();
29
+ // Step 2: Clone repo
30
+ this.log.info("Cloning repository...");
31
+ await this.gitOps.clone(repoInfo.gitUrl);
32
+ // Step 3: Get default branch for PR base
33
+ const { branch: baseBranch, method: detectionMethod } = await this.gitOps.getDefaultBranch();
34
+ this.log.info(`Default branch: ${baseBranch} (detected via ${detectionMethod})`);
35
+ // Step 4: Create/checkout branch
36
+ this.log.info(`Switching to branch: ${branchName}`);
37
+ await this.gitOps.createBranch(branchName);
38
+ // Step 5: Write config file
39
+ this.log.info(`Writing ${fileName}...`);
40
+ const fileContent = convertContentToString(repoConfig.content, fileName);
41
+ // Step 6: Check for changes and determine action
42
+ // NOTE: This is NOT a race condition. We intentionally:
43
+ // 1. Capture action type (create/update) BEFORE writing - for PR title
44
+ // 2. Check git status AFTER writing - to detect actual content changes
45
+ // The action type is cosmetic for the PR; hasChanges() determines whether to proceed.
46
+ // If file exists with identical content: action="update", hasChanges=false -> skip (correct)
47
+ // If file doesn't exist: action="create", hasChanges=true -> proceed (correct)
48
+ const filePath = join(workDir, fileName);
49
+ let action;
50
+ let wouldHaveChanges;
51
+ if (dryRun) {
52
+ action = existsSync(filePath) ? "update" : "create";
53
+ wouldHaveChanges = this.gitOps.wouldChange(fileName, fileContent);
54
+ }
55
+ else {
56
+ // Capture action and write atomically (in same sync block)
57
+ action = existsSync(filePath) ? "update" : "create";
58
+ this.gitOps.writeFile(fileName, fileContent);
59
+ wouldHaveChanges = await this.gitOps.hasChanges();
60
+ }
61
+ if (!wouldHaveChanges) {
62
+ return {
63
+ success: true,
64
+ repoName,
65
+ message: "No changes detected",
66
+ skipped: true,
67
+ };
68
+ }
69
+ // Step 7: Commit
70
+ this.log.info("Committing changes...");
71
+ await this.gitOps.commit(`chore: sync ${fileName}`);
72
+ // Step 8: Push
73
+ this.log.info("Pushing to remote...");
74
+ await this.gitOps.push(branchName);
75
+ // Step 9: Create PR
76
+ this.log.info("Creating pull request...");
77
+ const prResult = await createPR({
78
+ repoInfo,
79
+ branchName,
80
+ baseBranch,
81
+ fileName,
82
+ action,
83
+ workDir,
84
+ dryRun,
85
+ retries,
86
+ });
87
+ return {
88
+ success: prResult.success,
89
+ repoName,
90
+ message: prResult.message,
91
+ prUrl: prResult.url,
92
+ };
93
+ }
94
+ finally {
95
+ // Always cleanup workspace on completion or failure (Improvement 3)
96
+ if (this.gitOps) {
97
+ try {
98
+ this.gitOps.cleanWorkspace();
99
+ }
100
+ catch {
101
+ // Ignore cleanup errors - best effort
102
+ }
103
+ }
104
+ }
105
+ }
106
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Default patterns indicating permanent errors that should NOT be retried.
3
+ * These typically indicate configuration issues, auth failures, or invalid resources.
4
+ * Export allows customization for different environments.
5
+ */
6
+ export declare const DEFAULT_PERMANENT_ERROR_PATTERNS: RegExp[];
7
+ /**
8
+ * Default patterns indicating transient errors that SHOULD be retried.
9
+ * These typically indicate temporary network or service issues.
10
+ * Export allows customization for different environments.
11
+ */
12
+ export declare const DEFAULT_TRANSIENT_ERROR_PATTERNS: RegExp[];
13
+ export interface RetryOptions {
14
+ /** Maximum number of retries (default: 3) */
15
+ retries?: number;
16
+ /** Callback when a retry attempt fails */
17
+ onRetry?: (error: Error, attempt: number) => void;
18
+ /** Custom permanent error patterns (defaults to DEFAULT_PERMANENT_ERROR_PATTERNS) */
19
+ permanentErrorPatterns?: RegExp[];
20
+ /** Custom transient error patterns (defaults to DEFAULT_TRANSIENT_ERROR_PATTERNS) */
21
+ transientErrorPatterns?: RegExp[];
22
+ }
23
+ /**
24
+ * Classifies an error as permanent (should not retry) or transient (should retry).
25
+ * @param error The error to classify
26
+ * @param patterns Custom patterns to use (defaults to DEFAULT_PERMANENT_ERROR_PATTERNS)
27
+ * @returns true if the error is permanent, false if it might be transient
28
+ */
29
+ export declare function isPermanentError(error: Error, patterns?: RegExp[]): boolean;
30
+ /**
31
+ * Checks if an error matches known transient patterns.
32
+ * @param error The error to check
33
+ * @param patterns Custom patterns to use (defaults to DEFAULT_TRANSIENT_ERROR_PATTERNS)
34
+ * @returns true if the error appears to be transient
35
+ */
36
+ export declare function isTransientError(error: Error, patterns?: RegExp[]): boolean;
37
+ /**
38
+ * Wraps an async operation with retry logic using exponential backoff.
39
+ * Automatically classifies errors and aborts retries for permanent failures.
40
+ *
41
+ * @param fn The async function to run with retry
42
+ * @param options Retry configuration options
43
+ * @returns The result of the function if successful
44
+ * @throws AbortError for permanent failures, or the last error after all retries exhausted
45
+ */
46
+ export declare function withRetry<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T>;
47
+ /**
48
+ * Wraps a synchronous operation in a Promise for use with retry logic.
49
+ * @param fn The sync function to run
50
+ * @returns A Promise that resolves/rejects with the sync result
51
+ */
52
+ export declare function promisify<T>(fn: () => T): Promise<T>;
53
+ export { AbortError } from "p-retry";
@@ -0,0 +1,143 @@
1
+ import pRetry, { AbortError } from "p-retry";
2
+ import { logger } from "./logger.js";
3
+ /**
4
+ * Default patterns indicating permanent errors that should NOT be retried.
5
+ * These typically indicate configuration issues, auth failures, or invalid resources.
6
+ * Export allows customization for different environments.
7
+ */
8
+ export const DEFAULT_PERMANENT_ERROR_PATTERNS = [
9
+ /permission\s*denied/i,
10
+ /authentication\s*failed/i,
11
+ /bad\s*credentials/i,
12
+ /invalid\s*(token|credentials)/i,
13
+ /unauthorized/i,
14
+ /401\b/,
15
+ /403\b/,
16
+ /404\b/,
17
+ /not\s*found/i,
18
+ /does\s*not\s*exist/i,
19
+ /repository\s*not\s*found/i,
20
+ /no\s*such\s*(file|directory|remote|ref)/i,
21
+ /couldn't\s*find\s*remote\s*ref/i,
22
+ /invalid\s*remote/i,
23
+ /not\s*a\s*git\s*repository/i,
24
+ /non-fast-forward/i,
25
+ /remote\s*rejected/i,
26
+ ];
27
+ /**
28
+ * Default patterns indicating transient errors that SHOULD be retried.
29
+ * These typically indicate temporary network or service issues.
30
+ * Export allows customization for different environments.
31
+ */
32
+ export const DEFAULT_TRANSIENT_ERROR_PATTERNS = [
33
+ /timed?\s*out/i,
34
+ /ETIMEDOUT/,
35
+ /ECONNRESET/,
36
+ /ECONNREFUSED/,
37
+ /ENOTFOUND/,
38
+ /connection\s*(reset|refused|closed)/i,
39
+ /network\s*(error|unreachable)/i,
40
+ /rate\s*limit/i,
41
+ /too\s*many\s*requests/i,
42
+ /429\b/,
43
+ /500\b/,
44
+ /502\b/,
45
+ /503\b/,
46
+ /504\b/,
47
+ /service\s*unavailable/i,
48
+ /temporarily\s*unavailable/i,
49
+ /internal\s*server\s*error/i,
50
+ /temporary\s*(failure|error)/i,
51
+ /try\s*again/i,
52
+ /ssh_exchange_identification/i,
53
+ /could\s*not\s*resolve\s*host/i,
54
+ /unable\s*to\s*access/i,
55
+ ];
56
+ /**
57
+ * Classifies an error as permanent (should not retry) or transient (should retry).
58
+ * @param error The error to classify
59
+ * @param patterns Custom patterns to use (defaults to DEFAULT_PERMANENT_ERROR_PATTERNS)
60
+ * @returns true if the error is permanent, false if it might be transient
61
+ */
62
+ export function isPermanentError(error, patterns = DEFAULT_PERMANENT_ERROR_PATTERNS) {
63
+ const message = error.message;
64
+ const stderr = error.stderr?.toString() ?? "";
65
+ const combined = `${message} ${stderr}`;
66
+ // Check permanent patterns first - these always stop retries
67
+ for (const pattern of patterns) {
68
+ if (pattern.test(combined)) {
69
+ return true;
70
+ }
71
+ }
72
+ return false;
73
+ }
74
+ /**
75
+ * Checks if an error matches known transient patterns.
76
+ * @param error The error to check
77
+ * @param patterns Custom patterns to use (defaults to DEFAULT_TRANSIENT_ERROR_PATTERNS)
78
+ * @returns true if the error appears to be transient
79
+ */
80
+ export function isTransientError(error, patterns = DEFAULT_TRANSIENT_ERROR_PATTERNS) {
81
+ const message = error.message;
82
+ const stderr = error.stderr?.toString() ?? "";
83
+ const combined = `${message} ${stderr}`;
84
+ for (const pattern of patterns) {
85
+ if (pattern.test(combined)) {
86
+ return true;
87
+ }
88
+ }
89
+ return false;
90
+ }
91
+ /**
92
+ * Wraps an async operation with retry logic using exponential backoff.
93
+ * Automatically classifies errors and aborts retries for permanent failures.
94
+ *
95
+ * @param fn The async function to run with retry
96
+ * @param options Retry configuration options
97
+ * @returns The result of the function if successful
98
+ * @throws AbortError for permanent failures, or the last error after all retries exhausted
99
+ */
100
+ export async function withRetry(fn, options) {
101
+ const retries = options?.retries ?? 3;
102
+ const permanentPatterns = options?.permanentErrorPatterns;
103
+ return pRetry(async () => {
104
+ try {
105
+ return await fn();
106
+ }
107
+ catch (error) {
108
+ if (error instanceof Error &&
109
+ isPermanentError(error, permanentPatterns)) {
110
+ // Wrap in AbortError to stop retrying immediately
111
+ throw new AbortError(error.message);
112
+ }
113
+ throw error;
114
+ }
115
+ }, {
116
+ retries,
117
+ onFailedAttempt: (context) => {
118
+ // Only log if this isn't the last attempt
119
+ if (context.retriesLeft > 0) {
120
+ const msg = context.error.message || "Unknown error";
121
+ logger.info(`Attempt ${context.attemptNumber}/${retries + 1} failed: ${msg}. Retrying...`);
122
+ options?.onRetry?.(context.error, context.attemptNumber);
123
+ }
124
+ },
125
+ });
126
+ }
127
+ /**
128
+ * Wraps a synchronous operation in a Promise for use with retry logic.
129
+ * @param fn The sync function to run
130
+ * @returns A Promise that resolves/rejects with the sync result
131
+ */
132
+ export function promisify(fn) {
133
+ return new Promise((resolve, reject) => {
134
+ try {
135
+ resolve(fn());
136
+ }
137
+ catch (error) {
138
+ reject(error);
139
+ }
140
+ });
141
+ }
142
+ // Re-export AbortError for use in custom error handling
143
+ export { AbortError } from "p-retry";
@@ -0,0 +1,10 @@
1
+ import { PRResult } from "../pr-creator.js";
2
+ import { BasePRStrategy, PRStrategyOptions } from "./pr-strategy.js";
3
+ import { CommandExecutor } from "../command-executor.js";
4
+ export declare class AzurePRStrategy extends BasePRStrategy {
5
+ constructor(executor?: CommandExecutor);
6
+ private getOrgUrl;
7
+ private buildPRUrl;
8
+ checkExistingPR(options: PRStrategyOptions): Promise<string | null>;
9
+ create(options: PRStrategyOptions): Promise<PRResult>;
10
+ }
@@ -0,0 +1,78 @@
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 create(options) {
45
+ const { repoInfo, title, body, 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
+ // Write description to temp file to avoid shell escaping issues
52
+ const descFile = join(workDir, this.bodyFilePath);
53
+ writeFileSync(descFile, body, "utf-8");
54
+ // Azure CLI @file syntax: escape the full @path to handle special chars in workDir
55
+ 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`;
56
+ try {
57
+ const prId = await withRetry(() => this.executor.exec(command, workDir), {
58
+ retries,
59
+ });
60
+ return {
61
+ url: this.buildPRUrl(azureRepoInfo, prId),
62
+ success: true,
63
+ message: "PR created successfully",
64
+ };
65
+ }
66
+ finally {
67
+ // Clean up temp file - log warning on failure instead of throwing
68
+ try {
69
+ if (existsSync(descFile)) {
70
+ unlinkSync(descFile);
71
+ }
72
+ }
73
+ catch (cleanupError) {
74
+ logger.info(`Warning: Failed to clean up temp file ${descFile}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
75
+ }
76
+ }
77
+ }
78
+ }
@@ -0,0 +1,6 @@
1
+ import { PRResult } from "../pr-creator.js";
2
+ import { BasePRStrategy, PRStrategyOptions } from "./pr-strategy.js";
3
+ export declare class GitHubPRStrategy extends BasePRStrategy {
4
+ checkExistingPR(options: PRStrategyOptions): Promise<string | null>;
5
+ create(options: PRStrategyOptions): Promise<PRResult>;
6
+ }
@@ -0,0 +1,65 @@
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 create(options) {
35
+ const { repoInfo, title, body, branchName, baseBranch, workDir, retries = 3, } = options;
36
+ if (!isGitHubRepo(repoInfo)) {
37
+ throw new Error("Expected GitHub repository");
38
+ }
39
+ // Write body to temp file to avoid shell escaping issues
40
+ const bodyFile = join(workDir, this.bodyFilePath);
41
+ writeFileSync(bodyFile, body, "utf-8");
42
+ const command = `gh pr create --title ${escapeShellArg(title)} --body-file ${escapeShellArg(bodyFile)} --base ${escapeShellArg(baseBranch)} --head ${escapeShellArg(branchName)}`;
43
+ try {
44
+ const result = await withRetry(() => this.executor.exec(command, workDir), { retries });
45
+ // Extract URL from output
46
+ const urlMatch = result.match(/https:\/\/github\.com\/[^\s]+/);
47
+ return {
48
+ url: urlMatch?.[0] ?? result,
49
+ success: true,
50
+ message: "PR created successfully",
51
+ };
52
+ }
53
+ finally {
54
+ // Clean up temp file - log warning on failure instead of throwing
55
+ try {
56
+ if (existsSync(bodyFile)) {
57
+ unlinkSync(bodyFile);
58
+ }
59
+ }
60
+ catch (cleanupError) {
61
+ logger.info(`Warning: Failed to clean up temp file ${bodyFile}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
62
+ }
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,12 @@
1
+ import { RepoInfo } from "../repo-detector.js";
2
+ import type { PRStrategy } from "./pr-strategy.js";
3
+ export type { PRStrategy, PRStrategyOptions } from "./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
+ * Note: repoInfo is passed via PRStrategyOptions.execute() rather than constructor
10
+ * to ensure LSP compliance (all strategies have identical constructors).
11
+ */
12
+ export declare function getPRStrategy(repoInfo: RepoInfo): 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
+ * Note: repoInfo is passed via PRStrategyOptions.execute() rather than constructor
10
+ * to ensure LSP compliance (all strategies have identical constructors).
11
+ */
12
+ export function getPRStrategy(repoInfo) {
13
+ if (isGitHubRepo(repoInfo)) {
14
+ return new GitHubPRStrategy();
15
+ }
16
+ if (isAzureDevOpsRepo(repoInfo)) {
17
+ return new AzurePRStrategy();
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,70 @@
1
+ import { PRResult } from "../pr-creator.js";
2
+ import { RepoInfo } from "../repo-detector.js";
3
+ import { CommandExecutor } from "../command-executor.js";
4
+ export interface PRStrategyOptions {
5
+ repoInfo: RepoInfo;
6
+ title: string;
7
+ body: string;
8
+ branchName: string;
9
+ baseBranch: string;
10
+ workDir: string;
11
+ /** Number of retries for API operations (default: 3) */
12
+ retries?: number;
13
+ }
14
+ /**
15
+ * Interface for PR creation strategies (platform-specific implementations).
16
+ * Strategies focus on platform-specific logic (checkExistingPR, create).
17
+ * Use PRWorkflowExecutor for full workflow orchestration with error handling.
18
+ */
19
+ export interface PRStrategy {
20
+ /**
21
+ * Check if a PR already exists for the given branch
22
+ * @returns PR URL if exists, null otherwise
23
+ */
24
+ checkExistingPR(options: PRStrategyOptions): Promise<string | null>;
25
+ /**
26
+ * Create a new PR
27
+ * @returns Result with URL and status
28
+ */
29
+ create(options: PRStrategyOptions): Promise<PRResult>;
30
+ /**
31
+ * Execute the full PR creation workflow
32
+ * @deprecated Use PRWorkflowExecutor.execute() for better SRP
33
+ */
34
+ execute(options: PRStrategyOptions): Promise<PRResult>;
35
+ }
36
+ export declare abstract class BasePRStrategy implements PRStrategy {
37
+ protected bodyFilePath: string;
38
+ protected executor: CommandExecutor;
39
+ constructor(executor?: CommandExecutor);
40
+ abstract checkExistingPR(options: PRStrategyOptions): Promise<string | null>;
41
+ abstract create(options: PRStrategyOptions): Promise<PRResult>;
42
+ /**
43
+ * Execute the full PR creation workflow:
44
+ * 1. Check for existing PR
45
+ * 2. If exists, return it
46
+ * 3. Otherwise, create new PR
47
+ *
48
+ * @deprecated Use PRWorkflowExecutor.execute() for better SRP
49
+ */
50
+ execute(options: PRStrategyOptions): Promise<PRResult>;
51
+ }
52
+ /**
53
+ * Orchestrates the PR creation workflow with error handling.
54
+ * Follows Single Responsibility Principle by separating workflow orchestration
55
+ * from platform-specific PR creation logic.
56
+ *
57
+ * Workflow:
58
+ * 1. Check for existing PR on the branch
59
+ * 2. If exists, return existing PR URL
60
+ * 3. Otherwise, create new PR
61
+ * 4. Handle errors and return failure result
62
+ */
63
+ export declare class PRWorkflowExecutor {
64
+ private readonly strategy;
65
+ constructor(strategy: PRStrategy);
66
+ /**
67
+ * Execute the full PR creation workflow with error handling.
68
+ */
69
+ execute(options: PRStrategyOptions): Promise<PRResult>;
70
+ }