@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,75 +1,107 @@
1
1
  import { escapeShellArg } from "../shared/shell-utils.js";
2
- import { defaultExecutor, } from "../shared/command-executor.js";
3
2
  import { withRetry } from "../shared/retry-utils.js";
3
+ import { toErrorMessage } from "../shared/type-guards.js";
4
4
  /**
5
- * Wrapper around GitOps that adds authentication to network operations.
5
+ * Adds authentication to network git operations and delegates local ops.
6
6
  *
7
- * When auth options are provided, network operations (clone, fetch, push,
8
- * getDefaultBranch) use `-c url.insteadOf` to override credentials per-command.
9
- * This allows different tokens for different repos without global git config.
10
- *
11
- * Local operations (commit, writeFile, etc.) pass through unchanged.
7
+ * When auth options are provided, clone uses an embedded token URL which sets
8
+ * the remote origin. Subsequent operations (fetch, push, getDefaultBranch)
9
+ * reuse that authenticated remote URL no extra auth setup per operation.
12
10
  */
13
11
  export class AuthenticatedGitOps {
14
- gitOps;
15
- auth;
12
+ localOps;
16
13
  executor;
17
14
  workDir;
18
15
  retries;
19
- constructor(gitOps, auth) {
20
- this.gitOps = gitOps;
16
+ auth;
17
+ log;
18
+ constructor(localOps, executor, workDir, retries, auth, log) {
19
+ this.localOps = localOps;
20
+ this.executor = executor;
21
+ this.workDir = workDir;
22
+ this.retries = retries;
21
23
  this.auth = auth;
22
- // Extract executor and workDir from gitOps via reflection
23
- const internal = gitOps;
24
- this.executor = internal.executor ?? defaultExecutor;
25
- this.workDir = internal.workDir ?? ".";
26
- this.retries = internal.retries ?? 3;
24
+ this.log = log;
27
25
  }
28
26
  async execWithRetry(command) {
29
27
  return withRetry(() => this.executor.exec(command, this.workDir), {
30
28
  retries: this.retries,
31
29
  });
32
30
  }
33
- // ============================================================
34
- // Network operations - use authenticated command when token provided
35
- // ============================================================
36
31
  /**
37
32
  * Build the authenticated remote URL.
38
33
  */
39
34
  getAuthenticatedUrl() {
35
+ if (!this.auth) {
36
+ throw new Error("getAuthenticatedUrl() called without auth options");
37
+ }
40
38
  const { token, host, owner, repo } = this.auth;
41
39
  return `https://x-access-token:${token}@${host}/${owner}/${repo}`;
42
40
  }
41
+ // --- ILocalGitOps delegation ---
42
+ cleanWorkspace() {
43
+ return this.localOps.cleanWorkspace();
44
+ }
45
+ createBranch(branchName) {
46
+ return this.localOps.createBranch(branchName);
47
+ }
48
+ writeFile(fileName, content) {
49
+ return this.localOps.writeFile(fileName, content);
50
+ }
51
+ setExecutable(fileName) {
52
+ return this.localOps.setExecutable(fileName);
53
+ }
54
+ getFileContent(fileName) {
55
+ return this.localOps.getFileContent(fileName);
56
+ }
57
+ wouldChange(fileName, content) {
58
+ return this.localOps.wouldChange(fileName, content);
59
+ }
60
+ hasChanges() {
61
+ return this.localOps.hasChanges();
62
+ }
63
+ getChangedFiles() {
64
+ return this.localOps.getChangedFiles();
65
+ }
66
+ hasStagedChanges() {
67
+ return this.localOps.hasStagedChanges();
68
+ }
69
+ fileExistsOnBranch(fileName, branch) {
70
+ return this.localOps.fileExistsOnBranch(fileName, branch);
71
+ }
72
+ fileExists(fileName) {
73
+ return this.localOps.fileExists(fileName);
74
+ }
75
+ deleteFile(fileName) {
76
+ return this.localOps.deleteFile(fileName);
77
+ }
78
+ commit(message) {
79
+ return this.localOps.commit(message);
80
+ }
81
+ getDefaultBranchLocal() {
82
+ return this.localOps.getDefaultBranchLocal();
83
+ }
84
+ // --- INetworkGitOps with auth wrapping ---
85
+ // Note: exec() usage here is safe — all user inputs are escaped via escapeShellArg()
43
86
  async clone(gitUrl) {
44
87
  if (!this.auth) {
45
- return this.gitOps.clone(gitUrl);
88
+ const command = `git clone ${escapeShellArg(gitUrl)} .`;
89
+ await this.execWithRetry(command);
90
+ return;
46
91
  }
47
- // Clone using authenticated URL directly - no insteadOf needed
48
92
  const authUrl = escapeShellArg(this.getAuthenticatedUrl());
49
93
  await this.execWithRetry(`git clone ${authUrl} .`);
50
94
  }
51
95
  async fetch(options) {
52
- if (!this.auth) {
53
- return this.gitOps.fetch(options);
54
- }
55
- // Remote URL already has auth from clone, just fetch
56
96
  const pruneFlag = options?.prune ? " --prune" : "";
57
97
  await this.execWithRetry(`git fetch origin${pruneFlag}`);
58
98
  }
59
99
  async push(branchName, options) {
60
- if (!this.auth) {
61
- return this.gitOps.push(branchName, options);
62
- }
63
- // Remote URL already has auth from clone, just push
64
100
  const forceFlag = options?.force ? "--force-with-lease " : "";
65
101
  const safeBranch = escapeShellArg(branchName);
66
102
  await this.execWithRetry(`git push ${forceFlag}-u origin ${safeBranch}`);
67
103
  }
68
104
  async getDefaultBranch() {
69
- if (!this.auth) {
70
- return this.gitOps.getDefaultBranch();
71
- }
72
- // Network operation - remote URL already has auth from clone
73
105
  try {
74
106
  const remoteInfo = await this.execWithRetry(`git remote show origin`);
75
107
  const match = remoteInfo.match(/HEAD branch: (\S+)/);
@@ -77,25 +109,12 @@ export class AuthenticatedGitOps {
77
109
  return { branch: match[1], method: "remote HEAD" };
78
110
  }
79
111
  }
80
- catch {
81
- // Fall through to local checks
82
- }
83
- // Local operations don't need auth
84
- try {
85
- await this.executor.exec("git rev-parse --verify origin/main", this.workDir);
86
- return { branch: "main", method: "origin/main exists" };
87
- }
88
- catch {
89
- // Continue
90
- }
91
- try {
92
- await this.executor.exec("git rev-parse --verify origin/master", this.workDir);
93
- return { branch: "master", method: "origin/master exists" };
94
- }
95
- catch {
96
- // Continue
112
+ catch (error) {
113
+ const msg = toErrorMessage(error);
114
+ this.log?.debug(`git remote show origin failed - ${msg}`);
97
115
  }
98
- return { branch: "main", method: "fallback default" };
116
+ // Local fallback operations don't need auth — delegate to localOps
117
+ return this.localOps.getDefaultBranchLocal();
99
118
  }
100
119
  /**
101
120
  * Execute ls-remote with authentication.
@@ -105,7 +124,6 @@ export class AuthenticatedGitOps {
105
124
  * branch existence where failure is expected for new branches.
106
125
  */
107
126
  async lsRemote(branchName, options) {
108
- // Remote URL already has auth from clone
109
127
  const safeBranch = escapeShellArg(branchName);
110
128
  const command = `git ls-remote --exit-code --heads origin ${safeBranch}`;
111
129
  if (options?.skipRetry) {
@@ -118,7 +136,6 @@ export class AuthenticatedGitOps {
118
136
  * Used by GraphQLCommitStrategy for creating/deleting remote branches.
119
137
  */
120
138
  async pushRefspec(refspec, options) {
121
- // Remote URL already has auth from clone
122
139
  const deleteFlag = options?.delete ? "--delete " : "";
123
140
  const safeRefspec = escapeShellArg(refspec);
124
141
  await this.execWithRetry(`git push ${deleteFlag}-u origin ${safeRefspec}`);
@@ -128,50 +145,7 @@ export class AuthenticatedGitOps {
128
145
  * Used by GraphQLCommitStrategy to update local refs.
129
146
  */
130
147
  async fetchBranch(branchName) {
131
- // Remote URL already has auth from clone
132
148
  const safeBranch = escapeShellArg(branchName);
133
149
  await this.execWithRetry(`git fetch origin +${safeBranch}:refs/remotes/origin/${safeBranch}`);
134
150
  }
135
- // ============================================================
136
- // Local operations - delegate directly to GitOps
137
- // ============================================================
138
- cleanWorkspace() {
139
- return this.gitOps.cleanWorkspace();
140
- }
141
- async createBranch(branchName) {
142
- return this.gitOps.createBranch(branchName);
143
- }
144
- writeFile(fileName, content) {
145
- return this.gitOps.writeFile(fileName, content);
146
- }
147
- async setExecutable(fileName) {
148
- return this.gitOps.setExecutable(fileName);
149
- }
150
- getFileContent(fileName) {
151
- return this.gitOps.getFileContent(fileName);
152
- }
153
- wouldChange(fileName, content) {
154
- return this.gitOps.wouldChange(fileName, content);
155
- }
156
- async hasChanges() {
157
- return this.gitOps.hasChanges();
158
- }
159
- async getChangedFiles() {
160
- return this.gitOps.getChangedFiles();
161
- }
162
- async hasStagedChanges() {
163
- return this.gitOps.hasStagedChanges();
164
- }
165
- async fileExistsOnBranch(fileName, branch) {
166
- return this.gitOps.fileExistsOnBranch(fileName, branch);
167
- }
168
- fileExists(fileName) {
169
- return this.gitOps.fileExists(fileName);
170
- }
171
- deleteFile(fileName) {
172
- return this.gitOps.deleteFile(fileName);
173
- }
174
- async commit(message) {
175
- return this.gitOps.commit(message);
176
- }
177
151
  }
@@ -1,11 +1,13 @@
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 AzurePRStrategy extends BasePRStrategy {
5
- constructor(executor?: ICommandExecutor);
7
+ constructor(executor?: ICommandExecutor, log?: IPRStrategyLogger);
6
8
  private getOrgUrl;
7
9
  private buildPRUrl;
8
- checkExistingPR(options: PRStrategyOptions): Promise<string | null>;
10
+ checkExistingPR(options: CloseExistingPROptions): Promise<string | null>;
9
11
  closeExistingPR(options: CloseExistingPROptions): Promise<boolean>;
10
12
  create(options: PRStrategyOptions): Promise<PRResult>;
11
13
  /**
@@ -1,14 +1,15 @@
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 { isAzureDevOpsRepo, } 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 { assertAzureDevOpsRepo, } from "../shared/repo-detector.js";
5
+ import { BasePRStrategy } from "./pr-strategy.js";
6
+ import { withRetry } from "../shared/retry-utils.js";
7
+ import { toErrorMessage, safeCleanup } from "../shared/type-guards.js";
8
8
  import { sanitizeCredentials } from "../shared/sanitize-utils.js";
9
+ import { getStderr } from "../shared/command-executor.js";
9
10
  export class AzurePRStrategy extends BasePRStrategy {
10
- constructor(executor) {
11
- super(executor);
11
+ constructor(executor, log) {
12
+ super(executor, log);
12
13
  this.bodyFilePath = ".pr-description.md";
13
14
  }
14
15
  getOrgUrl(repoInfo) {
@@ -19,9 +20,7 @@ export class AzurePRStrategy extends BasePRStrategy {
19
20
  }
20
21
  async checkExistingPR(options) {
21
22
  const { repoInfo, branchName, baseBranch, workDir, retries = 3 } = options;
22
- if (!isAzureDevOpsRepo(repoInfo)) {
23
- throw new Error("Expected Azure DevOps repository");
24
- }
23
+ assertAzureDevOpsRepo(repoInfo, "Azure PR strategy");
25
24
  const azureRepoInfo = repoInfo;
26
25
  const orgUrl = this.getOrgUrl(azureRepoInfo);
27
26
  const command = `az repos pr list --repository ${escapeShellArg(azureRepoInfo.repo)} --source-branch ${escapeShellArg(branchName)} --target-branch ${escapeShellArg(baseBranch)} --org ${escapeShellArg(orgUrl)} --project ${escapeShellArg(azureRepoInfo.project)} --query "[0].pullRequestId" -o tsv`;
@@ -30,23 +29,16 @@ export class AzurePRStrategy extends BasePRStrategy {
30
29
  return existingPRId ? this.buildPRUrl(azureRepoInfo, existingPRId) : null;
31
30
  }
32
31
  catch (error) {
33
- if (error instanceof Error) {
34
- if (isPermanentError(error)) {
35
- throw error;
36
- }
37
- const stderr = error.stderr ?? "";
38
- if (stderr && !stderr.includes("does not exist")) {
39
- logger.info(`Debug: Azure PR check failed - ${sanitizeCredentials(stderr).trim()}`);
40
- }
32
+ const stderr = getStderr(error);
33
+ if (stderr && !stderr.includes("does not exist")) {
34
+ this.log?.debug(`Azure PR check failed - ${sanitizeCredentials(stderr).trim()}`);
41
35
  }
42
36
  return null;
43
37
  }
44
38
  }
45
39
  async closeExistingPR(options) {
46
40
  const { repoInfo, branchName, baseBranch, workDir, retries = 3 } = options;
47
- if (!isAzureDevOpsRepo(repoInfo)) {
48
- throw new Error("Expected Azure DevOps repository");
49
- }
41
+ assertAzureDevOpsRepo(repoInfo, "Azure PR strategy");
50
42
  const azureRepoInfo = repoInfo;
51
43
  const orgUrl = this.getOrgUrl(azureRepoInfo);
52
44
  // First check if there's an existing PR
@@ -56,8 +48,6 @@ export class AzurePRStrategy extends BasePRStrategy {
56
48
  baseBranch,
57
49
  workDir,
58
50
  retries,
59
- title: "", // Not used for check
60
- body: "", // Not used for check
61
51
  });
62
52
  if (!existingUrl) {
63
53
  return false;
@@ -65,7 +55,7 @@ export class AzurePRStrategy extends BasePRStrategy {
65
55
  // Extract PR ID from URL
66
56
  const prInfo = this.parsePRUrl(existingUrl);
67
57
  if (!prInfo) {
68
- logger.info(`Warning: Could not parse PR URL: ${existingUrl}`);
58
+ this.log?.warn(`Could not parse PR URL: ${existingUrl}`);
69
59
  return false;
70
60
  }
71
61
  // Abandon the PR (Azure DevOps equivalent of closing)
@@ -76,13 +66,11 @@ export class AzurePRStrategy extends BasePRStrategy {
76
66
  });
77
67
  }
78
68
  catch (error) {
79
- const message = error instanceof Error ? error.message : String(error);
80
- logger.info(`Warning: Failed to abandon PR #${prInfo.prId}: ${message}`);
69
+ const message = toErrorMessage(error);
70
+ this.log?.warn(`Failed to abandon PR #${prInfo.prId}: ${message}`);
81
71
  return false;
82
72
  }
83
- // Delete the source branch - need to get object_id first
84
73
  try {
85
- // Get the branch's object_id
86
74
  const getRefCommand = `az repos ref list --repository ${escapeShellArg(azureRepoInfo.repo)} --org ${escapeShellArg(orgUrl)} --project ${escapeShellArg(azureRepoInfo.project)} --filter heads/${escapeShellArg(branchName)} --query "[0].objectId" -o tsv`;
87
75
  const objectId = await withRetry(() => this.executor.exec(getRefCommand, workDir), { retries });
88
76
  if (objectId) {
@@ -92,19 +80,16 @@ export class AzurePRStrategy extends BasePRStrategy {
92
80
  }
93
81
  catch (error) {
94
82
  // Branch deletion failure is not critical - PR is already abandoned
95
- const message = error instanceof Error ? error.message : String(error);
96
- logger.info(`Warning: Failed to delete branch ${branchName}: ${message}`);
83
+ const message = toErrorMessage(error);
84
+ this.log?.warn(`Failed to delete branch ${branchName}: ${message}`);
97
85
  }
98
86
  return true;
99
87
  }
100
88
  async create(options) {
101
89
  const { repoInfo, title, body, branchName, baseBranch, workDir, retries = 3, } = options;
102
- if (!isAzureDevOpsRepo(repoInfo)) {
103
- throw new Error("Expected Azure DevOps repository");
104
- }
90
+ assertAzureDevOpsRepo(repoInfo, "Azure PR strategy");
105
91
  const azureRepoInfo = repoInfo;
106
92
  const orgUrl = this.getOrgUrl(azureRepoInfo);
107
- // Write description to temp file to avoid shell escaping issues
108
93
  const descFile = join(workDir, this.bodyFilePath);
109
94
  writeFileSync(descFile, body, "utf-8");
110
95
  // Azure CLI @file syntax: escape the full @path to handle special chars in workDir
@@ -120,15 +105,10 @@ export class AzurePRStrategy extends BasePRStrategy {
120
105
  };
121
106
  }
122
107
  finally {
123
- // Clean up temp file - log warning on failure instead of throwing
124
- try {
125
- if (existsSync(descFile)) {
108
+ safeCleanup(() => {
109
+ if (existsSync(descFile))
126
110
  unlinkSync(descFile);
127
- }
128
- }
129
- catch (cleanupError) {
130
- logger.info(`Warning: Failed to clean up temp file ${descFile}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
131
- }
111
+ }, `failed to remove ${descFile}`, this.log ?? { debug() { } });
132
112
  }
133
113
  }
134
114
  /**
@@ -171,50 +151,22 @@ export class AzurePRStrategy extends BasePRStrategy {
171
151
  ? "--delete-source-branch true"
172
152
  : "";
173
153
  if (config.mode === "auto") {
174
- // Enable auto-complete (no pre-check needed - always available in Azure DevOps)
175
- const command = `az repos pr update --id ${escapeShellArg(prInfo.prId)} --auto-complete true ${squashFlag} ${deleteBranchFlag} --org ${escapeShellArg(orgUrl)}`.trim();
176
- try {
177
- await withRetry(() => this.executor.exec(command, workDir), {
178
- retries,
179
- });
180
- return {
181
- success: true,
182
- message: "Auto-complete enabled. PR will merge when all policies pass.",
183
- merged: false,
184
- autoMergeEnabled: true,
185
- };
186
- }
187
- catch (error) {
188
- const message = error instanceof Error ? error.message : String(error);
189
- return {
190
- success: false,
191
- message: `Failed to enable auto-complete: ${message}`,
192
- merged: false,
193
- };
194
- }
154
+ const autoCommand = `az repos pr update --id ${escapeShellArg(prInfo.prId)} --auto-complete true ${squashFlag} ${deleteBranchFlag} --org ${escapeShellArg(orgUrl)}`.trim();
155
+ return this.executeMergeCommand(() => this.executor.exec(autoCommand, workDir), retries, {
156
+ success: true,
157
+ message: "Auto-complete enabled. PR will merge when all policies pass.",
158
+ merged: false,
159
+ autoMergeEnabled: true,
160
+ }, "Failed to enable auto-complete");
195
161
  }
196
162
  if (config.mode === "force") {
197
- // Bypass policies and complete the PR
198
163
  const bypassReason = config.bypassReason ?? "Automated config sync via xfg";
199
- const command = `az repos pr update --id ${escapeShellArg(prInfo.prId)} --bypass-policy true --bypass-policy-reason ${escapeShellArg(bypassReason)} --status completed ${squashFlag} ${deleteBranchFlag} --org ${escapeShellArg(orgUrl)}`.trim();
200
- try {
201
- await withRetry(() => this.executor.exec(command, workDir), {
202
- retries,
203
- });
204
- return {
205
- success: true,
206
- message: "PR completed by bypassing policies.",
207
- merged: true,
208
- };
209
- }
210
- catch (error) {
211
- const message = error instanceof Error ? error.message : String(error);
212
- return {
213
- success: false,
214
- message: `Failed to bypass policies and complete PR: ${message}`,
215
- merged: false,
216
- };
217
- }
164
+ const forceCommand = `az repos pr update --id ${escapeShellArg(prInfo.prId)} --bypass-policy true --bypass-policy-reason ${escapeShellArg(bypassReason)} --status completed ${squashFlag} ${deleteBranchFlag} --org ${escapeShellArg(orgUrl)}`.trim();
165
+ return this.executeMergeCommand(() => this.executor.exec(forceCommand, workDir), retries, {
166
+ success: true,
167
+ message: "PR completed by bypassing policies.",
168
+ merged: true,
169
+ }, "Failed to bypass policies and complete PR");
218
170
  }
219
171
  return {
220
172
  success: false,
@@ -0,0 +1,6 @@
1
+ export declare function sanitizeBranchName(fileName: string): string;
2
+ /**
3
+ * Validates a user-provided branch name against git's naming rules.
4
+ * @throws Error if the branch name is invalid
5
+ */
6
+ export declare function validateBranchName(branchName: string): void;
@@ -0,0 +1,29 @@
1
+ export function sanitizeBranchName(fileName) {
2
+ return fileName
3
+ .toLowerCase()
4
+ .replace(/\.[^.]+$/, "") // Remove extension
5
+ .replace(/[^a-z0-9-]/g, "-") // Replace non-alphanumeric with dashes
6
+ .replace(/-+/g, "-") // Collapse multiple dashes
7
+ .replace(/^-|-$/g, ""); // Remove leading/trailing dashes
8
+ }
9
+ /**
10
+ * Validates a user-provided branch name against git's naming rules.
11
+ * @throws Error if the branch name is invalid
12
+ */
13
+ export function validateBranchName(branchName) {
14
+ if (!branchName || branchName.trim() === "") {
15
+ throw new Error("Branch name cannot be empty");
16
+ }
17
+ if (branchName.startsWith(".") || branchName.startsWith("-")) {
18
+ throw new Error('Branch name cannot start with "." or "-"');
19
+ }
20
+ // Git disallows: space, ~, ^, :, ?, *, [, \, and consecutive dots (..)
21
+ if (/[\s~^:?*[\\]/.test(branchName) || branchName.includes("..")) {
22
+ throw new Error("Branch name contains invalid characters");
23
+ }
24
+ if (branchName.endsWith("/") ||
25
+ branchName.endsWith(".lock") ||
26
+ branchName.endsWith(".")) {
27
+ throw new Error("Branch name has invalid ending");
28
+ }
29
+ }
@@ -1,11 +1,16 @@
1
1
  import { RepoInfo } from "../shared/repo-detector.js";
2
2
  import type { ICommitStrategy } from "./types.js";
3
+ import { GitHubAppTokenManager } from "./github-app-token-manager.js";
3
4
  import { ICommandExecutor } from "../shared/command-executor.js";
4
5
  /**
5
6
  * Checks if GitHub App credentials are configured via environment variables.
6
7
  * Both XFG_GITHUB_APP_ID and XFG_GITHUB_APP_PRIVATE_KEY must be set.
7
8
  */
8
9
  export declare function hasGitHubAppCredentials(): boolean;
10
+ /**
11
+ * Creates a GitHubAppTokenManager if credentials are configured, otherwise null.
12
+ */
13
+ export declare function createTokenManager(): GitHubAppTokenManager | null;
9
14
  /**
10
15
  * Factory function to get the appropriate commit strategy for a repository.
11
16
  *
@@ -1,6 +1,7 @@
1
1
  import { isGitHubRepo } from "../shared/repo-detector.js";
2
2
  import { GitCommitStrategy } from "./git-commit-strategy.js";
3
3
  import { GraphQLCommitStrategy } from "./graphql-commit-strategy.js";
4
+ import { GitHubAppTokenManager } from "./github-app-token-manager.js";
4
5
  /**
5
6
  * Checks if GitHub App credentials are configured via environment variables.
6
7
  * Both XFG_GITHUB_APP_ID and XFG_GITHUB_APP_PRIVATE_KEY must be set.
@@ -8,6 +9,15 @@ import { GraphQLCommitStrategy } from "./graphql-commit-strategy.js";
8
9
  export function hasGitHubAppCredentials() {
9
10
  return !!(process.env.XFG_GITHUB_APP_ID && process.env.XFG_GITHUB_APP_PRIVATE_KEY);
10
11
  }
12
+ /**
13
+ * Creates a GitHubAppTokenManager if credentials are configured, otherwise null.
14
+ */
15
+ export function createTokenManager() {
16
+ if (!hasGitHubAppCredentials()) {
17
+ return null;
18
+ }
19
+ return new GitHubAppTokenManager(process.env.XFG_GITHUB_APP_ID, process.env.XFG_GITHUB_APP_PRIVATE_KEY);
20
+ }
11
21
  /**
12
22
  * Factory function to get the appropriate commit strategy for a repository.
13
23
  *
@@ -19,9 +19,8 @@ export class GitCommitStrategy {
19
19
  */
20
20
  async commit(options) {
21
21
  const { branchName, message, workDir, retries = 3, force = true, gitOps, } = options;
22
- // Stage all changes
23
- await this.executor.exec("git add -A", workDir);
24
22
  // Commit with the message (--no-verify to skip pre-commit hooks)
23
+ // Staging is handled by CommitPushManager before calling commit()
25
24
  await this.executor.exec(`git commit --no-verify -m ${escapeShellArg(message)}`, workDir);
26
25
  // Push with authentication via gitOps if available
27
26
  if (gitOps) {
@@ -1,49 +1,21 @@
1
1
  import { ICommandExecutor } from "../shared/command-executor.js";
2
- export interface IGitOps {
3
- cleanWorkspace(): void;
4
- clone(gitUrl: string): Promise<void>;
5
- fetch(options?: {
6
- prune?: boolean;
7
- }): Promise<void>;
8
- createBranch(branchName: string): Promise<void>;
9
- commit(message: string): Promise<boolean>;
10
- push(branchName: string, options?: {
11
- force?: boolean;
12
- }): Promise<void>;
13
- getDefaultBranch(): Promise<{
14
- branch: string;
15
- method: string;
16
- }>;
17
- writeFile(fileName: string, content: string): void;
18
- setExecutable(fileName: string): Promise<void>;
19
- getFileContent(fileName: string): string | null;
20
- deleteFile(fileName: string): void;
21
- wouldChange(fileName: string, content: string): boolean;
22
- hasChanges(): Promise<boolean>;
23
- getChangedFiles(): Promise<string[]>;
24
- hasStagedChanges(): Promise<boolean>;
25
- fileExistsOnBranch(fileName: string, branch: string): Promise<boolean>;
26
- fileExists(fileName: string): boolean;
27
- }
2
+ import type { ILocalGitOps } from "./types.js";
28
3
  export interface GitOpsOptions {
29
4
  workDir: string;
30
5
  dryRun?: boolean;
31
6
  executor?: ICommandExecutor;
32
- /** Number of retries for network operations (default: 3) */
33
- retries?: number;
7
+ /** Optional logger for debug messages */
8
+ log?: {
9
+ debug(msg: string): void;
10
+ };
34
11
  }
35
- export declare class GitOps implements IGitOps {
36
- private workDir;
37
- private dryRun;
38
- private executor;
39
- private retries;
12
+ export declare class GitOps implements ILocalGitOps {
13
+ private readonly _workDir;
14
+ private readonly dryRun;
15
+ private readonly _executor;
16
+ private readonly log?;
40
17
  constructor(options: GitOpsOptions);
41
18
  private exec;
42
- /**
43
- * Run a command with retry logic for transient failures.
44
- * Used for network operations like clone, fetch, push.
45
- */
46
- private execWithRetry;
47
19
  /**
48
20
  * Validates that a file path doesn't escape the workspace directory.
49
21
  * @returns The resolved absolute file path
@@ -51,14 +23,6 @@ export declare class GitOps implements IGitOps {
51
23
  */
52
24
  private validatePath;
53
25
  cleanWorkspace(): void;
54
- clone(gitUrl: string): Promise<void>;
55
- /**
56
- * Fetch from remote with optional pruning of stale refs.
57
- * Used to update local tracking refs after remote branch deletion.
58
- */
59
- fetch(options?: {
60
- prune?: boolean;
61
- }): Promise<void>;
62
26
  /**
63
27
  * Create a new branch from the current HEAD.
64
28
  * Always creates fresh - existing branches should be cleaned up beforehand
@@ -100,9 +64,6 @@ export declare class GitOps implements IGitOps {
100
64
  * Used for createOnly checks against the base branch (not the working directory).
101
65
  */
102
66
  fileExistsOnBranch(fileName: string, branch: string): Promise<boolean>;
103
- /**
104
- * Check if a file exists in the working directory.
105
- */
106
67
  fileExists(fileName: string): boolean;
107
68
  /**
108
69
  * Delete a file from the working directory.
@@ -117,17 +78,12 @@ export declare class GitOps implements IGitOps {
117
78
  * @returns true if a commit was made, false if there were no staged changes
118
79
  */
119
80
  commit(message: string): Promise<boolean>;
120
- push(branchName: string, options?: {
121
- force?: boolean;
122
- }): Promise<void>;
123
- getDefaultBranch(): Promise<{
81
+ /**
82
+ * Fallback default branch detection using local refs only.
83
+ * Checks origin/main, then origin/master, then defaults to "main".
84
+ */
85
+ getDefaultBranchLocal(): Promise<{
124
86
  branch: string;
125
87
  method: string;
126
88
  }>;
127
89
  }
128
- export declare function sanitizeBranchName(fileName: string): string;
129
- /**
130
- * Validates a user-provided branch name against git's naming rules.
131
- * @throws Error if the branch name is invalid
132
- */
133
- export declare function validateBranchName(branchName: string): void;