@aspruyt/xfg 4.0.0 → 4.0.1

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 (173) hide show
  1. package/dist/cli/index.d.ts +1 -2
  2. package/dist/cli/index.js +0 -1
  3. package/dist/cli/program.js +7 -2
  4. package/dist/cli/{settings/results-collector.d.ts → results-collector.d.ts} +1 -1
  5. package/dist/cli/{settings/results-collector.js → results-collector.js} +2 -1
  6. package/dist/cli/settings-report-builder.d.ts +1 -3
  7. package/dist/cli/sync-command.d.ts +2 -24
  8. package/dist/cli/sync-command.js +295 -301
  9. package/dist/cli/types.d.ts +60 -40
  10. package/dist/cli/types.js +1 -12
  11. package/dist/config/errors.d.ts +9 -0
  12. package/dist/config/errors.js +11 -0
  13. package/dist/config/file-reference-resolver.d.ts +2 -1
  14. package/dist/config/file-reference-resolver.js +10 -8
  15. package/dist/config/formatter.d.ts +3 -2
  16. package/dist/config/index.d.ts +4 -6
  17. package/dist/config/index.js +4 -8
  18. package/dist/config/loader.js +4 -2
  19. package/dist/config/merge.d.ts +0 -9
  20. package/dist/config/merge.js +2 -7
  21. package/dist/config/normalizer.d.ts +4 -0
  22. package/dist/config/normalizer.js +61 -110
  23. package/dist/config/types.d.ts +15 -19
  24. package/dist/config/types.js +1 -1
  25. package/dist/config/validator.d.ts +0 -4
  26. package/dist/config/validator.js +286 -363
  27. package/dist/config/validators/file-validator.d.ts +2 -8
  28. package/dist/config/validators/file-validator.js +6 -17
  29. package/dist/config/validators/index.d.ts +3 -3
  30. package/dist/config/validators/index.js +3 -3
  31. package/dist/config/validators/repo-settings-validator.d.ts +0 -6
  32. package/dist/config/validators/repo-settings-validator.js +9 -9
  33. package/dist/config/validators/ruleset-validator.d.ts +0 -14
  34. package/dist/config/validators/ruleset-validator.js +28 -28
  35. package/dist/lifecycle/ado-migration-source.js +2 -1
  36. package/dist/lifecycle/github-lifecycle-provider.d.ts +6 -5
  37. package/dist/lifecycle/github-lifecycle-provider.js +79 -90
  38. package/dist/lifecycle/index.d.ts +2 -6
  39. package/dist/lifecycle/index.js +0 -4
  40. package/dist/lifecycle/lifecycle-formatter.d.ts +2 -1
  41. package/dist/lifecycle/lifecycle-formatter.js +4 -0
  42. package/dist/lifecycle/lifecycle-helpers.d.ts +3 -2
  43. package/dist/lifecycle/repo-lifecycle-manager.js +4 -11
  44. package/dist/lifecycle/types.d.ts +0 -8
  45. package/dist/output/github-summary.d.ts +5 -0
  46. package/dist/output/github-summary.js +9 -2
  47. package/dist/output/index.d.ts +2 -2
  48. package/dist/output/index.js +1 -1
  49. package/dist/output/lifecycle-report.js +5 -23
  50. package/dist/output/settings-report.d.ts +14 -3
  51. package/dist/output/settings-report.js +137 -197
  52. package/dist/output/summary-utils.d.ts +1 -1
  53. package/dist/output/summary-utils.js +2 -1
  54. package/dist/output/sync-report.js +5 -8
  55. package/dist/output/unified-summary.d.ts +2 -1
  56. package/dist/output/unified-summary.js +71 -133
  57. package/dist/settings/base-processor.d.ts +67 -0
  58. package/dist/settings/base-processor.js +91 -0
  59. package/dist/settings/index.d.ts +4 -3
  60. package/dist/settings/index.js +3 -3
  61. package/dist/settings/labels/converter.d.ts +2 -1
  62. package/dist/settings/labels/github-labels-strategy.d.ts +9 -18
  63. package/dist/settings/labels/github-labels-strategy.js +17 -73
  64. package/dist/settings/labels/index.d.ts +2 -6
  65. package/dist/settings/labels/index.js +1 -9
  66. package/dist/settings/labels/processor.d.ts +6 -30
  67. package/dist/settings/labels/processor.js +62 -152
  68. package/dist/settings/labels/types.d.ts +5 -8
  69. package/dist/settings/repo-settings/formatter.d.ts +2 -2
  70. package/dist/settings/repo-settings/formatter.js +6 -6
  71. package/dist/settings/repo-settings/github-repo-settings-strategy.d.ts +11 -12
  72. package/dist/settings/repo-settings/github-repo-settings-strategy.js +32 -79
  73. package/dist/settings/repo-settings/index.d.ts +2 -5
  74. package/dist/settings/repo-settings/index.js +1 -9
  75. package/dist/settings/repo-settings/processor.d.ts +6 -27
  76. package/dist/settings/repo-settings/processor.js +51 -104
  77. package/dist/settings/repo-settings/types.d.ts +7 -9
  78. package/dist/settings/rulesets/diff-algorithm.d.ts +0 -4
  79. package/dist/settings/rulesets/diff-algorithm.js +1 -10
  80. package/dist/settings/rulesets/diff.d.ts +1 -1
  81. package/dist/settings/rulesets/diff.js +2 -21
  82. package/dist/settings/rulesets/formatter.d.ts +1 -3
  83. package/dist/settings/rulesets/formatter.js +1 -8
  84. package/dist/settings/rulesets/github-ruleset-strategy.d.ts +11 -51
  85. package/dist/settings/rulesets/github-ruleset-strategy.js +24 -85
  86. package/dist/settings/rulesets/index.d.ts +3 -6
  87. package/dist/settings/rulesets/index.js +5 -9
  88. package/dist/settings/rulesets/processor.d.ts +8 -33
  89. package/dist/settings/rulesets/processor.js +58 -151
  90. package/dist/settings/rulesets/types.d.ts +35 -6
  91. package/dist/shared/command-executor.d.ts +2 -22
  92. package/dist/shared/command-executor.js +8 -7
  93. package/dist/shared/env.d.ts +0 -8
  94. package/dist/shared/env.js +14 -70
  95. package/dist/shared/file-status.d.ts +2 -0
  96. package/dist/shared/file-status.js +13 -0
  97. package/dist/shared/gh-api-utils.d.ts +46 -0
  98. package/dist/shared/gh-api-utils.js +107 -0
  99. package/dist/shared/index.d.ts +5 -5
  100. package/dist/shared/index.js +3 -3
  101. package/dist/shared/interpolation-engine.d.ts +31 -0
  102. package/dist/shared/interpolation-engine.js +50 -0
  103. package/dist/shared/logger.d.ts +3 -7
  104. package/dist/shared/logger.js +4 -1
  105. package/dist/shared/repo-detector.d.ts +17 -2
  106. package/dist/shared/repo-detector.js +27 -0
  107. package/dist/shared/retry-utils.d.ts +9 -17
  108. package/dist/shared/retry-utils.js +22 -28
  109. package/dist/shared/sanitize-utils.d.ts +0 -7
  110. package/dist/shared/sanitize-utils.js +0 -7
  111. package/dist/shared/shell-utils.d.ts +1 -0
  112. package/dist/shared/shell-utils.js +3 -0
  113. package/dist/shared/string-utils.d.ts +4 -0
  114. package/dist/shared/string-utils.js +6 -0
  115. package/dist/shared/type-guards.d.ts +17 -0
  116. package/dist/shared/type-guards.js +26 -0
  117. package/dist/shared/workspace-utils.d.ts +0 -4
  118. package/dist/shared/workspace-utils.js +0 -4
  119. package/dist/{sync → shared}/xfg-template.d.ts +3 -2
  120. package/dist/{sync → shared}/xfg-template.js +13 -54
  121. package/dist/sync/auth-options-builder.d.ts +4 -5
  122. package/dist/sync/auth-options-builder.js +15 -26
  123. package/dist/sync/branch-manager.d.ts +5 -0
  124. package/dist/sync/branch-manager.js +12 -10
  125. package/dist/sync/commit-push-manager.d.ts +1 -1
  126. package/dist/sync/commit-push-manager.js +22 -18
  127. package/dist/sync/diff-utils.d.ts +4 -9
  128. package/dist/sync/diff-utils.js +2 -19
  129. package/dist/sync/file-sync-orchestrator.js +9 -8
  130. package/dist/sync/file-writer.d.ts +2 -1
  131. package/dist/sync/file-writer.js +3 -6
  132. package/dist/sync/index.d.ts +2 -15
  133. package/dist/sync/index.js +0 -19
  134. package/dist/sync/manifest-manager.d.ts +4 -0
  135. package/dist/sync/manifest-manager.js +5 -1
  136. package/dist/sync/manifest.d.ts +10 -41
  137. package/dist/sync/manifest.js +11 -56
  138. package/dist/sync/pr-merge-handler.d.ts +2 -6
  139. package/dist/sync/pr-merge-handler.js +6 -3
  140. package/dist/sync/repository-processor.d.ts +1 -2
  141. package/dist/sync/repository-processor.js +20 -12
  142. package/dist/sync/repository-session.js +5 -14
  143. package/dist/sync/sync-workflow.js +31 -38
  144. package/dist/sync/types.d.ts +43 -178
  145. package/dist/vcs/authenticated-git-ops.d.ts +27 -70
  146. package/dist/vcs/authenticated-git-ops.js +70 -96
  147. package/dist/vcs/azure-pr-strategy.d.ts +6 -4
  148. package/dist/vcs/azure-pr-strategy.js +34 -82
  149. package/dist/vcs/branch-utils.d.ts +6 -0
  150. package/dist/vcs/branch-utils.js +29 -0
  151. package/dist/vcs/commit-strategy-selector.d.ts +5 -0
  152. package/dist/vcs/commit-strategy-selector.js +10 -0
  153. package/dist/vcs/git-commit-strategy.js +1 -2
  154. package/dist/vcs/git-ops.d.ts +15 -59
  155. package/dist/vcs/git-ops.js +46 -110
  156. package/dist/vcs/github-app-token-manager.d.ts +0 -6
  157. package/dist/vcs/github-app-token-manager.js +5 -12
  158. package/dist/vcs/github-pr-strategy.d.ts +5 -5
  159. package/dist/vcs/github-pr-strategy.js +44 -122
  160. package/dist/vcs/gitlab-pr-strategy.d.ts +6 -4
  161. package/dist/vcs/gitlab-pr-strategy.js +39 -87
  162. package/dist/vcs/graphql-commit-strategy.d.ts +3 -4
  163. package/dist/vcs/graphql-commit-strategy.js +31 -63
  164. package/dist/vcs/index.d.ts +3 -16
  165. package/dist/vcs/index.js +2 -33
  166. package/dist/vcs/pr-creator.d.ts +9 -9
  167. package/dist/vcs/pr-creator.js +11 -10
  168. package/dist/vcs/pr-strategy-factory.d.ts +5 -0
  169. package/dist/vcs/pr-strategy-factory.js +17 -0
  170. package/dist/vcs/pr-strategy.d.ts +13 -26
  171. package/dist/vcs/pr-strategy.js +20 -25
  172. package/dist/vcs/types.d.ts +87 -21
  173. package/package.json +2 -1
