@aspruyt/xfg 3.1.2 → 3.1.4
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/authenticated-git-ops.d.ts +112 -0
- package/dist/authenticated-git-ops.js +170 -0
- package/dist/repository-processor.d.ts +5 -4
- package/dist/repository-processor.js +33 -4
- package/dist/strategies/commit-strategy.d.ts +3 -0
- package/dist/strategies/git-commit-strategy.js +13 -8
- package/dist/strategies/graphql-commit-strategy.d.ts +0 -14
- package/dist/strategies/graphql-commit-strategy.js +32 -33
- package/package.json +1 -1
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { GitOps } from "./git-ops.js";
|
|
2
|
+
/**
|
|
3
|
+
* Options for authenticated git operations.
|
|
4
|
+
*/
|
|
5
|
+
export interface GitAuthOptions {
|
|
6
|
+
/** Access token for authentication */
|
|
7
|
+
token: string;
|
|
8
|
+
/** Git host (e.g., "github.com", "github.mycompany.com") */
|
|
9
|
+
host: string;
|
|
10
|
+
/** Repository owner */
|
|
11
|
+
owner: string;
|
|
12
|
+
/** Repository name */
|
|
13
|
+
repo: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Interface for authenticated git operations.
|
|
17
|
+
* Enables proper mocking in tests without relying on class inheritance.
|
|
18
|
+
*/
|
|
19
|
+
export interface IAuthenticatedGitOps {
|
|
20
|
+
clone(gitUrl: string): Promise<void>;
|
|
21
|
+
fetch(options?: {
|
|
22
|
+
prune?: boolean;
|
|
23
|
+
}): Promise<void>;
|
|
24
|
+
push(branchName: string, options?: {
|
|
25
|
+
force?: boolean;
|
|
26
|
+
}): Promise<void>;
|
|
27
|
+
getDefaultBranch(): Promise<{
|
|
28
|
+
branch: string;
|
|
29
|
+
method: string;
|
|
30
|
+
}>;
|
|
31
|
+
lsRemote(branchName: string): Promise<string>;
|
|
32
|
+
pushRefspec(refspec: string, options?: {
|
|
33
|
+
delete?: boolean;
|
|
34
|
+
}): Promise<void>;
|
|
35
|
+
fetchBranch(branchName: string): Promise<void>;
|
|
36
|
+
cleanWorkspace(): void;
|
|
37
|
+
createBranch(branchName: string): Promise<void>;
|
|
38
|
+
writeFile(fileName: string, content: string): void;
|
|
39
|
+
setExecutable(fileName: string): Promise<void>;
|
|
40
|
+
getFileContent(fileName: string): string | null;
|
|
41
|
+
wouldChange(fileName: string, content: string): boolean;
|
|
42
|
+
hasChanges(): Promise<boolean>;
|
|
43
|
+
getChangedFiles(): Promise<string[]>;
|
|
44
|
+
hasStagedChanges(): Promise<boolean>;
|
|
45
|
+
fileExistsOnBranch(fileName: string, branch: string): Promise<boolean>;
|
|
46
|
+
fileExists(fileName: string): boolean;
|
|
47
|
+
deleteFile(fileName: string): void;
|
|
48
|
+
commit(message: string): Promise<boolean>;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Wrapper around GitOps that adds authentication to network operations.
|
|
52
|
+
*
|
|
53
|
+
* When auth options are provided, network operations (clone, fetch, push,
|
|
54
|
+
* getDefaultBranch) use `-c url.insteadOf` to override credentials per-command.
|
|
55
|
+
* This allows different tokens for different repos without global git config.
|
|
56
|
+
*
|
|
57
|
+
* Local operations (commit, writeFile, etc.) pass through unchanged.
|
|
58
|
+
*/
|
|
59
|
+
export declare class AuthenticatedGitOps implements IAuthenticatedGitOps {
|
|
60
|
+
private gitOps;
|
|
61
|
+
private auth?;
|
|
62
|
+
private executor;
|
|
63
|
+
private workDir;
|
|
64
|
+
private retries;
|
|
65
|
+
constructor(gitOps: GitOps, auth?: GitAuthOptions);
|
|
66
|
+
private execWithRetry;
|
|
67
|
+
/**
|
|
68
|
+
* Build the authenticated remote URL.
|
|
69
|
+
*/
|
|
70
|
+
private getAuthenticatedUrl;
|
|
71
|
+
clone(gitUrl: string): Promise<void>;
|
|
72
|
+
fetch(options?: {
|
|
73
|
+
prune?: boolean;
|
|
74
|
+
}): Promise<void>;
|
|
75
|
+
push(branchName: string, options?: {
|
|
76
|
+
force?: boolean;
|
|
77
|
+
}): Promise<void>;
|
|
78
|
+
getDefaultBranch(): Promise<{
|
|
79
|
+
branch: string;
|
|
80
|
+
method: string;
|
|
81
|
+
}>;
|
|
82
|
+
/**
|
|
83
|
+
* Execute ls-remote with authentication.
|
|
84
|
+
* Used by GraphQLCommitStrategy to check if branch exists on remote.
|
|
85
|
+
*/
|
|
86
|
+
lsRemote(branchName: string): Promise<string>;
|
|
87
|
+
/**
|
|
88
|
+
* Execute push with custom refspec (e.g., HEAD:branchName).
|
|
89
|
+
* Used by GraphQLCommitStrategy for creating/deleting remote branches.
|
|
90
|
+
*/
|
|
91
|
+
pushRefspec(refspec: string, options?: {
|
|
92
|
+
delete?: boolean;
|
|
93
|
+
}): Promise<void>;
|
|
94
|
+
/**
|
|
95
|
+
* Fetch a specific branch from remote.
|
|
96
|
+
* Used by GraphQLCommitStrategy to update local refs.
|
|
97
|
+
*/
|
|
98
|
+
fetchBranch(branchName: string): Promise<void>;
|
|
99
|
+
cleanWorkspace(): void;
|
|
100
|
+
createBranch(branchName: string): Promise<void>;
|
|
101
|
+
writeFile(fileName: string, content: string): void;
|
|
102
|
+
setExecutable(fileName: string): Promise<void>;
|
|
103
|
+
getFileContent(fileName: string): string | null;
|
|
104
|
+
wouldChange(fileName: string, content: string): boolean;
|
|
105
|
+
hasChanges(): Promise<boolean>;
|
|
106
|
+
getChangedFiles(): Promise<string[]>;
|
|
107
|
+
hasStagedChanges(): Promise<boolean>;
|
|
108
|
+
fileExistsOnBranch(fileName: string, branch: string): Promise<boolean>;
|
|
109
|
+
fileExists(fileName: string): boolean;
|
|
110
|
+
deleteFile(fileName: string): void;
|
|
111
|
+
commit(message: string): Promise<boolean>;
|
|
112
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { escapeShellArg } from "./shell-utils.js";
|
|
2
|
+
import { defaultExecutor } from "./command-executor.js";
|
|
3
|
+
import { withRetry } from "./retry-utils.js";
|
|
4
|
+
/**
|
|
5
|
+
* Wrapper around GitOps that adds authentication to network operations.
|
|
6
|
+
*
|
|
7
|
+
* When auth options are provided, network operations (clone, fetch, push,
|
|
8
|
+
* getDefaultBranch) use `-c url.insteadOf` to override credentials per-command.
|
|
9
|
+
* This allows different tokens for different repos without global git config.
|
|
10
|
+
*
|
|
11
|
+
* Local operations (commit, writeFile, etc.) pass through unchanged.
|
|
12
|
+
*/
|
|
13
|
+
export class AuthenticatedGitOps {
|
|
14
|
+
gitOps;
|
|
15
|
+
auth;
|
|
16
|
+
executor;
|
|
17
|
+
workDir;
|
|
18
|
+
retries;
|
|
19
|
+
constructor(gitOps, auth) {
|
|
20
|
+
this.gitOps = gitOps;
|
|
21
|
+
this.auth = auth;
|
|
22
|
+
// Extract executor and workDir from gitOps via reflection
|
|
23
|
+
const internal = gitOps;
|
|
24
|
+
this.executor = internal.executor ?? defaultExecutor;
|
|
25
|
+
this.workDir = internal.workDir ?? ".";
|
|
26
|
+
this.retries = internal.retries ?? 3;
|
|
27
|
+
}
|
|
28
|
+
async execWithRetry(command) {
|
|
29
|
+
return withRetry(() => this.executor.exec(command, this.workDir), {
|
|
30
|
+
retries: this.retries,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
// ============================================================
|
|
34
|
+
// Network operations - use authenticated command when token provided
|
|
35
|
+
// ============================================================
|
|
36
|
+
/**
|
|
37
|
+
* Build the authenticated remote URL.
|
|
38
|
+
*/
|
|
39
|
+
getAuthenticatedUrl() {
|
|
40
|
+
const { token, host, owner, repo } = this.auth;
|
|
41
|
+
return `https://x-access-token:${token}@${host}/${owner}/${repo}`;
|
|
42
|
+
}
|
|
43
|
+
async clone(gitUrl) {
|
|
44
|
+
if (!this.auth) {
|
|
45
|
+
return this.gitOps.clone(gitUrl);
|
|
46
|
+
}
|
|
47
|
+
// Clone using authenticated URL directly - no insteadOf needed
|
|
48
|
+
const authUrl = escapeShellArg(this.getAuthenticatedUrl());
|
|
49
|
+
await this.execWithRetry(`git clone ${authUrl} .`);
|
|
50
|
+
}
|
|
51
|
+
async fetch(options) {
|
|
52
|
+
if (!this.auth) {
|
|
53
|
+
return this.gitOps.fetch(options);
|
|
54
|
+
}
|
|
55
|
+
// Remote URL already has auth from clone, just fetch
|
|
56
|
+
const pruneFlag = options?.prune ? " --prune" : "";
|
|
57
|
+
await this.execWithRetry(`git fetch origin${pruneFlag}`);
|
|
58
|
+
}
|
|
59
|
+
async push(branchName, options) {
|
|
60
|
+
if (!this.auth) {
|
|
61
|
+
return this.gitOps.push(branchName, options);
|
|
62
|
+
}
|
|
63
|
+
// Remote URL already has auth from clone, just push
|
|
64
|
+
const forceFlag = options?.force ? "--force-with-lease " : "";
|
|
65
|
+
const safeBranch = escapeShellArg(branchName);
|
|
66
|
+
await this.execWithRetry(`git push ${forceFlag}-u origin ${safeBranch}`);
|
|
67
|
+
}
|
|
68
|
+
async getDefaultBranch() {
|
|
69
|
+
if (!this.auth) {
|
|
70
|
+
return this.gitOps.getDefaultBranch();
|
|
71
|
+
}
|
|
72
|
+
// Network operation - remote URL already has auth from clone
|
|
73
|
+
try {
|
|
74
|
+
const remoteInfo = await this.execWithRetry(`git remote show origin`);
|
|
75
|
+
const match = remoteInfo.match(/HEAD branch: (\S+)/);
|
|
76
|
+
if (match) {
|
|
77
|
+
return { branch: match[1], method: "remote HEAD" };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// Fall through to local checks
|
|
82
|
+
}
|
|
83
|
+
// Local operations don't need auth
|
|
84
|
+
try {
|
|
85
|
+
await this.executor.exec("git rev-parse --verify origin/main", this.workDir);
|
|
86
|
+
return { branch: "main", method: "origin/main exists" };
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// Continue
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
await this.executor.exec("git rev-parse --verify origin/master", this.workDir);
|
|
93
|
+
return { branch: "master", method: "origin/master exists" };
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Continue
|
|
97
|
+
}
|
|
98
|
+
return { branch: "main", method: "fallback default" };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Execute ls-remote with authentication.
|
|
102
|
+
* Used by GraphQLCommitStrategy to check if branch exists on remote.
|
|
103
|
+
*/
|
|
104
|
+
async lsRemote(branchName) {
|
|
105
|
+
// Remote URL already has auth from clone
|
|
106
|
+
const safeBranch = escapeShellArg(branchName);
|
|
107
|
+
return this.execWithRetry(`git ls-remote --exit-code --heads origin ${safeBranch}`);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Execute push with custom refspec (e.g., HEAD:branchName).
|
|
111
|
+
* Used by GraphQLCommitStrategy for creating/deleting remote branches.
|
|
112
|
+
*/
|
|
113
|
+
async pushRefspec(refspec, options) {
|
|
114
|
+
// Remote URL already has auth from clone
|
|
115
|
+
const deleteFlag = options?.delete ? "--delete " : "";
|
|
116
|
+
const safeRefspec = escapeShellArg(refspec);
|
|
117
|
+
await this.execWithRetry(`git push ${deleteFlag}-u origin ${safeRefspec}`);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Fetch a specific branch from remote.
|
|
121
|
+
* Used by GraphQLCommitStrategy to update local refs.
|
|
122
|
+
*/
|
|
123
|
+
async fetchBranch(branchName) {
|
|
124
|
+
// Remote URL already has auth from clone
|
|
125
|
+
const safeBranch = escapeShellArg(branchName);
|
|
126
|
+
await this.execWithRetry(`git fetch origin ${safeBranch}:refs/remotes/origin/${safeBranch}`);
|
|
127
|
+
}
|
|
128
|
+
// ============================================================
|
|
129
|
+
// Local operations - delegate directly to GitOps
|
|
130
|
+
// ============================================================
|
|
131
|
+
cleanWorkspace() {
|
|
132
|
+
return this.gitOps.cleanWorkspace();
|
|
133
|
+
}
|
|
134
|
+
async createBranch(branchName) {
|
|
135
|
+
return this.gitOps.createBranch(branchName);
|
|
136
|
+
}
|
|
137
|
+
writeFile(fileName, content) {
|
|
138
|
+
return this.gitOps.writeFile(fileName, content);
|
|
139
|
+
}
|
|
140
|
+
async setExecutable(fileName) {
|
|
141
|
+
return this.gitOps.setExecutable(fileName);
|
|
142
|
+
}
|
|
143
|
+
getFileContent(fileName) {
|
|
144
|
+
return this.gitOps.getFileContent(fileName);
|
|
145
|
+
}
|
|
146
|
+
wouldChange(fileName, content) {
|
|
147
|
+
return this.gitOps.wouldChange(fileName, content);
|
|
148
|
+
}
|
|
149
|
+
async hasChanges() {
|
|
150
|
+
return this.gitOps.hasChanges();
|
|
151
|
+
}
|
|
152
|
+
async getChangedFiles() {
|
|
153
|
+
return this.gitOps.getChangedFiles();
|
|
154
|
+
}
|
|
155
|
+
async hasStagedChanges() {
|
|
156
|
+
return this.gitOps.hasStagedChanges();
|
|
157
|
+
}
|
|
158
|
+
async fileExistsOnBranch(fileName, branch) {
|
|
159
|
+
return this.gitOps.fileExistsOnBranch(fileName, branch);
|
|
160
|
+
}
|
|
161
|
+
fileExists(fileName) {
|
|
162
|
+
return this.gitOps.fileExists(fileName);
|
|
163
|
+
}
|
|
164
|
+
deleteFile(fileName) {
|
|
165
|
+
return this.gitOps.deleteFile(fileName);
|
|
166
|
+
}
|
|
167
|
+
async commit(message) {
|
|
168
|
+
return this.gitOps.commit(message);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { RepoConfig } from "./config.js";
|
|
2
2
|
import { RepoInfo } from "./repo-detector.js";
|
|
3
|
-
import {
|
|
3
|
+
import { GitOpsOptions } from "./git-ops.js";
|
|
4
|
+
import { IAuthenticatedGitOps, GitAuthOptions } from "./authenticated-git-ops.js";
|
|
4
5
|
import { ILogger } from "./logger.js";
|
|
5
6
|
import { CommandExecutor } from "./command-executor.js";
|
|
6
7
|
import { DiffStats } from "./diff-utils.js";
|
|
@@ -20,10 +21,10 @@ export interface ProcessorOptions {
|
|
|
20
21
|
noDelete?: boolean;
|
|
21
22
|
}
|
|
22
23
|
/**
|
|
23
|
-
* Factory function type for creating
|
|
24
|
+
* Factory function type for creating IAuthenticatedGitOps instances.
|
|
24
25
|
* Allows dependency injection for testing.
|
|
25
26
|
*/
|
|
26
|
-
export type GitOpsFactory = (options: GitOpsOptions) =>
|
|
27
|
+
export type GitOpsFactory = (options: GitOpsOptions, auth?: GitAuthOptions) => IAuthenticatedGitOps;
|
|
27
28
|
export interface ProcessorResult {
|
|
28
29
|
success: boolean;
|
|
29
30
|
repoName: string;
|
|
@@ -46,7 +47,7 @@ export declare class RepositoryProcessor {
|
|
|
46
47
|
private readonly tokenManager;
|
|
47
48
|
/**
|
|
48
49
|
* Creates a new RepositoryProcessor.
|
|
49
|
-
* @param gitOpsFactory - Optional factory for creating
|
|
50
|
+
* @param gitOpsFactory - Optional factory for creating AuthenticatedGitOps instances (for testing)
|
|
50
51
|
* @param log - Optional logger instance (for testing)
|
|
51
52
|
*/
|
|
52
53
|
constructor(gitOpsFactory?: GitOpsFactory, log?: ILogger);
|
|
@@ -4,6 +4,7 @@ import { convertContentToString, } from "./config.js";
|
|
|
4
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
|
+
import { AuthenticatedGitOps, } from "./authenticated-git-ops.js";
|
|
7
8
|
import { createPR, mergePR } from "./pr-creator.js";
|
|
8
9
|
import { logger } from "./logger.js";
|
|
9
10
|
import { getPRStrategy, getCommitStrategy, hasGitHubAppCredentials, } from "./strategies/index.js";
|
|
@@ -34,11 +35,13 @@ export class RepositoryProcessor {
|
|
|
34
35
|
tokenManager;
|
|
35
36
|
/**
|
|
36
37
|
* Creates a new RepositoryProcessor.
|
|
37
|
-
* @param gitOpsFactory - Optional factory for creating
|
|
38
|
+
* @param gitOpsFactory - Optional factory for creating AuthenticatedGitOps instances (for testing)
|
|
38
39
|
* @param log - Optional logger instance (for testing)
|
|
39
40
|
*/
|
|
40
41
|
constructor(gitOpsFactory, log) {
|
|
41
|
-
this.gitOpsFactory =
|
|
42
|
+
this.gitOpsFactory =
|
|
43
|
+
gitOpsFactory ??
|
|
44
|
+
((opts, auth) => new AuthenticatedGitOps(new GitOps(opts), auth));
|
|
42
45
|
this.log = log ?? logger;
|
|
43
46
|
// Initialize GitHub App token manager if credentials are configured
|
|
44
47
|
if (hasGitHubAppCredentials()) {
|
|
@@ -63,11 +66,23 @@ export class RepositoryProcessor {
|
|
|
63
66
|
skipped: true,
|
|
64
67
|
};
|
|
65
68
|
}
|
|
69
|
+
// Build auth options - use installation token OR fall back to GH_TOKEN for PAT flow
|
|
70
|
+
const effectiveToken = token ?? (isGitHubRepo(repoInfo) ? process.env.GH_TOKEN : undefined);
|
|
71
|
+
const authOptions = effectiveToken
|
|
72
|
+
? {
|
|
73
|
+
token: effectiveToken,
|
|
74
|
+
host: isGitHubRepo(repoInfo)
|
|
75
|
+
? repoInfo.host
|
|
76
|
+
: "github.com",
|
|
77
|
+
owner: repoInfo.owner,
|
|
78
|
+
repo: repoInfo.repo,
|
|
79
|
+
}
|
|
80
|
+
: undefined;
|
|
66
81
|
this.gitOps = this.gitOpsFactory({
|
|
67
82
|
workDir,
|
|
68
83
|
dryRun,
|
|
69
84
|
retries: this.retries,
|
|
70
|
-
});
|
|
85
|
+
}, authOptions);
|
|
71
86
|
// Determine merge mode early - affects workflow steps
|
|
72
87
|
const mergeMode = repoConfig.prOptions?.merge ?? "auto";
|
|
73
88
|
const isDirectMode = mergeMode === "direct";
|
|
@@ -320,6 +335,7 @@ export class RepositoryProcessor {
|
|
|
320
335
|
// Use force push (--force-with-lease) for PR branches, not for direct mode
|
|
321
336
|
force: !isDirectMode,
|
|
322
337
|
token,
|
|
338
|
+
gitOps: this.gitOps,
|
|
323
339
|
});
|
|
324
340
|
this.log.info(`Committed: ${commitResult.sha} (verified: ${commitResult.verified})`);
|
|
325
341
|
}
|
|
@@ -454,11 +470,23 @@ export class RepositoryProcessor {
|
|
|
454
470
|
skipped: true,
|
|
455
471
|
};
|
|
456
472
|
}
|
|
473
|
+
// Build auth options - use installation token OR fall back to GH_TOKEN for PAT flow
|
|
474
|
+
const effectiveToken = token ?? (isGitHubRepo(repoInfo) ? process.env.GH_TOKEN : undefined);
|
|
475
|
+
const authOptions = effectiveToken
|
|
476
|
+
? {
|
|
477
|
+
token: effectiveToken,
|
|
478
|
+
host: isGitHubRepo(repoInfo)
|
|
479
|
+
? repoInfo.host
|
|
480
|
+
: "github.com",
|
|
481
|
+
owner: repoInfo.owner,
|
|
482
|
+
repo: repoInfo.repo,
|
|
483
|
+
}
|
|
484
|
+
: undefined;
|
|
457
485
|
this.gitOps = this.gitOpsFactory({
|
|
458
486
|
workDir,
|
|
459
487
|
dryRun,
|
|
460
488
|
retries: this.retries,
|
|
461
|
-
});
|
|
489
|
+
}, authOptions);
|
|
462
490
|
const mergeMode = repoConfig.prOptions?.merge ?? "auto";
|
|
463
491
|
const isDirectMode = mergeMode === "direct";
|
|
464
492
|
try {
|
|
@@ -534,6 +562,7 @@ export class RepositoryProcessor {
|
|
|
534
562
|
retries: this.retries,
|
|
535
563
|
force: !isDirectMode,
|
|
536
564
|
token,
|
|
565
|
+
gitOps: this.gitOps,
|
|
537
566
|
});
|
|
538
567
|
}
|
|
539
568
|
catch (error) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { RepoInfo } from "../repo-detector.js";
|
|
2
|
+
import { IAuthenticatedGitOps } from "../authenticated-git-ops.js";
|
|
2
3
|
export interface FileChange {
|
|
3
4
|
path: string;
|
|
4
5
|
content: string | null;
|
|
@@ -14,6 +15,8 @@ export interface CommitOptions {
|
|
|
14
15
|
force?: boolean;
|
|
15
16
|
/** GitHub App installation token for authentication (used by GraphQLCommitStrategy) */
|
|
16
17
|
token?: string;
|
|
18
|
+
/** Authenticated git operations wrapper (used by GraphQLCommitStrategy for network ops) */
|
|
19
|
+
gitOps?: IAuthenticatedGitOps;
|
|
17
20
|
}
|
|
18
21
|
export interface CommitResult {
|
|
19
22
|
sha: string;
|
|
@@ -18,18 +18,23 @@ export class GitCommitStrategy {
|
|
|
18
18
|
* @returns Commit result with SHA and verified: false (no signature)
|
|
19
19
|
*/
|
|
20
20
|
async commit(options) {
|
|
21
|
-
const { branchName, message, workDir, retries = 3, force = true } = options;
|
|
21
|
+
const { branchName, message, workDir, retries = 3, force = true, gitOps, } = options;
|
|
22
22
|
// Stage all changes
|
|
23
23
|
await this.executor.exec("git add -A", workDir);
|
|
24
24
|
// Commit with the message (--no-verify to skip pre-commit hooks)
|
|
25
25
|
await this.executor.exec(`git commit --no-verify -m ${escapeShellArg(message)}`, workDir);
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
// Push with authentication via gitOps if available
|
|
27
|
+
if (gitOps) {
|
|
28
|
+
await gitOps.push(branchName, { force });
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
// Fallback for non-authenticated scenarios (shouldn't happen in practice)
|
|
32
|
+
const forceFlag = force ? "--force-with-lease " : "";
|
|
33
|
+
const pushCommand = `git push ${forceFlag}-u origin ${escapeShellArg(branchName)}`;
|
|
34
|
+
await withRetry(() => this.executor.exec(pushCommand, workDir), {
|
|
35
|
+
retries,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
33
38
|
// Get the commit SHA
|
|
34
39
|
const sha = await this.executor.exec("git rev-parse HEAD", workDir);
|
|
35
40
|
return {
|
|
@@ -44,20 +44,6 @@ export declare class GraphQLCommitStrategy implements CommitStrategy {
|
|
|
44
44
|
* Execute the createCommitOnBranch GraphQL mutation.
|
|
45
45
|
*/
|
|
46
46
|
private executeGraphQLMutation;
|
|
47
|
-
/**
|
|
48
|
-
* Build a git command with optional token authentication override.
|
|
49
|
-
* When a token is provided, uses -c url.insteadOf to override the global
|
|
50
|
-
* git config and authenticate with the provided token instead.
|
|
51
|
-
*
|
|
52
|
-
* This is critical for GitHub App authentication where the global git config
|
|
53
|
-
* may have a PAT token embedded, but we need to use the GitHub App installation token.
|
|
54
|
-
*
|
|
55
|
-
* Uses a repo-specific URL pattern (including owner/repo) so it has a LONGER
|
|
56
|
-
* prefix match than the global config and takes precedence.
|
|
57
|
-
*
|
|
58
|
-
* Applies to all remote operations: push, fetch, ls-remote, etc.
|
|
59
|
-
*/
|
|
60
|
-
private buildAuthenticatedGitCommand;
|
|
61
47
|
/**
|
|
62
48
|
* Ensure the branch exists on the remote and matches local HEAD.
|
|
63
49
|
* createCommitOnBranch requires the branch to already exist.
|
|
@@ -69,10 +69,12 @@ export class GraphQLCommitStrategy {
|
|
|
69
69
|
throw new Error(`GraphQL payload exceeds 50 MB limit (${Math.round(totalSize / (1024 * 1024))} MB). ` +
|
|
70
70
|
`Consider using smaller files or the git commit strategy.`);
|
|
71
71
|
}
|
|
72
|
+
// Get gitOps for authenticated network operations
|
|
73
|
+
const gitOps = options.gitOps;
|
|
72
74
|
// Ensure the branch exists on remote and is up-to-date with local HEAD
|
|
73
75
|
// createCommitOnBranch requires the branch to already exist
|
|
74
76
|
// For PR branches (force=true), we force-update to ensure fresh start from main
|
|
75
|
-
await this.ensureBranchExistsOnRemote(branchName, workDir, options.force,
|
|
77
|
+
await this.ensureBranchExistsOnRemote(branchName, workDir, options.force, gitOps);
|
|
76
78
|
// Retry loop for expectedHeadOid mismatch
|
|
77
79
|
let lastError = null;
|
|
78
80
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
@@ -80,7 +82,12 @@ export class GraphQLCommitStrategy {
|
|
|
80
82
|
// Fetch from remote to ensure we have the latest HEAD
|
|
81
83
|
// This is critical for expectedHeadOid to match
|
|
82
84
|
const safeBranch = escapeShellArg(branchName);
|
|
83
|
-
|
|
85
|
+
if (gitOps) {
|
|
86
|
+
await gitOps.fetchBranch(branchName);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
await this.executor.exec(`git fetch origin ${safeBranch}:refs/remotes/origin/${safeBranch}`, workDir);
|
|
90
|
+
}
|
|
84
91
|
// Get the remote HEAD SHA for this branch (not local HEAD)
|
|
85
92
|
const headSha = await this.executor.exec(`git rev-parse origin/${safeBranch}`, workDir);
|
|
86
93
|
// Build and execute the GraphQL mutation
|
|
@@ -171,31 +178,6 @@ export class GraphQLCommitStrategy {
|
|
|
171
178
|
pushed: true, // GraphQL commits are pushed directly
|
|
172
179
|
};
|
|
173
180
|
}
|
|
174
|
-
/**
|
|
175
|
-
* Build a git command with optional token authentication override.
|
|
176
|
-
* When a token is provided, uses -c url.insteadOf to override the global
|
|
177
|
-
* git config and authenticate with the provided token instead.
|
|
178
|
-
*
|
|
179
|
-
* This is critical for GitHub App authentication where the global git config
|
|
180
|
-
* may have a PAT token embedded, but we need to use the GitHub App installation token.
|
|
181
|
-
*
|
|
182
|
-
* Uses a repo-specific URL pattern (including owner/repo) so it has a LONGER
|
|
183
|
-
* prefix match than the global config and takes precedence.
|
|
184
|
-
*
|
|
185
|
-
* Applies to all remote operations: push, fetch, ls-remote, etc.
|
|
186
|
-
*/
|
|
187
|
-
buildAuthenticatedGitCommand(gitArgs, token, host = "github.com", owner, repo) {
|
|
188
|
-
if (!token) {
|
|
189
|
-
return `git ${gitArgs}`;
|
|
190
|
-
}
|
|
191
|
-
// Use repo-specific URL pattern for LONGER prefix match to override global config
|
|
192
|
-
// Global config: url."https://x-access-token:PAT@github.com/".insteadOf = "https://github.com/"
|
|
193
|
-
// Our config: url."https://x-access-token:APP@github.com/owner/repo".insteadOf = "https://github.com/owner/repo"
|
|
194
|
-
// The longer prefix (owner/repo) takes precedence in git's URL matching
|
|
195
|
-
const repoPath = owner && repo ? `${owner}/${repo}` : "";
|
|
196
|
-
const urlOverride = `url."https://x-access-token:${token}@${host}/${repoPath}".insteadOf="https://${host}/${repoPath}"`;
|
|
197
|
-
return `git -c ${escapeShellArg(urlOverride)} ${gitArgs}`;
|
|
198
|
-
}
|
|
199
181
|
/**
|
|
200
182
|
* Ensure the branch exists on the remote and matches local HEAD.
|
|
201
183
|
* createCommitOnBranch requires the branch to already exist.
|
|
@@ -205,23 +187,40 @@ export class GraphQLCommitStrategy {
|
|
|
205
187
|
*
|
|
206
188
|
* For direct mode (force=false): just ensure branch exists.
|
|
207
189
|
*/
|
|
208
|
-
async ensureBranchExistsOnRemote(branchName, workDir, force,
|
|
190
|
+
async ensureBranchExistsOnRemote(branchName, workDir, force, gitOps) {
|
|
209
191
|
// Branch name was validated in commit(), safe for shell use
|
|
210
192
|
try {
|
|
211
193
|
// Check if the branch exists on remote
|
|
212
|
-
|
|
194
|
+
if (gitOps) {
|
|
195
|
+
await gitOps.lsRemote(branchName);
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
await this.executor.exec(`git ls-remote --exit-code --heads origin ${escapeShellArg(branchName)}`, workDir);
|
|
199
|
+
}
|
|
213
200
|
// Branch exists - for PR branches, delete and recreate to ensure fresh from main
|
|
214
201
|
if (force) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
202
|
+
if (gitOps) {
|
|
203
|
+
await gitOps.pushRefspec(branchName, { delete: true });
|
|
204
|
+
// Now push fresh branch from local HEAD
|
|
205
|
+
await gitOps.pushRefspec(`HEAD:${branchName}`);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
await this.executor.exec(`git push origin --delete ${escapeShellArg(branchName)}`, workDir);
|
|
209
|
+
// Now push fresh branch from local HEAD
|
|
210
|
+
await this.executor.exec(`git push -u origin HEAD:${escapeShellArg(branchName)}`, workDir);
|
|
211
|
+
}
|
|
218
212
|
}
|
|
219
213
|
// For direct mode (force=false), leave existing branch as-is
|
|
220
214
|
}
|
|
221
215
|
catch {
|
|
222
216
|
// Branch doesn't exist on remote, push it
|
|
223
217
|
// This pushes the current local branch to create it on remote
|
|
224
|
-
|
|
218
|
+
if (gitOps) {
|
|
219
|
+
await gitOps.pushRefspec(`HEAD:${branchName}`);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
await this.executor.exec(`git push -u origin HEAD:${escapeShellArg(branchName)}`, workDir);
|
|
223
|
+
}
|
|
225
224
|
}
|
|
226
225
|
}
|
|
227
226
|
/**
|