@aspruyt/xfg 6.0.3 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/dist/cli/lifecycle-report-builder.d.ts +2 -2
  2. package/dist/cli/lifecycle-report-builder.js +3 -11
  3. package/dist/cli/program.d.ts +2 -1
  4. package/dist/cli/program.js +2 -3
  5. package/dist/cli/repo-sync-runner.d.ts +24 -0
  6. package/dist/cli/repo-sync-runner.js +156 -0
  7. package/dist/cli/results-collector.d.ts +1 -1
  8. package/dist/cli/results-collector.js +2 -2
  9. package/dist/cli/settings-factories.d.ts +7 -0
  10. package/dist/cli/settings-factories.js +27 -0
  11. package/dist/cli/settings-report-builder.d.ts +1 -1
  12. package/dist/cli/settings-report-builder.js +12 -23
  13. package/dist/cli/settings-runner.d.ts +2 -0
  14. package/dist/cli/settings-runner.js +87 -0
  15. package/dist/cli/sync-command.d.ts +1 -1
  16. package/dist/cli/sync-command.js +31 -372
  17. package/dist/cli/sync-report-builder.d.ts +1 -1
  18. package/dist/cli/sync-utils.d.ts +8 -0
  19. package/dist/cli/sync-utils.js +36 -0
  20. package/dist/cli/types.d.ts +5 -7
  21. package/dist/cli/unified-summary.d.ts +1 -3
  22. package/dist/cli/unified-summary.js +7 -5
  23. package/dist/cli.js +2 -1
  24. package/dist/{shared → config}/env.js +2 -2
  25. package/dist/config/extends-resolver.js +4 -3
  26. package/dist/config/file-reference-resolver.js +4 -2
  27. package/dist/config/formatter.js +18 -1
  28. package/dist/config/index.d.ts +1 -1
  29. package/dist/config/loader.js +30 -6
  30. package/dist/config/merge.d.ts +11 -1
  31. package/dist/config/merge.js +78 -6
  32. package/dist/config/normalizer.js +53 -38
  33. package/dist/config/validator.d.ts +1 -4
  34. package/dist/config/validator.js +13 -599
  35. package/dist/config/validators/file-validator.d.ts +2 -1
  36. package/dist/config/validators/file-validator.js +9 -1
  37. package/dist/config/validators/group-validator.d.ts +3 -0
  38. package/dist/config/validators/group-validator.js +167 -0
  39. package/dist/config/validators/repo-entry-validator.d.ts +2 -0
  40. package/dist/config/validators/repo-entry-validator.js +165 -0
  41. package/dist/config/validators/repo-settings-validator.js +18 -7
  42. package/dist/config/validators/ruleset-validator.js +2 -5
  43. package/dist/config/validators/shared.d.ts +11 -0
  44. package/dist/config/validators/shared.js +242 -0
  45. package/dist/lifecycle/ado-migration-source.js +2 -4
  46. package/dist/lifecycle/github-lifecycle-provider.d.ts +7 -11
  47. package/dist/lifecycle/github-lifecycle-provider.js +125 -136
  48. package/dist/lifecycle/{lifecycle-helpers.d.ts → helpers.d.ts} +5 -1
  49. package/dist/lifecycle/{lifecycle-helpers.js → helpers.js} +9 -8
  50. package/dist/lifecycle/index.d.ts +2 -2
  51. package/dist/lifecycle/index.js +1 -1
  52. package/dist/lifecycle/repo-lifecycle-factory.d.ts +2 -2
  53. package/dist/output/github-summary.js +2 -3
  54. package/dist/output/index.d.ts +4 -0
  55. package/dist/output/index.js +4 -0
  56. package/dist/output/lifecycle-report.d.ts +1 -1
  57. package/dist/output/lifecycle-report.js +5 -0
  58. package/dist/output/sync-report.d.ts +25 -3
  59. package/dist/output/sync-report.js +11 -11
  60. package/dist/settings/base-processor.d.ts +18 -7
  61. package/dist/settings/base-processor.js +26 -5
  62. package/dist/settings/code-scanning/diff.js +2 -2
  63. package/dist/settings/code-scanning/formatter.d.ts +2 -6
  64. package/dist/settings/code-scanning/formatter.js +2 -25
  65. package/dist/settings/code-scanning/github-code-scanning-strategy.d.ts +3 -7
  66. package/dist/settings/code-scanning/github-code-scanning-strategy.js +2 -2
  67. package/dist/settings/code-scanning/processor.js +6 -4
  68. package/dist/settings/code-scanning/types.d.ts +10 -8
  69. package/dist/settings/labels/github-labels-strategy.d.ts +3 -11
  70. package/dist/settings/labels/types.d.ts +12 -10
  71. package/dist/settings/repo-settings/diff.d.ts +1 -1
  72. package/dist/settings/repo-settings/diff.js +1 -1
  73. package/dist/settings/repo-settings/formatter.d.ts +2 -6
  74. package/dist/settings/repo-settings/formatter.js +4 -23
  75. package/dist/settings/repo-settings/github-repo-settings-strategy.d.ts +2 -2
  76. package/dist/settings/repo-settings/github-repo-settings-strategy.js +8 -7
  77. package/dist/settings/repo-settings/processor.js +11 -11
  78. package/dist/settings/repo-settings/types.d.ts +2 -2
  79. package/dist/settings/rulesets/diff-algorithm.js +4 -2
  80. package/dist/settings/rulesets/diff.js +2 -51
  81. package/dist/settings/rulesets/formatter.js +4 -0
  82. package/dist/settings/rulesets/github-ruleset-strategy.d.ts +3 -3
  83. package/dist/settings/rulesets/github-ruleset-strategy.js +4 -6
  84. package/dist/settings/rulesets/index.d.ts +1 -1
  85. package/dist/settings/rulesets/index.js +0 -2
  86. package/dist/settings/rulesets/processor.js +1 -1
  87. package/dist/settings/rulesets/types.d.ts +6 -2
  88. package/dist/shared/command-executor.d.ts +4 -4
  89. package/dist/shared/command-executor.js +9 -7
  90. package/dist/shared/diff-format.d.ts +1 -0
  91. package/dist/shared/diff-format.js +10 -0
  92. package/dist/shared/errors.d.ts +7 -4
  93. package/dist/shared/errors.js +8 -8
  94. package/dist/shared/gh-api-utils.d.ts +3 -34
  95. package/dist/shared/gh-api-utils.js +23 -53
  96. package/dist/shared/gh-token-utils.d.ts +26 -0
  97. package/dist/shared/gh-token-utils.js +32 -0
  98. package/dist/shared/json-utils.js +1 -1
  99. package/dist/shared/regex-utils.d.ts +1 -0
  100. package/dist/shared/regex-utils.js +3 -0
  101. package/dist/shared/retry-utils.d.ts +1 -0
  102. package/dist/shared/retry-utils.js +13 -7
  103. package/dist/sync/auth-options-builder.js +1 -1
  104. package/dist/sync/branch-manager.js +5 -3
  105. package/dist/sync/commit-push-manager.js +2 -3
  106. package/dist/sync/diff-utils.d.ts +0 -1
  107. package/dist/sync/diff-utils.js +5 -10
  108. package/dist/sync/file-sync-orchestrator.js +0 -2
  109. package/dist/sync/file-writer.d.ts +3 -0
  110. package/dist/sync/file-writer.js +84 -81
  111. package/dist/sync/index.d.ts +0 -1
  112. package/dist/sync/index.js +0 -1
  113. package/dist/sync/manifest.js +1 -1
  114. package/dist/sync/pr-merge-handler.js +6 -6
  115. package/dist/sync/sync-workflow.js +1 -1
  116. package/dist/sync/types.d.ts +2 -2
  117. package/dist/vcs/ado-pr-strategy.d.ts +3 -5
  118. package/dist/vcs/ado-pr-strategy.js +131 -33
  119. package/dist/vcs/authenticated-git-ops.js +45 -23
  120. package/dist/vcs/git-commit-strategy.js +10 -6
  121. package/dist/vcs/git-ops.js +30 -24
  122. package/dist/vcs/github-pr-strategy.d.ts +3 -2
  123. package/dist/vcs/github-pr-strategy.js +80 -30
  124. package/dist/vcs/gitlab-pr-strategy.d.ts +2 -5
  125. package/dist/vcs/gitlab-pr-strategy.js +88 -87
  126. package/dist/vcs/graphql-commit-strategy.d.ts +1 -5
  127. package/dist/vcs/graphql-commit-strategy.js +21 -37
  128. package/dist/vcs/pr-creator.js +9 -2
  129. package/dist/vcs/pr-strategy.d.ts +2 -3
  130. package/dist/vcs/pr-strategy.js +0 -1
  131. package/dist/vcs/types.d.ts +9 -5
  132. package/package.json +5 -5
  133. package/dist/config/validators/index.d.ts +0 -3
  134. package/dist/config/validators/index.js +0 -6
  135. package/dist/output/types.d.ts +0 -20
  136. package/dist/output/types.js +0 -1
  137. package/dist/shared/shell-utils.d.ts +0 -6
  138. package/dist/shared/shell-utils.js +0 -17
  139. /package/dist/{shared → config}/env.d.ts +0 -0
  140. /package/dist/lifecycle/{lifecycle-formatter.d.ts → formatter.d.ts} +0 -0
  141. /package/dist/lifecycle/{lifecycle-formatter.js → formatter.js} +0 -0
  142. /package/dist/{vcs → shared}/sanitize-utils.d.ts +0 -0
  143. /package/dist/{vcs → shared}/sanitize-utils.js +0 -0
