@aspruyt/json-config-sync 3.7.0 → 3.8.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 +84 -15
- package/dist/config-normalizer.js +29 -0
- package/dist/config.d.ts +11 -0
- package/dist/index.js +34 -1
- package/dist/pr-creator.d.ts +10 -0
- package/dist/pr-creator.js +24 -1
- package/dist/repository-processor.d.ts +5 -0
- package/dist/repository-processor.js +34 -2
- package/dist/strategies/azure-pr-strategy.d.ts +6 -1
- package/dist/strategies/azure-pr-strategy.js +92 -1
- package/dist/strategies/github-pr-strategy.d.ts +11 -1
- package/dist/strategies/github-pr-strategy.js +115 -1
- package/dist/strategies/index.d.ts +1 -1
- package/dist/strategies/pr-strategy.d.ts +26 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,6 +73,7 @@ json-config-sync --config ./config.yaml
|
|
|
73
73
|
- **Empty Files** - Create files with no content (e.g., `.prettierignore`)
|
|
74
74
|
- **YAML Comments** - Add header comments and schema directives to YAML files
|
|
75
75
|
- **GitHub & Azure DevOps** - Works with both platforms
|
|
76
|
+
- **Auto-Merge PRs** - Automatically merge PRs when checks pass, or force merge with admin privileges
|
|
76
77
|
- **Dry-Run Mode** - Preview changes without creating PRs
|
|
77
78
|
- **Error Resilience** - Continues processing if individual repos fail
|
|
78
79
|
- **Automatic Retries** - Retries transient network errors with exponential backoff
|
|
@@ -135,13 +136,16 @@ json-config-sync --config ./config.yaml --branch feature/update-eslint
|
|
|
135
136
|
|
|
136
137
|
### Options
|
|
137
138
|
|
|
138
|
-
| Option
|
|
139
|
-
|
|
|
140
|
-
| `--config`
|
|
141
|
-
| `--dry-run`
|
|
142
|
-
| `--work-dir`
|
|
143
|
-
| `--retries`
|
|
144
|
-
| `--branch`
|
|
139
|
+
| Option | Alias | Description | Required |
|
|
140
|
+
| ------------------ | ----- | ------------------------------------------------------------------------------------ | -------- |
|
|
141
|
+
| `--config` | `-c` | Path to YAML config file | Yes |
|
|
142
|
+
| `--dry-run` | `-d` | Show what would be done without making changes | No |
|
|
143
|
+
| `--work-dir` | `-w` | Temporary directory for cloning (default: `./tmp`) | No |
|
|
144
|
+
| `--retries` | `-r` | Number of retries for network operations (default: 3) | No |
|
|
145
|
+
| `--branch` | `-b` | Override branch name (default: `chore/sync-config`) | No |
|
|
146
|
+
| `--merge` | `-m` | PR merge mode: `manual` (default), `auto` (merge when checks pass), `force` (bypass) | No |
|
|
147
|
+
| `--merge-strategy` | | Merge strategy: `merge` (default), `squash`, `rebase` | No |
|
|
148
|
+
| `--delete-branch` | | Delete source branch after merge | No |
|
|
145
149
|
|
|
146
150
|
## Configuration Format
|
|
147
151
|
|
|
@@ -164,10 +168,11 @@ repos: # List of repositories
|
|
|
164
168
|
|
|
165
169
|
### Root-Level Fields
|
|
166
170
|
|
|
167
|
-
| Field
|
|
168
|
-
|
|
|
169
|
-
| `files`
|
|
170
|
-
| `repos`
|
|
171
|
+
| Field | Description | Required |
|
|
172
|
+
| ----------- | ---------------------------------------------------- | -------- |
|
|
173
|
+
| `files` | Map of target filenames to configs | Yes |
|
|
174
|
+
| `repos` | Array of repository configurations | Yes |
|
|
175
|
+
| `prOptions` | Global PR merge options (can be overridden per-repo) | No |
|
|
171
176
|
|
|
172
177
|
### Per-File Fields
|
|
173
178
|
|
|
@@ -182,10 +187,20 @@ repos: # List of repositories
|
|
|
182
187
|
|
|
183
188
|
### Per-Repo Fields
|
|
184
189
|
|
|
185
|
-
| Field
|
|
186
|
-
|
|
|
187
|
-
| `git`
|
|
188
|
-
| `files`
|
|
190
|
+
| Field | Description | Required |
|
|
191
|
+
| ----------- | -------------------------------------------- | -------- |
|
|
192
|
+
| `git` | Git URL (string) or array of URLs | Yes |
|
|
193
|
+
| `files` | Per-repo file overrides (optional) | No |
|
|
194
|
+
| `prOptions` | Per-repo PR merge options (overrides global) | No |
|
|
195
|
+
|
|
196
|
+
### PR Options Fields
|
|
197
|
+
|
|
198
|
+
| Field | Description | Default |
|
|
199
|
+
| --------------- | --------------------------------------------------------------------------- | -------- |
|
|
200
|
+
| `merge` | Merge mode: `manual` (leave open), `auto` (merge when checks pass), `force` | `manual` |
|
|
201
|
+
| `mergeStrategy` | How to merge: `merge`, `squash`, `rebase` | `merge` |
|
|
202
|
+
| `deleteBranch` | Delete source branch after merge | `false` |
|
|
203
|
+
| `bypassReason` | Reason for bypassing policies (Azure DevOps only, required for `force`) | - |
|
|
189
204
|
|
|
190
205
|
### Per-Repo File Override Fields
|
|
191
206
|
|
|
@@ -632,6 +647,60 @@ config/
|
|
|
632
647
|
|
|
633
648
|
**Security:** File references are restricted to the config file's directory tree. Paths like `@../../../etc/passwd` or `@/etc/passwd` are blocked.
|
|
634
649
|
|
|
650
|
+
### Auto-Merge PRs
|
|
651
|
+
|
|
652
|
+
Configure PRs to merge automatically when checks pass, or force merge using admin privileges:
|
|
653
|
+
|
|
654
|
+
```yaml
|
|
655
|
+
files:
|
|
656
|
+
.prettierrc.json:
|
|
657
|
+
content:
|
|
658
|
+
semi: false
|
|
659
|
+
singleQuote: true
|
|
660
|
+
|
|
661
|
+
# Global PR options - apply to all repos
|
|
662
|
+
prOptions:
|
|
663
|
+
merge: auto # auto-merge when checks pass
|
|
664
|
+
mergeStrategy: squash # squash commits
|
|
665
|
+
deleteBranch: true # cleanup after merge
|
|
666
|
+
|
|
667
|
+
repos:
|
|
668
|
+
# These repos use global prOptions (auto-merge)
|
|
669
|
+
- git:
|
|
670
|
+
- git@github.com:org/frontend.git
|
|
671
|
+
- git@github.com:org/backend.git
|
|
672
|
+
|
|
673
|
+
# This repo overrides to force merge (bypass required reviews)
|
|
674
|
+
- git: git@github.com:org/internal-tool.git
|
|
675
|
+
prOptions:
|
|
676
|
+
merge: force
|
|
677
|
+
bypassReason: "Automated config sync" # Azure DevOps only
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
**Merge Modes:**
|
|
681
|
+
|
|
682
|
+
| Mode | GitHub Behavior | Azure DevOps Behavior |
|
|
683
|
+
| -------- | --------------------------------------- | -------------------------------------- |
|
|
684
|
+
| `manual` | Leave PR open for review (default) | Leave PR open for review |
|
|
685
|
+
| `auto` | Enable auto-merge (requires repo setup) | Enable auto-complete |
|
|
686
|
+
| `force` | Merge with `--admin` (bypass checks) | Bypass policies with `--bypass-policy` |
|
|
687
|
+
|
|
688
|
+
**GitHub Auto-Merge Note:** The `auto` mode requires auto-merge to be enabled in the repository settings. If not enabled, the tool will warn and leave the PR open for manual review. Enable it with:
|
|
689
|
+
|
|
690
|
+
```bash
|
|
691
|
+
gh repo edit org/repo --enable-auto-merge
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
**CLI Override:** You can override config file settings with CLI flags:
|
|
695
|
+
|
|
696
|
+
```bash
|
|
697
|
+
# Force merge all PRs (useful for urgent updates)
|
|
698
|
+
json-config-sync --config ./config.yaml --merge force
|
|
699
|
+
|
|
700
|
+
# Enable auto-merge with squash
|
|
701
|
+
json-config-sync --config ./config.yaml --merge auto --merge-strategy squash --delete-branch
|
|
702
|
+
```
|
|
703
|
+
|
|
635
704
|
## Supported Git URL Formats
|
|
636
705
|
|
|
637
706
|
### GitHub
|
|
@@ -10,6 +10,32 @@ function normalizeHeader(header) {
|
|
|
10
10
|
return [header];
|
|
11
11
|
return header;
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* Merges PR options: per-repo overrides global defaults.
|
|
15
|
+
* Returns undefined if no options are set.
|
|
16
|
+
*/
|
|
17
|
+
function mergePROptions(global, perRepo) {
|
|
18
|
+
if (!global && !perRepo)
|
|
19
|
+
return undefined;
|
|
20
|
+
if (!global)
|
|
21
|
+
return perRepo;
|
|
22
|
+
if (!perRepo)
|
|
23
|
+
return global;
|
|
24
|
+
const result = {};
|
|
25
|
+
const merge = perRepo.merge ?? global.merge;
|
|
26
|
+
const mergeStrategy = perRepo.mergeStrategy ?? global.mergeStrategy;
|
|
27
|
+
const deleteBranch = perRepo.deleteBranch ?? global.deleteBranch;
|
|
28
|
+
const bypassReason = perRepo.bypassReason ?? global.bypassReason;
|
|
29
|
+
if (merge !== undefined)
|
|
30
|
+
result.merge = merge;
|
|
31
|
+
if (mergeStrategy !== undefined)
|
|
32
|
+
result.mergeStrategy = mergeStrategy;
|
|
33
|
+
if (deleteBranch !== undefined)
|
|
34
|
+
result.deleteBranch = deleteBranch;
|
|
35
|
+
if (bypassReason !== undefined)
|
|
36
|
+
result.bypassReason = bypassReason;
|
|
37
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
38
|
+
}
|
|
13
39
|
/**
|
|
14
40
|
* Normalizes raw config into expanded, merged config.
|
|
15
41
|
* Pipeline: expand git arrays -> merge content -> interpolate env vars
|
|
@@ -95,9 +121,12 @@ export function normalizeConfig(raw) {
|
|
|
95
121
|
schemaUrl,
|
|
96
122
|
});
|
|
97
123
|
}
|
|
124
|
+
// Merge PR options: per-repo overrides global
|
|
125
|
+
const prOptions = mergePROptions(raw.prOptions, rawRepo.prOptions);
|
|
98
126
|
expandedRepos.push({
|
|
99
127
|
git: gitUrl,
|
|
100
128
|
files,
|
|
129
|
+
prOptions,
|
|
101
130
|
});
|
|
102
131
|
}
|
|
103
132
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import type { ArrayMergeStrategy } from "./merge.js";
|
|
2
2
|
export { convertContentToString } from "./config-formatter.js";
|
|
3
|
+
export type MergeMode = "manual" | "auto" | "force";
|
|
4
|
+
export type MergeStrategy = "merge" | "squash" | "rebase";
|
|
5
|
+
export interface PRMergeOptions {
|
|
6
|
+
merge?: MergeMode;
|
|
7
|
+
mergeStrategy?: MergeStrategy;
|
|
8
|
+
deleteBranch?: boolean;
|
|
9
|
+
bypassReason?: string;
|
|
10
|
+
}
|
|
3
11
|
export type ContentValue = Record<string, unknown> | string | string[];
|
|
4
12
|
export interface RawFileConfig {
|
|
5
13
|
content?: ContentValue;
|
|
@@ -20,10 +28,12 @@ export interface RawRepoFileOverride {
|
|
|
20
28
|
export interface RawRepoConfig {
|
|
21
29
|
git: string | string[];
|
|
22
30
|
files?: Record<string, RawRepoFileOverride | false>;
|
|
31
|
+
prOptions?: PRMergeOptions;
|
|
23
32
|
}
|
|
24
33
|
export interface RawConfig {
|
|
25
34
|
files: Record<string, RawFileConfig>;
|
|
26
35
|
repos: RawRepoConfig[];
|
|
36
|
+
prOptions?: PRMergeOptions;
|
|
27
37
|
}
|
|
28
38
|
export interface FileContent {
|
|
29
39
|
fileName: string;
|
|
@@ -36,6 +46,7 @@ export interface FileContent {
|
|
|
36
46
|
export interface RepoConfig {
|
|
37
47
|
git: string;
|
|
38
48
|
files: FileContent[];
|
|
49
|
+
prOptions?: PRMergeOptions;
|
|
39
50
|
}
|
|
40
51
|
export interface Config {
|
|
41
52
|
repos: RepoConfig[];
|
package/dist/index.js
CHANGED
|
@@ -26,6 +26,21 @@ program
|
|
|
26
26
|
.option("-w, --work-dir <path>", "Temporary directory for cloning", "./tmp")
|
|
27
27
|
.option("-r, --retries <number>", "Number of retries for network operations (0 to disable)", (v) => parseInt(v, 10), 3)
|
|
28
28
|
.option("-b, --branch <name>", "Override the branch name (default: chore/sync-{filename} or chore/sync-config)")
|
|
29
|
+
.option("-m, --merge <mode>", "PR merge mode: manual (default), auto (merge when checks pass), force (bypass requirements)", (value) => {
|
|
30
|
+
const valid = ["manual", "auto", "force"];
|
|
31
|
+
if (!valid.includes(value)) {
|
|
32
|
+
throw new Error(`Invalid merge mode: ${value}. Valid: ${valid.join(", ")}`);
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
})
|
|
36
|
+
.option("--merge-strategy <strategy>", "Merge strategy: merge (default), squash, rebase", (value) => {
|
|
37
|
+
const valid = ["merge", "squash", "rebase"];
|
|
38
|
+
if (!valid.includes(value)) {
|
|
39
|
+
throw new Error(`Invalid merge strategy: ${value}. Valid: ${valid.join(", ")}`);
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
})
|
|
43
|
+
.option("--delete-branch", "Delete source branch after merge")
|
|
29
44
|
.parse();
|
|
30
45
|
const options = program.opts();
|
|
31
46
|
/**
|
|
@@ -88,6 +103,15 @@ async function main() {
|
|
|
88
103
|
const processor = defaultProcessorFactory();
|
|
89
104
|
for (let i = 0; i < config.repos.length; i++) {
|
|
90
105
|
const repoConfig = config.repos[i];
|
|
106
|
+
// Apply CLI merge overrides to repo config
|
|
107
|
+
if (options.merge || options.mergeStrategy || options.deleteBranch) {
|
|
108
|
+
repoConfig.prOptions = {
|
|
109
|
+
...repoConfig.prOptions,
|
|
110
|
+
merge: options.merge ?? repoConfig.prOptions?.merge,
|
|
111
|
+
mergeStrategy: options.mergeStrategy ?? repoConfig.prOptions?.mergeStrategy,
|
|
112
|
+
deleteBranch: options.deleteBranch ?? repoConfig.prOptions?.deleteBranch,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
91
115
|
const current = i + 1;
|
|
92
116
|
let repoInfo;
|
|
93
117
|
try {
|
|
@@ -112,7 +136,16 @@ async function main() {
|
|
|
112
136
|
logger.skip(current, repoName, result.message);
|
|
113
137
|
}
|
|
114
138
|
else if (result.success) {
|
|
115
|
-
|
|
139
|
+
let message = result.prUrl ? `PR: ${result.prUrl}` : result.message;
|
|
140
|
+
if (result.mergeResult) {
|
|
141
|
+
if (result.mergeResult.merged) {
|
|
142
|
+
message += " (merged)";
|
|
143
|
+
}
|
|
144
|
+
else if (result.mergeResult.autoMergeEnabled) {
|
|
145
|
+
message += " (auto-merge enabled)";
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
logger.success(current, repoName, message);
|
|
116
149
|
}
|
|
117
150
|
else {
|
|
118
151
|
logger.error(current, repoName, result.message);
|
package/dist/pr-creator.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { RepoInfo } from "./repo-detector.js";
|
|
2
|
+
import { MergeResult, PRMergeConfig } from "./strategies/index.js";
|
|
2
3
|
export { escapeShellArg } from "./shell-utils.js";
|
|
3
4
|
export interface FileAction {
|
|
4
5
|
fileName: string;
|
|
@@ -28,3 +29,12 @@ export declare function formatPRBody(files: FileAction[]): string;
|
|
|
28
29
|
*/
|
|
29
30
|
export declare function formatPRTitle(files: FileAction[]): string;
|
|
30
31
|
export declare function createPR(options: PROptions): Promise<PRResult>;
|
|
32
|
+
export interface MergePROptions {
|
|
33
|
+
repoInfo: RepoInfo;
|
|
34
|
+
prUrl: string;
|
|
35
|
+
mergeConfig: PRMergeConfig;
|
|
36
|
+
workDir: string;
|
|
37
|
+
dryRun?: boolean;
|
|
38
|
+
retries?: number;
|
|
39
|
+
}
|
|
40
|
+
export declare function mergePR(options: MergePROptions): Promise<MergeResult>;
|
package/dist/pr-creator.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from "node:fs";
|
|
2
2
|
import { join, dirname } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { getPRStrategy } from "./strategies/index.js";
|
|
4
|
+
import { getPRStrategy, } from "./strategies/index.js";
|
|
5
5
|
// Re-export for backwards compatibility and testing
|
|
6
6
|
export { escapeShellArg } from "./shell-utils.js";
|
|
7
7
|
function loadPRTemplate() {
|
|
@@ -104,3 +104,26 @@ export async function createPR(options) {
|
|
|
104
104
|
retries,
|
|
105
105
|
});
|
|
106
106
|
}
|
|
107
|
+
export async function mergePR(options) {
|
|
108
|
+
const { repoInfo, prUrl, mergeConfig, workDir, dryRun, retries } = options;
|
|
109
|
+
if (dryRun) {
|
|
110
|
+
const modeText = mergeConfig.mode === "force"
|
|
111
|
+
? "force merge"
|
|
112
|
+
: mergeConfig.mode === "auto"
|
|
113
|
+
? "enable auto-merge"
|
|
114
|
+
: "leave open for manual review";
|
|
115
|
+
return {
|
|
116
|
+
success: true,
|
|
117
|
+
message: `[DRY RUN] Would ${modeText}`,
|
|
118
|
+
merged: false,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// Get the appropriate strategy and execute merge
|
|
122
|
+
const strategy = getPRStrategy(repoInfo);
|
|
123
|
+
return strategy.merge({
|
|
124
|
+
prUrl,
|
|
125
|
+
config: mergeConfig,
|
|
126
|
+
workDir,
|
|
127
|
+
retries,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -20,6 +20,11 @@ export interface ProcessorResult {
|
|
|
20
20
|
message: string;
|
|
21
21
|
prUrl?: string;
|
|
22
22
|
skipped?: boolean;
|
|
23
|
+
mergeResult?: {
|
|
24
|
+
merged: boolean;
|
|
25
|
+
autoMergeEnabled?: boolean;
|
|
26
|
+
message: string;
|
|
27
|
+
};
|
|
23
28
|
}
|
|
24
29
|
export declare class RepositoryProcessor {
|
|
25
30
|
private gitOps;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { convertContentToString } from "./config.js";
|
|
3
|
+
import { convertContentToString, } from "./config.js";
|
|
4
4
|
import { getRepoDisplayName } from "./repo-detector.js";
|
|
5
5
|
import { GitOps } from "./git-ops.js";
|
|
6
|
-
import { createPR } from "./pr-creator.js";
|
|
6
|
+
import { createPR, mergePR } from "./pr-creator.js";
|
|
7
7
|
import { logger } from "./logger.js";
|
|
8
8
|
/**
|
|
9
9
|
* Determines if a file should be marked as executable.
|
|
@@ -142,11 +142,43 @@ export class RepositoryProcessor {
|
|
|
142
142
|
dryRun,
|
|
143
143
|
retries,
|
|
144
144
|
});
|
|
145
|
+
// Step 10: Handle merge options if configured
|
|
146
|
+
const mergeMode = repoConfig.prOptions?.merge ?? "manual";
|
|
147
|
+
let mergeResult;
|
|
148
|
+
if (prResult.success && prResult.url && mergeMode !== "manual") {
|
|
149
|
+
this.log.info(`Handling merge (mode: ${mergeMode})...`);
|
|
150
|
+
const mergeConfig = {
|
|
151
|
+
mode: mergeMode,
|
|
152
|
+
strategy: repoConfig.prOptions?.mergeStrategy,
|
|
153
|
+
deleteBranch: repoConfig.prOptions?.deleteBranch,
|
|
154
|
+
bypassReason: repoConfig.prOptions?.bypassReason,
|
|
155
|
+
};
|
|
156
|
+
const result = await mergePR({
|
|
157
|
+
repoInfo,
|
|
158
|
+
prUrl: prResult.url,
|
|
159
|
+
mergeConfig,
|
|
160
|
+
workDir,
|
|
161
|
+
dryRun,
|
|
162
|
+
retries,
|
|
163
|
+
});
|
|
164
|
+
mergeResult = {
|
|
165
|
+
merged: result.merged ?? false,
|
|
166
|
+
autoMergeEnabled: result.autoMergeEnabled,
|
|
167
|
+
message: result.message,
|
|
168
|
+
};
|
|
169
|
+
if (!result.success) {
|
|
170
|
+
this.log.info(`Warning: Merge operation failed - ${result.message}`);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
this.log.info(result.message);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
145
176
|
return {
|
|
146
177
|
success: prResult.success,
|
|
147
178
|
repoName,
|
|
148
179
|
message: prResult.message,
|
|
149
180
|
prUrl: prResult.url,
|
|
181
|
+
mergeResult,
|
|
150
182
|
};
|
|
151
183
|
}
|
|
152
184
|
finally {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { PRResult } from "../pr-creator.js";
|
|
2
|
-
import { BasePRStrategy, PRStrategyOptions } from "./pr-strategy.js";
|
|
2
|
+
import { BasePRStrategy, PRStrategyOptions, MergeOptions, MergeResult } from "./pr-strategy.js";
|
|
3
3
|
import { CommandExecutor } from "../command-executor.js";
|
|
4
4
|
export declare class AzurePRStrategy extends BasePRStrategy {
|
|
5
5
|
constructor(executor?: CommandExecutor);
|
|
@@ -7,4 +7,9 @@ export declare class AzurePRStrategy extends BasePRStrategy {
|
|
|
7
7
|
private buildPRUrl;
|
|
8
8
|
checkExistingPR(options: PRStrategyOptions): Promise<string | null>;
|
|
9
9
|
create(options: PRStrategyOptions): Promise<PRResult>;
|
|
10
|
+
/**
|
|
11
|
+
* Extract PR ID and repo info from Azure DevOps PR URL.
|
|
12
|
+
*/
|
|
13
|
+
private parsePRUrl;
|
|
14
|
+
merge(options: MergeOptions): Promise<MergeResult>;
|
|
10
15
|
}
|
|
@@ -2,7 +2,7 @@ import { existsSync, writeFileSync, unlinkSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { escapeShellArg } from "../shell-utils.js";
|
|
4
4
|
import { isAzureDevOpsRepo } from "../repo-detector.js";
|
|
5
|
-
import { BasePRStrategy } from "./pr-strategy.js";
|
|
5
|
+
import { BasePRStrategy, } from "./pr-strategy.js";
|
|
6
6
|
import { logger } from "../logger.js";
|
|
7
7
|
import { withRetry, isPermanentError } from "../retry-utils.js";
|
|
8
8
|
export class AzurePRStrategy extends BasePRStrategy {
|
|
@@ -75,4 +75,95 @@ export class AzurePRStrategy extends BasePRStrategy {
|
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Extract PR ID and repo info from Azure DevOps PR URL.
|
|
80
|
+
*/
|
|
81
|
+
parsePRUrl(prUrl) {
|
|
82
|
+
// URL format: https://dev.azure.com/{org}/{project}/_git/{repo}/pullrequest/{prId}
|
|
83
|
+
const match = prUrl.match(/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+)\/pullrequest\/(\d+)/);
|
|
84
|
+
if (!match)
|
|
85
|
+
return null;
|
|
86
|
+
return {
|
|
87
|
+
organization: decodeURIComponent(match[1]),
|
|
88
|
+
project: decodeURIComponent(match[2]),
|
|
89
|
+
repo: decodeURIComponent(match[3]),
|
|
90
|
+
prId: match[4],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
async merge(options) {
|
|
94
|
+
const { prUrl, config, workDir, retries = 3 } = options;
|
|
95
|
+
// Manual mode: do nothing
|
|
96
|
+
if (config.mode === "manual") {
|
|
97
|
+
return {
|
|
98
|
+
success: true,
|
|
99
|
+
message: "PR left open for manual review",
|
|
100
|
+
merged: false,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
// Parse PR URL to extract details
|
|
104
|
+
const prInfo = this.parsePRUrl(prUrl);
|
|
105
|
+
if (!prInfo) {
|
|
106
|
+
return {
|
|
107
|
+
success: false,
|
|
108
|
+
message: `Invalid Azure DevOps PR URL: ${prUrl}`,
|
|
109
|
+
merged: false,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const orgUrl = `https://dev.azure.com/${encodeURIComponent(prInfo.organization)}`;
|
|
113
|
+
const squashFlag = config.strategy === "squash" ? "--squash true" : "";
|
|
114
|
+
const deleteBranchFlag = config.deleteBranch
|
|
115
|
+
? "--delete-source-branch true"
|
|
116
|
+
: "";
|
|
117
|
+
if (config.mode === "auto") {
|
|
118
|
+
// Enable auto-complete (no pre-check needed - always available in Azure DevOps)
|
|
119
|
+
const command = `az repos pr update --id ${escapeShellArg(prInfo.prId)} --auto-complete true ${squashFlag} ${deleteBranchFlag} --org ${escapeShellArg(orgUrl)} --project ${escapeShellArg(prInfo.project)}`.trim();
|
|
120
|
+
try {
|
|
121
|
+
await withRetry(() => this.executor.exec(command, workDir), {
|
|
122
|
+
retries,
|
|
123
|
+
});
|
|
124
|
+
return {
|
|
125
|
+
success: true,
|
|
126
|
+
message: "Auto-complete enabled. PR will merge when all policies pass.",
|
|
127
|
+
merged: false,
|
|
128
|
+
autoMergeEnabled: true,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
133
|
+
return {
|
|
134
|
+
success: false,
|
|
135
|
+
message: `Failed to enable auto-complete: ${message}`,
|
|
136
|
+
merged: false,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (config.mode === "force") {
|
|
141
|
+
// Bypass policies and complete the PR
|
|
142
|
+
const bypassReason = config.bypassReason ?? "Automated config sync via json-config-sync";
|
|
143
|
+
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();
|
|
144
|
+
try {
|
|
145
|
+
await withRetry(() => this.executor.exec(command, workDir), {
|
|
146
|
+
retries,
|
|
147
|
+
});
|
|
148
|
+
return {
|
|
149
|
+
success: true,
|
|
150
|
+
message: "PR completed by bypassing policies.",
|
|
151
|
+
merged: true,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
156
|
+
return {
|
|
157
|
+
success: false,
|
|
158
|
+
message: `Failed to bypass policies and complete PR: ${message}`,
|
|
159
|
+
merged: false,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
success: false,
|
|
165
|
+
message: `Unknown merge mode: ${config.mode}`,
|
|
166
|
+
merged: false,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
78
169
|
}
|
|
@@ -1,6 +1,16 @@
|
|
|
1
|
+
import { GitHubRepoInfo } from "../repo-detector.js";
|
|
1
2
|
import { PRResult } from "../pr-creator.js";
|
|
2
|
-
import { BasePRStrategy, PRStrategyOptions } from "./pr-strategy.js";
|
|
3
|
+
import { BasePRStrategy, PRStrategyOptions, MergeOptions, MergeResult } from "./pr-strategy.js";
|
|
3
4
|
export declare class GitHubPRStrategy extends BasePRStrategy {
|
|
4
5
|
checkExistingPR(options: PRStrategyOptions): Promise<string | null>;
|
|
5
6
|
create(options: PRStrategyOptions): Promise<PRResult>;
|
|
7
|
+
/**
|
|
8
|
+
* Check if auto-merge is enabled on the repository.
|
|
9
|
+
*/
|
|
10
|
+
checkAutoMergeEnabled(repoInfo: GitHubRepoInfo, workDir: string, retries?: number): Promise<boolean>;
|
|
11
|
+
/**
|
|
12
|
+
* Build merge strategy flag for gh pr merge command.
|
|
13
|
+
*/
|
|
14
|
+
private getMergeStrategyFlag;
|
|
15
|
+
merge(options: MergeOptions): Promise<MergeResult>;
|
|
6
16
|
}
|
|
@@ -2,7 +2,7 @@ import { existsSync, writeFileSync, unlinkSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { escapeShellArg } from "../shell-utils.js";
|
|
4
4
|
import { isGitHubRepo } from "../repo-detector.js";
|
|
5
|
-
import { BasePRStrategy } from "./pr-strategy.js";
|
|
5
|
+
import { BasePRStrategy, } from "./pr-strategy.js";
|
|
6
6
|
import { logger } from "../logger.js";
|
|
7
7
|
import { withRetry, isPermanentError } from "../retry-utils.js";
|
|
8
8
|
export class GitHubPRStrategy extends BasePRStrategy {
|
|
@@ -62,4 +62,118 @@ export class GitHubPRStrategy extends BasePRStrategy {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Check if auto-merge is enabled on the repository.
|
|
67
|
+
*/
|
|
68
|
+
async checkAutoMergeEnabled(repoInfo, workDir, retries = 3) {
|
|
69
|
+
const command = `gh api repos/${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)} --jq '.allow_auto_merge // false'`;
|
|
70
|
+
try {
|
|
71
|
+
const result = await withRetry(() => this.executor.exec(command, workDir), { retries });
|
|
72
|
+
return result.trim() === "true";
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
// If we can't check, assume auto-merge is not enabled
|
|
76
|
+
logger.info(`Warning: Could not check auto-merge status: ${error instanceof Error ? error.message : String(error)}`);
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Build merge strategy flag for gh pr merge command.
|
|
82
|
+
*/
|
|
83
|
+
getMergeStrategyFlag(strategy) {
|
|
84
|
+
switch (strategy) {
|
|
85
|
+
case "squash":
|
|
86
|
+
return "--squash";
|
|
87
|
+
case "rebase":
|
|
88
|
+
return "--rebase";
|
|
89
|
+
case "merge":
|
|
90
|
+
default:
|
|
91
|
+
return "--merge";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async merge(options) {
|
|
95
|
+
const { prUrl, config, workDir, retries = 3 } = options;
|
|
96
|
+
// Manual mode: do nothing
|
|
97
|
+
if (config.mode === "manual") {
|
|
98
|
+
return {
|
|
99
|
+
success: true,
|
|
100
|
+
message: "PR left open for manual review",
|
|
101
|
+
merged: false,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const strategyFlag = this.getMergeStrategyFlag(config.strategy);
|
|
105
|
+
const deleteBranchFlag = config.deleteBranch ? "--delete-branch" : "";
|
|
106
|
+
if (config.mode === "auto") {
|
|
107
|
+
// Check if auto-merge is enabled on the repo
|
|
108
|
+
// Extract owner/repo from PR URL
|
|
109
|
+
const match = prUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
|
|
110
|
+
if (match) {
|
|
111
|
+
const repoInfo = {
|
|
112
|
+
type: "github",
|
|
113
|
+
gitUrl: prUrl,
|
|
114
|
+
owner: match[1],
|
|
115
|
+
repo: match[2],
|
|
116
|
+
};
|
|
117
|
+
const autoMergeEnabled = await this.checkAutoMergeEnabled(repoInfo, workDir, retries);
|
|
118
|
+
if (!autoMergeEnabled) {
|
|
119
|
+
logger.info(`Warning: Auto-merge not enabled for '${repoInfo.owner}/${repoInfo.repo}'. PR left open for manual review.`);
|
|
120
|
+
logger.info(`To enable: gh repo edit ${repoInfo.owner}/${repoInfo.repo} --enable-auto-merge (requires admin)`);
|
|
121
|
+
return {
|
|
122
|
+
success: true,
|
|
123
|
+
message: `Auto-merge not enabled for repository. PR left open for manual review.`,
|
|
124
|
+
merged: false,
|
|
125
|
+
autoMergeEnabled: false,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Enable auto-merge
|
|
130
|
+
const command = `gh pr merge ${escapeShellArg(prUrl)} --auto ${strategyFlag} ${deleteBranchFlag}`.trim();
|
|
131
|
+
try {
|
|
132
|
+
await withRetry(() => this.executor.exec(command, workDir), {
|
|
133
|
+
retries,
|
|
134
|
+
});
|
|
135
|
+
return {
|
|
136
|
+
success: true,
|
|
137
|
+
message: "Auto-merge enabled. PR will merge when checks pass.",
|
|
138
|
+
merged: false,
|
|
139
|
+
autoMergeEnabled: true,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
144
|
+
return {
|
|
145
|
+
success: false,
|
|
146
|
+
message: `Failed to enable auto-merge: ${message}`,
|
|
147
|
+
merged: false,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (config.mode === "force") {
|
|
152
|
+
// Force merge using admin privileges
|
|
153
|
+
const command = `gh pr merge ${escapeShellArg(prUrl)} --admin ${strategyFlag} ${deleteBranchFlag}`.trim();
|
|
154
|
+
try {
|
|
155
|
+
await withRetry(() => this.executor.exec(command, workDir), {
|
|
156
|
+
retries,
|
|
157
|
+
});
|
|
158
|
+
return {
|
|
159
|
+
success: true,
|
|
160
|
+
message: "PR merged successfully using admin privileges.",
|
|
161
|
+
merged: true,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
166
|
+
return {
|
|
167
|
+
success: false,
|
|
168
|
+
message: `Failed to force merge: ${message}`,
|
|
169
|
+
merged: false,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
success: false,
|
|
175
|
+
message: `Unknown merge mode: ${config.mode}`,
|
|
176
|
+
merged: false,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
65
179
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { RepoInfo } from "../repo-detector.js";
|
|
2
2
|
import type { PRStrategy } from "./pr-strategy.js";
|
|
3
|
-
export type { PRStrategy, PRStrategyOptions } from "./pr-strategy.js";
|
|
3
|
+
export type { PRStrategy, PRStrategyOptions, PRMergeConfig, MergeOptions, MergeResult, } from "./pr-strategy.js";
|
|
4
4
|
export { BasePRStrategy, PRWorkflowExecutor } from "./pr-strategy.js";
|
|
5
5
|
export { GitHubPRStrategy } from "./github-pr-strategy.js";
|
|
6
6
|
export { AzurePRStrategy } from "./azure-pr-strategy.js";
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import { PRResult } from "../pr-creator.js";
|
|
2
2
|
import { RepoInfo } from "../repo-detector.js";
|
|
3
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
|
+
}
|
|
4
17
|
export interface PRStrategyOptions {
|
|
5
18
|
repoInfo: RepoInfo;
|
|
6
19
|
title: string;
|
|
@@ -11,9 +24,15 @@ export interface PRStrategyOptions {
|
|
|
11
24
|
/** Number of retries for API operations (default: 3) */
|
|
12
25
|
retries?: number;
|
|
13
26
|
}
|
|
27
|
+
export interface MergeOptions {
|
|
28
|
+
prUrl: string;
|
|
29
|
+
config: PRMergeConfig;
|
|
30
|
+
workDir: string;
|
|
31
|
+
retries?: number;
|
|
32
|
+
}
|
|
14
33
|
/**
|
|
15
34
|
* Interface for PR creation strategies (platform-specific implementations).
|
|
16
|
-
* Strategies focus on platform-specific logic (checkExistingPR, create).
|
|
35
|
+
* Strategies focus on platform-specific logic (checkExistingPR, create, merge).
|
|
17
36
|
* Use PRWorkflowExecutor for full workflow orchestration with error handling.
|
|
18
37
|
*/
|
|
19
38
|
export interface PRStrategy {
|
|
@@ -27,6 +46,11 @@ export interface PRStrategy {
|
|
|
27
46
|
* @returns Result with URL and status
|
|
28
47
|
*/
|
|
29
48
|
create(options: PRStrategyOptions): Promise<PRResult>;
|
|
49
|
+
/**
|
|
50
|
+
* Merge or enable auto-merge for a PR
|
|
51
|
+
* @returns Result with merge status
|
|
52
|
+
*/
|
|
53
|
+
merge(options: MergeOptions): Promise<MergeResult>;
|
|
30
54
|
/**
|
|
31
55
|
* Execute the full PR creation workflow
|
|
32
56
|
* @deprecated Use PRWorkflowExecutor.execute() for better SRP
|
|
@@ -39,6 +63,7 @@ export declare abstract class BasePRStrategy implements PRStrategy {
|
|
|
39
63
|
constructor(executor?: CommandExecutor);
|
|
40
64
|
abstract checkExistingPR(options: PRStrategyOptions): Promise<string | null>;
|
|
41
65
|
abstract create(options: PRStrategyOptions): Promise<PRResult>;
|
|
66
|
+
abstract merge(options: MergeOptions): Promise<MergeResult>;
|
|
42
67
|
/**
|
|
43
68
|
* Execute the full PR creation workflow:
|
|
44
69
|
* 1. Check for existing PR
|
package/package.json
CHANGED