@@ -2,30 +2,20 @@ import { rmSync, existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync,
2
2
  import { join, resolve, relative, isAbsolute, dirname } from "node:path";
3
3
  import { escapeShellArg } from "../shared/shell-utils.js";
4
4
  import { defaultExecutor, } from "../shared/command-executor.js";
5
- import { withRetry } from "../shared/retry-utils.js";
6
- import { logger } from "../shared/logger.js";
5
+ import { toErrorMessage } from "../shared/type-guards.js";
7
6
  export class GitOps {
8
- workDir;
7
+ _workDir;
9
8
  dryRun;
10
- executor;
11
- retries;
9
+ _executor;
10
+ log;
12
11
  constructor(options) {
13
- this.workDir = options.workDir;
12
+ this._workDir = options.workDir;
14
13
  this.dryRun = options.dryRun ?? false;
15
- this.executor = options.executor ?? defaultExecutor;
16
- this.retries = options.retries ?? 3;
14
+ this._executor = options.executor ?? defaultExecutor;
15
+ this.log = options.log;
17
16
  }
18
17
  async exec(command, cwd) {
19
- return this.executor.exec(command, cwd ?? this.workDir);
20
- }
21
- /**
22
- * Run a command with retry logic for transient failures.
23
- * Used for network operations like clone, fetch, push.
24
- */
25
- async execWithRetry(command, cwd) {
26
- return withRetry(() => this.exec(command, cwd), {
27
- retries: this.retries,
28
- });
18
+ return this._executor.exec(command, cwd ?? this._workDir);
29
19
  }
30
20
  /**
31
21
  * Validates that a file path doesn't escape the workspace directory.
@@ -33,9 +23,9 @@ export class GitOps {
33
23
  * @throws Error if path traversal is detected
34
24
  */
35
25
  validatePath(fileName) {
36
- const filePath = join(this.workDir, fileName);
26
+ const filePath = join(this._workDir, fileName);
37
27
  const resolvedPath = resolve(filePath);
38
- const resolvedWorkDir = resolve(this.workDir);
28
+ const resolvedWorkDir = resolve(this._workDir);
39
29
  const relativePath = relative(resolvedWorkDir, resolvedPath);
40
30
  if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
41
31
  throw new Error(`Path traversal detected: ${fileName}`);
@@ -43,21 +33,10 @@ export class GitOps {
43
33
  return filePath;
44
34
  }
45
35
  cleanWorkspace() {
46
- if (existsSync(this.workDir)) {
47
- rmSync(this.workDir, { recursive: true, force: true });
36
+ if (existsSync(this._workDir)) {
37
+ rmSync(this._workDir, { recursive: true, force: true });
48
38
  }
49
- mkdirSync(this.workDir, { recursive: true });
50
- }
51
- async clone(gitUrl) {
52
- await this.execWithRetry(`git clone ${escapeShellArg(gitUrl)} .`, this.workDir);
53
- }
54
- /**
55
- * Fetch from remote with optional pruning of stale refs.
56
- * Used to update local tracking refs after remote branch deletion.
57
- */
58
- async fetch(options) {
59
- const pruneFlag = options?.prune ? " --prune" : "";
60
- await this.execWithRetry(`git fetch origin${pruneFlag}`, this.workDir);
39
+ mkdirSync(this._workDir, { recursive: true });
61
40
  }
62
41
  /**
63
42
  * Create a new branch from the current HEAD.
@@ -66,10 +45,10 @@ export class GitOps {
66
45
  */
67
46
  async createBranch(branchName) {
68
47
  try {
69
- await this.exec(`git checkout -b ${escapeShellArg(branchName)}`, this.workDir);
48
+ await this.exec(`git checkout -b ${escapeShellArg(branchName)}`, this._workDir);
70
49
  }
71
50
  catch (error) {
72
- const message = error instanceof Error ? error.message : String(error);
51
+ const message = toErrorMessage(error);
73
52
  throw new Error(`Failed to create branch '${branchName}': ${message}`);
74
53
  }
75
54
  }
@@ -98,8 +77,8 @@ export class GitOps {
98
77
  // Set filesystem permissions (755 = rwxr-xr-x)
99
78
  chmodSync(filePath, 0o755);
100
79
  // Also update git's index so the executable bit is committed
101
- const relativePath = relative(this.workDir, filePath);
102
- await this.exec(`git update-index --add --chmod=+x ${escapeShellArg(relativePath)}`, this.workDir);
80
+ const relativePath = relative(this._workDir, filePath);
81
+ await this.exec(`git update-index --add --chmod=+x ${escapeShellArg(relativePath)}`, this._workDir);
103
82
  }
104
83
  /**
105
84
  * Get the content of a file in the workspace.
@@ -113,7 +92,8 @@ export class GitOps {
113
92
  try {
114
93
  return readFileSync(filePath, "utf-8");
115
94
  }
116
- catch {
95
+ catch (error) {
96
+ this.log?.debug(`Failed to read ${fileName}: ${toErrorMessage(error)}`);
117
97
  return null;
118
98
  }
119
99
  }
@@ -133,13 +113,13 @@ export class GitOps {
133
113
  const existingContent = readFileSync(filePath, "utf-8");
134
114
  return existingContent !== newContent;
135
115
  }
136
- catch {
137
- // If we can't read the file, assume it would change
116
+ catch (error) {
117
+ this.log?.debug(`Failed to read ${fileName} for comparison: ${toErrorMessage(error)}`);
138
118
  return true;
139
119
  }
140
120
  }
141
121
  async hasChanges() {
142
- const status = await this.exec("git status --porcelain", this.workDir);
122
+ const status = await this.exec("git status --porcelain", this._workDir);
143
123
  return status.length > 0;
144
124
  }
145
125
  /**
@@ -148,7 +128,7 @@ export class GitOps {
148
128
  * Uses the same this.exec() pattern as other methods in this class.
149
129
  */
150
130
  async getChangedFiles() {
151
- const status = await this.exec("git status --porcelain", this.workDir);
131
+ const status = await this.exec("git status --porcelain", this._workDir);
152
132
  if (!status)
153
133
  return [];
154
134
  return status
@@ -162,11 +142,13 @@ export class GitOps {
162
142
  */
163
143
  async hasStagedChanges() {
164
144
  try {
165
- await this.exec("git diff --cached --quiet", this.workDir);
145
+ await this.exec("git diff --cached --quiet", this._workDir);
166
146
  return false; // Exit code 0 = no staged changes
167
147
  }
168
- catch {
169
- return true; // Exit code 1 = there are staged changes
148
+ catch (error) {
149
+ // Exit code 1 is expected when staged changes exist
150
+ this.log?.debug(`hasStagedChanges: ${toErrorMessage(error)}`);
151
+ return true;
170
152
  }
171
153
  }
172
154
  /**
@@ -175,16 +157,15 @@ export class GitOps {
175
157
  */
176
158
  async fileExistsOnBranch(fileName, branch) {
177
159
  try {
178
- await this.exec(`git show ${escapeShellArg(branch)}:${escapeShellArg(fileName)}`, this.workDir);
160
+ await this.exec(`git show ${escapeShellArg(branch)}:${escapeShellArg(fileName)}`, this._workDir);
179
161
  return true;
180
162
  }
181
- catch {
163
+ catch (error) {
164
+ // Expected when file doesn't exist on branch
165
+ this.log?.debug(`fileExistsOnBranch(${fileName}, ${branch}): ${toErrorMessage(error)}`);
182
166
  return false;
183
167
  }
184
168
  }
185
- /**
186
- * Check if a file exists in the working directory.
187
- */
188
169
  fileExists(fileName) {
189
170
  const filePath = this.validatePath(fileName);
190
171
  return existsSync(filePath);
@@ -201,7 +182,7 @@ export class GitOps {
201
182
  }
202
183
  const filePath = this.validatePath(fileName);
203
184
  if (!existsSync(filePath)) {
204
- return; // File doesn't exist, nothing to delete
185
+ return;
205
186
  }
206
187
  rmSync(filePath);
207
188
  }
@@ -214,81 +195,36 @@ export class GitOps {
214
195
  if (this.dryRun) {
215
196
  return true;
216
197
  }
217
- await this.exec("git add -A", this.workDir);
198
+ await this.exec("git add -A", this._workDir);
218
199
  // Check if there are actually staged changes after git add
219
200
  if (!(await this.hasStagedChanges())) {
220
201
  return false; // No changes to commit
221
202
  }
222
203
  // Use --no-verify to skip pre-commit hooks
223
- await this.exec(`git commit --no-verify -m ${escapeShellArg(message)}`, this.workDir);
204
+ await this.exec(`git commit --no-verify -m ${escapeShellArg(message)}`, this._workDir);
224
205
  return true;
225
206
  }
226
- async push(branchName, options) {
227
- if (this.dryRun) {
228
- return;
229
- }
230
- const forceFlag = options?.force ? "--force-with-lease " : "";
231
- await this.execWithRetry(`git push ${forceFlag}-u origin ${escapeShellArg(branchName)}`, this.workDir);
232
- }
233
- async getDefaultBranch() {
234
- try {
235
- // Try to get the default branch from remote (network operation with retry)
236
- const remoteInfo = await this.execWithRetry("git remote show origin", this.workDir);
237
- const match = remoteInfo.match(/HEAD branch: (\S+)/);
238
- if (match && match[1] !== "(unknown)") {
239
- return { branch: match[1], method: "remote HEAD" };
240
- }
241
- }
242
- catch (error) {
243
- const msg = error instanceof Error ? error.message : String(error);
244
- logger.info(`Debug: git remote show origin failed - ${msg}`);
245
- }
246
- // Try common default branch names (local operations, no retry needed)
207
+ /**
208
+ * Fallback default branch detection using local refs only.
209
+ * Checks origin/main, then origin/master, then defaults to "main".
210
+ */
211
+ async getDefaultBranchLocal() {
247
212
  try {
248
- await this.exec("git rev-parse --verify origin/main", this.workDir);
213
+ await this.exec("git rev-parse --verify origin/main", this._workDir);
249
214
  return { branch: "main", method: "origin/main exists" };
250
215
  }
251
216
  catch (error) {
252
- const msg = error instanceof Error ? error.message : String(error);
253
- logger.info(`Debug: origin/main check failed - ${msg}`);
217
+ const msg = toErrorMessage(error);
218
+ this.log?.debug(`origin/main check failed - ${msg}`);
254
219
  }
255
220
  try {
256
- await this.exec("git rev-parse --verify origin/master", this.workDir);
221
+ await this.exec("git rev-parse --verify origin/master", this._workDir);
257
222
  return { branch: "master", method: "origin/master exists" };
258
223
  }
259
224
  catch (error) {
260
- const msg = error instanceof Error ? error.message : String(error);
261
- logger.info(`Debug: origin/master check failed - ${msg}`);
225
+ const msg = toErrorMessage(error);
226
+ this.log?.debug(`origin/master check failed - ${msg}`);
262
227
  }
263
228
  return { branch: "main", method: "fallback default" };
264
229
  }
265
230
  }
266
- export function sanitizeBranchName(fileName) {
267
- return fileName
268
- .toLowerCase()
269
- .replace(/\.[^.]+$/, "") // Remove extension
270
- .replace(/[^a-z0-9-]/g, "-") // Replace non-alphanumeric with dashes
271
- .replace(/-+/g, "-") // Collapse multiple dashes
272
- .replace(/^-|-$/g, ""); // Remove leading/trailing dashes
273
- }
274
- /**
275
- * Validates a user-provided branch name against git's naming rules.
276
- * @throws Error if the branch name is invalid
277
- */
278
- export function validateBranchName(branchName) {
279
- if (!branchName || branchName.trim() === "") {
280
- throw new Error("Branch name cannot be empty");
281
- }
282
- if (branchName.startsWith(".") || branchName.startsWith("-")) {
283
- throw new Error('Branch name cannot start with "." or "-"');
284
- }
285
- // Git disallows: space, ~, ^, :, ?, *, [, \, and consecutive dots (..)
286
- if (/[\s~^:?*[\\]/.test(branchName) || branchName.includes("..")) {
287
- throw new Error("Branch name contains invalid characters");
288
- }
289
- if (branchName.endsWith("/") ||
290
- branchName.endsWith(".lock") ||
291
- branchName.endsWith(".")) {
292
- throw new Error("Branch name has invalid ending");
293
- }
294
- }
@@ -1,6 +1,4 @@
1
1
  import type { GitHubRepoInfo } from "../shared/repo-detector.js";
2
- /** Duration to cache tokens (45 minutes in milliseconds) */
3
- export declare const TOKEN_CACHE_DURATION_MS: number;
4
2
  /**
5
3
  * Manages GitHub App authentication tokens for multiple organizations.
6
4
  * Handles JWT generation, installation discovery, and token caching.
@@ -25,10 +23,6 @@ export declare class GitHubAppTokenManager {
25
23
  * Stores installations in an internal map for later lookup.
26
24
  */
27
25
  discoverInstallations(apiHost: string): Promise<void>;
28
- /**
29
- * Gets the installation ID for a given owner on the specified API host.
30
- * Returns undefined if no installation is found.
31
- */
32
26
  getInstallationId(apiHost: string, owner: string): number | undefined;
33
27
  /**
34
28
  * Gets an installation access token for the given owner.
@@ -1,7 +1,7 @@
1
1
  import { createSign } from "node:crypto";
2
2
  import { withRetry } from "../shared/retry-utils.js";
3
3
  /** Duration to cache tokens (45 minutes in milliseconds) */
4
- export const TOKEN_CACHE_DURATION_MS = 45 * 60 * 1000;
4
+ const TOKEN_CACHE_DURATION_MS = 45 * 60 * 1000;
5
5
  /**
6
6
  * Manages GitHub App authentication tokens for multiple organizations.
7
7
  * Handles JWT generation, installation discovery, and token caching.
@@ -60,10 +60,8 @@ export class GitHubAppTokenManager {
60
60
  },
61
61
  });
62
62
  if (!res.ok) {
63
- const status = res.status;
64
- // Throw error with status code for retry logic
65
- const error = new Error(`GitHub API error: ${status}`);
66
- throw error;
63
+ const body = await res.text().catch(() => "");
64
+ throw new Error(`GitHub API error: ${res.status}${body ? ` - ${body}` : ""}`);
67
65
  }
68
66
  return res;
69
67
  });
@@ -74,10 +72,6 @@ export class GitHubAppTokenManager {
74
72
  }
75
73
  this.discoveredHosts.add(apiHost);
76
74
  }
77
- /**
78
- * Gets the installation ID for a given owner on the specified API host.
79
- * Returns undefined if no installation is found.
80
- */
81
75
  getInstallationId(apiHost, owner) {
82
76
  const key = `${apiHost}:${owner}`;
83
77
  return this.installations.get(key);
@@ -111,9 +105,8 @@ export class GitHubAppTokenManager {
111
105
  },
112
106
  });
113
107
  if (!res.ok) {
114
- const status = res.status;
115
- const error = new Error(`GitHub API error: ${status}`);
116
- throw error;
108
+ const body = await res.text().catch(() => "");
109
+ throw new Error(`GitHub API error: ${res.status}${body ? ` - ${body}` : ""}`);
117
110
  }
118
111
  return res;
119
112
  });
@@ -1,14 +1,14 @@
1
- import { GitHubRepoInfo } from "../shared/repo-detector.js";
2
- import { PRResult } from "./pr-creator.js";
3
- import { BasePRStrategy, PRStrategyOptions, CloseExistingPROptions, MergeOptions, MergeResult } from "./pr-strategy.js";
1
+ import type { PRResult } from "./types.js";
2
+ import { BasePRStrategy } from "./pr-strategy.js";
3
+ import type { PRStrategyOptions, CloseExistingPROptions, MergeOptions, MergeResult } from "./types.js";
4
4
  export declare class GitHubPRStrategy extends BasePRStrategy {
5
- checkExistingPR(options: PRStrategyOptions): Promise<string | null>;
5
+ checkExistingPR(options: CloseExistingPROptions): Promise<string | null>;
6
6
  closeExistingPR(options: CloseExistingPROptions): Promise<boolean>;
7
7
  create(options: PRStrategyOptions): Promise<PRResult>;
8
8
  /**
9
9
  * Check if auto-merge is enabled on the repository.
10
10
  */
11
- checkAutoMergeEnabled(repoInfo: GitHubRepoInfo, workDir: string, retries?: number, token?: string): Promise<boolean>;
11
+ private checkAutoMergeEnabled;
12
12
  /**
13
13
  * Build merge strategy flag for gh pr merge command.
14
14
  */
@@ -1,11 +1,13 @@
1
1
  import { existsSync, writeFileSync, unlinkSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { escapeShellArg } from "../shared/shell-utils.js";
4
- import { isGitHubRepo } from "../shared/repo-detector.js";
5
- import { BasePRStrategy, } from "./pr-strategy.js";
6
- import { logger } from "../shared/logger.js";
7
- import { withRetry, isPermanentError } from "../shared/retry-utils.js";
3
+ import { escapeShellArg, escapeRegExp } from "../shared/shell-utils.js";
4
+ import { assertGitHubRepo } from "../shared/repo-detector.js";
5
+ import { BasePRStrategy } from "./pr-strategy.js";
6
+ import { withRetry } from "../shared/retry-utils.js";
8
7
  import { sanitizeCredentials } from "../shared/sanitize-utils.js";
8
+ import { toErrorMessage, safeCleanup } from "../shared/type-guards.js";
9
+ import { getStderr } from "../shared/command-executor.js";
10
+ import { buildTokenEnv, getHostnameFlag } from "../shared/gh-api-utils.js";
9
11
  /**
10
12
  * Get the repo flag value for gh CLI commands.
11
13
  * Returns HOST/OWNER/REPO for GHE, OWNER/REPO for github.com.
@@ -16,22 +18,6 @@ function getRepoFlag(repoInfo) {
16
18
  }
17
19
  return `${repoInfo.owner}/${repoInfo.repo}`;
18
20
  }
19
- /**
20
- * Get the hostname flag for gh api commands.
21
- * Returns "--hostname HOST" for GHE, empty string for github.com.
22
- */
23
- function getHostnameFlag(repoInfo) {
24
- if (repoInfo.host && repoInfo.host !== "github.com") {
25
- return `--hostname ${escapeShellArg(repoInfo.host)}`;
26
- }
27
- return "";
28
- }
29
- /**
30
- * Escape special regex characters in a string.
31
- */
32
- function escapeRegExp(str) {
33
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
34
- }
35
21
  /**
36
22
  * Build regex to match PR URLs for the given host.
37
23
  */
@@ -39,19 +25,10 @@ function buildPRUrlRegex(host) {
39
25
  const escapedHost = escapeRegExp(host);
40
26
  return new RegExp(`https://${escapedHost}/[\\w-]+/[\\w.-]+/pull/\\d+`);
41
27
  }
42
- /**
43
- * Build environment variables for gh CLI commands.
44
- * Uses env vars instead of command string interpolation to avoid shell injection.
45
- */
46
- function buildTokenEnv(token) {
47
- return token ? { GH_TOKEN: token } : undefined;
48
- }
49
28
  export class GitHubPRStrategy extends BasePRStrategy {
50
29
  async checkExistingPR(options) {
51
30
  const { repoInfo, branchName, workDir, retries = 3, token } = options;
52
- if (!isGitHubRepo(repoInfo)) {
53
- throw new Error("Expected GitHub repository");
54
- }
31
+ assertGitHubRepo(repoInfo, "GitHub PR strategy");
55
32
  const repoFlag = getRepoFlag(repoInfo);
56
33
  const tokenEnv = buildTokenEnv(token);
57
34
  const command = `gh pr list --repo ${escapeShellArg(repoFlag)} --head ${escapeShellArg(branchName)} --json url --jq '.[0].url'`;
@@ -60,25 +37,16 @@ export class GitHubPRStrategy extends BasePRStrategy {
60
37
  return existingPR || null;
61
38
  }
62
39
  catch (error) {
63
- if (error instanceof Error) {
64
- // Throw on permanent errors (auth failures, etc.)
65
- if (isPermanentError(error)) {
66
- throw error;
67
- }
68
- // Log unexpected errors for debugging (expected: empty result means no PR)
69
- const stderr = error.stderr ?? "";
70
- if (stderr && !stderr.includes("no pull requests match")) {
71
- logger.info(`Debug: GitHub PR check failed - ${sanitizeCredentials(stderr).trim()}`);
72
- }
40
+ const stderr = getStderr(error);
41
+ if (stderr && !stderr.includes("no pull requests match")) {
42
+ this.log?.debug(`GitHub PR check failed - ${sanitizeCredentials(stderr).trim()}`);
73
43
  }
74
44
  return null;
75
45
  }
76
46
  }
77
47
  async closeExistingPR(options) {
78
48
  const { repoInfo, branchName, baseBranch, workDir, retries = 3, token, } = options;
79
- if (!isGitHubRepo(repoInfo)) {
80
- throw new Error("Expected GitHub repository");
81
- }
49
+ assertGitHubRepo(repoInfo, "GitHub PR strategy");
82
50
  // First check if there's an existing PR (pass token through)
83
51
  const existingUrl = await this.checkExistingPR({
84
52
  repoInfo,
@@ -86,8 +54,6 @@ export class GitHubPRStrategy extends BasePRStrategy {
86
54
  baseBranch,
87
55
  workDir,
88
56
  retries,
89
- title: "", // Not used for check
90
- body: "", // Not used for check
91
57
  token,
92
58
  });
93
59
  if (!existingUrl) {
@@ -96,10 +62,9 @@ export class GitHubPRStrategy extends BasePRStrategy {
96
62
  // Extract PR number from URL
97
63
  const prNumber = existingUrl.match(/\/pull\/(\d+)/)?.[1];
98
64
  if (!prNumber) {
99
- throw new Error(`Could not extract PR number from URL: ${existingUrl}`);
65
+ this.log?.warn(`Could not extract PR number from URL: ${existingUrl}`);
66
+ return false;
100
67
  }
101
- // Close the PR and delete the branch
102
- // Token is passed via env var to avoid shell injection
103
68
  const repoFlag = getRepoFlag(repoInfo);
104
69
  const tokenEnv = buildTokenEnv(token);
105
70
  const command = `gh pr close ${escapeShellArg(prNumber)} --repo ${escapeShellArg(repoFlag)} --delete-branch`;
@@ -108,20 +73,16 @@ export class GitHubPRStrategy extends BasePRStrategy {
108
73
  return true;
109
74
  }
110
75
  catch (error) {
111
- const message = error instanceof Error ? error.message : String(error);
112
- logger.info(`Warning: Failed to close existing PR #${prNumber}: ${message}`);
76
+ const message = toErrorMessage(error);
77
+ this.log?.warn(`Failed to close existing PR #${prNumber}: ${message}`);
113
78
  return false;
114
79
  }
115
80
  }
116
81
  async create(options) {
117
82
  const { repoInfo, title, body, branchName, baseBranch, workDir, retries = 3, token, labels, } = options;
118
- if (!isGitHubRepo(repoInfo)) {
119
- throw new Error("Expected GitHub repository");
120
- }
121
- // Write body to temp file to avoid shell escaping issues
83
+ assertGitHubRepo(repoInfo, "GitHub PR strategy");
122
84
  const bodyFile = join(workDir, this.bodyFilePath);
123
85
  writeFileSync(bodyFile, body, "utf-8");
124
- // Token is passed via env var to avoid shell injection
125
86
  const tokenEnv = buildTokenEnv(token);
126
87
  let command = `gh pr create --title ${escapeShellArg(title)} --body-file ${escapeShellArg(bodyFile)} --base ${escapeShellArg(baseBranch)} --head ${escapeShellArg(branchName)}`;
127
88
  // Append label flags
@@ -133,7 +94,7 @@ export class GitHubPRStrategy extends BasePRStrategy {
133
94
  try {
134
95
  const result = await withRetry(() => this.executor.exec(command, workDir, { env: tokenEnv }), { retries });
135
96
  // Extract URL from output - use strict regex for valid PR URLs only
136
- const host = repoInfo.host || "github.com";
97
+ const host = repoInfo.host;
137
98
  const urlRegex = buildPRUrlRegex(host);
138
99
  const urlMatch = result.match(urlRegex);
139
100
  if (!urlMatch) {
@@ -146,15 +107,10 @@ export class GitHubPRStrategy extends BasePRStrategy {
146
107
  };
147
108
  }
148
109
  finally {
149
- // Clean up temp file - log warning on failure instead of throwing
150
- try {
151
- if (existsSync(bodyFile)) {
110
+ safeCleanup(() => {
111
+ if (existsSync(bodyFile))
152
112
  unlinkSync(bodyFile);
153
- }
154
- }
155
- catch (cleanupError) {
156
- logger.info(`Warning: Failed to clean up temp file ${bodyFile}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
157
- }
113
+ }, `failed to remove ${bodyFile}`, this.log ?? { debug() { } });
158
114
  }
159
115
  }
160
116
  /**
@@ -163,7 +119,6 @@ export class GitHubPRStrategy extends BasePRStrategy {
163
119
  async checkAutoMergeEnabled(repoInfo, workDir, retries = 3, token) {
164
120
  const hostnameFlag = getHostnameFlag(repoInfo);
165
121
  const hostnamePart = hostnameFlag ? `${hostnameFlag} ` : "";
166
- // Token is passed via env var to avoid shell injection
167
122
  const tokenEnv = buildTokenEnv(token);
168
123
  const command = `gh api ${hostnamePart}repos/${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)} --jq '.allow_auto_merge // false'`;
169
124
  try {
@@ -172,7 +127,7 @@ export class GitHubPRStrategy extends BasePRStrategy {
172
127
  }
173
128
  catch (error) {
174
129
  // If we can't check, assume auto-merge is not enabled
175
- logger.info(`Warning: Could not check auto-merge status: ${error instanceof Error ? error.message : String(error)}`);
130
+ this.log?.warn(`Could not check auto-merge status: ${toErrorMessage(error)}`);
176
131
  return false;
177
132
  }
178
133
  }
@@ -191,7 +146,7 @@ export class GitHubPRStrategy extends BasePRStrategy {
191
146
  }
192
147
  }
193
148
  async merge(options) {
194
- const { prUrl, config, workDir, retries = 3, token } = options;
149
+ const { prUrl, repoInfo, config, workDir, retries = 3, token } = options;
195
150
  // Manual mode: do nothing
196
151
  if (config.mode === "manual") {
197
152
  return {
@@ -202,71 +157,38 @@ export class GitHubPRStrategy extends BasePRStrategy {
202
157
  }
203
158
  const strategyFlag = this.getMergeStrategyFlag(config.strategy);
204
159
  const deleteBranchFlag = config.deleteBranch ? "--delete-branch" : "";
205
- // Token is passed via env var to avoid shell injection
206
160
  const tokenEnv = buildTokenEnv(token);
207
161
  if (config.mode === "auto") {
208
162
  // Check if auto-merge is enabled on the repo
209
- // Extract host/owner/repo from PR URL (supports both github.com and GHE)
210
- const match = prUrl.match(/https:\/\/([^/]+)\/([^/]+)\/([^/]+)/);
211
- if (match) {
212
- const repoInfo = {
213
- type: "github",
214
- gitUrl: prUrl,
215
- owner: match[2],
216
- repo: match[3],
217
- host: match[1],
218
- };
219
- const autoMergeEnabled = await this.checkAutoMergeEnabled(repoInfo, workDir, retries, token);
220
- if (!autoMergeEnabled) {
221
- logger.info(`Warning: Auto-merge not enabled for '${repoInfo.owner}/${repoInfo.repo}'. PR left open for manual review.`);
222
- logger.info(`To enable: gh repo edit ${getRepoFlag(repoInfo)} --enable-auto-merge (requires admin)`);
223
- return {
224
- success: true,
225
- message: `Auto-merge not enabled for repository. PR left open for manual review.`,
226
- merged: false,
227
- autoMergeEnabled: false,
228
- };
229
- }
230
- }
231
- // Enable auto-merge
232
- const command = `gh pr merge ${escapeShellArg(prUrl)} --auto ${strategyFlag} ${deleteBranchFlag}`.trim();
233
- try {
234
- await withRetry(() => this.executor.exec(command, workDir, { env: tokenEnv }), { retries });
163
+ assertGitHubRepo(repoInfo, "GitHub PR strategy");
164
+ const autoMergeEnabled = await this.checkAutoMergeEnabled(repoInfo, workDir, retries, token);
165
+ if (!autoMergeEnabled) {
166
+ this.log?.warn(`Auto-merge not enabled for '${repoInfo.owner}/${repoInfo.repo}'. PR left open for manual review.`);
167
+ this.log?.info(`To enable: gh repo edit ${getRepoFlag(repoInfo)} --enable-auto-merge (requires admin)`);
235
168
  return {
236
169
  success: true,
237
- message: "Auto-merge enabled. PR will merge when checks pass.",
238
- merged: false,
239
- autoMergeEnabled: true,
240
- };
241
- }
242
- catch (error) {
243
- const message = error instanceof Error ? error.message : String(error);
244
- return {
245
- success: false,
246
- message: `Failed to enable auto-merge: ${message}`,
170
+ message: `Auto-merge not enabled for repository. PR left open for manual review.`,
247
171
  merged: false,
172
+ autoMergeEnabled: false,
248
173
  };
249
174
  }
175
+ // Enable auto-merge
176
+ const autoCommand = `gh pr merge ${escapeShellArg(prUrl)} --auto ${strategyFlag} ${deleteBranchFlag}`.trim();
177
+ return this.executeMergeCommand(() => this.executor.exec(autoCommand, workDir, { env: tokenEnv }), retries, {
178
+ success: true,
179
+ message: "Auto-merge enabled. PR will merge when checks pass.",
180
+ merged: false,
181
+ autoMergeEnabled: true,
182
+ }, "Failed to enable auto-merge");
250
183
  }
251
184
  if (config.mode === "force") {
252
185
  // Force merge using admin privileges
253
- const command = `gh pr merge ${escapeShellArg(prUrl)} --admin ${strategyFlag} ${deleteBranchFlag}`.trim();
254
- try {
255
- await withRetry(() => this.executor.exec(command, workDir, { env: tokenEnv }), { retries });
256
- return {
257
- success: true,
258
- message: "PR merged successfully using admin privileges.",
259
- merged: true,
260
- };
261
- }
262
- catch (error) {
263
- const message = error instanceof Error ? error.message : String(error);
264
- return {
265
- success: false,
266
- message: `Failed to force merge: ${message}`,
267
- merged: false,
268
- };
269
- }
186
+ const forceCommand = `gh pr merge ${escapeShellArg(prUrl)} --admin ${strategyFlag} ${deleteBranchFlag}`.trim();
187
+ return this.executeMergeCommand(() => this.executor.exec(forceCommand, workDir, { env: tokenEnv }), retries, {
188
+ success: true,
189
+ message: "PR merged successfully using admin privileges.",
190
+ merged: true,
191
+ }, "Failed to force merge");
270
192
  }
271
193
  return {
272
194
  success: false,
@@ -1,8 +1,10 @@
1
- import { PRResult } from "./pr-creator.js";
2
- import { BasePRStrategy, PRStrategyOptions, CloseExistingPROptions, MergeOptions, MergeResult } from "./pr-strategy.js";
1
+ import type { PRResult } from "./types.js";
2
+ import { BasePRStrategy } from "./pr-strategy.js";
3
+ import type { IPRStrategyLogger } from "./pr-strategy.js";
4
+ import type { PRStrategyOptions, CloseExistingPROptions, MergeOptions, MergeResult } from "./types.js";
3
5
  import { ICommandExecutor } from "../shared/command-executor.js";
4
6
  export declare class GitLabPRStrategy extends BasePRStrategy {
5
- constructor(executor?: ICommandExecutor);
7
+ constructor(executor?: ICommandExecutor, log?: IPRStrategyLogger);
6
8
  /**
7
9
  * Build the repo flag for glab commands.
8
10
  * Format: namespace/repo (supports nested groups)
@@ -20,7 +22,7 @@ export declare class GitLabPRStrategy extends BasePRStrategy {
20
22
  * Build merge strategy flags for glab mr merge command.
21
23
  */
22
24
  private getMergeStrategyFlag;
23
- checkExistingPR(options: PRStrategyOptions): Promise<string | null>;
25
+ checkExistingPR(options: CloseExistingPROptions): Promise<string | null>;
24
26
  closeExistingPR(options: CloseExistingPROptions): Promise<boolean>;
25
27
  create(options: PRStrategyOptions): Promise<PRResult>;
26
28
  merge(options: MergeOptions): Promise<MergeResult>;