@@ -1,15 +1,15 @@
1
1
  import { existsSync, writeFileSync, unlinkSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { escapeShellArg, escapeRegExp } from "../shared/shell-utils.js";
3
+ import { escapeRegExp } from "../shared/regex-utils.js";
4
4
  import { assertGitHubRepo } from "../repo/index.js";
5
5
  import { BasePRStrategy } from "./pr-strategy.js";
6
6
  import { withRetry, isPermanentError } from "../shared/retry-utils.js";
7
- import { sanitizeCredentials } from "./sanitize-utils.js";
7
+ import { sanitizeCredentials } from "../shared/sanitize-utils.js";
8
8
  import { toErrorMessage } from "../shared/type-guards.js";
9
9
  import { safeCleanup } from "../shared/cleanup-utils.js";
10
10
  import { NO_OP_DEBUG_LOG } from "../shared/logger.js";
11
11
  import { getStderr } from "../shared/command-executor.js";
12
- import { buildTokenEnv, getHostnameFlag } from "../shared/gh-api-utils.js";
12
+ import { buildTokenEnv, buildHostnameArgs } from "../shared/gh-api-utils.js";
13
13
  import { SyncError } from "../shared/errors.js";
14
14
  /**
15
15
  * Get the repo flag value for gh CLI commands.
@@ -26,14 +26,26 @@ function buildPRUrlRegex(host) {
26
26
  return new RegExp(`https://${escapedHost}/[\\w-]+/[\\w.-]+/pull/\\d+`);
27
27
  }
28
28
  export class GitHubPRStrategy extends BasePRStrategy {
29
+ bodyFilePath = ".pr-body.md";
29
30
  async findExistingPRUrl(options) {
30
31
  const { repoInfo, branchName, workDir, retries = 3, token } = options;
31
32
  assertGitHubRepo(repoInfo, "GitHub PR strategy");
32
33
  const repoFlag = getRepoFlag(repoInfo);
33
34
  const tokenEnv = buildTokenEnv(token);
34
- const command = `gh pr list --repo ${escapeShellArg(repoFlag)} --head ${escapeShellArg(branchName)} --json url --jq '.[0].url'`;
35
+ const args = [
36
+ "pr",
37
+ "list",
38
+ "--repo",
39
+ repoFlag,
40
+ "--head",
41
+ branchName,
42
+ "--json",
43
+ "url",
44
+ "--jq",
45
+ ".[0].url",
46
+ ];
35
47
  try {
36
- const existingPR = await withRetry(() => this.executor.exec(command, workDir, { env: tokenEnv }), { retries, log: this.log });
48
+ const existingPR = await withRetry(() => this.executor.exec("gh", args, workDir, { env: tokenEnv }), { retries, log: this.log });
37
49
  return existingPR || null;
38
50
  }
39
51
  catch (error) {
@@ -50,7 +62,6 @@ export class GitHubPRStrategy extends BasePRStrategy {
50
62
  async closeExistingPR(options) {
51
63
  const { repoInfo, branchName, baseBranch, workDir, retries = 3, token, } = options;
52
64
  assertGitHubRepo(repoInfo, "GitHub PR strategy");
53
- // First check if there's an existing PR (pass token through)
54
65
  const existingUrl = await this.findExistingPRUrl({
55
66
  repoInfo,
56
67
  branchName,
@@ -60,25 +71,33 @@ export class GitHubPRStrategy extends BasePRStrategy {
60
71
  token,
61
72
  });
62
73
  if (!existingUrl) {
63
- return false;
74
+ return { status: "no_pr" };
64
75
  }
65
- // Extract PR number from URL
66
76
  const prNumber = existingUrl.match(/\/pull\/(\d+)/)?.[1];
67
77
  if (!prNumber) {
68
- this.log?.warn(`Could not extract PR number from URL: ${existingUrl}`);
69
- return false;
78
+ return {
79
+ status: "close_failed",
80
+ message: `Could not extract PR number from URL: ${existingUrl}`,
81
+ };
70
82
  }
71
83
  const repoFlag = getRepoFlag(repoInfo);
72
84
  const tokenEnv = buildTokenEnv(token);
73
- const command = `gh pr close ${escapeShellArg(prNumber)} --repo ${escapeShellArg(repoFlag)} --delete-branch`;
85
+ const args = [
86
+ "pr",
87
+ "close",
88
+ prNumber,
89
+ "--repo",
90
+ repoFlag,
91
+ "--delete-branch",
92
+ ];
74
93
  try {
75
- await withRetry(() => this.executor.exec(command, workDir, { env: tokenEnv }), { retries, log: this.log });
76
- return true;
94
+ await withRetry(() => this.executor.exec("gh", args, workDir, { env: tokenEnv }), { retries, log: this.log });
95
+ return { status: "closed" };
77
96
  }
78
97
  catch (error) {
79
98
  const message = toErrorMessage(error);
80
99
  this.log?.warn(`Failed to close existing PR #${prNumber}: ${message}`);
81
- return false;
100
+ return { status: "close_failed", message };
82
101
  }
83
102
  }
84
103
  async create(options) {
@@ -89,18 +108,28 @@ export class GitHubPRStrategy extends BasePRStrategy {
89
108
  writeFileSync(bodyFile, body, "utf-8");
90
109
  }
91
110
  catch (err) {
92
- throw new SyncError(`Failed to write PR description to ${bodyFile}: ${toErrorMessage(err)}`);
111
+ throw new SyncError(`Failed to write PR description to ${bodyFile}: ${toErrorMessage(err)}`, { cause: err });
93
112
  }
94
113
  const tokenEnv = buildTokenEnv(token);
95
- let command = `gh pr create --title ${escapeShellArg(title)} --body-file ${escapeShellArg(bodyFile)} --base ${escapeShellArg(baseBranch)} --head ${escapeShellArg(branchName)}`;
96
- // Append label flags
114
+ const args = [
115
+ "pr",
116
+ "create",
117
+ "--title",
118
+ title,
119
+ "--body-file",
120
+ bodyFile,
121
+ "--base",
122
+ baseBranch,
123
+ "--head",
124
+ branchName,
125
+ ];
97
126
  if (labels && labels.length > 0) {
98
127
  for (const label of labels) {
99
- command += ` --label ${escapeShellArg(label)}`;
128
+ args.push("--label", label);
100
129
  }
101
130
  }
102
131
  try {
103
- const result = await withRetry(() => this.executor.exec(command, workDir, { env: tokenEnv }), { retries, log: this.log });
132
+ const result = await withRetry(() => this.executor.exec("gh", args, workDir, { env: tokenEnv }), { retries, log: this.log });
104
133
  // Extract URL from output - use strict regex for valid PR URLs only
105
134
  const host = repoInfo.host;
106
135
  const urlRegex = buildPRUrlRegex(host);
@@ -125,12 +154,17 @@ export class GitHubPRStrategy extends BasePRStrategy {
125
154
  * Check if auto-merge is enabled on the repository.
126
155
  */
127
156
  async checkAutoMergeEnabled(repoInfo, workDir, retries = 3, token) {
128
- const hostnameFlag = getHostnameFlag(repoInfo);
129
- const hostnamePart = hostnameFlag ? `${hostnameFlag} ` : "";
157
+ const hostnameArgs = buildHostnameArgs(repoInfo);
130
158
  const tokenEnv = buildTokenEnv(token);
131
- const command = `gh api ${hostnamePart}repos/${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)} --jq '.allow_auto_merge // false'`;
159
+ const args = [
160
+ "api",
161
+ ...hostnameArgs,
162
+ `repos/${repoInfo.owner}/${repoInfo.repo}`,
163
+ "--jq",
164
+ ".allow_auto_merge // false",
165
+ ];
132
166
  try {
133
- const result = await withRetry(() => this.executor.exec(command, workDir, { env: tokenEnv }), { retries, log: this.log });
167
+ const result = await withRetry(() => this.executor.exec("gh", args, workDir, { env: tokenEnv }), { retries, log: this.log });
134
168
  return result.trim() === "true";
135
169
  }
136
170
  catch (error) {
@@ -149,8 +183,12 @@ export class GitHubPRStrategy extends BasePRStrategy {
149
183
  case "rebase":
150
184
  return "--rebase";
151
185
  case "merge":
152
- default:
186
+ case undefined:
153
187
  return "--merge";
188
+ default: {
189
+ const _exhaustive = strategy;
190
+ throw new Error(`Unexpected merge strategy: ${_exhaustive}`);
191
+ }
154
192
  }
155
193
  }
156
194
  async merge(options) {
@@ -163,7 +201,6 @@ export class GitHubPRStrategy extends BasePRStrategy {
163
201
  };
164
202
  }
165
203
  const strategyFlag = this.getMergeStrategyFlag(config.strategy);
166
- const deleteBranchFlag = config.deleteBranch ? "--delete-branch" : "";
167
204
  const tokenEnv = buildTokenEnv(token);
168
205
  if (config.mode === "auto") {
169
206
  // Check if auto-merge is enabled on the repo
@@ -179,9 +216,15 @@ export class GitHubPRStrategy extends BasePRStrategy {
179
216
  autoMergeEnabled: false,
180
217
  };
181
218
  }
182
- // Enable auto-merge
183
- const autoCommand = `gh pr merge ${escapeShellArg(prUrl)} --auto ${strategyFlag} ${deleteBranchFlag}`.trim();
184
- return this.executeMergeCommand(() => this.executor.exec(autoCommand, workDir, { env: tokenEnv }), retries, {
219
+ const autoArgs = [
220
+ "pr",
221
+ "merge",
222
+ prUrl,
223
+ "--auto",
224
+ strategyFlag,
225
+ ...(config.deleteBranch ? ["--delete-branch"] : []),
226
+ ];
227
+ return this.executeMergeCommand(() => this.executor.exec("gh", autoArgs, workDir, { env: tokenEnv }), retries, {
185
228
  success: true,
186
229
  message: "Auto-merge enabled. PR will merge when checks pass.",
187
230
  merged: false,
@@ -190,8 +233,15 @@ export class GitHubPRStrategy extends BasePRStrategy {
190
233
  }
191
234
  if (config.mode === "force") {
192
235
  this.log?.warn(`Force-merging PR ${prUrl} using admin privileges (bypasses branch protection)`);
193
- const forceCommand = `gh pr merge ${escapeShellArg(prUrl)} --admin ${strategyFlag} ${deleteBranchFlag}`.trim();
194
- return this.executeMergeCommand(() => this.executor.exec(forceCommand, workDir, { env: tokenEnv }), retries, {
236
+ const forceArgs = [
237
+ "pr",
238
+ "merge",
239
+ prUrl,
240
+ "--admin",
241
+ strategyFlag,
242
+ ...(config.deleteBranch ? ["--delete-branch"] : []),
243
+ ];
244
+ return this.executeMergeCommand(() => this.executor.exec("gh", forceArgs, workDir, { env: tokenEnv }), retries, {
195
245
  success: true,
196
246
  message: "PR merged successfully using admin privileges.",
197
247
  merged: true,
@@ -1,10 +1,7 @@
1
1
  import type { PRResult } from "./types.js";
2
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";
5
- import { type ICommandExecutor } from "../shared/command-executor.js";
3
+ import type { PRStrategyOptions, CloseExistingPROptions, ClosePRResult, MergeOptions, MergeResult } from "./types.js";
6
4
  export declare class GitLabPRStrategy extends BasePRStrategy {
7
- constructor(executor: ICommandExecutor, log?: IPRStrategyLogger);
8
5
  /**
9
6
  * Build the repo flag for glab commands.
10
7
  * Format: namespace/repo (supports nested groups)
@@ -23,7 +20,7 @@ export declare class GitLabPRStrategy extends BasePRStrategy {
23
20
  */
24
21
  private getMergeStrategyFlag;
25
22
  findExistingPRUrl(options: CloseExistingPROptions): Promise<string | null>;
26
- closeExistingPR(options: CloseExistingPROptions): Promise<boolean>;
23
+ closeExistingPR(options: CloseExistingPROptions): Promise<ClosePRResult>;
27
24
  create(options: PRStrategyOptions): Promise<PRResult>;
28
25
  merge(options: MergeOptions): Promise<MergeResult>;
29
26
  }
@@ -1,21 +1,13 @@
1
- import { existsSync, writeFileSync, unlinkSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { escapeShellArg } from "../shared/shell-utils.js";
4
1
  import { assertGitLabRepo } from "../repo/index.js";
5
2
  import { BasePRStrategy } from "./pr-strategy.js";
6
3
  import { withRetry, isPermanentError } from "../shared/retry-utils.js";
7
- import { getStderr, } from "../shared/command-executor.js";
4
+ import { getStderr } from "../shared/command-executor.js";
8
5
  import { parseApiJson } from "../shared/json-utils.js";
9
- import { sanitizeCredentials } from "./sanitize-utils.js";
6
+ import { sanitizeCredentials } from "../shared/sanitize-utils.js";
10
7
  import { toErrorMessage } from "../shared/type-guards.js";
11
- import { safeCleanup } from "../shared/cleanup-utils.js";
12
- import { NO_OP_DEBUG_LOG } from "../shared/logger.js";
13
8
  import { SyncError } from "../shared/errors.js";
9
+ const MR_CREATED_MSG = "MR created successfully";
14
10
  export class GitLabPRStrategy extends BasePRStrategy {
15
- constructor(executor, log) {
16
- super(executor, log);
17
- this.bodyFilePath = ".mr-description.md";
18
- }
19
11
  /**
20
12
  * Build the repo flag for glab commands.
21
13
  * Format: namespace/repo (supports nested groups)
@@ -61,19 +53,30 @@ export class GitLabPRStrategy extends BasePRStrategy {
61
53
  case "rebase":
62
54
  return "--rebase";
63
55
  case "merge":
64
- default:
56
+ case undefined:
65
57
  return "";
58
+ /* c8 ignore next 4 */
59
+ default: {
60
+ const _exhaustive = strategy;
61
+ throw new Error(`Unexpected merge strategy: ${_exhaustive}`);
62
+ }
66
63
  }
67
64
  }
68
65
  async findExistingPRUrl(options) {
69
66
  const { repoInfo, branchName, workDir, retries = 3 } = options;
70
67
  assertGitLabRepo(repoInfo, "GitLab PR strategy");
71
68
  const repoFlag = this.getRepoFlag(repoInfo);
72
- // Use glab mr list with JSON output for reliable parsing
73
- // Note: glab mr list returns open MRs by default (use -c for closed, -M for merged)
74
- const command = `glab mr list --source-branch ${escapeShellArg(branchName)} -R ${escapeShellArg(repoFlag)} -F json`;
75
69
  try {
76
- const result = await withRetry(() => this.executor.exec(command, workDir), { retries, log: this.log });
70
+ const result = await withRetry(() => this.executor.exec("glab", [
71
+ "mr",
72
+ "list",
73
+ "--source-branch",
74
+ branchName,
75
+ "-R",
76
+ repoFlag,
77
+ "-F",
78
+ "json",
79
+ ], workDir), { retries, log: this.log });
77
80
  if (!result || result.trim() === "" || result.trim() === "[]") {
78
81
  return null;
79
82
  }
@@ -97,7 +100,6 @@ export class GitLabPRStrategy extends BasePRStrategy {
97
100
  async closeExistingPR(options) {
98
101
  const { repoInfo, branchName, baseBranch, workDir, retries = 3 } = options;
99
102
  assertGitLabRepo(repoInfo, "GitLab PR strategy");
100
- // First check if there's an existing MR
101
103
  const existingUrl = await this.findExistingPRUrl({
102
104
  repoInfo,
103
105
  branchName,
@@ -106,84 +108,74 @@ export class GitLabPRStrategy extends BasePRStrategy {
106
108
  retries,
107
109
  });
108
110
  if (!existingUrl) {
109
- return false;
111
+ return { status: "no_pr" };
110
112
  }
111
- // Extract MR IID from URL
112
113
  const mrInfo = this.parseMRUrl(existingUrl);
113
114
  if (!mrInfo) {
114
- this.log?.warn(`Could not extract MR IID from URL: ${existingUrl}`);
115
- return false;
115
+ return {
116
+ status: "close_failed",
117
+ message: `Could not extract MR IID from URL: ${existingUrl}`,
118
+ };
116
119
  }
117
120
  const repoFlag = this.getRepoFlag(repoInfo);
118
- // Close the MR
119
- const closeCommand = `glab mr close ${escapeShellArg(mrInfo.mrIid)} -R ${escapeShellArg(repoFlag)}`;
120
121
  try {
121
- await withRetry(() => this.executor.exec(closeCommand, workDir), {
122
- retries,
123
- log: this.log,
124
- });
122
+ await withRetry(() => this.executor.exec("glab", ["mr", "close", mrInfo.mrIid, "-R", repoFlag], workDir), { retries, log: this.log });
125
123
  }
126
124
  catch (error) {
127
125
  const message = toErrorMessage(error);
128
126
  this.log?.warn(`Failed to close existing MR !${mrInfo.mrIid}: ${message}`);
129
- return false;
127
+ return { status: "close_failed", message };
130
128
  }
131
- const deleteBranchCommand = `git push origin --delete ${escapeShellArg(branchName)}`;
132
129
  try {
133
- await withRetry(() => this.executor.exec(deleteBranchCommand, workDir), {
134
- retries,
135
- log: this.log,
136
- });
130
+ await withRetry(() => this.executor.exec("git", ["push", "origin", "--delete", branchName], workDir), { retries, log: this.log });
137
131
  }
138
132
  catch (error) {
139
- // Branch deletion failure is not critical
140
- const message = toErrorMessage(error);
141
- this.log?.warn(`Failed to delete branch ${branchName}: ${message}`);
133
+ const message = `MR !${mrInfo.mrIid} closed but branch ${branchName} deletion failed: ${toErrorMessage(error)}`;
134
+ this.log?.warn(message);
135
+ return { status: "close_failed", message };
142
136
  }
143
- return true;
137
+ return { status: "closed" };
144
138
  }
145
139
  async create(options) {
146
140
  const { repoInfo, title, body, branchName, baseBranch, workDir, retries = 3, } = options;
147
141
  assertGitLabRepo(repoInfo, "GitLab PR strategy");
148
142
  const repoFlag = this.getRepoFlag(repoInfo);
149
- const descFile = join(workDir, this.bodyFilePath);
150
- try {
151
- writeFileSync(descFile, body, "utf-8");
152
- }
153
- catch (err) {
154
- throw new SyncError(`Failed to write PR description to ${descFile}: ${toErrorMessage(err)}`);
155
- }
156
- // glab mr create with description from file
157
- const command = `glab mr create --source-branch ${escapeShellArg(branchName)} --target-branch ${escapeShellArg(baseBranch)} --title ${escapeShellArg(title)} --description "$(cat ${escapeShellArg(descFile)})" --yes -R ${escapeShellArg(repoFlag)}`;
158
- try {
159
- const result = await withRetry(() => this.executor.exec(command, workDir), { retries, log: this.log });
160
- // Extract MR URL from output
161
- // glab typically outputs the URL directly
162
- const urlMatch = result.match(/https:\/\/[^\s]+\/-\/merge_requests\/\d+/);
163
- if (urlMatch) {
164
- return {
165
- url: urlMatch[0],
166
- success: true,
167
- message: "MR created successfully",
168
- };
169
- }
170
- // Fallback: extract MR number and build URL
171
- const mrMatch = result.match(/!(\d+)/);
172
- if (mrMatch) {
173
- return {
174
- url: this.buildMRUrl(repoInfo, mrMatch[1]),
175
- success: true,
176
- message: "MR created successfully",
177
- };
178
- }
179
- throw new SyncError(`Could not parse MR URL from output: ${result}`);
143
+ const args = [
144
+ "mr",
145
+ "create",
146
+ "--source-branch",
147
+ branchName,
148
+ "--target-branch",
149
+ baseBranch,
150
+ "--title",
151
+ title,
152
+ "--description",
153
+ body,
154
+ "--yes",
155
+ "-R",
156
+ repoFlag,
157
+ ];
158
+ const result = await withRetry(() => this.executor.exec("glab", args, workDir), { retries, log: this.log });
159
+ // Extract MR URL from output
160
+ // glab typically outputs the URL directly
161
+ const urlMatch = result.match(/https:\/\/[^\s]+\/-\/merge_requests\/\d+/);
162
+ if (urlMatch) {
163
+ return {
164
+ url: urlMatch[0],
165
+ success: true,
166
+ message: MR_CREATED_MSG,
167
+ };
180
168
  }
181
- finally {
182
- safeCleanup(() => {
183
- if (existsSync(descFile))
184
- unlinkSync(descFile);
185
- }, `failed to remove ${descFile}`, this.log ?? NO_OP_DEBUG_LOG);
169
+ // Fallback: extract MR number and build URL
170
+ const mrMatch = result.match(/!(\d+)/);
171
+ if (mrMatch) {
172
+ return {
173
+ url: this.buildMRUrl(repoInfo, mrMatch[1]),
174
+ success: true,
175
+ message: MR_CREATED_MSG,
176
+ };
186
177
  }
178
+ throw new SyncError(`Could not parse MR URL from output: ${result}`);
187
179
  }
188
180
  async merge(options) {
189
181
  const { prUrl, config, workDir, retries = 3 } = options;
@@ -205,18 +197,19 @@ export class GitLabPRStrategy extends BasePRStrategy {
205
197
  }
206
198
  const repoFlag = `${mrInfo.namespace}/${mrInfo.repo}`;
207
199
  const strategyFlag = this.getMergeStrategyFlag(config.strategy);
208
- const deleteBranchFlag = config.deleteBranch
209
- ? "--remove-source-branch"
210
- : "";
211
200
  if (config.mode === "auto") {
212
- // Enable auto-merge when pipeline succeeds
213
- const autoFlagParts = [
201
+ const args = [
202
+ "mr",
203
+ "merge",
204
+ mrInfo.mrIid,
214
205
  "--when-pipeline-succeeds",
215
- strategyFlag,
216
- deleteBranchFlag,
217
- ].filter(Boolean);
218
- const autoCommand = `glab mr merge ${escapeShellArg(mrInfo.mrIid)} ${autoFlagParts.join(" ")} -R ${escapeShellArg(repoFlag)} -y`;
219
- return this.executeMergeCommand(() => this.executor.exec(autoCommand.trim(), workDir), retries, {
206
+ ...(strategyFlag ? [strategyFlag] : []),
207
+ ...(config.deleteBranch ? ["--remove-source-branch"] : []),
208
+ "-R",
209
+ repoFlag,
210
+ "-y",
211
+ ];
212
+ return this.executeMergeCommand(() => this.executor.exec("glab", args, workDir), retries, {
220
213
  success: true,
221
214
  message: "Auto-merge enabled. MR will merge when pipeline succeeds.",
222
215
  merged: false,
@@ -224,10 +217,18 @@ export class GitLabPRStrategy extends BasePRStrategy {
224
217
  }, "Failed to enable auto-merge");
225
218
  }
226
219
  if (config.mode === "force") {
227
- // Force merge immediately
228
- const forceFlagParts = [strategyFlag, deleteBranchFlag].filter(Boolean);
229
- const forceCommand = `glab mr merge ${escapeShellArg(mrInfo.mrIid)} ${forceFlagParts.join(" ")} -R ${escapeShellArg(repoFlag)} -y`;
230
- return this.executeMergeCommand(() => this.executor.exec(forceCommand.trim(), workDir), retries, {
220
+ this.log?.warn(`Force-merging MR ${mrInfo.mrIid} immediately (bypasses pipeline requirements)`);
221
+ const args = [
222
+ "mr",
223
+ "merge",
224
+ mrInfo.mrIid,
225
+ ...(strategyFlag ? [strategyFlag] : []),
226
+ ...(config.deleteBranch ? ["--remove-source-branch"] : []),
227
+ "-R",
228
+ repoFlag,
229
+ "-y",
230
+ ];
231
+ return this.executeMergeCommand(() => this.executor.exec("glab", args, workDir), retries, {
231
232
  success: true,
232
233
  message: "MR merged successfully.",
233
234
  merged: true,
@@ -76,11 +76,7 @@ export declare class GraphQLCommitStrategy implements ICommitStrategy {
76
76
  * This happens when the branch was updated between getting HEAD and making the commit.
77
77
  */
78
78
  private isHeadOidMismatchError;
79
- /**
80
- * Execute a GraphQL query or mutation for ref operations.
81
- * Handles command construction, retry, error sanitization, and response parsing.
82
- * Uses gh CLI's --input flag to pass GraphQL via stdin (same pattern as executeGraphQLMutation).
83
- */
79
+ private execGraphQL;
84
80
  private executeGraphQLRefOp;
85
81
  /**
86
82
  * Query the remote for a repository's Node ID and a ref's Node ID.
@@ -1,9 +1,8 @@
1
1
  import { isGitHubRepo } from "../repo/index.js";
2
- import { escapeShellArg } from "../shared/shell-utils.js";
3
2
  import { withRetry, CORE_PERMANENT_ERROR_PATTERNS, DEFAULT_PERMANENT_ERROR_PATTERNS, } from "../shared/retry-utils.js";
4
3
  import { toErrorMessage } from "../shared/type-guards.js";
5
4
  import { parseApiJson } from "../shared/json-utils.js";
6
- import { buildTokenEnv } from "../shared/gh-api-utils.js";
5
+ import { buildHostnameArgs, buildTokenEnv } from "../shared/gh-api-utils.js";
7
6
  import { ValidationError, GraphQLApiError } from "../shared/errors.js";
8
7
  /**
9
8
  * Maximum payload size for GitHub GraphQL API (50MB).
@@ -104,15 +103,11 @@ export class GraphQLCommitStrategy {
104
103
  let lastError = null;
105
104
  for (let attempt = 0; attempt <= retries; attempt++) {
106
105
  try {
107
- const safeBranch = escapeShellArg(branchName);
108
- if (gitOps) {
109
- await gitOps.fetchBranch(branchName);
106
+ if (!gitOps) {
107
+ throw new ValidationError("gitOps is required for GraphQL commit strategy");
110
108
  }
111
- else {
112
- await this.executor.exec(`git fetch origin +${safeBranch}:refs/remotes/origin/${safeBranch}`, workDir);
113
- }
114
- // Get the remote HEAD SHA for this branch (not local HEAD)
115
- const headSha = await this.executor.exec(`git rev-parse origin/${safeBranch}`, workDir);
109
+ await gitOps.fetchBranch(branchName);
110
+ const headSha = await this.executor.exec("git", ["rev-parse", `origin/${branchName}`], workDir);
116
111
  const result = await this.executeGraphQLMutation(repoInfo, branchName, message, headSha.trim(), additions, deletions, workDir, token);
117
112
  return result;
118
113
  }
@@ -125,6 +120,7 @@ export class GraphQLCommitStrategy {
125
120
  throw lastError;
126
121
  }
127
122
  }
123
+ // Defensive — loop always exits via return or throw, but TS needs this for exhaustiveness
128
124
  throw (lastError ?? new GraphQLApiError("Unexpected error in GraphQL commit"));
129
125
  }
130
126
  /**
@@ -164,19 +160,12 @@ export class GraphQLCommitStrategy {
164
160
  query: mutation,
165
161
  variables,
166
162
  });
167
- const hostnameArg = repoInfo.host !== "github.com"
168
- ? `--hostname ${escapeShellArg(repoInfo.host)}`
169
- : "";
170
- const tokenEnv = buildTokenEnv(token);
171
- const command = `echo ${escapeShellArg(requestBody)} | gh api graphql ${hostnameArg} --input -`;
172
163
  let response;
173
164
  try {
174
- response = await withRetry(() => this.executor.exec(command, workDir, { env: tokenEnv }), {
175
- permanentErrorPatterns: [
176
- ...DEFAULT_PERMANENT_ERROR_PATTERNS,
177
- ...OID_MISMATCH_PATTERNS,
178
- ],
179
- });
165
+ response = await this.execGraphQL(requestBody, repoInfo, workDir, token, [
166
+ ...DEFAULT_PERMANENT_ERROR_PATTERNS,
167
+ ...OID_MISMATCH_PATTERNS,
168
+ ]);
180
169
  }
181
170
  catch (error) {
182
171
  throw this.sanitizeCommandError(error, repositoryNameWithOwner);
@@ -215,7 +204,7 @@ export class GraphQLCommitStrategy {
215
204
  if (refId && force) {
216
205
  // Branch exists + force: delete then recreate from local HEAD
217
206
  await this.deleteRemoteRef(refId, workDir, repoInfo, token);
218
- const sha = (await this.executor.exec("git rev-parse HEAD", workDir)).trim();
207
+ const sha = (await this.executor.exec("git", ["rev-parse", "HEAD"], workDir)).trim();
219
208
  await this.createRemoteRef(repositoryId, branchName, sha, workDir, repoInfo, token);
220
209
  }
221
210
  else if (!refId) {
@@ -223,7 +212,7 @@ export class GraphQLCommitStrategy {
223
212
  // Race condition: on newly created forks, queryRemoteRef may return null
224
213
  // due to eventual consistency, but the branch may exist by the time we
225
214
  // try to create it. Treat "already exists" as success.
226
- const sha = (await this.executor.exec("git rev-parse HEAD", workDir)).trim();
215
+ const sha = (await this.executor.exec("git", ["rev-parse", "HEAD"], workDir)).trim();
227
216
  try {
228
217
  await this.createRemoteRef(repositoryId, branchName, sha, workDir, repoInfo, token);
229
218
  }
@@ -261,7 +250,9 @@ export class GraphQLCommitStrategy {
261
250
  if (cleanMessage.length > 2000) {
262
251
  cleanMessage = cleanMessage.substring(0, 2000) + "... (truncated)";
263
252
  }
264
- return new GraphQLApiError(`Commit failed for ${repo}: ${cleanMessage}`);
253
+ return new GraphQLApiError(`Commit failed for ${repo}: ${cleanMessage}`, {
254
+ cause: error,
255
+ });
265
256
  }
266
257
  /**
267
258
  * Check if an error is due to expectedHeadOid mismatch (optimistic locking failure).
@@ -271,23 +262,16 @@ export class GraphQLCommitStrategy {
271
262
  const message = error.message.toLowerCase();
272
263
  return OID_MISMATCH_PATTERNS.some((pattern) => pattern.test(message));
273
264
  }
274
- /**
275
- * Execute a GraphQL query or mutation for ref operations.
276
- * Handles command construction, retry, error sanitization, and response parsing.
277
- * Uses gh CLI's --input flag to pass GraphQL via stdin (same pattern as executeGraphQLMutation).
278
- */
265
+ async execGraphQL(requestBody, repoInfo, workDir, token, permanentErrorPatterns) {
266
+ const hostnameArgs = buildHostnameArgs(repoInfo);
267
+ const tokenEnv = buildTokenEnv(token);
268
+ return withRetry(() => this.executor.exec("gh", ["api", "graphql", ...hostnameArgs, "--input", "-"], workDir, { env: tokenEnv, input: requestBody }), { permanentErrorPatterns });
269
+ }
279
270
  async executeGraphQLRefOp(queryOrMutation, repoInfo, workDir, token) {
280
271
  const requestBody = JSON.stringify({ query: queryOrMutation });
281
- const hostnameArg = repoInfo.host !== "github.com"
282
- ? `--hostname ${escapeShellArg(repoInfo.host)}`
283
- : "";
284
- const tokenEnv = buildTokenEnv(token);
285
- const command = `echo ${escapeShellArg(requestBody)} | gh api graphql ${hostnameArg} --input -`;
286
272
  let response;
287
273
  try {
288
- response = await withRetry(() => this.executor.exec(command, workDir, { env: tokenEnv }), {
289
- permanentErrorPatterns: GraphQLCommitStrategy.GRAPHQL_PERMANENT_ERROR_PATTERNS,
290
- });
274
+ response = await this.execGraphQL(requestBody, repoInfo, workDir, token, GraphQLCommitStrategy.GRAPHQL_PERMANENT_ERROR_PATTERNS);
291
275
  }
292
276
  catch (error) {
293
277
  throw this.sanitizeCommandError(error, `${repoInfo.owner}/${repoInfo.repo}`);
@@ -47,8 +47,15 @@ function formatFileChanges(files) {
47
47
  case "delete":
48
48
  actionText = "Deleted";
49
49
  break;
50
- default:
51
- actionText = "Changed";
50
+ /* c8 ignore next 3 -- filtered out by line 77 */
51
+ case "skip":
52
+ actionText = "Skipped";
53
+ break;
54
+ /* c8 ignore next 4 */
55
+ default: {
56
+ const _exhaustive = f.action;
57
+ throw new Error(`Unexpected action: ${_exhaustive}`);
58
+ }
52
59
  }
53
60
  return `- ${actionText} \`${f.fileName}\``;
54
61
  })