@aspruyt/xfg 2.2.0 → 3.0.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.
@@ -0,0 +1,49 @@
1
+ import type { GitHubRepoInfo } from "./repo-detector.js";
2
+ /** Duration to cache tokens (45 minutes in milliseconds) */
3
+ export declare const TOKEN_CACHE_DURATION_MS: number;
4
+ /**
5
+ * Manages GitHub App authentication tokens for multiple organizations.
6
+ * Handles JWT generation, installation discovery, and token caching.
7
+ */
8
+ export declare class GitHubAppTokenManager {
9
+ private readonly appId;
10
+ private readonly privateKey;
11
+ /** Map of "apiHost:owner" -> installation ID */
12
+ private installations;
13
+ /** Set of API hosts that have been discovered */
14
+ private discoveredHosts;
15
+ /** Map of "apiHost:owner" -> cached token */
16
+ private tokenCache;
17
+ constructor(appId: string, privateKey: string);
18
+ /**
19
+ * Generates a JWT for GitHub App authentication.
20
+ * The JWT is signed with RS256 and valid for 10 minutes.
21
+ */
22
+ generateJWT(): string;
23
+ /**
24
+ * Discovers all installations for this GitHub App on the given API host.
25
+ * Stores installations in an internal map for later lookup.
26
+ */
27
+ discoverInstallations(apiHost: string): Promise<void>;
28
+ /**
29
+ * Gets the installation ID for a given owner on the specified API host.
30
+ * Returns undefined if no installation is found.
31
+ */
32
+ getInstallationId(apiHost: string, owner: string): number | undefined;
33
+ /**
34
+ * Gets an installation access token for the given owner.
35
+ * Returns null if no installation is found for the owner.
36
+ * Tokens are cached for 45 minutes.
37
+ */
38
+ getTokenForOwner(apiHost: string, owner: string): Promise<string | null>;
39
+ /**
40
+ * Gets an installation access token for a repository.
41
+ * Automatically discovers installations if not already done for the host.
42
+ * Derives the API host from the repository host.
43
+ */
44
+ getTokenForRepo(repoInfo: GitHubRepoInfo): Promise<string | null>;
45
+ /**
46
+ * FOR TESTING ONLY: Manually expire a cached token.
47
+ */
48
+ _expireCacheForTesting(apiHost: string, owner: string): void;
49
+ }
@@ -0,0 +1,173 @@
1
+ import { createSign } from "node:crypto";
2
+ import { withRetry } from "./retry-utils.js";
3
+ /** Duration to cache tokens (45 minutes in milliseconds) */
4
+ export const TOKEN_CACHE_DURATION_MS = 45 * 60 * 1000;
5
+ /**
6
+ * Manages GitHub App authentication tokens for multiple organizations.
7
+ * Handles JWT generation, installation discovery, and token caching.
8
+ */
9
+ export class GitHubAppTokenManager {
10
+ appId;
11
+ privateKey;
12
+ /** Map of "apiHost:owner" -> installation ID */
13
+ installations = new Map();
14
+ /** Set of API hosts that have been discovered */
15
+ discoveredHosts = new Set();
16
+ /** Map of "apiHost:owner" -> cached token */
17
+ tokenCache = new Map();
18
+ constructor(appId, privateKey) {
19
+ this.appId = appId;
20
+ this.privateKey = privateKey;
21
+ }
22
+ /**
23
+ * Generates a JWT for GitHub App authentication.
24
+ * The JWT is signed with RS256 and valid for 10 minutes.
25
+ */
26
+ generateJWT() {
27
+ const now = Math.floor(Date.now() / 1000);
28
+ const header = {
29
+ alg: "RS256",
30
+ typ: "JWT",
31
+ };
32
+ const payload = {
33
+ iat: now - 60, // Issued 60 seconds ago to account for clock drift
34
+ exp: now + 600, // Expires in 10 minutes
35
+ iss: this.appId,
36
+ };
37
+ const encodedHeader = base64UrlEncode(JSON.stringify(header));
38
+ const encodedPayload = base64UrlEncode(JSON.stringify(payload));
39
+ const signatureInput = `${encodedHeader}.${encodedPayload}`;
40
+ const sign = createSign("RSA-SHA256");
41
+ sign.update(signatureInput);
42
+ const signature = sign.sign(this.privateKey);
43
+ const encodedSignature = base64UrlEncode(signature);
44
+ return `${encodedHeader}.${encodedPayload}.${encodedSignature}`;
45
+ }
46
+ /**
47
+ * Discovers all installations for this GitHub App on the given API host.
48
+ * Stores installations in an internal map for later lookup.
49
+ */
50
+ async discoverInstallations(apiHost) {
51
+ const url = `https://${apiHost}/app/installations`;
52
+ const jwt = this.generateJWT();
53
+ const response = await withRetry(async () => {
54
+ const res = await fetch(url, {
55
+ method: "GET",
56
+ headers: {
57
+ Authorization: `Bearer ${jwt}`,
58
+ Accept: "application/vnd.github+json",
59
+ "X-GitHub-Api-Version": "2022-11-28",
60
+ },
61
+ });
62
+ if (!res.ok) {
63
+ const status = res.status;
64
+ // Throw error with status code for retry logic
65
+ const error = new Error(`GitHub API error: ${status}`);
66
+ throw error;
67
+ }
68
+ return res;
69
+ });
70
+ const installations = (await response.json());
71
+ for (const installation of installations) {
72
+ const key = `${apiHost}:${installation.account.login}`;
73
+ this.installations.set(key, installation.id);
74
+ }
75
+ this.discoveredHosts.add(apiHost);
76
+ }
77
+ /**
78
+ * Gets the installation ID for a given owner on the specified API host.
79
+ * Returns undefined if no installation is found.
80
+ */
81
+ getInstallationId(apiHost, owner) {
82
+ const key = `${apiHost}:${owner}`;
83
+ return this.installations.get(key);
84
+ }
85
+ /**
86
+ * Gets an installation access token for the given owner.
87
+ * Returns null if no installation is found for the owner.
88
+ * Tokens are cached for 45 minutes.
89
+ */
90
+ async getTokenForOwner(apiHost, owner) {
91
+ const installationId = this.getInstallationId(apiHost, owner);
92
+ if (installationId === undefined) {
93
+ return null;
94
+ }
95
+ const cacheKey = `${apiHost}:${owner}`;
96
+ // Check cache
97
+ const cached = this.tokenCache.get(cacheKey);
98
+ if (cached && cached.expiresAt > Date.now()) {
99
+ return cached.token;
100
+ }
101
+ // Fetch new token
102
+ const url = `https://${apiHost}/app/installations/${installationId}/access_tokens`;
103
+ const jwt = this.generateJWT();
104
+ const response = await withRetry(async () => {
105
+ const res = await fetch(url, {
106
+ method: "POST",
107
+ headers: {
108
+ Authorization: `Bearer ${jwt}`,
109
+ Accept: "application/vnd.github+json",
110
+ "X-GitHub-Api-Version": "2022-11-28",
111
+ },
112
+ });
113
+ if (!res.ok) {
114
+ const status = res.status;
115
+ const error = new Error(`GitHub API error: ${status}`);
116
+ throw error;
117
+ }
118
+ return res;
119
+ });
120
+ const tokenResponse = (await response.json());
121
+ // Cache the token
122
+ this.tokenCache.set(cacheKey, {
123
+ token: tokenResponse.token,
124
+ expiresAt: Date.now() + TOKEN_CACHE_DURATION_MS,
125
+ });
126
+ return tokenResponse.token;
127
+ }
128
+ /**
129
+ * Gets an installation access token for a repository.
130
+ * Automatically discovers installations if not already done for the host.
131
+ * Derives the API host from the repository host.
132
+ */
133
+ async getTokenForRepo(repoInfo) {
134
+ const apiHost = deriveApiHost(repoInfo.host);
135
+ // Auto-discover if needed
136
+ if (!this.discoveredHosts.has(apiHost)) {
137
+ await this.discoverInstallations(apiHost);
138
+ }
139
+ return this.getTokenForOwner(apiHost, repoInfo.owner);
140
+ }
141
+ /**
142
+ * FOR TESTING ONLY: Manually expire a cached token.
143
+ */
144
+ _expireCacheForTesting(apiHost, owner) {
145
+ const cacheKey = `${apiHost}:${owner}`;
146
+ const cached = this.tokenCache.get(cacheKey);
147
+ if (cached) {
148
+ cached.expiresAt = 0;
149
+ }
150
+ }
151
+ }
152
+ /**
153
+ * Encodes data as base64url (no padding).
154
+ */
155
+ function base64UrlEncode(data) {
156
+ const buffer = typeof data === "string" ? Buffer.from(data) : data;
157
+ return buffer
158
+ .toString("base64")
159
+ .replace(/\+/g, "-")
160
+ .replace(/\//g, "_")
161
+ .replace(/=+$/, "");
162
+ }
163
+ /**
164
+ * Derives the GitHub API host from a repository host.
165
+ * - github.com -> api.github.com
166
+ * - ghe.example.com -> ghe.example.com/api/v3
167
+ */
168
+ function deriveApiHost(host) {
169
+ if (host === "github.com") {
170
+ return "api.github.com";
171
+ }
172
+ return `${host}/api/v3`;
173
+ }
@@ -19,6 +19,8 @@ export interface PROptions {
19
19
  prTemplate?: string;
20
20
  /** Optional command executor for shell commands (for testing) */
21
21
  executor?: CommandExecutor;
22
+ /** GitHub App installation token for authentication */
23
+ token?: string;
22
24
  }
23
25
  export interface PRResult {
24
26
  url?: string;
@@ -50,5 +52,7 @@ export interface MergePROptions {
50
52
  retries?: number;
51
53
  /** Optional command executor for shell commands (for testing) */
52
54
  executor?: CommandExecutor;
55
+ /** GitHub App installation token for authentication */
56
+ token?: string;
53
57
  }
54
58
  export declare function mergePR(options: MergePROptions): Promise<MergeResult>;
@@ -97,7 +97,7 @@ export function formatPRTitle(files) {
97
97
  return `chore: sync ${changedFiles.length} config files`;
98
98
  }
99
99
  export async function createPR(options) {
100
- const { repoInfo, branchName, baseBranch, files, workDir, dryRun, retries, prTemplate, executor, } = options;
100
+ const { repoInfo, branchName, baseBranch, files, workDir, dryRun, retries, prTemplate, executor, token, } = options;
101
101
  const title = formatPRTitle(files);
102
102
  const body = formatPRBody(files, repoInfo, prTemplate);
103
103
  if (dryRun) {
@@ -116,10 +116,11 @@ export async function createPR(options) {
116
116
  baseBranch,
117
117
  workDir,
118
118
  retries,
119
+ token,
119
120
  });
120
121
  }
121
122
  export async function mergePR(options) {
122
- const { repoInfo, prUrl, mergeConfig, workDir, dryRun, retries, executor } = options;
123
+ const { repoInfo, prUrl, mergeConfig, workDir, dryRun, retries, executor, token, } = options;
123
124
  if (dryRun) {
124
125
  const modeText = mergeConfig.mode === "force"
125
126
  ? "force merge"
@@ -139,5 +140,6 @@ export async function mergePR(options) {
139
140
  config: mergeConfig,
140
141
  workDir,
141
142
  retries,
143
+ token,
142
144
  });
143
145
  }
@@ -43,6 +43,7 @@ export declare class RepositoryProcessor {
43
43
  private readonly log;
44
44
  private retries;
45
45
  private executor;
46
+ private readonly tokenManager;
46
47
  /**
47
48
  * Creates a new RepositoryProcessor.
48
49
  * @param gitOpsFactory - Optional factory for creating GitOps instances (for testing)
@@ -1,15 +1,16 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { convertContentToString, } from "./config.js";
4
- import { getRepoDisplayName } from "./repo-detector.js";
4
+ import { getRepoDisplayName, isGitHubRepo, } from "./repo-detector.js";
5
5
  import { interpolateXfgContent } from "./xfg-template.js";
6
6
  import { GitOps } from "./git-ops.js";
7
7
  import { createPR, mergePR } from "./pr-creator.js";
8
8
  import { logger } from "./logger.js";
9
- import { getPRStrategy, getCommitStrategy } from "./strategies/index.js";
9
+ import { getPRStrategy, getCommitStrategy, hasGitHubAppCredentials, } from "./strategies/index.js";
10
10
  import { defaultExecutor } from "./command-executor.js";
11
11
  import { getFileStatus, generateDiff, createDiffStats, incrementDiffStats, } from "./diff-utils.js";
12
12
  import { loadManifest, saveManifest, updateManifest, MANIFEST_FILENAME, } from "./manifest.js";
13
+ import { GitHubAppTokenManager } from "./github-app-token-manager.js";
13
14
  /**
14
15
  * Determines if a file should be marked as executable.
15
16
  * .sh files are auto-executable unless explicit executable: false is set.
@@ -30,6 +31,7 @@ export class RepositoryProcessor {
30
31
  log;
31
32
  retries = 3;
32
33
  executor = defaultExecutor;
34
+ tokenManager;
33
35
  /**
34
36
  * Creates a new RepositoryProcessor.
35
37
  * @param gitOpsFactory - Optional factory for creating GitOps instances (for testing)
@@ -38,12 +40,40 @@ export class RepositoryProcessor {
38
40
  constructor(gitOpsFactory, log) {
39
41
  this.gitOpsFactory = gitOpsFactory ?? ((opts) => new GitOps(opts));
40
42
  this.log = log ?? logger;
43
+ // Initialize GitHub App token manager if credentials are configured
44
+ if (hasGitHubAppCredentials()) {
45
+ this.tokenManager = new GitHubAppTokenManager(process.env.XFG_GITHUB_APP_ID, process.env.XFG_GITHUB_APP_PRIVATE_KEY);
46
+ }
47
+ else {
48
+ this.tokenManager = null;
49
+ }
41
50
  }
42
51
  async process(repoConfig, repoInfo, options) {
43
52
  const repoName = getRepoDisplayName(repoInfo);
44
53
  const { branchName, workDir, dryRun, prTemplate } = options;
45
54
  this.retries = options.retries ?? 3;
46
55
  this.executor = options.executor ?? defaultExecutor;
56
+ // For GitHub repos with token manager, get installation token
57
+ let token;
58
+ if (this.tokenManager && isGitHubRepo(repoInfo)) {
59
+ try {
60
+ const tokenResult = await this.tokenManager.getTokenForRepo(repoInfo);
61
+ if (tokenResult === null) {
62
+ // No installation found for this owner - skip the repo
63
+ return {
64
+ success: true,
65
+ repoName,
66
+ message: `No GitHub App installation found for ${repoInfo.owner}`,
67
+ skipped: true,
68
+ };
69
+ }
70
+ token = tokenResult;
71
+ }
72
+ catch (error) {
73
+ // Token retrieval failed - continue without token and let auth fail naturally
74
+ this.log.info(`Warning: Failed to get GitHub App token: ${error instanceof Error ? error.message : String(error)}`);
75
+ }
76
+ }
47
77
  this.gitOps = this.gitOpsFactory({
48
78
  workDir,
49
79
  dryRun,
@@ -78,6 +108,7 @@ export class RepositoryProcessor {
78
108
  baseBranch,
79
109
  workDir,
80
110
  retries: this.retries,
111
+ token,
81
112
  });
82
113
  if (closed) {
83
114
  this.log.info("Closed existing PR and deleted branch for fresh sync");
@@ -101,19 +132,10 @@ export class RepositoryProcessor {
101
132
  // - Dry-run: Uses wouldChange() for read-only content comparison (no side effects)
102
133
  // - Normal: Uses git status after writing (source of truth for what git will commit)
103
134
  //
104
- // This is intentional. git status is more accurate because it respects .gitattributes
105
- // (line ending normalization, filters) and detects executable bit changes. However,
106
- // it requires actually writing files, which defeats dry-run's purpose.
107
- //
108
- // For config files (JSON/YAML), these approaches produce identical results in practice.
109
- // Edge cases (repos with unusual git attributes on config files) are essentially nonexistent.
110
- const changedFiles = [];
111
- const diffStats = createDiffStats();
112
- // Track pre-write actions for non-dry-run mode (issue #252)
113
- // We need to know if a file was created vs updated BEFORE writing it
114
- const preWriteActions = new Map();
115
- // Track file changes for commit strategy (path -> content, null for deletion)
135
+ // Track all file changes with content and action - single source of truth
136
+ // Used for both commit message generation and actual commit
116
137
  const fileChangesForCommit = new Map();
138
+ const diffStats = createDiffStats();
117
139
  for (const file of repoConfig.files) {
118
140
  const filePath = join(workDir, file.fileName);
119
141
  const fileExistsLocal = existsSync(filePath);
@@ -123,7 +145,10 @@ export class RepositoryProcessor {
123
145
  const existsOnBase = await this.gitOps.fileExistsOnBranch(file.fileName, baseBranch);
124
146
  if (existsOnBase) {
125
147
  this.log.info(`Skipping ${file.fileName} (createOnly: exists on ${baseBranch})`);
126
- changedFiles.push({ fileName: file.fileName, action: "skip" });
148
+ fileChangesForCommit.set(file.fileName, {
149
+ content: null,
150
+ action: "skip",
151
+ });
127
152
  continue;
128
153
  }
129
154
  }
@@ -141,37 +166,37 @@ export class RepositoryProcessor {
141
166
  header: file.header,
142
167
  schemaUrl: file.schemaUrl,
143
168
  });
144
- // Determine action type (create vs update)
169
+ // Determine action type (create vs update) BEFORE writing
145
170
  const action = fileExistsLocal
146
171
  ? "update"
147
172
  : "create";
173
+ // Check if file would change (needed for both modes)
174
+ const existingContent = this.gitOps.getFileContent(file.fileName);
175
+ const changed = this.gitOps.wouldChange(file.fileName, fileContent);
176
+ if (changed) {
177
+ // Track in single source of truth
178
+ fileChangesForCommit.set(file.fileName, {
179
+ content: fileContent,
180
+ action,
181
+ });
182
+ }
148
183
  if (dryRun) {
149
- // In dry-run, check if file would change and show diff
150
- const existingContent = this.gitOps.getFileContent(file.fileName);
151
- const changed = this.gitOps.wouldChange(file.fileName, fileContent);
184
+ // In dry-run, show diff but don't write
152
185
  const status = getFileStatus(existingContent !== null, changed);
153
- // Track stats
154
186
  incrementDiffStats(diffStats, status);
155
- if (changed) {
156
- changedFiles.push({ fileName: file.fileName, action });
157
- }
158
- // Generate and display diff
159
187
  const diffLines = generateDiff(existingContent, fileContent, file.fileName);
160
188
  this.log.fileDiff(file.fileName, status, diffLines);
161
189
  }
162
190
  else {
163
- // Write the file and store pre-write action for stats calculation
164
- preWriteActions.set(file.fileName, action);
191
+ // Write the file
165
192
  this.gitOps.writeFile(file.fileName, fileContent);
166
- // Track content for commit strategy
167
- fileChangesForCommit.set(file.fileName, fileContent);
168
193
  }
169
194
  }
170
195
  // Step 5b: Set executable permission for files that need it
171
- const skippedFileNames = new Set(changedFiles.filter((f) => f.action === "skip").map((f) => f.fileName));
172
196
  for (const file of repoConfig.files) {
173
197
  // Skip files that were excluded (createOnly + exists)
174
- if (skippedFileNames.has(file.fileName)) {
198
+ const tracked = fileChangesForCommit.get(file.fileName);
199
+ if (tracked?.action === "skip") {
175
200
  continue;
176
201
  }
177
202
  if (shouldBeExecutable(file)) {
@@ -195,6 +220,11 @@ export class RepositoryProcessor {
195
220
  for (const fileName of filesToDelete) {
196
221
  // Only delete if file actually exists in the working directory
197
222
  if (this.gitOps.fileExists(fileName)) {
223
+ // Track deletion in single source of truth
224
+ fileChangesForCommit.set(fileName, {
225
+ content: null,
226
+ action: "delete",
227
+ });
198
228
  if (dryRun) {
199
229
  // In dry-run, show what would be deleted
200
230
  this.log.fileDiff(fileName, "DELETED", []);
@@ -203,10 +233,7 @@ export class RepositoryProcessor {
203
233
  else {
204
234
  this.log.info(`Deleting orphaned file: ${fileName}`);
205
235
  this.gitOps.deleteFile(fileName);
206
- // Track deletion for commit strategy
207
- fileChangesForCommit.set(fileName, null);
208
236
  }
209
- changedFiles.push({ fileName, action: "delete" });
210
237
  }
211
238
  }
212
239
  }
@@ -217,85 +244,41 @@ export class RepositoryProcessor {
217
244
  // Only save if there are managed files for any config, or if we had a previous manifest
218
245
  const hasAnyManagedFiles = Object.keys(newManifest.configs).length > 0;
219
246
  if (hasAnyManagedFiles || existingManifest !== null) {
220
- if (!dryRun) {
221
- saveManifest(workDir, newManifest);
222
- // Track manifest content for commit strategy
223
- const manifestContent = JSON.stringify(newManifest, null, 2) + "\n";
224
- fileChangesForCommit.set(MANIFEST_FILENAME, manifestContent);
225
- }
226
247
  // Track manifest file as changed if it would be different
227
248
  const existingConfigs = existingManifest?.configs ?? {};
228
249
  const manifestChanged = JSON.stringify(existingConfigs) !==
229
250
  JSON.stringify(newManifest.configs);
230
251
  if (manifestChanged) {
231
252
  const manifestExisted = existsSync(join(workDir, MANIFEST_FILENAME));
232
- changedFiles.push({
233
- fileName: MANIFEST_FILENAME,
253
+ const manifestContent = JSON.stringify(newManifest, null, 2) + "\n";
254
+ fileChangesForCommit.set(MANIFEST_FILENAME, {
255
+ content: manifestContent,
234
256
  action: manifestExisted ? "update" : "create",
235
257
  });
236
258
  }
259
+ if (!dryRun) {
260
+ saveManifest(workDir, newManifest);
261
+ }
237
262
  }
238
263
  // Show diff summary in dry-run mode
239
264
  if (dryRun) {
240
265
  this.log.diffSummary(diffStats.newCount, diffStats.modifiedCount, diffStats.unchangedCount, diffStats.deletedCount);
241
266
  }
242
- // Step 6: Check for changes (exclude skipped files)
243
- let hasChanges;
244
- if (dryRun) {
245
- hasChanges = changedFiles.filter((f) => f.action !== "skip").length > 0;
246
- }
247
- else {
248
- hasChanges = await this.gitOps.hasChanges();
249
- // If there are changes, determine which files changed
250
- if (hasChanges) {
251
- // Get the actual list of changed files from git status
252
- const gitChangedFiles = new Set(await this.gitOps.getChangedFiles());
253
- // Build set of files already tracked (skip, delete, manifest updates added earlier)
254
- const alreadyTracked = new Set(changedFiles.map((f) => f.fileName));
255
- // Add config files that actually changed according to git
256
- for (const file of repoConfig.files) {
257
- if (alreadyTracked.has(file.fileName)) {
258
- continue; // Already tracked (skipped, deleted, or manifest)
259
- }
260
- // Only include files that git reports as changed
261
- if (!gitChangedFiles.has(file.fileName)) {
262
- continue; // File didn't actually change
263
- }
264
- // Use pre-write action (issue #252) - we stored whether file existed
265
- // BEFORE writing, which is the correct basis for create vs update
266
- const action = preWriteActions.get(file.fileName) ?? "update";
267
- changedFiles.push({ fileName: file.fileName, action });
268
- }
269
- // Add any other files from git status that aren't already tracked
270
- // This catches files like .xfg.json when manifestChanged was false
271
- // but git still reports a change (e.g., due to formatting differences)
272
- for (const gitFile of gitChangedFiles) {
273
- if (changedFiles.some((f) => f.fileName === gitFile)) {
274
- continue; // Already tracked
275
- }
276
- const filePath = join(workDir, gitFile);
277
- const action = existsSync(filePath)
278
- ? "update"
279
- : "create";
280
- changedFiles.push({ fileName: gitFile, action });
281
- }
282
- // Calculate diff stats from changedFiles (issue #252)
283
- for (const file of changedFiles) {
284
- switch (file.action) {
285
- case "create":
286
- incrementDiffStats(diffStats, "NEW");
287
- break;
288
- case "update":
289
- incrementDiffStats(diffStats, "MODIFIED");
290
- break;
291
- case "delete":
292
- incrementDiffStats(diffStats, "DELETED");
293
- break;
294
- // "skip" files are not counted in stats
295
- }
296
- }
267
+ // Step 6: Derive changedFiles from single source of truth
268
+ // This ensures dry-run and non-dry-run modes use identical logic
269
+ const changedFiles = Array.from(fileChangesForCommit.entries()).map(([fileName, info]) => ({ fileName, action: info.action }));
270
+ // Calculate diff stats for non-dry-run mode (dry-run already calculated above)
271
+ if (!dryRun) {
272
+ for (const [, info] of fileChangesForCommit) {
273
+ if (info.action === "create")
274
+ incrementDiffStats(diffStats, "NEW");
275
+ else if (info.action === "update")
276
+ incrementDiffStats(diffStats, "MODIFIED");
277
+ else if (info.action === "delete")
278
+ incrementDiffStats(diffStats, "DELETED");
297
279
  }
298
280
  }
281
+ const hasChanges = changedFiles.filter((f) => f.action !== "skip").length > 0;
299
282
  if (!hasChanges) {
300
283
  return {
301
284
  success: true,
@@ -315,11 +298,10 @@ export class RepositoryProcessor {
315
298
  this.log.info(`Would push to ${pushBranch}...`);
316
299
  }
317
300
  else {
318
- // Build file changes for commit strategy
319
- const fileChanges = [];
320
- for (const [path, content] of fileChangesForCommit.entries()) {
321
- fileChanges.push({ path, content });
322
- }
301
+ // Build file changes for commit strategy (filter out skipped files)
302
+ const fileChanges = Array.from(fileChangesForCommit.entries())
303
+ .filter(([, info]) => info.action !== "skip")
304
+ .map(([path, info]) => ({ path, content: info.content }));
323
305
  // Check if there are actually staged changes (edge case handling)
324
306
  // This handles scenarios where git status shows changes but git add doesn't stage anything
325
307
  // (e.g., due to .gitattributes normalization)
@@ -348,6 +330,7 @@ export class RepositoryProcessor {
348
330
  retries: this.retries,
349
331
  // Use force push (--force-with-lease) for PR branches, not for direct mode
350
332
  force: !isDirectMode,
333
+ token,
351
334
  });
352
335
  this.log.info(`Committed: ${commitResult.sha} (verified: ${commitResult.verified})`);
353
336
  }
@@ -390,6 +373,7 @@ export class RepositoryProcessor {
390
373
  retries: this.retries,
391
374
  prTemplate,
392
375
  executor: this.executor,
376
+ token,
393
377
  });
394
378
  // Step 10: Handle merge options if configured
395
379
  let mergeResult;
@@ -409,6 +393,7 @@ export class RepositoryProcessor {
409
393
  dryRun,
410
394
  retries: this.retries,
411
395
  executor: this.executor,
396
+ token,
412
397
  });
413
398
  mergeResult = {
414
399
  merged: result.merged ?? false,
@@ -1,11 +1,17 @@
1
1
  import { RepoInfo } from "../repo-detector.js";
2
2
  import { CommitStrategy } from "./commit-strategy.js";
3
3
  import { CommandExecutor } from "../command-executor.js";
4
+ /**
5
+ * Checks if GitHub App credentials are configured via environment variables.
6
+ * Both XFG_GITHUB_APP_ID and XFG_GITHUB_APP_PRIVATE_KEY must be set.
7
+ */
8
+ export declare function hasGitHubAppCredentials(): boolean;
4
9
  /**
5
10
  * Factory function to get the appropriate commit strategy for a repository.
6
11
  *
7
- * For GitHub repositories with GH_INSTALLATION_TOKEN set, returns GraphQLCommitStrategy
8
- * which creates verified commits via the GitHub GraphQL API.
12
+ * For GitHub repositories with GitHub App credentials (XFG_GITHUB_APP_ID and
13
+ * XFG_GITHUB_APP_PRIVATE_KEY), returns GraphQLCommitStrategy which creates
14
+ * verified commits via the GitHub GraphQL API.
9
15
  *
10
16
  * For all other cases (GitHub with PAT, Azure DevOps, GitLab), returns GitCommitStrategy
11
17
  * which uses standard git commands.
@@ -1,11 +1,19 @@
1
1
  import { isGitHubRepo } from "../repo-detector.js";
2
2
  import { GitCommitStrategy } from "./git-commit-strategy.js";
3
3
  import { GraphQLCommitStrategy } from "./graphql-commit-strategy.js";
4
+ /**
5
+ * Checks if GitHub App credentials are configured via environment variables.
6
+ * Both XFG_GITHUB_APP_ID and XFG_GITHUB_APP_PRIVATE_KEY must be set.
7
+ */
8
+ export function hasGitHubAppCredentials() {
9
+ return !!(process.env.XFG_GITHUB_APP_ID && process.env.XFG_GITHUB_APP_PRIVATE_KEY);
10
+ }
4
11
  /**
5
12
  * Factory function to get the appropriate commit strategy for a repository.
6
13
  *
7
- * For GitHub repositories with GH_INSTALLATION_TOKEN set, returns GraphQLCommitStrategy
8
- * which creates verified commits via the GitHub GraphQL API.
14
+ * For GitHub repositories with GitHub App credentials (XFG_GITHUB_APP_ID and
15
+ * XFG_GITHUB_APP_PRIVATE_KEY), returns GraphQLCommitStrategy which creates
16
+ * verified commits via the GitHub GraphQL API.
9
17
  *
10
18
  * For all other cases (GitHub with PAT, Azure DevOps, GitLab), returns GitCommitStrategy
11
19
  * which uses standard git commands.
@@ -14,7 +22,7 @@ import { GraphQLCommitStrategy } from "./graphql-commit-strategy.js";
14
22
  * @param executor - Optional command executor for shell commands
15
23
  */
16
24
  export function getCommitStrategy(repoInfo, executor) {
17
- if (isGitHubRepo(repoInfo) && process.env.GH_INSTALLATION_TOKEN) {
25
+ if (isGitHubRepo(repoInfo) && hasGitHubAppCredentials()) {
18
26
  return new GraphQLCommitStrategy(executor);
19
27
  }
20
28
  return new GitCommitStrategy(executor);
@@ -12,6 +12,8 @@ export interface CommitOptions {
12
12
  retries?: number;
13
13
  /** Use force push (--force-with-lease). Default: true for PR branches, false for direct push to main. */
14
14
  force?: boolean;
15
+ /** GitHub App installation token for authentication (used by GraphQLCommitStrategy) */
16
+ token?: string;
15
17
  }
16
18
  export interface CommitResult {
17
19
  sha: string;
@@ -8,7 +8,7 @@ export declare class GitHubPRStrategy extends BasePRStrategy {
8
8
  /**
9
9
  * Check if auto-merge is enabled on the repository.
10
10
  */
11
- checkAutoMergeEnabled(repoInfo: GitHubRepoInfo, workDir: string, retries?: number): Promise<boolean>;
11
+ checkAutoMergeEnabled(repoInfo: GitHubRepoInfo, workDir: string, retries?: number, token?: string): Promise<boolean>;
12
12
  /**
13
13
  * Build merge strategy flag for gh pr merge command.
14
14
  */
@@ -38,14 +38,21 @@ function buildPRUrlRegex(host) {
38
38
  const escapedHost = escapeRegExp(host);
39
39
  return new RegExp(`https://${escapedHost}/[\\w-]+/[\\w.-]+/pull/\\d+`);
40
40
  }
41
+ /**
42
+ * Build the GH_TOKEN environment prefix for commands if a token is provided.
43
+ */
44
+ function buildTokenPrefix(token) {
45
+ return token ? `GH_TOKEN=${token} ` : "";
46
+ }
41
47
  export class GitHubPRStrategy extends BasePRStrategy {
42
48
  async checkExistingPR(options) {
43
- const { repoInfo, branchName, workDir, retries = 3 } = options;
49
+ const { repoInfo, branchName, workDir, retries = 3, token } = options;
44
50
  if (!isGitHubRepo(repoInfo)) {
45
51
  throw new Error("Expected GitHub repository");
46
52
  }
47
53
  const repoFlag = getRepoFlag(repoInfo);
48
- const command = `gh pr list --repo ${escapeShellArg(repoFlag)} --head ${escapeShellArg(branchName)} --json url --jq '.[0].url'`;
54
+ const tokenPrefix = buildTokenPrefix(token);
55
+ const command = `${tokenPrefix}gh pr list --repo ${escapeShellArg(repoFlag)} --head ${escapeShellArg(branchName)} --json url --jq '.[0].url'`;
49
56
  try {
50
57
  const existingPR = await withRetry(() => this.executor.exec(command, workDir), { retries });
51
58
  return existingPR || null;
@@ -66,11 +73,11 @@ export class GitHubPRStrategy extends BasePRStrategy {
66
73
  }
67
74
  }
68
75
  async closeExistingPR(options) {
69
- const { repoInfo, branchName, baseBranch, workDir, retries = 3 } = options;
76
+ const { repoInfo, branchName, baseBranch, workDir, retries = 3, token, } = options;
70
77
  if (!isGitHubRepo(repoInfo)) {
71
78
  throw new Error("Expected GitHub repository");
72
79
  }
73
- // First check if there's an existing PR
80
+ // First check if there's an existing PR (pass token through)
74
81
  const existingUrl = await this.checkExistingPR({
75
82
  repoInfo,
76
83
  branchName,
@@ -79,6 +86,7 @@ export class GitHubPRStrategy extends BasePRStrategy {
79
86
  retries,
80
87
  title: "", // Not used for check
81
88
  body: "", // Not used for check
89
+ token,
82
90
  });
83
91
  if (!existingUrl) {
84
92
  return false;
@@ -89,8 +97,10 @@ export class GitHubPRStrategy extends BasePRStrategy {
89
97
  throw new Error(`Could not extract PR number from URL: ${existingUrl}`);
90
98
  }
91
99
  // Close the PR and delete the branch
100
+ // Token is passed via GH_TOKEN env prefix for gh CLI authentication
92
101
  const repoFlag = getRepoFlag(repoInfo);
93
- const command = `gh pr close ${escapeShellArg(prNumber)} --repo ${escapeShellArg(repoFlag)} --delete-branch`;
102
+ const tokenPrefix = buildTokenPrefix(token);
103
+ const command = `${tokenPrefix}gh pr close ${escapeShellArg(prNumber)} --repo ${escapeShellArg(repoFlag)} --delete-branch`;
94
104
  try {
95
105
  await withRetry(() => this.executor.exec(command, workDir), { retries });
96
106
  return true;
@@ -102,14 +112,16 @@ export class GitHubPRStrategy extends BasePRStrategy {
102
112
  }
103
113
  }
104
114
  async create(options) {
105
- const { repoInfo, title, body, branchName, baseBranch, workDir, retries = 3, } = options;
115
+ const { repoInfo, title, body, branchName, baseBranch, workDir, retries = 3, token, } = options;
106
116
  if (!isGitHubRepo(repoInfo)) {
107
117
  throw new Error("Expected GitHub repository");
108
118
  }
109
119
  // Write body to temp file to avoid shell escaping issues
110
120
  const bodyFile = join(workDir, this.bodyFilePath);
111
121
  writeFileSync(bodyFile, body, "utf-8");
112
- const command = `gh pr create --title ${escapeShellArg(title)} --body-file ${escapeShellArg(bodyFile)} --base ${escapeShellArg(baseBranch)} --head ${escapeShellArg(branchName)}`;
122
+ // Token is passed via GH_TOKEN env prefix for gh CLI authentication
123
+ const tokenPrefix = buildTokenPrefix(token);
124
+ const command = `${tokenPrefix}gh pr create --title ${escapeShellArg(title)} --body-file ${escapeShellArg(bodyFile)} --base ${escapeShellArg(baseBranch)} --head ${escapeShellArg(branchName)}`;
113
125
  try {
114
126
  const result = await withRetry(() => this.executor.exec(command, workDir), { retries });
115
127
  // Extract URL from output - use strict regex for valid PR URLs only
@@ -140,10 +152,12 @@ export class GitHubPRStrategy extends BasePRStrategy {
140
152
  /**
141
153
  * Check if auto-merge is enabled on the repository.
142
154
  */
143
- async checkAutoMergeEnabled(repoInfo, workDir, retries = 3) {
155
+ async checkAutoMergeEnabled(repoInfo, workDir, retries = 3, token) {
144
156
  const hostnameFlag = getHostnameFlag(repoInfo);
145
157
  const hostnamePart = hostnameFlag ? `${hostnameFlag} ` : "";
146
- const command = `gh api ${hostnamePart}repos/${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)} --jq '.allow_auto_merge // false'`;
158
+ // Token is passed via GH_TOKEN env prefix for gh CLI authentication
159
+ const tokenPrefix = buildTokenPrefix(token);
160
+ const command = `${tokenPrefix}gh api ${hostnamePart}repos/${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)} --jq '.allow_auto_merge // false'`;
147
161
  try {
148
162
  const result = await withRetry(() => this.executor.exec(command, workDir), { retries });
149
163
  return result.trim() === "true";
@@ -169,7 +183,7 @@ export class GitHubPRStrategy extends BasePRStrategy {
169
183
  }
170
184
  }
171
185
  async merge(options) {
172
- const { prUrl, config, workDir, retries = 3 } = options;
186
+ const { prUrl, config, workDir, retries = 3, token } = options;
173
187
  // Manual mode: do nothing
174
188
  if (config.mode === "manual") {
175
189
  return {
@@ -180,6 +194,8 @@ export class GitHubPRStrategy extends BasePRStrategy {
180
194
  }
181
195
  const strategyFlag = this.getMergeStrategyFlag(config.strategy);
182
196
  const deleteBranchFlag = config.deleteBranch ? "--delete-branch" : "";
197
+ // Token is passed via GH_TOKEN env prefix for gh CLI authentication
198
+ const tokenPrefix = buildTokenPrefix(token);
183
199
  if (config.mode === "auto") {
184
200
  // Check if auto-merge is enabled on the repo
185
201
  // Extract host/owner/repo from PR URL (supports both github.com and GHE)
@@ -192,7 +208,7 @@ export class GitHubPRStrategy extends BasePRStrategy {
192
208
  repo: match[3],
193
209
  host: match[1],
194
210
  };
195
- const autoMergeEnabled = await this.checkAutoMergeEnabled(repoInfo, workDir, retries);
211
+ const autoMergeEnabled = await this.checkAutoMergeEnabled(repoInfo, workDir, retries, token);
196
212
  if (!autoMergeEnabled) {
197
213
  logger.info(`Warning: Auto-merge not enabled for '${repoInfo.owner}/${repoInfo.repo}'. PR left open for manual review.`);
198
214
  logger.info(`To enable: gh repo edit ${getRepoFlag(repoInfo)} --enable-auto-merge (requires admin)`);
@@ -205,7 +221,7 @@ export class GitHubPRStrategy extends BasePRStrategy {
205
221
  }
206
222
  }
207
223
  // Enable auto-merge
208
- const command = `gh pr merge ${escapeShellArg(prUrl)} --auto ${strategyFlag} ${deleteBranchFlag}`.trim();
224
+ const command = `${tokenPrefix}gh pr merge ${escapeShellArg(prUrl)} --auto ${strategyFlag} ${deleteBranchFlag}`.trim();
209
225
  try {
210
226
  await withRetry(() => this.executor.exec(command, workDir), {
211
227
  retries,
@@ -228,7 +244,7 @@ export class GitHubPRStrategy extends BasePRStrategy {
228
244
  }
229
245
  if (config.mode === "force") {
230
246
  // Force merge using admin privileges
231
- const command = `gh pr merge ${escapeShellArg(prUrl)} --admin ${strategyFlag} ${deleteBranchFlag}`.trim();
247
+ const command = `${tokenPrefix}gh pr merge ${escapeShellArg(prUrl)} --admin ${strategyFlag} ${deleteBranchFlag}`.trim();
232
248
  try {
233
249
  await withRetry(() => this.executor.exec(command, workDir), {
234
250
  retries,
@@ -49,7 +49,7 @@ export class GraphQLCommitStrategy {
49
49
  * @throws Error if repo is not GitHub, payload exceeds 50MB, or API fails
50
50
  */
51
51
  async commit(options) {
52
- const { repoInfo, branchName, message, fileChanges, workDir, retries = 3, } = options;
52
+ const { repoInfo, branchName, message, fileChanges, workDir, retries = 3, token, } = options;
53
53
  // Validate this is a GitHub repo
54
54
  if (!isGitHubRepo(repoInfo)) {
55
55
  throw new Error(`GraphQL commit strategy requires GitHub repositories. Got: ${repoInfo.type}`);
@@ -83,7 +83,7 @@ export class GraphQLCommitStrategy {
83
83
  // Get the remote HEAD SHA for this branch (not local HEAD)
84
84
  const headSha = await this.executor.exec(`git rev-parse origin/${branchName}`, workDir);
85
85
  // Build and execute the GraphQL mutation
86
- const result = await this.executeGraphQLMutation(githubInfo, branchName, message, headSha.trim(), additions, deletions, workDir);
86
+ const result = await this.executeGraphQLMutation(githubInfo, branchName, message, headSha.trim(), additions, deletions, workDir, token);
87
87
  return result;
88
88
  }
89
89
  catch (error) {
@@ -103,7 +103,7 @@ export class GraphQLCommitStrategy {
103
103
  /**
104
104
  * Execute the createCommitOnBranch GraphQL mutation.
105
105
  */
106
- async executeGraphQLMutation(repoInfo, branchName, message, expectedHeadOid, additions, deletions, workDir) {
106
+ async executeGraphQLMutation(repoInfo, branchName, message, expectedHeadOid, additions, deletions, workDir, token) {
107
107
  const repositoryNameWithOwner = `${repoInfo.owner}/${repoInfo.repo}`;
108
108
  // Build file additions with base64 encoding
109
109
  const fileAdditions = additions.map((fc) => ({
@@ -149,7 +149,11 @@ export class GraphQLCommitStrategy {
149
149
  const hostnameArg = repoInfo.host !== "github.com"
150
150
  ? `--hostname ${escapeShellArg(repoInfo.host)}`
151
151
  : "";
152
- const command = `echo ${escapeShellArg(requestBody)} | gh api graphql ${hostnameArg} --input -`;
152
+ // Use token parameter for authentication when provided
153
+ // This ensures the GitHub App is used as the commit author, not github-actions[bot]
154
+ // The token is passed via Authorization header rather than relying on GH_TOKEN env var
155
+ const authArg = token ? `-H "Authorization: token ${token}"` : "";
156
+ const command = `echo ${escapeShellArg(requestBody)} | gh api graphql ${authArg} ${hostnameArg} --input -`;
153
157
  const response = await this.executor.exec(command, workDir);
154
158
  // Parse the response
155
159
  const parsed = JSON.parse(response);
@@ -9,7 +9,7 @@ export { GitLabPRStrategy } from "./gitlab-pr-strategy.js";
9
9
  export type { CommitStrategy, CommitOptions, CommitResult, FileChange, } from "./commit-strategy.js";
10
10
  export { GitCommitStrategy } from "./git-commit-strategy.js";
11
11
  export { GraphQLCommitStrategy, MAX_PAYLOAD_SIZE, } from "./graphql-commit-strategy.js";
12
- export { getCommitStrategy } from "./commit-strategy-selector.js";
12
+ export { getCommitStrategy, hasGitHubAppCredentials, } from "./commit-strategy-selector.js";
13
13
  /**
14
14
  * Factory function to get the appropriate PR strategy for a repository.
15
15
  * @param repoInfo - Repository information
@@ -8,7 +8,7 @@ export { AzurePRStrategy } from "./azure-pr-strategy.js";
8
8
  export { GitLabPRStrategy } from "./gitlab-pr-strategy.js";
9
9
  export { GitCommitStrategy } from "./git-commit-strategy.js";
10
10
  export { GraphQLCommitStrategy, MAX_PAYLOAD_SIZE, } from "./graphql-commit-strategy.js";
11
- export { getCommitStrategy } from "./commit-strategy-selector.js";
11
+ export { getCommitStrategy, hasGitHubAppCredentials, } from "./commit-strategy-selector.js";
12
12
  /**
13
13
  * Factory function to get the appropriate PR strategy for a repository.
14
14
  * @param repoInfo - Repository information
@@ -23,12 +23,16 @@ export interface PRStrategyOptions {
23
23
  workDir: string;
24
24
  /** Number of retries for API operations (default: 3) */
25
25
  retries?: number;
26
+ /** GitHub App installation token for authentication */
27
+ token?: string;
26
28
  }
27
29
  export interface MergeOptions {
28
30
  prUrl: string;
29
31
  config: PRMergeConfig;
30
32
  workDir: string;
31
33
  retries?: number;
34
+ /** GitHub App installation token for authentication */
35
+ token?: string;
32
36
  }
33
37
  /**
34
38
  * Options for closing an existing PR.
@@ -39,6 +43,8 @@ export interface CloseExistingPROptions {
39
43
  baseBranch: string;
40
44
  workDir: string;
41
45
  retries?: number;
46
+ /** GitHub App installation token for authentication */
47
+ token?: string;
42
48
  }
43
49
  /**
44
50
  * Interface for PR creation strategies (platform-specific implementations).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/xfg",
3
- "version": "2.2.0",
3
+ "version": "3.0.0",
4
4
  "description": "CLI tool to sync JSON, JSON5, YAML, or text configuration files across multiple Git repositories via pull requests or direct push",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",