@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.
- package/dist/github-app-token-manager.d.ts +49 -0
- package/dist/github-app-token-manager.js +173 -0
- package/dist/pr-creator.d.ts +4 -0
- package/dist/pr-creator.js +4 -2
- package/dist/repository-processor.d.ts +1 -0
- package/dist/repository-processor.js +86 -101
- package/dist/strategies/commit-strategy-selector.d.ts +8 -2
- package/dist/strategies/commit-strategy-selector.js +11 -3
- package/dist/strategies/commit-strategy.d.ts +2 -0
- package/dist/strategies/github-pr-strategy.d.ts +1 -1
- package/dist/strategies/github-pr-strategy.js +29 -13
- package/dist/strategies/graphql-commit-strategy.js +8 -4
- package/dist/strategies/index.d.ts +1 -1
- package/dist/strategies/index.js +1 -1
- package/dist/strategies/pr-strategy.d.ts +6 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/pr-creator.d.ts
CHANGED
|
@@ -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>;
|
package/dist/pr-creator.js
CHANGED
|
@@ -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
|
-
//
|
|
105
|
-
//
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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:
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
321
|
-
|
|
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
|
|
8
|
-
*
|
|
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
|
|
8
|
-
*
|
|
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) &&
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
package/dist/strategies/index.js
CHANGED
|
@@ -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": "
|
|
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",
|