@aspruyt/xfg 4.0.2 → 4.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/dist/cli/index.d.ts +1 -1
  2. package/dist/cli/index.js +0 -6
  3. package/dist/cli/program.js +3 -2
  4. package/dist/cli/settings-report-builder.js +4 -4
  5. package/dist/cli/sync-command.js +72 -36
  6. package/dist/cli/sync-report-builder.d.ts +2 -6
  7. package/dist/cli/types.d.ts +2 -14
  8. package/dist/cli/types.js +1 -9
  9. package/dist/config/file-reference-resolver.js +13 -23
  10. package/dist/config/formatter.d.ts +0 -6
  11. package/dist/config/formatter.js +0 -9
  12. package/dist/config/index.d.ts +1 -2
  13. package/dist/config/index.js +0 -2
  14. package/dist/config/loader.d.ts +1 -1
  15. package/dist/config/loader.js +3 -3
  16. package/dist/config/normalizer.d.ts +1 -1
  17. package/dist/config/normalizer.js +44 -57
  18. package/dist/config/validator.d.ts +1 -1
  19. package/dist/config/validator.js +120 -121
  20. package/dist/config/validators/file-validator.d.ts +2 -4
  21. package/dist/config/validators/file-validator.js +3 -7
  22. package/dist/config/validators/repo-settings-validator.js +1 -1
  23. package/dist/config/validators/ruleset-validator.js +28 -12
  24. package/dist/index.d.ts +3 -1
  25. package/dist/index.js +0 -1
  26. package/dist/lifecycle/ado-migration-source.d.ts +2 -1
  27. package/dist/lifecycle/ado-migration-source.js +7 -5
  28. package/dist/lifecycle/github-lifecycle-provider.d.ts +6 -3
  29. package/dist/lifecycle/github-lifecycle-provider.js +29 -19
  30. package/dist/lifecycle/lifecycle-formatter.js +2 -1
  31. package/dist/lifecycle/lifecycle-helpers.d.ts +5 -1
  32. package/dist/lifecycle/lifecycle-helpers.js +4 -4
  33. package/dist/lifecycle/repo-lifecycle-factory.d.ts +4 -4
  34. package/dist/lifecycle/repo-lifecycle-factory.js +12 -9
  35. package/dist/lifecycle/repo-lifecycle-manager.d.ts +4 -1
  36. package/dist/lifecycle/repo-lifecycle-manager.js +11 -7
  37. package/dist/lifecycle/types.d.ts +0 -15
  38. package/dist/output/github-summary.d.ts +6 -5
  39. package/dist/output/github-summary.js +36 -52
  40. package/dist/output/index.d.ts +2 -2
  41. package/dist/output/index.js +1 -1
  42. package/dist/output/lifecycle-report.d.ts +2 -12
  43. package/dist/output/lifecycle-report.js +18 -35
  44. package/dist/output/settings-report.d.ts +4 -4
  45. package/dist/output/settings-report.js +6 -6
  46. package/dist/output/sync-report.d.ts +4 -6
  47. package/dist/output/sync-report.js +2 -2
  48. package/dist/output/unified-summary.d.ts +1 -0
  49. package/dist/output/unified-summary.js +8 -8
  50. package/dist/settings/base-processor.d.ts +1 -1
  51. package/dist/settings/base-processor.js +1 -1
  52. package/dist/settings/index.d.ts +3 -3
  53. package/dist/settings/index.js +3 -3
  54. package/dist/settings/labels/diff.js +3 -2
  55. package/dist/settings/labels/formatter.js +3 -3
  56. package/dist/settings/labels/github-labels-strategy.d.ts +2 -23
  57. package/dist/settings/labels/github-labels-strategy.js +8 -28
  58. package/dist/settings/labels/index.d.ts +1 -0
  59. package/dist/settings/labels/index.js +2 -0
  60. package/dist/settings/labels/processor.d.ts +2 -2
  61. package/dist/settings/labels/processor.js +3 -4
  62. package/dist/settings/labels/types.d.ts +0 -3
  63. package/dist/settings/repo-settings/diff.d.ts +1 -1
  64. package/dist/settings/repo-settings/diff.js +2 -2
  65. package/dist/settings/repo-settings/formatter.d.ts +1 -1
  66. package/dist/settings/repo-settings/formatter.js +4 -4
  67. package/dist/settings/repo-settings/github-repo-settings-strategy.d.ts +2 -7
  68. package/dist/settings/repo-settings/github-repo-settings-strategy.js +9 -17
  69. package/dist/settings/repo-settings/index.d.ts +1 -0
  70. package/dist/settings/repo-settings/index.js +2 -0
  71. package/dist/settings/repo-settings/processor.d.ts +2 -2
  72. package/dist/settings/repo-settings/processor.js +5 -6
  73. package/dist/settings/repo-settings/types.d.ts +9 -13
  74. package/dist/settings/repo-settings/types.js +1 -14
  75. package/dist/settings/rulesets/diff-algorithm.d.ts +0 -1
  76. package/dist/settings/rulesets/diff-algorithm.js +6 -8
  77. package/dist/settings/rulesets/formatter.js +15 -51
  78. package/dist/settings/rulesets/github-ruleset-strategy.d.ts +2 -20
  79. package/dist/settings/rulesets/github-ruleset-strategy.js +6 -30
  80. package/dist/settings/rulesets/index.d.ts +2 -1
  81. package/dist/settings/rulesets/index.js +3 -1
  82. package/dist/settings/rulesets/processor.d.ts +2 -2
  83. package/dist/settings/rulesets/processor.js +3 -4
  84. package/dist/{vcs → shared}/branch-utils.js +5 -4
  85. package/dist/shared/command-executor.d.ts +2 -1
  86. package/dist/shared/command-executor.js +9 -5
  87. package/dist/shared/env.d.ts +6 -6
  88. package/dist/shared/env.js +10 -17
  89. package/dist/shared/errors.d.ts +26 -0
  90. package/dist/shared/errors.js +34 -0
  91. package/dist/shared/gh-api-utils.d.ts +21 -14
  92. package/dist/shared/gh-api-utils.js +33 -22
  93. package/dist/shared/index.d.ts +9 -2
  94. package/dist/shared/index.js +16 -2
  95. package/dist/shared/logger.d.ts +24 -1
  96. package/dist/shared/logger.js +8 -3
  97. package/dist/shared/repo-detector.js +9 -11
  98. package/dist/shared/retry-utils.d.ts +5 -7
  99. package/dist/shared/retry-utils.js +3 -10
  100. package/dist/shared/shell-utils.d.ts +0 -3
  101. package/dist/shared/shell-utils.js +2 -4
  102. package/dist/shared/type-guards.d.ts +2 -9
  103. package/dist/shared/type-guards.js +0 -6
  104. package/dist/shared/xfg-template.d.ts +2 -2
  105. package/dist/shared/xfg-template.js +2 -1
  106. package/dist/sync/auth-options-builder.d.ts +3 -2
  107. package/dist/sync/auth-options-builder.js +14 -10
  108. package/dist/sync/branch-manager.d.ts +12 -7
  109. package/dist/sync/branch-manager.js +4 -7
  110. package/dist/sync/commit-message.d.ts +1 -1
  111. package/dist/sync/commit-push-manager.d.ts +8 -2
  112. package/dist/sync/commit-push-manager.js +6 -5
  113. package/dist/sync/file-sync-orchestrator.js +17 -21
  114. package/dist/sync/file-writer.js +3 -5
  115. package/dist/sync/index.d.ts +1 -1
  116. package/dist/sync/manifest-manager.d.ts +1 -0
  117. package/dist/sync/manifest.d.ts +4 -7
  118. package/dist/sync/manifest.js +42 -45
  119. package/dist/sync/repository-processor.d.ts +5 -2
  120. package/dist/sync/repository-processor.js +11 -17
  121. package/dist/sync/repository-session.js +2 -1
  122. package/dist/sync/sync-workflow.d.ts +2 -2
  123. package/dist/sync/sync-workflow.js +16 -23
  124. package/dist/sync/types.d.ts +20 -25
  125. package/dist/vcs/authenticated-git-ops.d.ts +3 -4
  126. package/dist/vcs/authenticated-git-ops.js +5 -1
  127. package/dist/vcs/azure-pr-strategy.d.ts +6 -1
  128. package/dist/vcs/azure-pr-strategy.js +38 -31
  129. package/dist/vcs/commit-strategy-selector.d.ts +10 -19
  130. package/dist/vcs/commit-strategy-selector.js +8 -24
  131. package/dist/vcs/git-commit-strategy.d.ts +1 -1
  132. package/dist/vcs/git-commit-strategy.js +1 -3
  133. package/dist/vcs/git-ops.d.ts +4 -8
  134. package/dist/vcs/git-ops.js +18 -22
  135. package/dist/vcs/github-app-token-manager.js +9 -8
  136. package/dist/vcs/github-pr-strategy.js +18 -11
  137. package/dist/vcs/gitlab-pr-strategy.d.ts +1 -1
  138. package/dist/vcs/gitlab-pr-strategy.js +14 -7
  139. package/dist/vcs/graphql-commit-strategy.d.ts +1 -7
  140. package/dist/vcs/graphql-commit-strategy.js +24 -32
  141. package/dist/vcs/index.d.ts +2 -1
  142. package/dist/vcs/pr-creator.d.ts +6 -9
  143. package/dist/vcs/pr-strategy-factory.d.ts +1 -1
  144. package/dist/vcs/pr-strategy-factory.js +2 -1
  145. package/dist/vcs/pr-strategy.d.ts +1 -1
  146. package/dist/vcs/pr-strategy.js +2 -3
  147. package/dist/vcs/types.d.ts +6 -10
  148. package/package.json +2 -2
  149. package/dist/config/errors.d.ts +0 -9
  150. package/dist/config/errors.js +0 -11
  151. /package/dist/{vcs → shared}/branch-utils.d.ts +0 -0
