@aspruyt/xfg 3.10.0 → 3.10.1
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/shared/logger.d.ts +2 -0
- package/dist/shared/logger.js +3 -0
- package/dist/shared/retry-utils.js +1 -0
- package/dist/sync/file-writer.js +6 -0
- package/dist/vcs/graphql-commit-strategy.d.ts +29 -0
- package/dist/vcs/graphql-commit-strategy.js +92 -35
- package/dist/vcs/types.d.ts +1 -1
- package/package.json +1 -1
package/dist/shared/logger.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { FileStatus } from "../sync/diff-utils.js";
|
|
2
2
|
export interface ILogger {
|
|
3
3
|
info(message: string): void;
|
|
4
|
+
warn(message: string): void;
|
|
4
5
|
debug(message: string): void;
|
|
5
6
|
fileDiff(fileName: string, status: FileStatus, diffLines: string[]): void;
|
|
6
7
|
diffSummary(newCount: number, modifiedCount: number, unchangedCount: number, deletedCount?: number): void;
|
|
@@ -21,6 +22,7 @@ export declare class Logger implements ILogger {
|
|
|
21
22
|
setTotal(total: number): void;
|
|
22
23
|
progress(current: number, repoName: string, message: string): void;
|
|
23
24
|
info(message: string): void;
|
|
25
|
+
warn(message: string): void;
|
|
24
26
|
debug(message: string): void;
|
|
25
27
|
success(current: number, repoName: string, message: string): void;
|
|
26
28
|
skip(current: number, repoName: string, reason: string): void;
|
package/dist/shared/logger.js
CHANGED
|
@@ -17,6 +17,9 @@ export class Logger {
|
|
|
17
17
|
info(message) {
|
|
18
18
|
console.log(chalk.gray(` ${message}`));
|
|
19
19
|
}
|
|
20
|
+
warn(message) {
|
|
21
|
+
console.log(chalk.yellow(` ⚠ ${message}`));
|
|
22
|
+
}
|
|
20
23
|
debug(message) {
|
|
21
24
|
if (process.env.DEBUG || process.env.XFG_DEBUG) {
|
|
22
25
|
console.log(chalk.dim(` [debug] ${message}`));
|
|
@@ -8,6 +8,7 @@ import { sanitizeCredentials } from "./sanitize-utils.js";
|
|
|
8
8
|
*/
|
|
9
9
|
export const DEFAULT_PERMANENT_ERROR_PATTERNS = [
|
|
10
10
|
/permission\s*denied/i,
|
|
11
|
+
/not\s*accessible\s*by\s*integration/i,
|
|
11
12
|
/authentication\s*failed/i,
|
|
12
13
|
/bad\s*credentials/i,
|
|
13
14
|
/invalid\s*(token|credentials)/i,
|
package/dist/sync/file-writer.js
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { convertContentToString } from "../config/formatter.js";
|
|
4
4
|
import { interpolateXfgContent } from "./xfg-template.js";
|
|
5
5
|
import { getFileStatus, generateDiff, createDiffStats, incrementDiffStats, } from "./diff-utils.js";
|
|
6
|
+
import { hasGitHubAppCredentials } from "../vcs/commit-strategy-selector.js";
|
|
6
7
|
/**
|
|
7
8
|
* Determines if a file should be marked as executable.
|
|
8
9
|
* .sh files are auto-executable unless explicit executable: false is set.
|
|
@@ -92,6 +93,11 @@ export class FileWriter {
|
|
|
92
93
|
continue;
|
|
93
94
|
}
|
|
94
95
|
if (shouldBeExecutable(file)) {
|
|
96
|
+
if (tracked?.action === "create" && hasGitHubAppCredentials()) {
|
|
97
|
+
log.warn(`${file.fileName}: GitHub App commits cannot set executable mode on new files. ` +
|
|
98
|
+
`The file will be created as non-executable (100644). ` +
|
|
99
|
+
`See: https://anthony-spruyt.github.io/xfg/examples/executable-files/`);
|
|
100
|
+
}
|
|
95
101
|
log.info(`Setting executable: ${file.fileName}`);
|
|
96
102
|
await gitOps.setExecutable(file.fileName);
|
|
97
103
|
}
|
|
@@ -30,6 +30,13 @@ export declare function validateBranchName(branchName: string): void;
|
|
|
30
30
|
* This strategy is GitHub-only and requires the `gh` CLI to be authenticated.
|
|
31
31
|
*/
|
|
32
32
|
export declare class GraphQLCommitStrategy implements ICommitStrategy {
|
|
33
|
+
/**
|
|
34
|
+
* GraphQL permanent error patterns for ref operations.
|
|
35
|
+
* Differs from DEFAULT_PERMANENT_ERROR_PATTERNS which has
|
|
36
|
+
* git-CLI-specific patterns (/remote\s*rejected/i) that don't
|
|
37
|
+
* apply to GraphQL responses.
|
|
38
|
+
*/
|
|
39
|
+
private static readonly GRAPHQL_PERMANENT_ERROR_PATTERNS;
|
|
33
40
|
private executor;
|
|
34
41
|
constructor(executor?: ICommandExecutor);
|
|
35
42
|
/**
|
|
@@ -48,6 +55,9 @@ export declare class GraphQLCommitStrategy implements ICommitStrategy {
|
|
|
48
55
|
* Ensure the branch exists on the remote and matches local HEAD.
|
|
49
56
|
* createCommitOnBranch requires the branch to already exist.
|
|
50
57
|
*
|
|
58
|
+
* Uses GraphQL ref mutations instead of git push to support repos
|
|
59
|
+
* with required_signatures on all branches.
|
|
60
|
+
*
|
|
51
61
|
* For PR branches (force=true): delete existing remote branch and recreate
|
|
52
62
|
* from local HEAD to ensure a fresh start from main.
|
|
53
63
|
*
|
|
@@ -66,4 +76,23 @@ export declare class GraphQLCommitStrategy implements ICommitStrategy {
|
|
|
66
76
|
* This happens when the branch was updated between getting HEAD and making the commit.
|
|
67
77
|
*/
|
|
68
78
|
private isHeadOidMismatchError;
|
|
79
|
+
/**
|
|
80
|
+
* Execute a GraphQL query or mutation for ref operations.
|
|
81
|
+
* Handles command construction, retry, error sanitization, and response parsing.
|
|
82
|
+
* Uses gh CLI's --input flag to pass GraphQL via stdin (same pattern as executeGraphQLMutation).
|
|
83
|
+
*/
|
|
84
|
+
private executeGraphQLRefOp;
|
|
85
|
+
/**
|
|
86
|
+
* Query the remote for a repository's Node ID and a ref's Node ID.
|
|
87
|
+
* Returns repositoryId (always) and refId (null if branch doesn't exist).
|
|
88
|
+
*/
|
|
89
|
+
private queryRemoteRef;
|
|
90
|
+
/**
|
|
91
|
+
* Create a branch ref on the remote via GraphQL createRef mutation.
|
|
92
|
+
*/
|
|
93
|
+
private createRemoteRef;
|
|
94
|
+
/**
|
|
95
|
+
* Delete a branch ref on the remote via GraphQL deleteRef mutation.
|
|
96
|
+
*/
|
|
97
|
+
private deleteRemoteRef;
|
|
69
98
|
}
|
|
@@ -48,6 +48,24 @@ const OID_MISMATCH_PATTERNS = [
|
|
|
48
48
|
* This strategy is GitHub-only and requires the `gh` CLI to be authenticated.
|
|
49
49
|
*/
|
|
50
50
|
export class GraphQLCommitStrategy {
|
|
51
|
+
/**
|
|
52
|
+
* GraphQL permanent error patterns for ref operations.
|
|
53
|
+
* Differs from DEFAULT_PERMANENT_ERROR_PATTERNS which has
|
|
54
|
+
* git-CLI-specific patterns (/remote\s*rejected/i) that don't
|
|
55
|
+
* apply to GraphQL responses.
|
|
56
|
+
*/
|
|
57
|
+
static GRAPHQL_PERMANENT_ERROR_PATTERNS = [
|
|
58
|
+
/not\s*found/i,
|
|
59
|
+
/unauthorized/i,
|
|
60
|
+
/permission\s*denied/i,
|
|
61
|
+
/not\s*accessible\s*by\s*integration/i,
|
|
62
|
+
/bad\s*credentials/i,
|
|
63
|
+
/invalid\s*(token|credentials)/i,
|
|
64
|
+
/401\b/,
|
|
65
|
+
/403\b/,
|
|
66
|
+
/does\s*not\s*exist/i,
|
|
67
|
+
/could\s*not\s*resolve/i,
|
|
68
|
+
];
|
|
51
69
|
executor;
|
|
52
70
|
constructor(executor) {
|
|
53
71
|
this.executor = executor ?? defaultExecutor;
|
|
@@ -85,7 +103,7 @@ export class GraphQLCommitStrategy {
|
|
|
85
103
|
// Ensure the branch exists on remote and is up-to-date with local HEAD
|
|
86
104
|
// createCommitOnBranch requires the branch to already exist
|
|
87
105
|
// For PR branches (force=true), we force-update to ensure fresh start from main
|
|
88
|
-
await this.ensureBranchExistsOnRemote(branchName, workDir, options.force,
|
|
106
|
+
await this.ensureBranchExistsOnRemote(branchName, workDir, options.force, githubInfo, token);
|
|
89
107
|
// Retry loop for expectedHeadOid mismatch
|
|
90
108
|
let lastError = null;
|
|
91
109
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
@@ -204,47 +222,31 @@ export class GraphQLCommitStrategy {
|
|
|
204
222
|
* Ensure the branch exists on the remote and matches local HEAD.
|
|
205
223
|
* createCommitOnBranch requires the branch to already exist.
|
|
206
224
|
*
|
|
225
|
+
* Uses GraphQL ref mutations instead of git push to support repos
|
|
226
|
+
* with required_signatures on all branches.
|
|
227
|
+
*
|
|
207
228
|
* For PR branches (force=true): delete existing remote branch and recreate
|
|
208
229
|
* from local HEAD to ensure a fresh start from main.
|
|
209
230
|
*
|
|
210
231
|
* For direct mode (force=false): just ensure branch exists.
|
|
211
232
|
*/
|
|
212
|
-
async ensureBranchExistsOnRemote(branchName, workDir, force,
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
// Check if the branch exists on remote
|
|
216
|
-
// Use skipRetry because failure is expected for new branches
|
|
217
|
-
if (gitOps) {
|
|
218
|
-
await gitOps.lsRemote(branchName, { skipRetry: true });
|
|
219
|
-
}
|
|
220
|
-
else {
|
|
221
|
-
await this.executor.exec(`git ls-remote --exit-code --heads origin ${escapeShellArg(branchName)}`, workDir);
|
|
222
|
-
}
|
|
223
|
-
// Branch exists - for PR branches, delete and recreate to ensure fresh from main
|
|
224
|
-
if (force) {
|
|
225
|
-
if (gitOps) {
|
|
226
|
-
await gitOps.pushRefspec(branchName, { delete: true });
|
|
227
|
-
// Now push fresh branch from local HEAD
|
|
228
|
-
await gitOps.pushRefspec(`HEAD:${branchName}`);
|
|
229
|
-
}
|
|
230
|
-
else {
|
|
231
|
-
await this.executor.exec(`git push origin --delete ${escapeShellArg(branchName)}`, workDir);
|
|
232
|
-
// Now push fresh branch from local HEAD
|
|
233
|
-
await this.executor.exec(`git push -u origin HEAD:${escapeShellArg(branchName)}`, workDir);
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
// For direct mode (force=false), leave existing branch as-is
|
|
233
|
+
async ensureBranchExistsOnRemote(branchName, workDir, force, repoInfo, token) {
|
|
234
|
+
if (!repoInfo) {
|
|
235
|
+
throw new Error("repoInfo is required for GraphQL ref operations");
|
|
237
236
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
237
|
+
const { repositoryId, refId } = await this.queryRemoteRef(repoInfo, branchName, workDir, token);
|
|
238
|
+
if (refId && force) {
|
|
239
|
+
// Branch exists + force: delete then recreate from local HEAD
|
|
240
|
+
await this.deleteRemoteRef(refId, workDir, repoInfo, token);
|
|
241
|
+
const sha = (await this.executor.exec("git rev-parse HEAD", workDir)).trim();
|
|
242
|
+
await this.createRemoteRef(repositoryId, branchName, sha, workDir, repoInfo, token);
|
|
243
|
+
}
|
|
244
|
+
else if (!refId) {
|
|
245
|
+
// Branch doesn't exist: create from local HEAD
|
|
246
|
+
const sha = (await this.executor.exec("git rev-parse HEAD", workDir)).trim();
|
|
247
|
+
await this.createRemoteRef(repositoryId, branchName, sha, workDir, repoInfo, token);
|
|
247
248
|
}
|
|
249
|
+
// refId exists + !force: no-op (branch already exists)
|
|
248
250
|
}
|
|
249
251
|
/**
|
|
250
252
|
* Sanitize command execution errors to remove the GraphQL payload.
|
|
@@ -284,4 +286,59 @@ export class GraphQLCommitStrategy {
|
|
|
284
286
|
// GitHub may return this generic error for OID mismatches
|
|
285
287
|
message.includes("was provided invalid value"));
|
|
286
288
|
}
|
|
289
|
+
/**
|
|
290
|
+
* Execute a GraphQL query or mutation for ref operations.
|
|
291
|
+
* Handles command construction, retry, error sanitization, and response parsing.
|
|
292
|
+
* Uses gh CLI's --input flag to pass GraphQL via stdin (same pattern as executeGraphQLMutation).
|
|
293
|
+
*/
|
|
294
|
+
async executeGraphQLRefOp(queryOrMutation, repoInfo, workDir, token) {
|
|
295
|
+
const requestBody = JSON.stringify({ query: queryOrMutation });
|
|
296
|
+
const hostnameArg = repoInfo.host !== "github.com"
|
|
297
|
+
? `--hostname ${escapeShellArg(repoInfo.host)}`
|
|
298
|
+
: "";
|
|
299
|
+
const tokenPrefix = token ? `GH_TOKEN=${token} ` : "";
|
|
300
|
+
const command = `echo ${escapeShellArg(requestBody)} | ${tokenPrefix}gh api graphql ${hostnameArg} --input -`;
|
|
301
|
+
let response;
|
|
302
|
+
try {
|
|
303
|
+
response = await withRetry(() => this.executor.exec(command, workDir), {
|
|
304
|
+
permanentErrorPatterns: GraphQLCommitStrategy.GRAPHQL_PERMANENT_ERROR_PATTERNS,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
throw this.sanitizeCommandError(error, `${repoInfo.owner}/${repoInfo.repo}`);
|
|
309
|
+
}
|
|
310
|
+
const parsed = JSON.parse(response);
|
|
311
|
+
if (parsed.errors) {
|
|
312
|
+
throw new Error(`GraphQL error: ${parsed.errors.map((e) => e.message).join(", ")}`);
|
|
313
|
+
}
|
|
314
|
+
return parsed;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Query the remote for a repository's Node ID and a ref's Node ID.
|
|
318
|
+
* Returns repositoryId (always) and refId (null if branch doesn't exist).
|
|
319
|
+
*/
|
|
320
|
+
async queryRemoteRef(repoInfo, branchName, workDir, token) {
|
|
321
|
+
const query = `{ repository(owner: ${JSON.stringify(repoInfo.owner)}, name: ${JSON.stringify(repoInfo.repo)}) { id ref(qualifiedName: ${JSON.stringify(`refs/heads/${branchName}`)}) { id } } }`;
|
|
322
|
+
const parsed = await this.executeGraphQLRefOp(query, repoInfo, workDir, token);
|
|
323
|
+
const repo = parsed.data?.repository;
|
|
324
|
+
const repositoryId = repo?.id;
|
|
325
|
+
if (!repositoryId) {
|
|
326
|
+
throw new Error(`GraphQL response missing repository ID for ${repoInfo.owner}/${repoInfo.repo}`);
|
|
327
|
+
}
|
|
328
|
+
return { repositoryId, refId: repo?.ref?.id ?? null };
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Create a branch ref on the remote via GraphQL createRef mutation.
|
|
332
|
+
*/
|
|
333
|
+
async createRemoteRef(repositoryId, branchName, oid, workDir, repoInfo, token) {
|
|
334
|
+
const mutation = `mutation { createRef(input: { repositoryId: ${JSON.stringify(repositoryId)}, name: ${JSON.stringify(`refs/heads/${branchName}`)}, oid: ${JSON.stringify(oid)} }) { clientMutationId } }`;
|
|
335
|
+
await this.executeGraphQLRefOp(mutation, repoInfo, workDir, token);
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Delete a branch ref on the remote via GraphQL deleteRef mutation.
|
|
339
|
+
*/
|
|
340
|
+
async deleteRemoteRef(refId, workDir, repoInfo, token) {
|
|
341
|
+
const mutation = `mutation { deleteRef(input: { refId: ${JSON.stringify(refId)} }) { clientMutationId } }`;
|
|
342
|
+
await this.executeGraphQLRefOp(mutation, repoInfo, workDir, token);
|
|
343
|
+
}
|
|
287
344
|
}
|
package/dist/vcs/types.d.ts
CHANGED
|
@@ -96,7 +96,7 @@ export interface CommitOptions {
|
|
|
96
96
|
force?: boolean;
|
|
97
97
|
/** GitHub App installation token for authentication (used by GraphQLCommitStrategy) */
|
|
98
98
|
token?: string;
|
|
99
|
-
/** Authenticated git operations wrapper (used by GraphQLCommitStrategy for
|
|
99
|
+
/** Authenticated git operations wrapper (used by GraphQLCommitStrategy for fetchBranch() during OID mismatch retries) */
|
|
100
100
|
gitOps?: IAuthenticatedGitOps;
|
|
101
101
|
}
|
|
102
102
|
export interface CommitResult {
|
package/package.json
CHANGED