@aspruyt/xfg 4.0.0 → 4.0.2

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 (174) hide show
  1. package/README.md +1 -2
  2. package/dist/cli/index.d.ts +1 -2
  3. package/dist/cli/index.js +0 -1
  4. package/dist/cli/program.js +7 -2
  5. package/dist/cli/{settings/results-collector.d.ts → results-collector.d.ts} +1 -1
  6. package/dist/cli/{settings/results-collector.js → results-collector.js} +2 -1
  7. package/dist/cli/settings-report-builder.d.ts +1 -3
  8. package/dist/cli/sync-command.d.ts +2 -24
  9. package/dist/cli/sync-command.js +295 -301
  10. package/dist/cli/types.d.ts +60 -40
  11. package/dist/cli/types.js +1 -12
  12. package/dist/config/errors.d.ts +9 -0
  13. package/dist/config/errors.js +11 -0
  14. package/dist/config/file-reference-resolver.d.ts +2 -1
  15. package/dist/config/file-reference-resolver.js +10 -8
  16. package/dist/config/formatter.d.ts +3 -2
  17. package/dist/config/index.d.ts +4 -6
  18. package/dist/config/index.js +4 -8
  19. package/dist/config/loader.js +4 -2
  20. package/dist/config/merge.d.ts +0 -9
  21. package/dist/config/merge.js +2 -7
  22. package/dist/config/normalizer.d.ts +4 -0
  23. package/dist/config/normalizer.js +61 -110
  24. package/dist/config/types.d.ts +15 -19
  25. package/dist/config/types.js +1 -1
  26. package/dist/config/validator.d.ts +0 -4
  27. package/dist/config/validator.js +286 -363
  28. package/dist/config/validators/file-validator.d.ts +2 -8
  29. package/dist/config/validators/file-validator.js +6 -17
  30. package/dist/config/validators/index.d.ts +3 -3
  31. package/dist/config/validators/index.js +3 -3
  32. package/dist/config/validators/repo-settings-validator.d.ts +0 -6
  33. package/dist/config/validators/repo-settings-validator.js +9 -9
  34. package/dist/config/validators/ruleset-validator.d.ts +0 -14
  35. package/dist/config/validators/ruleset-validator.js +28 -28
  36. package/dist/lifecycle/ado-migration-source.js +2 -1
  37. package/dist/lifecycle/github-lifecycle-provider.d.ts +6 -5
  38. package/dist/lifecycle/github-lifecycle-provider.js +79 -90
  39. package/dist/lifecycle/index.d.ts +2 -6
  40. package/dist/lifecycle/index.js +0 -4
  41. package/dist/lifecycle/lifecycle-formatter.d.ts +2 -1
  42. package/dist/lifecycle/lifecycle-formatter.js +4 -0
  43. package/dist/lifecycle/lifecycle-helpers.d.ts +3 -2
  44. package/dist/lifecycle/repo-lifecycle-manager.js +4 -11
  45. package/dist/lifecycle/types.d.ts +0 -8
  46. package/dist/output/github-summary.d.ts +5 -0
  47. package/dist/output/github-summary.js +9 -2
  48. package/dist/output/index.d.ts +2 -2
  49. package/dist/output/index.js +1 -1
  50. package/dist/output/lifecycle-report.js +5 -23
  51. package/dist/output/settings-report.d.ts +14 -3
  52. package/dist/output/settings-report.js +137 -197
  53. package/dist/output/summary-utils.d.ts +1 -1
  54. package/dist/output/summary-utils.js +2 -1
  55. package/dist/output/sync-report.js +5 -8
  56. package/dist/output/unified-summary.d.ts +2 -1
  57. package/dist/output/unified-summary.js +71 -133
  58. package/dist/settings/base-processor.d.ts +67 -0
  59. package/dist/settings/base-processor.js +91 -0
  60. package/dist/settings/index.d.ts +4 -3
  61. package/dist/settings/index.js +3 -3
  62. package/dist/settings/labels/converter.d.ts +2 -1
  63. package/dist/settings/labels/github-labels-strategy.d.ts +9 -18
  64. package/dist/settings/labels/github-labels-strategy.js +17 -73
  65. package/dist/settings/labels/index.d.ts +2 -6
  66. package/dist/settings/labels/index.js +1 -9
  67. package/dist/settings/labels/processor.d.ts +6 -30
  68. package/dist/settings/labels/processor.js +62 -152
  69. package/dist/settings/labels/types.d.ts +5 -8
  70. package/dist/settings/repo-settings/formatter.d.ts +2 -2
  71. package/dist/settings/repo-settings/formatter.js +6 -6
  72. package/dist/settings/repo-settings/github-repo-settings-strategy.d.ts +11 -12
  73. package/dist/settings/repo-settings/github-repo-settings-strategy.js +32 -79
  74. package/dist/settings/repo-settings/index.d.ts +2 -5
  75. package/dist/settings/repo-settings/index.js +1 -9
  76. package/dist/settings/repo-settings/processor.d.ts +6 -27
  77. package/dist/settings/repo-settings/processor.js +51 -104
  78. package/dist/settings/repo-settings/types.d.ts +7 -9
  79. package/dist/settings/rulesets/diff-algorithm.d.ts +0 -4
  80. package/dist/settings/rulesets/diff-algorithm.js +1 -10
  81. package/dist/settings/rulesets/diff.d.ts +1 -1
  82. package/dist/settings/rulesets/diff.js +2 -21
  83. package/dist/settings/rulesets/formatter.d.ts +1 -3
  84. package/dist/settings/rulesets/formatter.js +1 -8
  85. package/dist/settings/rulesets/github-ruleset-strategy.d.ts +11 -51
  86. package/dist/settings/rulesets/github-ruleset-strategy.js +24 -85
  87. package/dist/settings/rulesets/index.d.ts +3 -6
  88. package/dist/settings/rulesets/index.js +5 -9
  89. package/dist/settings/rulesets/processor.d.ts +8 -33
  90. package/dist/settings/rulesets/processor.js +58 -151
  91. package/dist/settings/rulesets/types.d.ts +35 -6
  92. package/dist/shared/command-executor.d.ts +2 -22
  93. package/dist/shared/command-executor.js +8 -7
  94. package/dist/shared/env.d.ts +0 -8
  95. package/dist/shared/env.js +14 -70
  96. package/dist/shared/file-status.d.ts +2 -0
  97. package/dist/shared/file-status.js +13 -0
  98. package/dist/shared/gh-api-utils.d.ts +46 -0
  99. package/dist/shared/gh-api-utils.js +107 -0
  100. package/dist/shared/index.d.ts +5 -5
  101. package/dist/shared/index.js +3 -3
  102. package/dist/shared/interpolation-engine.d.ts +31 -0
  103. package/dist/shared/interpolation-engine.js +50 -0
  104. package/dist/shared/logger.d.ts +3 -7
  105. package/dist/shared/logger.js +4 -1
  106. package/dist/shared/repo-detector.d.ts +17 -2
  107. package/dist/shared/repo-detector.js +27 -0
  108. package/dist/shared/retry-utils.d.ts +9 -17
  109. package/dist/shared/retry-utils.js +22 -28
  110. package/dist/shared/sanitize-utils.d.ts +0 -7
  111. package/dist/shared/sanitize-utils.js +0 -7
  112. package/dist/shared/shell-utils.d.ts +1 -0
  113. package/dist/shared/shell-utils.js +3 -0
  114. package/dist/shared/string-utils.d.ts +4 -0
  115. package/dist/shared/string-utils.js +6 -0
  116. package/dist/shared/type-guards.d.ts +17 -0
  117. package/dist/shared/type-guards.js +26 -0
  118. package/dist/shared/workspace-utils.d.ts +0 -4
  119. package/dist/shared/workspace-utils.js +0 -4
  120. package/dist/{sync → shared}/xfg-template.d.ts +3 -2
  121. package/dist/{sync → shared}/xfg-template.js +13 -54
  122. package/dist/sync/auth-options-builder.d.ts +4 -5
  123. package/dist/sync/auth-options-builder.js +15 -26
  124. package/dist/sync/branch-manager.d.ts +5 -0
  125. package/dist/sync/branch-manager.js +12 -10
  126. package/dist/sync/commit-push-manager.d.ts +1 -1
  127. package/dist/sync/commit-push-manager.js +22 -18
  128. package/dist/sync/diff-utils.d.ts +4 -9
  129. package/dist/sync/diff-utils.js +2 -19
  130. package/dist/sync/file-sync-orchestrator.js +9 -8
  131. package/dist/sync/file-writer.d.ts +2 -1
  132. package/dist/sync/file-writer.js +3 -6
  133. package/dist/sync/index.d.ts +2 -15
  134. package/dist/sync/index.js +0 -19
  135. package/dist/sync/manifest-manager.d.ts +4 -0
  136. package/dist/sync/manifest-manager.js +5 -1
  137. package/dist/sync/manifest.d.ts +10 -41
  138. package/dist/sync/manifest.js +11 -56
  139. package/dist/sync/pr-merge-handler.d.ts +2 -6
  140. package/dist/sync/pr-merge-handler.js +6 -3
  141. package/dist/sync/repository-processor.d.ts +1 -2
  142. package/dist/sync/repository-processor.js +20 -12
  143. package/dist/sync/repository-session.js +5 -14
  144. package/dist/sync/sync-workflow.js +31 -38
  145. package/dist/sync/types.d.ts +43 -178
  146. package/dist/vcs/authenticated-git-ops.d.ts +27 -70
  147. package/dist/vcs/authenticated-git-ops.js +70 -96
  148. package/dist/vcs/azure-pr-strategy.d.ts +6 -4
  149. package/dist/vcs/azure-pr-strategy.js +34 -82
  150. package/dist/vcs/branch-utils.d.ts +6 -0
  151. package/dist/vcs/branch-utils.js +29 -0
  152. package/dist/vcs/commit-strategy-selector.d.ts +5 -0
  153. package/dist/vcs/commit-strategy-selector.js +10 -0
  154. package/dist/vcs/git-commit-strategy.js +1 -2
  155. package/dist/vcs/git-ops.d.ts +15 -59
  156. package/dist/vcs/git-ops.js +46 -110
  157. package/dist/vcs/github-app-token-manager.d.ts +0 -6
  158. package/dist/vcs/github-app-token-manager.js +5 -12
  159. package/dist/vcs/github-pr-strategy.d.ts +5 -5
  160. package/dist/vcs/github-pr-strategy.js +44 -122
  161. package/dist/vcs/gitlab-pr-strategy.d.ts +6 -4
  162. package/dist/vcs/gitlab-pr-strategy.js +39 -87
  163. package/dist/vcs/graphql-commit-strategy.d.ts +3 -4
  164. package/dist/vcs/graphql-commit-strategy.js +31 -63
  165. package/dist/vcs/index.d.ts +3 -16
  166. package/dist/vcs/index.js +2 -33
  167. package/dist/vcs/pr-creator.d.ts +9 -9
  168. package/dist/vcs/pr-creator.js +11 -10
  169. package/dist/vcs/pr-strategy-factory.d.ts +5 -0
  170. package/dist/vcs/pr-strategy-factory.js +17 -0
  171. package/dist/vcs/pr-strategy.d.ts +13 -26
  172. package/dist/vcs/pr-strategy.js +20 -25
  173. package/dist/vcs/types.d.ts +87 -21
  174. package/package.json +2 -1