@@ -1,8 +1,8 @@
1
- import { rmSync, existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, } from "node:fs";
1
+ import { rmSync, existsSync, statSync, mkdirSync, writeFileSync, readFileSync, chmodSync, } from "node:fs";
2
2
  import { join, resolve, relative, isAbsolute, dirname } from "node:path";
3
3
  import { escapeShellArg } from "../shared/shell-utils.js";
4
- import { defaultExecutor, } from "../shared/command-executor.js";
5
4
  import { toErrorMessage } from "../shared/type-guards.js";
5
+ import { ValidationError, SyncError } from "../shared/errors.js";
6
6
  export class GitOps {
7
7
  _workDir;
8
8
  dryRun;
@@ -11,7 +11,7 @@ export class GitOps {
11
11
  constructor(options) {
12
12
  this._workDir = options.workDir;
13
13
  this.dryRun = options.dryRun ?? false;
14
- this._executor = options.executor ?? defaultExecutor;
14
+ this._executor = options.executor;
15
15
  this.log = options.log;
16
16
  }
17
17
  async exec(command, cwd) {
@@ -28,7 +28,7 @@ export class GitOps {
28
28
  const resolvedWorkDir = resolve(this._workDir);
29
29
  const relativePath = relative(resolvedWorkDir, resolvedPath);
30
30
  if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
31
- throw new Error(`Path traversal detected: ${fileName}`);
31
+ throw new ValidationError(`Path traversal detected: ${fileName}`);
32
32
  }
33
33
  return filePath;
34
34
  }
@@ -49,7 +49,7 @@ export class GitOps {
49
49
  }
50
50
  catch (error) {
51
51
  const message = toErrorMessage(error);
52
- throw new Error(`Failed to create branch '${branchName}': ${message}`);
52
+ throw new SyncError(`Failed to create branch '${branchName}': ${message}`);
53
53
  }
54
54
  }
55
55
  writeFile(fileName, content) {
@@ -86,14 +86,18 @@ export class GitOps {
86
86
  */
87
87
  getFileContent(fileName) {
88
88
  const filePath = this.validatePath(fileName);
89
- if (!existsSync(filePath)) {
90
- return null;
91
- }
92
89
  try {
90
+ if (!existsSync(filePath) || !statSync(filePath).isFile()) {
91
+ return null;
92
+ }
93
93
  return readFileSync(filePath, "utf-8");
94
94
  }
95
95
  catch (error) {
96
- this.log?.debug(`Failed to read ${fileName}: ${toErrorMessage(error)}`);
96
+ const code = error.code;
97
+ if (code === "ENOENT" || code === "EACCES") {
98
+ return null;
99
+ }
100
+ this.log?.debug(`Unexpected error reading ${fileName}: ${error.message}`);
97
101
  return null;
98
102
  }
99
103
  }
@@ -136,20 +140,12 @@ export class GitOps {
136
140
  .filter((line) => line.length > 0)
137
141
  .map((line) => line.slice(3)); // Remove status prefix (e.g., " M ", "?? ", "A ")
138
142
  }
139
- /**
140
- * Check if there are staged changes ready to commit.
141
- * Uses `git diff --cached --quiet` which exits with 1 if there are staged changes.
142
- */
143
+ async stageAll() {
144
+ await this.exec("git add -A", this._workDir);
145
+ }
143
146
  async hasStagedChanges() {
144
- try {
145
- await this.exec("git diff --cached --quiet", this._workDir);
146
- return false; // Exit code 0 = no staged changes
147
- }
148
- catch (error) {
149
- // Exit code 1 is expected when staged changes exist
150
- this.log?.debug(`hasStagedChanges: ${toErrorMessage(error)}`);
151
- return true;
152
- }
147
+ const diff = await this.exec("git diff --cached --name-only", this._workDir);
148
+ return diff.length > 0;
153
149
  }
154
150
  /**
155
151
  * Check if a file exists on a specific branch.
@@ -1,7 +1,14 @@
1
1
  import { createSign } from "node:crypto";
2
2
  import { withRetry } from "../shared/retry-utils.js";
3
+ import { SyncError } from "../shared/errors.js";
3
4
  /** Duration to cache tokens (45 minutes in milliseconds) */
4
5
  const TOKEN_CACHE_DURATION_MS = 45 * 60 * 1000;
6
+ async function assertOkResponse(res, context) {
7
+ if (!res.ok) {
8
+ const body = await res.text().catch(() => "");
9
+ throw new SyncError(`${context}: ${res.status}${body ? ` - ${body}` : ""}`);
10
+ }
11
+ }
5
12
  /**
6
13
  * Manages GitHub App authentication tokens for multiple organizations.
7
14
  * Handles JWT generation, installation discovery, and token caching.
@@ -59,10 +66,7 @@ export class GitHubAppTokenManager {
59
66
  "X-GitHub-Api-Version": "2022-11-28",
60
67
  },
61
68
  });
62
- if (!res.ok) {
63
- const body = await res.text().catch(() => "");
64
- throw new Error(`GitHub API error: ${res.status}${body ? ` - ${body}` : ""}`);
65
- }
69
+ await assertOkResponse(res, "GitHub App installations");
66
70
  return res;
67
71
  });
68
72
  const installations = (await response.json());
@@ -104,10 +108,7 @@ export class GitHubAppTokenManager {
104
108
  "X-GitHub-Api-Version": "2022-11-28",
105
109
  },
106
110
  });
107
- if (!res.ok) {
108
- const body = await res.text().catch(() => "");
109
- throw new Error(`GitHub API error: ${res.status}${body ? ` - ${body}` : ""}`);
110
- }
111
+ await assertOkResponse(res, "GitHub App access token");
111
112
  return res;
112
113
  });
113
114
  const tokenResponse = (await response.json());
@@ -3,17 +3,19 @@ import { join } from "node:path";
3
3
  import { escapeShellArg, escapeRegExp } from "../shared/shell-utils.js";
4
4
  import { assertGitHubRepo } from "../shared/repo-detector.js";
5
5
  import { BasePRStrategy } from "./pr-strategy.js";
6
- import { withRetry } from "../shared/retry-utils.js";
6
+ import { withRetry, isPermanentError } from "../shared/retry-utils.js";
7
7
  import { sanitizeCredentials } from "../shared/sanitize-utils.js";
8
8
  import { toErrorMessage, safeCleanup } from "../shared/type-guards.js";
9
+ import { NO_OP_DEBUG_LOG } from "../shared/logger.js";
9
10
  import { getStderr } from "../shared/command-executor.js";
10
11
  import { buildTokenEnv, getHostnameFlag } from "../shared/gh-api-utils.js";
12
+ import { SyncError } from "../shared/errors.js";
11
13
  /**
12
14
  * Get the repo flag value for gh CLI commands.
13
15
  * Returns HOST/OWNER/REPO for GHE, OWNER/REPO for github.com.
14
16
  */
15
17
  function getRepoFlag(repoInfo) {
16
- if (repoInfo.host && repoInfo.host !== "github.com") {
18
+ if (repoInfo.host !== "github.com") {
17
19
  return `${repoInfo.host}/${repoInfo.owner}/${repoInfo.repo}`;
18
20
  }
19
21
  return `${repoInfo.owner}/${repoInfo.repo}`;
@@ -33,10 +35,13 @@ export class GitHubPRStrategy extends BasePRStrategy {
33
35
  const tokenEnv = buildTokenEnv(token);
34
36
  const command = `gh pr list --repo ${escapeShellArg(repoFlag)} --head ${escapeShellArg(branchName)} --json url --jq '.[0].url'`;
35
37
  try {
36
- const existingPR = await withRetry(() => this.executor.exec(command, workDir, { env: tokenEnv }), { retries });
38
+ const existingPR = await withRetry(() => this.executor.exec(command, workDir, { env: tokenEnv }), { retries, log: this.log });
37
39
  return existingPR || null;
38
40
  }
39
41
  catch (error) {
42
+ if (isPermanentError(error)) {
43
+ throw error;
44
+ }
40
45
  const stderr = getStderr(error);
41
46
  if (stderr && !stderr.includes("no pull requests match")) {
42
47
  this.log?.debug(`GitHub PR check failed - ${sanitizeCredentials(stderr).trim()}`);
@@ -69,7 +74,7 @@ export class GitHubPRStrategy extends BasePRStrategy {
69
74
  const tokenEnv = buildTokenEnv(token);
70
75
  const command = `gh pr close ${escapeShellArg(prNumber)} --repo ${escapeShellArg(repoFlag)} --delete-branch`;
71
76
  try {
72
- await withRetry(() => this.executor.exec(command, workDir, { env: tokenEnv }), { retries });
77
+ await withRetry(() => this.executor.exec(command, workDir, { env: tokenEnv }), { retries, log: this.log });
73
78
  return true;
74
79
  }
75
80
  catch (error) {
@@ -92,13 +97,13 @@ export class GitHubPRStrategy extends BasePRStrategy {
92
97
  }
93
98
  }
94
99
  try {
95
- const result = await withRetry(() => this.executor.exec(command, workDir, { env: tokenEnv }), { retries });
100
+ const result = await withRetry(() => this.executor.exec(command, workDir, { env: tokenEnv }), { retries, log: this.log });
96
101
  // Extract URL from output - use strict regex for valid PR URLs only
97
102
  const host = repoInfo.host;
98
103
  const urlRegex = buildPRUrlRegex(host);
99
104
  const urlMatch = result.match(urlRegex);
100
105
  if (!urlMatch) {
101
- throw new Error(`Could not parse PR URL from output: ${result}`);
106
+ throw new SyncError(`Could not parse PR URL from output: ${result}`);
102
107
  }
103
108
  return {
104
109
  url: urlMatch[0],
@@ -110,7 +115,7 @@ export class GitHubPRStrategy extends BasePRStrategy {
110
115
  safeCleanup(() => {
111
116
  if (existsSync(bodyFile))
112
117
  unlinkSync(bodyFile);
113
- }, `failed to remove ${bodyFile}`, this.log ?? { debug() { } });
118
+ }, `failed to remove ${bodyFile}`, this.log ?? NO_OP_DEBUG_LOG);
114
119
  }
115
120
  }
116
121
  /**
@@ -122,7 +127,7 @@ export class GitHubPRStrategy extends BasePRStrategy {
122
127
  const tokenEnv = buildTokenEnv(token);
123
128
  const command = `gh api ${hostnamePart}repos/${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)} --jq '.allow_auto_merge // false'`;
124
129
  try {
125
- const result = await withRetry(() => this.executor.exec(command, workDir, { env: tokenEnv }), { retries });
130
+ const result = await withRetry(() => this.executor.exec(command, workDir, { env: tokenEnv }), { retries, log: this.log });
126
131
  return result.trim() === "true";
127
132
  }
128
133
  catch (error) {
@@ -147,7 +152,6 @@ export class GitHubPRStrategy extends BasePRStrategy {
147
152
  }
148
153
  async merge(options) {
149
154
  const { prUrl, repoInfo, config, workDir, retries = 3, token } = options;
150
- // Manual mode: do nothing
151
155
  if (config.mode === "manual") {
152
156
  return {
153
157
  success: true,
@@ -182,7 +186,7 @@ export class GitHubPRStrategy extends BasePRStrategy {
182
186
  }, "Failed to enable auto-merge");
183
187
  }
184
188
  if (config.mode === "force") {
185
- // Force merge using admin privileges
189
+ this.log?.warn(`Force-merging PR ${prUrl} using admin privileges (bypasses branch protection)`);
186
190
  const forceCommand = `gh pr merge ${escapeShellArg(prUrl)} --admin ${strategyFlag} ${deleteBranchFlag}`.trim();
187
191
  return this.executeMergeCommand(() => this.executor.exec(forceCommand, workDir, { env: tokenEnv }), retries, {
188
192
  success: true,
@@ -190,9 +194,12 @@ export class GitHubPRStrategy extends BasePRStrategy {
190
194
  merged: true,
191
195
  }, "Failed to force merge");
192
196
  }
197
+ // "direct" mode doesn't create PRs, so merge() should not be called for it.
198
+ // This is a defensive fallback for type safety.
199
+ const _exhaustive = config.mode;
193
200
  return {
194
201
  success: false,
195
- message: `Unknown merge mode: ${config.mode}`,
202
+ message: `Merge not applicable for mode: ${_exhaustive}`,
196
203
  merged: false,
197
204
  };
198
205
  }
@@ -4,7 +4,7 @@ import type { IPRStrategyLogger } from "./pr-strategy.js";
4
4
  import type { PRStrategyOptions, CloseExistingPROptions, MergeOptions, MergeResult } from "./types.js";
5
5
  import { ICommandExecutor } from "../shared/command-executor.js";
6
6
  export declare class GitLabPRStrategy extends BasePRStrategy {
7
- constructor(executor?: ICommandExecutor, log?: IPRStrategyLogger);
7
+ constructor(executor: ICommandExecutor, log?: IPRStrategyLogger);
8
8
  /**
9
9
  * Build the repo flag for glab commands.
10
10
  * Format: namespace/repo (supports nested groups)
@@ -3,11 +3,13 @@ import { join } from "node:path";
3
3
  import { escapeShellArg } from "../shared/shell-utils.js";
4
4
  import { assertGitLabRepo } from "../shared/repo-detector.js";
5
5
  import { BasePRStrategy } from "./pr-strategy.js";
6
- import { withRetry } from "../shared/retry-utils.js";
6
+ import { withRetry, isPermanentError } from "../shared/retry-utils.js";
7
7
  import { getStderr } from "../shared/command-executor.js";
8
8
  import { parseApiJson } from "../shared/gh-api-utils.js";
9
9
  import { sanitizeCredentials } from "../shared/sanitize-utils.js";
10
10
  import { toErrorMessage, safeCleanup } from "../shared/type-guards.js";
11
+ import { NO_OP_DEBUG_LOG } from "../shared/logger.js";
12
+ import { SyncError } from "../shared/errors.js";
11
13
  export class GitLabPRStrategy extends BasePRStrategy {
12
14
  constructor(executor, log) {
13
15
  super(executor, log);
@@ -70,7 +72,7 @@ export class GitLabPRStrategy extends BasePRStrategy {
70
72
  // Note: glab mr list returns open MRs by default (use -c for closed, -M for merged)
71
73
  const command = `glab mr list --source-branch ${escapeShellArg(branchName)} -R ${escapeShellArg(repoFlag)} -F json`;
72
74
  try {
73
- const result = await withRetry(() => this.executor.exec(command, workDir), { retries });
75
+ const result = await withRetry(() => this.executor.exec(command, workDir), { retries, log: this.log });
74
76
  if (!result || result.trim() === "" || result.trim() === "[]") {
75
77
  return null;
76
78
  }
@@ -81,6 +83,9 @@ export class GitLabPRStrategy extends BasePRStrategy {
81
83
  return null;
82
84
  }
83
85
  catch (error) {
86
+ if (isPermanentError(error)) {
87
+ throw error;
88
+ }
84
89
  const stderr = getStderr(error);
85
90
  if (stderr && !stderr.includes("no merge requests")) {
86
91
  this.log?.debug(`GitLab MR check failed - ${sanitizeCredentials(stderr).trim()}`);
@@ -114,6 +119,7 @@ export class GitLabPRStrategy extends BasePRStrategy {
114
119
  try {
115
120
  await withRetry(() => this.executor.exec(closeCommand, workDir), {
116
121
  retries,
122
+ log: this.log,
117
123
  });
118
124
  }
119
125
  catch (error) {
@@ -125,6 +131,7 @@ export class GitLabPRStrategy extends BasePRStrategy {
125
131
  try {
126
132
  await withRetry(() => this.executor.exec(deleteBranchCommand, workDir), {
127
133
  retries,
134
+ log: this.log,
128
135
  });
129
136
  }
130
137
  catch (error) {
@@ -143,7 +150,7 @@ export class GitLabPRStrategy extends BasePRStrategy {
143
150
  // glab mr create with description from file
144
151
  const command = `glab mr create --source-branch ${escapeShellArg(branchName)} --target-branch ${escapeShellArg(baseBranch)} --title ${escapeShellArg(title)} --description "$(cat ${escapeShellArg(descFile)})" --yes -R ${escapeShellArg(repoFlag)}`;
145
152
  try {
146
- const result = await withRetry(() => this.executor.exec(command, workDir), { retries });
153
+ const result = await withRetry(() => this.executor.exec(command, workDir), { retries, log: this.log });
147
154
  // Extract MR URL from output
148
155
  // glab typically outputs the URL directly
149
156
  const urlMatch = result.match(/https:\/\/[^\s]+\/-\/merge_requests\/\d+/);
@@ -163,18 +170,17 @@ export class GitLabPRStrategy extends BasePRStrategy {
163
170
  message: "MR created successfully",
164
171
  };
165
172
  }
166
- throw new Error(`Could not parse MR URL from output: ${result}`);
173
+ throw new SyncError(`Could not parse MR URL from output: ${result}`);
167
174
  }
168
175
  finally {
169
176
  safeCleanup(() => {
170
177
  if (existsSync(descFile))
171
178
  unlinkSync(descFile);
172
- }, `failed to remove ${descFile}`, this.log ?? { debug() { } });
179
+ }, `failed to remove ${descFile}`, this.log ?? NO_OP_DEBUG_LOG);
173
180
  }
174
181
  }
175
182
  async merge(options) {
176
183
  const { prUrl, config, workDir, retries = 3 } = options;
177
- // Manual mode: do nothing
178
184
  if (config.mode === "manual") {
179
185
  return {
180
186
  success: true,
@@ -221,9 +227,10 @@ export class GitLabPRStrategy extends BasePRStrategy {
221
227
  merged: true,
222
228
  }, "Failed to force merge");
223
229
  }
230
+ const _exhaustive = config.mode;
224
231
  return {
225
232
  success: false,
226
- message: `Unknown merge mode: ${config.mode}`,
233
+ message: `Merge not applicable for mode: ${_exhaustive}`,
227
234
  merged: false,
228
235
  };
229
236
  }
@@ -37,7 +37,7 @@ export declare class GraphQLCommitStrategy implements ICommitStrategy {
37
37
  */
38
38
  private static readonly GRAPHQL_PERMANENT_ERROR_PATTERNS;
39
39
  private executor;
40
- constructor(executor?: ICommandExecutor);
40
+ constructor(executor: ICommandExecutor);
41
41
  /**
42
42
  * Create a commit with the given file changes using GitHub's GraphQL API.
43
43
  * Uses the createCommitOnBranch mutation for verified commits.
@@ -86,12 +86,6 @@ export declare class GraphQLCommitStrategy implements ICommitStrategy {
86
86
  * Returns repositoryId (always) and refId (null if branch doesn't exist).
87
87
  */
88
88
  private queryRemoteRef;
89
- /**
90
- * Create a branch ref on the remote via GraphQL createRef mutation.
91
- */
92
89
  private createRemoteRef;
93
- /**
94
- * Delete a branch ref on the remote via GraphQL deleteRef mutation.
95
- */
96
90
  private deleteRemoteRef;
97
91
  }
@@ -1,9 +1,9 @@
1
- import { defaultExecutor, } from "../shared/command-executor.js";
2
1
  import { isGitHubRepo } from "../shared/repo-detector.js";
3
2
  import { escapeShellArg } from "../shared/shell-utils.js";
4
3
  import { withRetry, CORE_PERMANENT_ERROR_PATTERNS, DEFAULT_PERMANENT_ERROR_PATTERNS, } from "../shared/retry-utils.js";
5
4
  import { toErrorMessage } from "../shared/type-guards.js";
6
- import { parseApiJson } from "../shared/gh-api-utils.js";
5
+ import { parseApiJson, buildTokenEnv } from "../shared/gh-api-utils.js";
6
+ import { ValidationError, GraphQLApiError } from "../shared/errors.js";
7
7
  /**
8
8
  * Maximum payload size for GitHub GraphQL API (50MB).
9
9
  * Base64 encoding adds ~33% overhead, so raw content should be checked.
@@ -27,7 +27,7 @@ export const SAFE_BRANCH_NAME_PATTERN = /^[a-zA-Z0-9][-a-zA-Z0-9_./]*$/;
27
27
  */
28
28
  export function validateSafeBranchName(branchName) {
29
29
  if (!SAFE_BRANCH_NAME_PATTERN.test(branchName)) {
30
- throw new Error(`Invalid branch name for GraphQL commit strategy: "${branchName}". ` +
30
+ throw new ValidationError(`Invalid branch name for GraphQL commit strategy: "${branchName}". ` +
31
31
  `Branch names must start with alphanumeric and contain only ` +
32
32
  `alphanumeric characters, hyphens, underscores, dots, and forward slashes.`);
33
33
  }
@@ -62,7 +62,7 @@ export class GraphQLCommitStrategy {
62
62
  ];
63
63
  executor;
64
64
  constructor(executor) {
65
- this.executor = executor ?? defaultExecutor;
65
+ this.executor = executor;
66
66
  }
67
67
  /**
68
68
  * Create a commit with the given file changes using GitHub's GraphQL API.
@@ -74,10 +74,9 @@ export class GraphQLCommitStrategy {
74
74
  async commit(options) {
75
75
  const { repoInfo, branchName, message, fileChanges, workDir, retries = 3, token, } = options;
76
76
  if (!isGitHubRepo(repoInfo)) {
77
- throw new Error(`GraphQL commit strategy requires GitHub repositories. Got: ${repoInfo.type}`);
77
+ throw new ValidationError(`GraphQL commit strategy requires GitHub repositories. Got: ${repoInfo.type}`);
78
78
  }
79
79
  validateSafeBranchName(branchName);
80
- const githubInfo = repoInfo;
81
80
  const additions = fileChanges.filter((fc) => fc.content !== null);
82
81
  const deletions = fileChanges.filter((fc) => fc.content === null);
83
82
  // Base64 encoding adds ~33% overhead to raw content size
@@ -86,13 +85,13 @@ export class GraphQLCommitStrategy {
86
85
  return sum + base64Size;
87
86
  }, 0);
88
87
  if (totalSize > MAX_PAYLOAD_SIZE) {
89
- throw new Error(`GraphQL payload exceeds 50 MB limit (${Math.round(totalSize / (1024 * 1024))} MB). ` +
88
+ throw new ValidationError(`GraphQL payload exceeds 50 MB limit (${Math.round(totalSize / (1024 * 1024))} MB). ` +
90
89
  `Consider using smaller files or the git commit strategy.`);
91
90
  }
92
91
  const gitOps = options.gitOps;
93
92
  // createCommitOnBranch requires the branch to already exist on remote.
94
93
  // For PR branches (force=true), force-update ensures a fresh start from main.
95
- await this.ensureBranchExistsOnRemote(branchName, workDir, options.force, githubInfo, token);
94
+ await this.ensureBranchExistsOnRemote(branchName, workDir, options.force, repoInfo, token);
96
95
  // Outer retry loop for expectedHeadOid mismatch — each iteration re-fetches
97
96
  // the remote HEAD so the next mutation uses a fresh OID.
98
97
  let lastError = null;
@@ -107,7 +106,7 @@ export class GraphQLCommitStrategy {
107
106
  }
108
107
  // Get the remote HEAD SHA for this branch (not local HEAD)
109
108
  const headSha = await this.executor.exec(`git rev-parse origin/${safeBranch}`, workDir);
110
- const result = await this.executeGraphQLMutation(githubInfo, branchName, message, headSha.trim(), additions, deletions, workDir, token);
109
+ const result = await this.executeGraphQLMutation(repoInfo, branchName, message, headSha.trim(), additions, deletions, workDir, token);
111
110
  return result;
112
111
  }
113
112
  catch (error) {
@@ -119,7 +118,7 @@ export class GraphQLCommitStrategy {
119
118
  throw lastError;
120
119
  }
121
120
  }
122
- throw lastError ?? new Error("Unexpected error in GraphQL commit");
121
+ throw (lastError ?? new GraphQLApiError("Unexpected error in GraphQL commit"));
123
122
  }
124
123
  /**
125
124
  * Execute the createCommitOnBranch GraphQL mutation.
@@ -161,7 +160,7 @@ export class GraphQLCommitStrategy {
161
160
  const hostnameArg = repoInfo.host !== "github.com"
162
161
  ? `--hostname ${escapeShellArg(repoInfo.host)}`
163
162
  : "";
164
- const tokenEnv = token ? { GH_TOKEN: token } : undefined;
163
+ const tokenEnv = buildTokenEnv(token);
165
164
  const command = `echo ${escapeShellArg(requestBody)} | gh api graphql ${hostnameArg} --input -`;
166
165
  let response;
167
166
  try {
@@ -177,14 +176,11 @@ export class GraphQLCommitStrategy {
177
176
  }
178
177
  const parsed = parseApiJson(response, "GraphQL createCommitOnBranch response");
179
178
  if (parsed.errors) {
180
- const errors = parsed.errors;
181
- throw new Error(`GraphQL error: ${errors.map((e) => e.message).join(", ")}`);
179
+ throw new GraphQLApiError(parsed.errors.map((e) => e.message).join(", "));
182
180
  }
183
- const data = parsed.data;
184
- const commit = data?.createCommitOnBranch?.commit;
185
- const oid = commit?.oid;
181
+ const oid = parsed.data?.createCommitOnBranch?.commit?.oid;
186
182
  if (!oid) {
187
- throw new Error("GraphQL response missing commit OID");
183
+ throw new GraphQLApiError("Response missing commit OID");
188
184
  }
189
185
  return {
190
186
  sha: oid,
@@ -206,7 +202,7 @@ export class GraphQLCommitStrategy {
206
202
  */
207
203
  async ensureBranchExistsOnRemote(branchName, workDir, force, repoInfo, token) {
208
204
  if (!repoInfo) {
209
- throw new Error("repoInfo is required for GraphQL ref operations");
205
+ throw new GraphQLApiError("repoInfo is required for ref operations");
210
206
  }
211
207
  const { repositoryId, refId } = await this.queryRemoteRef(repoInfo, branchName, workDir, token);
212
208
  if (refId && force) {
@@ -258,7 +254,7 @@ export class GraphQLCommitStrategy {
258
254
  if (cleanMessage.length > 2000) {
259
255
  cleanMessage = cleanMessage.substring(0, 2000) + "... (truncated)";
260
256
  }
261
- return new Error(`GraphQL commit failed for ${repo}: ${cleanMessage}`);
257
+ return new GraphQLApiError(`Commit failed for ${repo}: ${cleanMessage}`);
262
258
  }
263
259
  /**
264
260
  * Check if an error is due to expectedHeadOid mismatch (optimistic locking failure).
@@ -278,7 +274,7 @@ export class GraphQLCommitStrategy {
278
274
  const hostnameArg = repoInfo.host !== "github.com"
279
275
  ? `--hostname ${escapeShellArg(repoInfo.host)}`
280
276
  : "";
281
- const tokenEnv = token ? { GH_TOKEN: token } : undefined;
277
+ const tokenEnv = buildTokenEnv(token);
282
278
  const command = `echo ${escapeShellArg(requestBody)} | gh api graphql ${hostnameArg} --input -`;
283
279
  let response;
284
280
  try {
@@ -291,7 +287,7 @@ export class GraphQLCommitStrategy {
291
287
  }
292
288
  const parsed = parseApiJson(response, "GraphQL API response");
293
289
  if (parsed.errors) {
294
- throw new Error(`GraphQL error: ${parsed.errors.map((e) => e.message).join(", ")}`);
290
+ throw new GraphQLApiError(parsed.errors.map((e) => e.message).join(", "));
295
291
  }
296
292
  return parsed;
297
293
  }
@@ -301,24 +297,20 @@ export class GraphQLCommitStrategy {
301
297
  */
302
298
  async queryRemoteRef(repoInfo, branchName, workDir, token) {
303
299
  const query = `{ repository(owner: ${JSON.stringify(repoInfo.owner)}, name: ${JSON.stringify(repoInfo.repo)}) { id ref(qualifiedName: ${JSON.stringify(`refs/heads/${branchName}`)}) { id } } }`;
304
- const parsed = await this.executeGraphQLRefOp(query, repoInfo, workDir, token);
305
- const repo = parsed.data?.repository;
306
- const repositoryId = repo?.id;
300
+ const repoResponse = await this.executeGraphQLRefOp(query, repoInfo, workDir, token);
301
+ const repositoryId = repoResponse.data?.repository?.id;
307
302
  if (!repositoryId) {
308
- throw new Error(`GraphQL response missing repository ID for ${repoInfo.owner}/${repoInfo.repo}`);
303
+ throw new GraphQLApiError(`Response missing repository ID for ${repoInfo.owner}/${repoInfo.repo}`);
309
304
  }
310
- return { repositoryId, refId: repo?.ref?.id ?? null };
305
+ return {
306
+ repositoryId,
307
+ refId: repoResponse.data?.repository?.ref?.id ?? null,
308
+ };
311
309
  }
312
- /**
313
- * Create a branch ref on the remote via GraphQL createRef mutation.
314
- */
315
310
  async createRemoteRef(repositoryId, branchName, oid, workDir, repoInfo, token) {
316
311
  const mutation = `mutation { createRef(input: { repositoryId: ${JSON.stringify(repositoryId)}, name: ${JSON.stringify(`refs/heads/${branchName}`)}, oid: ${JSON.stringify(oid)} }) { clientMutationId } }`;
317
312
  await this.executeGraphQLRefOp(mutation, repoInfo, workDir, token);
318
313
  }
319
- /**
320
- * Delete a branch ref on the remote via GraphQL deleteRef mutation.
321
- */
322
314
  async deleteRemoteRef(refId, workDir, repoInfo, token) {
323
315
  const mutation = `mutation { deleteRef(input: { refId: ${JSON.stringify(refId)} }) { clientMutationId } }`;
324
316
  await this.executeGraphQLRefOp(mutation, repoInfo, workDir, token);
@@ -1,3 +1,4 @@
1
- export type { PRMergeConfig, FileChange } from "./types.js";
1
+ export type { PRMergeConfig, FileChange, FileAction, IGitOps, ILocalGitOps, INetworkGitOps, IPRStrategy, GitAuthOptions, PRResult, PRStrategyOptions, MergeOptions, MergeResult, CloseExistingPROptions, CommitOptions, CommitResult, ICommitStrategy, } from "./types.js";
2
+ export type { GitOpsOptions } from "./git-ops.js";
2
3
  export { getCommitStrategy, createTokenManager, } from "./commit-strategy-selector.js";
3
4
  export { getPRStrategy } from "./pr-strategy-factory.js";
@@ -1,11 +1,8 @@
1
1
  import { RepoInfo } from "../shared/repo-detector.js";
2
2
  import type { IPRStrategyLogger } from "./pr-strategy.js";
3
- import type { MergeResult, PRMergeConfig, PRResult } from "./types.js";
3
+ import type { FileAction, MergeResult, PRMergeConfig, PRResult } from "./types.js";
4
4
  import { ICommandExecutor } from "../shared/command-executor.js";
5
- export interface FileAction {
6
- fileName: string;
7
- action: "create" | "update" | "skip" | "delete";
8
- }
5
+ export type { FileAction };
9
6
  interface PROptions {
10
7
  repoInfo: RepoInfo;
11
8
  branchName: string;
@@ -17,8 +14,8 @@ interface PROptions {
17
14
  retries?: number;
18
15
  /** Custom PR body template */
19
16
  prTemplate?: string;
20
- /** Optional command executor for shell commands (for testing) */
21
- executor?: ICommandExecutor;
17
+ /** Command executor for shell commands */
18
+ executor: ICommandExecutor;
22
19
  /** GitHub App installation token for authentication */
23
20
  token?: string;
24
21
  /** Labels to apply to the created PR */
@@ -50,8 +47,8 @@ interface MergePROptions {
50
47
  workDir: string;
51
48
  dryRun?: boolean;
52
49
  retries?: number;
53
- /** Optional command executor for shell commands (for testing) */
54
- executor?: ICommandExecutor;
50
+ /** Command executor for shell commands */
51
+ executor: ICommandExecutor;
55
52
  /** GitHub App installation token for authentication */
56
53
  token?: string;
57
54
  /** Optional logger for PR strategy debug/warn/info messages */
@@ -2,4 +2,4 @@ import { RepoInfo } from "../shared/repo-detector.js";
2
2
  import type { IPRStrategy } from "./types.js";
3
3
  import { ICommandExecutor } from "../shared/command-executor.js";
4
4
  import type { IPRStrategyLogger } from "./pr-strategy.js";
5
- export declare function getPRStrategy(repoInfo: RepoInfo, executor?: ICommandExecutor, log?: IPRStrategyLogger): IPRStrategy;
5
+ export declare function getPRStrategy(repoInfo: RepoInfo, executor: ICommandExecutor, log?: IPRStrategyLogger): IPRStrategy;
@@ -1,4 +1,5 @@
1
1
  import { isGitHubRepo, isAzureDevOpsRepo, isGitLabRepo, } from "../shared/repo-detector.js";
2
+ import { SyncError } from "../shared/errors.js";
2
3
  import { GitHubPRStrategy } from "./github-pr-strategy.js";
3
4
  import { AzurePRStrategy } from "./azure-pr-strategy.js";
4
5
  import { GitLabPRStrategy } from "./gitlab-pr-strategy.js";
@@ -13,5 +14,5 @@ export function getPRStrategy(repoInfo, executor, log) {
13
14
  return new GitLabPRStrategy(executor, log);
14
15
  }
15
16
  const _exhaustive = repoInfo;
16
- throw new Error(`Unknown repository type: ${JSON.stringify(_exhaustive)}`);
17
+ throw new SyncError(`Unknown repository type: ${JSON.stringify(_exhaustive)}`);
17
18
  }
@@ -10,7 +10,7 @@ export declare abstract class BasePRStrategy implements IPRStrategy {
10
10
  protected bodyFilePath: string;
11
11
  protected executor: ICommandExecutor;
12
12
  protected log?: IPRStrategyLogger;
13
- constructor(executor?: ICommandExecutor, log?: IPRStrategyLogger);
13
+ constructor(executor: ICommandExecutor, log?: IPRStrategyLogger);
14
14
  abstract checkExistingPR(options: CloseExistingPROptions): Promise<string | null>;
15
15
  abstract closeExistingPR(options: CloseExistingPROptions): Promise<boolean>;
16
16
  abstract create(options: PRStrategyOptions): Promise<PRResult>;
@@ -1,12 +1,11 @@
1
1
  import { toErrorMessage } from "../shared/type-guards.js";
2
2
  import { withRetry } from "../shared/retry-utils.js";
3
- import { defaultExecutor, } from "../shared/command-executor.js";
4
3
  export class BasePRStrategy {
5
4
  bodyFilePath = ".pr-body.md";
6
5
  executor;
7
6
  log;
8
7
  constructor(executor, log) {
9
- this.executor = executor ?? defaultExecutor;
8
+ this.executor = executor;
10
9
  this.log = log;
11
10
  }
12
11
  /**
@@ -15,7 +14,7 @@ export class BasePRStrategy {
15
14
  */
16
15
  async executeMergeCommand(execFn, retries, successResult, errorPrefix) {
17
16
  try {
18
- await withRetry(execFn, { retries });
17
+ await withRetry(execFn, { retries, log: this.log });
19
18
  return successResult;
20
19
  }
21
20
  catch (error) {