@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.
- package/README.md +27 -8
- package/dist/command-executor.d.ts +25 -0
- package/dist/command-executor.js +28 -0
- package/dist/config-formatter.d.ts +9 -0
- package/dist/config-formatter.js +21 -0
- package/dist/config-normalizer.d.ts +6 -0
- package/dist/config-normalizer.js +43 -0
- package/dist/config-validator.d.ts +6 -0
- package/dist/config-validator.js +54 -0
- package/dist/config.d.ts +2 -2
- package/dist/config.js +13 -88
- package/dist/git-ops.d.ts +29 -7
- package/dist/git-ops.js +123 -40
- package/dist/index.d.ts +19 -1
- package/dist/index.js +34 -64
- package/dist/logger.d.ts +3 -0
- package/dist/merge.d.ts +9 -0
- package/dist/merge.js +14 -9
- package/dist/pr-creator.d.ts +2 -0
- package/dist/pr-creator.js +14 -105
- package/dist/repo-detector.d.ts +15 -5
- package/dist/repo-detector.js +27 -8
- package/dist/repository-processor.d.ts +36 -0
- package/dist/repository-processor.js +106 -0
- package/dist/retry-utils.d.ts +53 -0
- package/dist/retry-utils.js +143 -0
- package/dist/strategies/azure-pr-strategy.d.ts +10 -0
- package/dist/strategies/azure-pr-strategy.js +78 -0
- package/dist/strategies/github-pr-strategy.d.ts +6 -0
- package/dist/strategies/github-pr-strategy.js +65 -0
- package/dist/strategies/index.d.ts +12 -0
- package/dist/strategies/index.js +22 -0
- package/dist/strategies/pr-strategy.d.ts +70 -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 +3 -2
package/dist/repo-detector.js
CHANGED
|
@@ -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
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
+
}
|