@@ -1,14 +1,16 @@
1
1
  import { existsSync, writeFileSync, unlinkSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { escapeShellArg } from "../shared/shell-utils.js";
4
- import { isGitLabRepo } 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";
4
+ import { assertGitLabRepo } from "../shared/repo-detector.js";
5
+ import { BasePRStrategy } from "./pr-strategy.js";
6
+ import { withRetry } from "../shared/retry-utils.js";
7
+ import { getStderr } from "../shared/command-executor.js";
8
+ import { parseApiJson } from "../shared/gh-api-utils.js";
8
9
  import { sanitizeCredentials } from "../shared/sanitize-utils.js";
10
+ import { toErrorMessage, safeCleanup } from "../shared/type-guards.js";
9
11
  export class GitLabPRStrategy extends BasePRStrategy {
10
- constructor(executor) {
11
- super(executor);
12
+ constructor(executor, log) {
13
+ super(executor, log);
12
14
  this.bodyFilePath = ".mr-description.md";
13
15
  }
14
16
  /**
@@ -62,9 +64,7 @@ export class GitLabPRStrategy extends BasePRStrategy {
62
64
  }
63
65
  async checkExistingPR(options) {
64
66
  const { repoInfo, branchName, workDir, retries = 3 } = options;
65
- if (!isGitLabRepo(repoInfo)) {
66
- throw new Error("Expected GitLab repository");
67
- }
67
+ assertGitLabRepo(repoInfo, "GitLab PR strategy");
68
68
  const repoFlag = this.getRepoFlag(repoInfo);
69
69
  // Use glab mr list with JSON output for reliable parsing
70
70
  // Note: glab mr list returns open MRs by default (use -c for closed, -M for merged)
@@ -74,33 +74,23 @@ export class GitLabPRStrategy extends BasePRStrategy {
74
74
  if (!result || result.trim() === "" || result.trim() === "[]") {
75
75
  return null;
76
76
  }
77
- // Parse JSON to get MR IID
78
- const mrs = JSON.parse(result);
77
+ const mrs = parseApiJson(result, "glab mr list");
79
78
  if (Array.isArray(mrs) && mrs.length > 0 && mrs[0].iid) {
80
79
  return this.buildMRUrl(repoInfo, String(mrs[0].iid));
81
80
  }
82
81
  return null;
83
82
  }
84
83
  catch (error) {
85
- if (error instanceof Error) {
86
- // Throw on permanent errors (auth failures, etc.)
87
- if (isPermanentError(error)) {
88
- throw error;
89
- }
90
- // Log unexpected errors for debugging
91
- const stderr = error.stderr ?? "";
92
- if (stderr && !stderr.includes("no merge requests")) {
93
- logger.info(`Debug: GitLab MR check failed - ${sanitizeCredentials(stderr).trim()}`);
94
- }
84
+ const stderr = getStderr(error);
85
+ if (stderr && !stderr.includes("no merge requests")) {
86
+ this.log?.debug(`GitLab MR check failed - ${sanitizeCredentials(stderr).trim()}`);
95
87
  }
96
88
  return null;
97
89
  }
98
90
  }
99
91
  async closeExistingPR(options) {
100
92
  const { repoInfo, branchName, baseBranch, workDir, retries = 3 } = options;
101
- if (!isGitLabRepo(repoInfo)) {
102
- throw new Error("Expected GitLab repository");
103
- }
93
+ assertGitLabRepo(repoInfo, "GitLab PR strategy");
104
94
  // First check if there's an existing MR
105
95
  const existingUrl = await this.checkExistingPR({
106
96
  repoInfo,
@@ -108,8 +98,6 @@ export class GitLabPRStrategy extends BasePRStrategy {
108
98
  baseBranch,
109
99
  workDir,
110
100
  retries,
111
- title: "", // Not used for check
112
- body: "", // Not used for check
113
101
  });
114
102
  if (!existingUrl) {
115
103
  return false;
@@ -117,7 +105,8 @@ export class GitLabPRStrategy extends BasePRStrategy {
117
105
  // Extract MR IID from URL
118
106
  const mrInfo = this.parseMRUrl(existingUrl);
119
107
  if (!mrInfo) {
120
- throw new Error(`Could not extract MR IID from URL: ${existingUrl}`);
108
+ this.log?.warn(`Could not extract MR IID from URL: ${existingUrl}`);
109
+ return false;
121
110
  }
122
111
  const repoFlag = this.getRepoFlag(repoInfo);
123
112
  // Close the MR
@@ -128,11 +117,10 @@ export class GitLabPRStrategy extends BasePRStrategy {
128
117
  });
129
118
  }
130
119
  catch (error) {
131
- const message = error instanceof Error ? error.message : String(error);
132
- logger.info(`Warning: Failed to close existing MR !${mrInfo.mrIid}: ${message}`);
120
+ const message = toErrorMessage(error);
121
+ this.log?.warn(`Failed to close existing MR !${mrInfo.mrIid}: ${message}`);
133
122
  return false;
134
123
  }
135
- // Delete the source branch via git
136
124
  const deleteBranchCommand = `git push origin --delete ${escapeShellArg(branchName)}`;
137
125
  try {
138
126
  await withRetry(() => this.executor.exec(deleteBranchCommand, workDir), {
@@ -141,18 +129,15 @@ export class GitLabPRStrategy extends BasePRStrategy {
141
129
  }
142
130
  catch (error) {
143
131
  // Branch deletion failure is not critical
144
- const message = error instanceof Error ? error.message : String(error);
145
- logger.info(`Warning: Failed to delete branch ${branchName}: ${message}`);
132
+ const message = toErrorMessage(error);
133
+ this.log?.warn(`Failed to delete branch ${branchName}: ${message}`);
146
134
  }
147
135
  return true;
148
136
  }
149
137
  async create(options) {
150
138
  const { repoInfo, title, body, branchName, baseBranch, workDir, retries = 3, } = options;
151
- if (!isGitLabRepo(repoInfo)) {
152
- throw new Error("Expected GitLab repository");
153
- }
139
+ assertGitLabRepo(repoInfo, "GitLab PR strategy");
154
140
  const repoFlag = this.getRepoFlag(repoInfo);
155
- // Write description to temp file to avoid shell escaping issues
156
141
  const descFile = join(workDir, this.bodyFilePath);
157
142
  writeFileSync(descFile, body, "utf-8");
158
143
  // glab mr create with description from file
@@ -181,15 +166,10 @@ export class GitLabPRStrategy extends BasePRStrategy {
181
166
  throw new Error(`Could not parse MR URL from output: ${result}`);
182
167
  }
183
168
  finally {
184
- // Clean up temp file - log warning on failure instead of throwing
185
- try {
186
- if (existsSync(descFile)) {
169
+ safeCleanup(() => {
170
+ if (existsSync(descFile))
187
171
  unlinkSync(descFile);
188
- }
189
- }
190
- catch (cleanupError) {
191
- logger.info(`Warning: Failed to clean up temp file ${descFile}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
192
- }
172
+ }, `failed to remove ${descFile}`, this.log ?? { debug() { } });
193
173
  }
194
174
  }
195
175
  async merge(options) {
@@ -218,56 +198,28 @@ export class GitLabPRStrategy extends BasePRStrategy {
218
198
  : "";
219
199
  if (config.mode === "auto") {
220
200
  // Enable auto-merge when pipeline succeeds
221
- // glab mr merge <id> --when-pipeline-succeeds [--squash] [--remove-source-branch]
222
- const flagParts = [
201
+ const autoFlagParts = [
223
202
  "--when-pipeline-succeeds",
224
203
  strategyFlag,
225
204
  deleteBranchFlag,
226
205
  ].filter(Boolean);
227
- const command = `glab mr merge ${escapeShellArg(mrInfo.mrIid)} ${flagParts.join(" ")} -R ${escapeShellArg(repoFlag)} -y`;
228
- try {
229
- await withRetry(() => this.executor.exec(command.trim(), workDir), {
230
- retries,
231
- });
232
- return {
233
- success: true,
234
- message: "Auto-merge enabled. MR will merge when pipeline succeeds.",
235
- merged: false,
236
- autoMergeEnabled: true,
237
- };
238
- }
239
- catch (error) {
240
- const message = error instanceof Error ? error.message : String(error);
241
- return {
242
- success: false,
243
- message: `Failed to enable auto-merge: ${message}`,
244
- merged: false,
245
- };
246
- }
206
+ const autoCommand = `glab mr merge ${escapeShellArg(mrInfo.mrIid)} ${autoFlagParts.join(" ")} -R ${escapeShellArg(repoFlag)} -y`;
207
+ return this.executeMergeCommand(() => this.executor.exec(autoCommand.trim(), workDir), retries, {
208
+ success: true,
209
+ message: "Auto-merge enabled. MR will merge when pipeline succeeds.",
210
+ merged: false,
211
+ autoMergeEnabled: true,
212
+ }, "Failed to enable auto-merge");
247
213
  }
248
214
  if (config.mode === "force") {
249
215
  // Force merge immediately
250
- // glab mr merge <id> --yes [--squash] [--remove-source-branch]
251
- const flagParts = [strategyFlag, deleteBranchFlag].filter(Boolean);
252
- const command = `glab mr merge ${escapeShellArg(mrInfo.mrIid)} ${flagParts.join(" ")} -R ${escapeShellArg(repoFlag)} -y`;
253
- try {
254
- await withRetry(() => this.executor.exec(command.trim(), workDir), {
255
- retries,
256
- });
257
- return {
258
- success: true,
259
- message: "MR merged successfully.",
260
- merged: true,
261
- };
262
- }
263
- catch (error) {
264
- const message = error instanceof Error ? error.message : String(error);
265
- return {
266
- success: false,
267
- message: `Failed to force merge: ${message}`,
268
- merged: false,
269
- };
270
- }
216
+ const forceFlagParts = [strategyFlag, deleteBranchFlag].filter(Boolean);
217
+ const forceCommand = `glab mr merge ${escapeShellArg(mrInfo.mrIid)} ${forceFlagParts.join(" ")} -R ${escapeShellArg(repoFlag)} -y`;
218
+ return this.executeMergeCommand(() => this.executor.exec(forceCommand.trim(), workDir), retries, {
219
+ success: true,
220
+ message: "MR merged successfully.",
221
+ merged: true,
222
+ }, "Failed to force merge");
271
223
  }
272
224
  return {
273
225
  success: false,
@@ -21,7 +21,7 @@ export declare const SAFE_BRANCH_NAME_PATTERN: RegExp;
21
21
  * Validates that a branch name is safe for use in shell commands.
22
22
  * Throws an error if the branch name contains potentially dangerous characters.
23
23
  */
24
- export declare function validateBranchName(branchName: string): void;
24
+ export declare function validateSafeBranchName(branchName: string): void;
25
25
  /**
26
26
  * GraphQL-based commit strategy using GitHub's createCommitOnBranch mutation.
27
27
  * Used with GitHub App authentication. Commits via this strategy ARE verified
@@ -32,9 +32,8 @@ export declare function validateBranchName(branchName: string): void;
32
32
  export declare class GraphQLCommitStrategy implements ICommitStrategy {
33
33
  /**
34
34
  * GraphQL permanent error patterns for ref operations.
35
- * Differs from DEFAULT_PERMANENT_ERROR_PATTERNS which has
36
- * git-CLI-specific patterns (/remote\s*rejected/i) that don't
37
- * apply to GraphQL responses.
35
+ * Extends CORE_PERMANENT_ERROR_PATTERNS with GraphQL-specific patterns
36
+ * (omits git-CLI patterns like /remote\s*rejected/i).
38
37
  */
39
38
  private static readonly GRAPHQL_PERMANENT_ERROR_PATTERNS;
40
39
  private executor;
@@ -1,7 +1,9 @@
1
1
  import { defaultExecutor, } from "../shared/command-executor.js";
2
2
  import { isGitHubRepo } from "../shared/repo-detector.js";
3
3
  import { escapeShellArg } from "../shared/shell-utils.js";
4
- import { withRetry, DEFAULT_PERMANENT_ERROR_PATTERNS, } from "../shared/retry-utils.js";
4
+ import { withRetry, CORE_PERMANENT_ERROR_PATTERNS, DEFAULT_PERMANENT_ERROR_PATTERNS, } from "../shared/retry-utils.js";
5
+ import { toErrorMessage } from "../shared/type-guards.js";
6
+ import { parseApiJson } from "../shared/gh-api-utils.js";
5
7
  /**
6
8
  * Maximum payload size for GitHub GraphQL API (50MB).
7
9
  * Base64 encoding adds ~33% overhead, so raw content should be checked.
@@ -23,7 +25,7 @@ export const SAFE_BRANCH_NAME_PATTERN = /^[a-zA-Z0-9][-a-zA-Z0-9_./]*$/;
23
25
  * Validates that a branch name is safe for use in shell commands.
24
26
  * Throws an error if the branch name contains potentially dangerous characters.
25
27
  */
26
- export function validateBranchName(branchName) {
28
+ export function validateSafeBranchName(branchName) {
27
29
  if (!SAFE_BRANCH_NAME_PATTERN.test(branchName)) {
28
30
  throw new Error(`Invalid branch name for GraphQL commit strategy: "${branchName}". ` +
29
31
  `Branch names must start with alphanumeric and contain only ` +
@@ -50,20 +52,11 @@ const OID_MISMATCH_PATTERNS = [
50
52
  export class GraphQLCommitStrategy {
51
53
  /**
52
54
  * GraphQL permanent error patterns for ref operations.
53
- * Differs from DEFAULT_PERMANENT_ERROR_PATTERNS which has
54
- * git-CLI-specific patterns (/remote\s*rejected/i) that don't
55
- * apply to GraphQL responses.
55
+ * Extends CORE_PERMANENT_ERROR_PATTERNS with GraphQL-specific patterns
56
+ * (omits git-CLI patterns like /remote\s*rejected/i).
56
57
  */
57
58
  static GRAPHQL_PERMANENT_ERROR_PATTERNS = [
58
- /not\s*found/i,
59
- /unauthorized/i,
60
- /permission\s*denied/i,
61
- /not\s*accessible\s*by\s*integration/i,
62
- /bad\s*credentials/i,
63
- /invalid\s*(token|credentials)/i,
64
- /401\b/,
65
- /403\b/,
66
- /does\s*not\s*exist/i,
59
+ ...CORE_PERMANENT_ERROR_PATTERNS,
67
60
  /could\s*not\s*resolve/i,
68
61
  /already\s*exists/i,
69
62
  ];
@@ -80,17 +73,14 @@ export class GraphQLCommitStrategy {
80
73
  */
81
74
  async commit(options) {
82
75
  const { repoInfo, branchName, message, fileChanges, workDir, retries = 3, token, } = options;
83
- // Validate this is a GitHub repo
84
76
  if (!isGitHubRepo(repoInfo)) {
85
77
  throw new Error(`GraphQL commit strategy requires GitHub repositories. Got: ${repoInfo.type}`);
86
78
  }
87
- // Validate branch name is safe for shell commands
88
- validateBranchName(branchName);
79
+ validateSafeBranchName(branchName);
89
80
  const githubInfo = repoInfo;
90
- // Separate additions from deletions
91
81
  const additions = fileChanges.filter((fc) => fc.content !== null);
92
82
  const deletions = fileChanges.filter((fc) => fc.content === null);
93
- // Calculate payload size (base64 adds ~33% overhead)
83
+ // Base64 encoding adds ~33% overhead to raw content size
94
84
  const totalSize = additions.reduce((sum, fc) => {
95
85
  const base64Size = Math.ceil((fc.content.length * 4) / 3);
96
86
  return sum + base64Size;
@@ -99,18 +89,15 @@ export class GraphQLCommitStrategy {
99
89
  throw new Error(`GraphQL payload exceeds 50 MB limit (${Math.round(totalSize / (1024 * 1024))} MB). ` +
100
90
  `Consider using smaller files or the git commit strategy.`);
101
91
  }
102
- // Get gitOps for authenticated network operations
103
92
  const gitOps = options.gitOps;
104
- // Ensure the branch exists on remote and is up-to-date with local HEAD
105
- // createCommitOnBranch requires the branch to already exist
106
- // For PR branches (force=true), we force-update to ensure fresh start from main
93
+ // createCommitOnBranch requires the branch to already exist on remote.
94
+ // For PR branches (force=true), force-update ensures a fresh start from main.
107
95
  await this.ensureBranchExistsOnRemote(branchName, workDir, options.force, githubInfo, token);
108
- // Retry loop for expectedHeadOid mismatch
96
+ // Outer retry loop for expectedHeadOid mismatch — each iteration re-fetches
97
+ // the remote HEAD so the next mutation uses a fresh OID.
109
98
  let lastError = null;
110
99
  for (let attempt = 0; attempt <= retries; attempt++) {
111
100
  try {
112
- // Fetch from remote to ensure we have the latest HEAD
113
- // This is critical for expectedHeadOid to match
114
101
  const safeBranch = escapeShellArg(branchName);
115
102
  if (gitOps) {
116
103
  await gitOps.fetchBranch(branchName);
@@ -120,22 +107,18 @@ export class GraphQLCommitStrategy {
120
107
  }
121
108
  // Get the remote HEAD SHA for this branch (not local HEAD)
122
109
  const headSha = await this.executor.exec(`git rev-parse origin/${safeBranch}`, workDir);
123
- // Build and execute the GraphQL mutation
124
110
  const result = await this.executeGraphQLMutation(githubInfo, branchName, message, headSha.trim(), additions, deletions, workDir, token);
125
111
  return result;
126
112
  }
127
113
  catch (error) {
128
- lastError = error instanceof Error ? error : new Error(String(error));
129
- // Check if this is an expectedHeadOid mismatch error (retryable)
114
+ lastError =
115
+ error instanceof Error ? error : new Error(toErrorMessage(error));
130
116
  if (this.isHeadOidMismatchError(lastError) && attempt < retries) {
131
- // Retry - the next iteration will fetch and get fresh HEAD SHA
132
117
  continue;
133
118
  }
134
- // For other errors, throw immediately
135
119
  throw lastError;
136
120
  }
137
121
  }
138
- // Should not reach here, but just in case
139
122
  throw lastError ?? new Error("Unexpected error in GraphQL commit");
140
123
  }
141
124
  /**
@@ -143,19 +126,14 @@ export class GraphQLCommitStrategy {
143
126
  */
144
127
  async executeGraphQLMutation(repoInfo, branchName, message, expectedHeadOid, additions, deletions, workDir, token) {
145
128
  const repositoryNameWithOwner = `${repoInfo.owner}/${repoInfo.repo}`;
146
- // Build file additions with base64 encoding
147
129
  const fileAdditions = additions.map((fc) => ({
148
130
  path: fc.path,
149
131
  contents: Buffer.from(fc.content).toString("base64"),
150
132
  }));
151
- // Build file deletions (path only)
152
133
  const fileDeletions = deletions.map((fc) => ({
153
134
  path: fc.path,
154
135
  }));
155
- // Build the mutation (minified to avoid shell escaping issues with newlines)
156
136
  const mutation = "mutation CreateCommit($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid } } }";
157
- // Build the input variables
158
- // Note: GitHub API doesn't accept empty arrays, so only include fields when non-empty
159
137
  const fileChanges = {};
160
138
  if (fileAdditions.length > 0) {
161
139
  fileChanges.additions = fileAdditions;
@@ -176,25 +154,18 @@ export class GraphQLCommitStrategy {
176
154
  fileChanges,
177
155
  },
178
156
  };
179
- // Build the GraphQL request body
180
157
  const requestBody = JSON.stringify({
181
158
  query: mutation,
182
159
  variables,
183
160
  });
184
- // Build the gh api graphql command
185
- // Use --input - to pass the JSON body via stdin (more reliable for complex nested JSON)
186
- // Use --hostname for GitHub Enterprise
187
161
  const hostnameArg = repoInfo.host !== "github.com"
188
162
  ? `--hostname ${escapeShellArg(repoInfo.host)}`
189
163
  : "";
190
- // Use token parameter for authentication when provided
191
- // This ensures the GitHub App is used as the commit author, not github-actions[bot]
192
- // GH_TOKEN env var must be set for the gh command (after the pipe), not echo
193
- const tokenPrefix = token ? `GH_TOKEN=${token} ` : "";
194
- const command = `echo ${escapeShellArg(requestBody)} | ${tokenPrefix}gh api graphql ${hostnameArg} --input -`;
164
+ const tokenEnv = token ? { GH_TOKEN: token } : undefined;
165
+ const command = `echo ${escapeShellArg(requestBody)} | gh api graphql ${hostnameArg} --input -`;
195
166
  let response;
196
167
  try {
197
- response = await withRetry(() => this.executor.exec(command, workDir), {
168
+ response = await withRetry(() => this.executor.exec(command, workDir, { env: tokenEnv }), {
198
169
  permanentErrorPatterns: [
199
170
  ...DEFAULT_PERMANENT_ERROR_PATTERNS,
200
171
  ...OID_MISMATCH_PATTERNS,
@@ -204,12 +175,14 @@ export class GraphQLCommitStrategy {
204
175
  catch (error) {
205
176
  throw this.sanitizeCommandError(error, repositoryNameWithOwner);
206
177
  }
207
- // Parse the response
208
- const parsed = JSON.parse(response);
178
+ const parsed = parseApiJson(response, "GraphQL createCommitOnBranch response");
209
179
  if (parsed.errors) {
210
- throw new Error(`GraphQL error: ${parsed.errors.map((e) => e.message).join(", ")}`);
180
+ const errors = parsed.errors;
181
+ throw new Error(`GraphQL error: ${errors.map((e) => e.message).join(", ")}`);
211
182
  }
212
- const oid = parsed.data?.createCommitOnBranch?.commit?.oid;
183
+ const data = parsed.data;
184
+ const commit = data?.createCommitOnBranch?.commit;
185
+ const oid = commit?.oid;
213
186
  if (!oid) {
214
187
  throw new Error("GraphQL response missing commit OID");
215
188
  }
@@ -252,7 +225,7 @@ export class GraphQLCommitStrategy {
252
225
  await this.createRemoteRef(repositoryId, branchName, sha, workDir, repoInfo, token);
253
226
  }
254
227
  catch (error) {
255
- const msg = error instanceof Error ? error.message : String(error);
228
+ const msg = toErrorMessage(error);
256
229
  if (/already exists/i.test(msg)) {
257
230
  // Branch was created between our query and create — that's fine
258
231
  return;
@@ -260,7 +233,6 @@ export class GraphQLCommitStrategy {
260
233
  throw error;
261
234
  }
262
235
  }
263
- // refId exists + !force: no-op (branch already exists)
264
236
  }
265
237
  /**
266
238
  * Sanitize command execution errors to remove the GraphQL payload.
@@ -269,7 +241,7 @@ export class GraphQLCommitStrategy {
269
241
  * of base64-encoded file contents). This extracts just the meaningful stderr.
270
242
  */
271
243
  sanitizeCommandError(error, repo) {
272
- const originalMessage = error instanceof Error ? error.message : String(error);
244
+ const originalMessage = toErrorMessage(error);
273
245
  let cleanMessage;
274
246
  if (originalMessage.startsWith("Command failed:")) {
275
247
  // Extract stderr: everything after the first newline
@@ -294,11 +266,7 @@ export class GraphQLCommitStrategy {
294
266
  */
295
267
  isHeadOidMismatchError(error) {
296
268
  const message = error.message.toLowerCase();
297
- return (message.includes("expected branch to point to") ||
298
- message.includes("expectedheadoid") ||
299
- message.includes("head oid") ||
300
- // GitHub may return this generic error for OID mismatches
301
- message.includes("was provided invalid value"));
269
+ return OID_MISMATCH_PATTERNS.some((pattern) => pattern.test(message));
302
270
  }
303
271
  /**
304
272
  * Execute a GraphQL query or mutation for ref operations.
@@ -310,18 +278,18 @@ export class GraphQLCommitStrategy {
310
278
  const hostnameArg = repoInfo.host !== "github.com"
311
279
  ? `--hostname ${escapeShellArg(repoInfo.host)}`
312
280
  : "";
313
- const tokenPrefix = token ? `GH_TOKEN=${token} ` : "";
314
- const command = `echo ${escapeShellArg(requestBody)} | ${tokenPrefix}gh api graphql ${hostnameArg} --input -`;
281
+ const tokenEnv = token ? { GH_TOKEN: token } : undefined;
282
+ const command = `echo ${escapeShellArg(requestBody)} | gh api graphql ${hostnameArg} --input -`;
315
283
  let response;
316
284
  try {
317
- response = await withRetry(() => this.executor.exec(command, workDir), {
285
+ response = await withRetry(() => this.executor.exec(command, workDir, { env: tokenEnv }), {
318
286
  permanentErrorPatterns: GraphQLCommitStrategy.GRAPHQL_PERMANENT_ERROR_PATTERNS,
319
287
  });
320
288
  }
321
289
  catch (error) {
322
290
  throw this.sanitizeCommandError(error, `${repoInfo.owner}/${repoInfo.repo}`);
323
291
  }
324
- const parsed = JSON.parse(response);
292
+ const parsed = parseApiJson(response, "GraphQL API response");
325
293
  if (parsed.errors) {
326
294
  throw new Error(`GraphQL error: ${parsed.errors.map((e) => e.message).join(", ")}`);
327
295
  }
@@ -1,16 +1,3 @@
1
- export type { PRMergeConfig, MergeResult, PRStrategyOptions, MergeOptions, CloseExistingPROptions, IPRStrategy, FileChange, CommitOptions, CommitResult, ICommitStrategy, } from "./types.js";
2
- export { GitOps, sanitizeBranchName, validateBranchName, type IGitOps, type GitOpsOptions, } from "./git-ops.js";
3
- export { AuthenticatedGitOps, type IAuthenticatedGitOps, type GitAuthOptions, } from "./authenticated-git-ops.js";
4
- export { GitHubAppTokenManager } from "./github-app-token-manager.js";
5
- export { createPR, mergePR, formatPRBody, formatPRTitle, escapeShellArg, type PROptions, type PRResult, type FileAction, type MergePROptions, } from "./pr-creator.js";
6
- export { BasePRStrategy, PRWorkflowExecutor } from "./pr-strategy.js";
7
- export { GitHubPRStrategy } from "./github-pr-strategy.js";
8
- export { AzurePRStrategy } from "./azure-pr-strategy.js";
9
- export { GitLabPRStrategy } from "./gitlab-pr-strategy.js";
10
- export { GitCommitStrategy } from "./git-commit-strategy.js";
11
- export { GraphQLCommitStrategy, MAX_PAYLOAD_SIZE, } from "./graphql-commit-strategy.js";
12
- export { getCommitStrategy, hasGitHubAppCredentials, } from "./commit-strategy-selector.js";
13
- import { RepoInfo } from "../shared/repo-detector.js";
14
- import type { IPRStrategy } from "./types.js";
15
- import { ICommandExecutor } from "../shared/command-executor.js";
16
- export declare function getPRStrategy(repoInfo: RepoInfo, executor?: ICommandExecutor): IPRStrategy;
1
+ export type { PRMergeConfig, FileChange } from "./types.js";
2
+ export { getCommitStrategy, createTokenManager, } from "./commit-strategy-selector.js";
3
+ export { getPRStrategy } from "./pr-strategy-factory.js";
package/dist/vcs/index.js CHANGED
@@ -1,35 +1,4 @@
1
- // Core git operations
2
- export { GitOps, sanitizeBranchName, validateBranchName, } from "./git-ops.js";
3
- // Authenticated git operations (with per-command auth)
4
- export { AuthenticatedGitOps, } from "./authenticated-git-ops.js";
5
- // GitHub App token management
6
- export { GitHubAppTokenManager } from "./github-app-token-manager.js";
7
- // PR creation utilities
8
- export { createPR, mergePR, formatPRBody, formatPRTitle, escapeShellArg, } from "./pr-creator.js";
9
- // PR strategies
10
- export { BasePRStrategy, PRWorkflowExecutor } from "./pr-strategy.js";
11
- export { GitHubPRStrategy } from "./github-pr-strategy.js";
12
- export { AzurePRStrategy } from "./azure-pr-strategy.js";
13
- export { GitLabPRStrategy } from "./gitlab-pr-strategy.js";
14
1
  // Commit strategies
15
- export { GitCommitStrategy } from "./git-commit-strategy.js";
16
- export { GraphQLCommitStrategy, MAX_PAYLOAD_SIZE, } from "./graphql-commit-strategy.js";
17
- export { getCommitStrategy, hasGitHubAppCredentials, } from "./commit-strategy-selector.js";
2
+ export { getCommitStrategy, createTokenManager, } from "./commit-strategy-selector.js";
18
3
  // PR strategy factory
19
- import { isGitHubRepo, isAzureDevOpsRepo, isGitLabRepo, } from "../shared/repo-detector.js";
20
- import { GitHubPRStrategy } from "./github-pr-strategy.js";
21
- import { AzurePRStrategy } from "./azure-pr-strategy.js";
22
- import { GitLabPRStrategy } from "./gitlab-pr-strategy.js";
23
- export function getPRStrategy(repoInfo, executor) {
24
- if (isGitHubRepo(repoInfo)) {
25
- return new GitHubPRStrategy(executor);
26
- }
27
- if (isAzureDevOpsRepo(repoInfo)) {
28
- return new AzurePRStrategy(executor);
29
- }
30
- if (isGitLabRepo(repoInfo)) {
31
- return new GitLabPRStrategy(executor);
32
- }
33
- const _exhaustive = repoInfo;
34
- throw new Error(`Unknown repository type: ${JSON.stringify(_exhaustive)}`);
35
- }
4
+ export { getPRStrategy } from "./pr-strategy-factory.js";
@@ -1,12 +1,12 @@
1
1
  import { RepoInfo } from "../shared/repo-detector.js";
2
- import type { MergeResult, PRMergeConfig } from "./types.js";
2
+ import type { IPRStrategyLogger } from "./pr-strategy.js";
3
+ import type { MergeResult, PRMergeConfig, PRResult } from "./types.js";
3
4
  import { ICommandExecutor } from "../shared/command-executor.js";
4
- export { escapeShellArg } from "../shared/shell-utils.js";
5
5
  export interface FileAction {
6
6
  fileName: string;
7
7
  action: "create" | "update" | "skip" | "delete";
8
8
  }
9
- export interface PROptions {
9
+ interface PROptions {
10
10
  repoInfo: RepoInfo;
11
11
  branchName: string;
12
12
  baseBranch: string;
@@ -23,12 +23,10 @@ export interface PROptions {
23
23
  token?: string;
24
24
  /** Labels to apply to the created PR */
25
25
  labels?: string[];
26
+ /** Optional logger for PR strategy debug/warn/info messages */
27
+ log?: IPRStrategyLogger;
26
28
  }
27
- export interface PRResult {
28
- url?: string;
29
- success: boolean;
30
- message: string;
31
- }
29
+ export type { PRResult } from "./types.js";
32
30
  /**
33
31
  * Format PR body using template with ${xfg:...} variables.
34
32
  *
@@ -45,7 +43,7 @@ export declare function formatPRBody(files: FileAction[], repoInfo: RepoInfo, cu
45
43
  */
46
44
  export declare function formatPRTitle(files: FileAction[]): string;
47
45
  export declare function createPR(options: PROptions): Promise<PRResult>;
48
- export interface MergePROptions {
46
+ interface MergePROptions {
49
47
  repoInfo: RepoInfo;
50
48
  prUrl: string;
51
49
  mergeConfig: PRMergeConfig;
@@ -56,5 +54,7 @@ export interface MergePROptions {
56
54
  executor?: ICommandExecutor;
57
55
  /** GitHub App installation token for authentication */
58
56
  token?: string;
57
+ /** Optional logger for PR strategy debug/warn/info messages */
58
+ log?: IPRStrategyLogger;
59
59
  }
60
60
  export declare function mergePR(options: MergePROptions): Promise<MergeResult>;
@@ -1,10 +1,9 @@
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 "./index.js";
5
- import { interpolateXfgContent } from "../sync/xfg-template.js";
6
- // Re-export for backwards compatibility and testing
7
- export { escapeShellArg } from "../shared/shell-utils.js";
4
+ import { getPRStrategy } from "./pr-strategy-factory.js";
5
+ import { PRWorkflowExecutor } from "./pr-strategy.js";
6
+ import { interpolateXfgContent } from "../shared/xfg-template.js";
8
7
  function loadDefaultTemplate() {
9
8
  // Try to find PR.md in the project root
10
9
  const __filename = fileURLToPath(import.meta.url);
@@ -97,7 +96,7 @@ export function formatPRTitle(files) {
97
96
  return `chore: sync ${changedFiles.length} config files`;
98
97
  }
99
98
  export async function createPR(options) {
100
- const { repoInfo, branchName, baseBranch, files, workDir, dryRun, retries, prTemplate, executor, token, labels, } = options;
99
+ const { repoInfo, branchName, baseBranch, files, workDir, dryRun, retries, prTemplate, executor, token, labels, log, } = options;
101
100
  const title = formatPRTitle(files);
102
101
  const body = formatPRBody(files, repoInfo, prTemplate);
103
102
  if (dryRun) {
@@ -106,9 +105,10 @@ export async function createPR(options) {
106
105
  message: `[DRY RUN] Would create PR: "${title}"`,
107
106
  };
108
107
  }
109
- // Get the appropriate strategy and execute
110
- const strategy = getPRStrategy(repoInfo, executor);
111
- return strategy.execute({
108
+ // Get the appropriate strategy and execute via workflow executor
109
+ const strategy = getPRStrategy(repoInfo, executor, log);
110
+ const workflow = new PRWorkflowExecutor(strategy);
111
+ return workflow.execute({
112
112
  repoInfo,
113
113
  title,
114
114
  body,
@@ -121,7 +121,7 @@ export async function createPR(options) {
121
121
  });
122
122
  }
123
123
  export async function mergePR(options) {
124
- const { repoInfo, prUrl, mergeConfig, workDir, dryRun, retries, executor, token, } = options;
124
+ const { repoInfo, prUrl, mergeConfig, workDir, dryRun, retries, executor, token, log, } = options;
125
125
  if (dryRun) {
126
126
  const modeText = mergeConfig.mode === "force"
127
127
  ? "force merge"
@@ -135,9 +135,10 @@ export async function mergePR(options) {
135
135
  };
136
136
  }
137
137
  // Get the appropriate strategy and execute merge
138
- const strategy = getPRStrategy(repoInfo, executor);
138
+ const strategy = getPRStrategy(repoInfo, executor, log);
139
139
  return strategy.merge({
140
140
  prUrl,
141
+ repoInfo,
141
142
  config: mergeConfig,
142
143
  workDir,
143
144
  retries,
@@ -0,0 +1,5 @@
1
+ import { RepoInfo } from "../shared/repo-detector.js";
2
+ import type { IPRStrategy } from "./types.js";
3
+ import { ICommandExecutor } from "../shared/command-executor.js";
4
+ import type { IPRStrategyLogger } from "./pr-strategy.js";
5
+ export declare function getPRStrategy(repoInfo: RepoInfo, executor?: ICommandExecutor, log?: IPRStrategyLogger): IPRStrategy;