@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 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 | Alias | Description | Required |
139
- | ------------ | ----- | ----------------------------------------------------- | -------- |
140
- | `--config` | `-c` | Path to YAML config file | Yes |
141
- | `--dry-run` | `-d` | Show what would be done without making changes | No |
142
- | `--work-dir` | `-w` | Temporary directory for cloning (default: `./tmp`) | No |
143
- | `--retries` | `-r` | Number of retries for network operations (default: 3) | No |
144
- | `--branch` | `-b` | Override branch name (default: `chore/sync-config`) | No |
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 | Description | Required |
168
- | ------- | ---------------------------------- | -------- |
169
- | `files` | Map of target filenames to configs | Yes |
170
- | `repos` | Array of repository configurations | Yes |
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 | Description | Required |
186
- | ------- | ---------------------------------- | -------- |
187
- | `git` | Git URL (string) or array of URLs | Yes |
188
- | `files` | Per-repo file overrides (optional) | No |
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
- logger.success(current, repoName, result.prUrl ? `PR: ${result.prUrl}` : result.message);
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);
@@ -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>;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/json-config-sync",
3
- "version": "3.7.0",
3
+ "version": "3.8.0",
4
4
  "description": "CLI tool to sync JSON or YAML configuration files across multiple GitHub and Azure DevOps repositories",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",