@aspruyt/xfg 2.1.2 → 2.2.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/README.md +1 -59
- package/dist/repository-processor.d.ts +2 -0
- package/dist/repository-processor.js +119 -129
- package/dist/strategies/commit-strategy-selector.d.ts +16 -0
- package/dist/strategies/commit-strategy-selector.js +21 -0
- package/dist/strategies/commit-strategy.d.ts +31 -0
- package/dist/strategies/commit-strategy.js +1 -0
- package/dist/strategies/git-commit-strategy.d.ts +18 -0
- package/dist/strategies/git-commit-strategy.js +41 -0
- package/dist/strategies/graphql-commit-strategy.d.ts +58 -0
- package/dist/strategies/graphql-commit-strategy.js +206 -0
- package/dist/strategies/index.d.ts +4 -0
- package/dist/strategies/index.js +3 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -70,64 +70,6 @@ repos:
|
|
|
70
70
|
|
|
71
71
|
**Result:** PRs are created in all three repos with identical `.prettierrc.json` files.
|
|
72
72
|
|
|
73
|
-
## Features
|
|
74
|
-
|
|
75
|
-
- **Multi-File Sync** - Sync multiple config files in a single run
|
|
76
|
-
- **Multi-Format Output** - JSON, JSON5, YAML, or plain text based on filename extension
|
|
77
|
-
- **Subdirectory Support** - Sync files to any path (e.g., `.github/workflows/ci.yaml`)
|
|
78
|
-
- **Text Files** - Sync `.gitignore`, `.markdownlintignore`, etc. with string or lines array
|
|
79
|
-
- **File References** - Use `@path/to/file` to load content from external template files
|
|
80
|
-
- **Content Inheritance** - Define base config once, override per-repo as needed
|
|
81
|
-
- **Multi-Repo Targeting** - Apply same config to multiple repos with array syntax
|
|
82
|
-
- **Environment Variables** - Use `${VAR}` syntax for dynamic values
|
|
83
|
-
- **Templating** - Use `${xfg:repo.name}` syntax for dynamic repo-specific content
|
|
84
|
-
- **Merge Strategies** - Control how arrays merge (replace, append, prepend)
|
|
85
|
-
- **Override Mode** - Skip merging entirely for specific repos
|
|
86
|
-
- **Empty Files** - Create files with no content (e.g., `.prettierignore`)
|
|
87
|
-
- **YAML Comments** - Add header comments and schema directives to YAML files
|
|
88
|
-
- **Multi-Platform** - Works with GitHub (including GitHub Enterprise Server), Azure DevOps, and GitLab (including self-hosted)
|
|
89
|
-
- **Auto-Merge PRs** - Automatically merge PRs when checks pass, or force merge with admin privileges
|
|
90
|
-
- **Direct Push Mode** - Push directly to default branch without creating PRs
|
|
91
|
-
- **Delete Orphaned Files** - Automatically remove files from repos when deleted from config (manifest-tracked)
|
|
92
|
-
- **Dry-Run Mode** - Preview changes without creating PRs
|
|
93
|
-
- **Error Resilience** - Continues processing if individual repos fail
|
|
94
|
-
- **Automatic Retries** - Retries transient network errors with exponential backoff
|
|
95
|
-
|
|
96
|
-
## Use Cases
|
|
97
|
-
|
|
98
|
-
### Platform Engineering Teams
|
|
99
|
-
|
|
100
|
-
Enforce organization-wide standards without requiring a monorepo. Push consistent tooling configs (linters, formatters, TypeScript settings) to hundreds of microservice repos from a single source of truth.
|
|
101
|
-
|
|
102
|
-
### CI/CD Workflow Standardization
|
|
103
|
-
|
|
104
|
-
Keep GitHub Actions workflows, Azure Pipelines, or GitLab CI configs in sync across all repos. Update a workflow once, create PRs everywhere.
|
|
105
|
-
|
|
106
|
-
### Security & Compliance Governance
|
|
107
|
-
|
|
108
|
-
Roll out security scanning configs (Dependabot, CodeQL, SAST tools) or compliance policies across your entire organization. Audit and update security settings from one place.
|
|
109
|
-
|
|
110
|
-
### Developer Experience Consistency
|
|
111
|
-
|
|
112
|
-
Sync `.editorconfig`, `.prettierrc`, `tsconfig.json`, and other DX configs so every repo feels the same. Onboard new team members faster with consistent tooling.
|
|
113
|
-
|
|
114
|
-
### Open Source Maintainers
|
|
115
|
-
|
|
116
|
-
Manage configuration across multiple related projects. Keep issue templates, contributing guidelines, and CI workflows consistent across your ecosystem.
|
|
117
|
-
|
|
118
|
-
### Configuration Drift Prevention
|
|
119
|
-
|
|
120
|
-
Detect and fix configuration drift automatically. Run xfg on a schedule to ensure repos stay in compliance with your standards.
|
|
121
|
-
|
|
122
|
-
**[See detailed use cases with examples →](https://anthony-spruyt.github.io/xfg/use-cases/)**
|
|
123
|
-
|
|
124
73
|
## Documentation
|
|
125
74
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
- [Getting Started](https://anthony-spruyt.github.io/xfg/getting-started/) - Installation and prerequisites
|
|
129
|
-
- [Configuration](https://anthony-spruyt.github.io/xfg/configuration/) - Full configuration reference
|
|
130
|
-
- [Examples](https://anthony-spruyt.github.io/xfg/examples/) - Real-world usage examples
|
|
131
|
-
- [Platforms](https://anthony-spruyt.github.io/xfg/platforms/) - GitHub, Azure DevOps, GitLab setup
|
|
132
|
-
- [CI/CD Integration](https://anthony-spruyt.github.io/xfg/ci-cd/) - GitHub Actions, Azure Pipelines
|
|
133
|
-
- [Troubleshooting](https://anthony-spruyt.github.io/xfg/troubleshooting/)
|
|
75
|
+
See **[anthony-spruyt.github.io/xfg](https://anthony-spruyt.github.io/xfg/)** for configuration reference, examples, platform setup, and troubleshooting.
|
|
@@ -41,6 +41,8 @@ export declare class RepositoryProcessor {
|
|
|
41
41
|
private gitOps;
|
|
42
42
|
private readonly gitOpsFactory;
|
|
43
43
|
private readonly log;
|
|
44
|
+
private retries;
|
|
45
|
+
private executor;
|
|
44
46
|
/**
|
|
45
47
|
* Creates a new RepositoryProcessor.
|
|
46
48
|
* @param gitOpsFactory - Optional factory for creating GitOps instances (for testing)
|
|
@@ -6,7 +6,7 @@ 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 } from "./strategies/index.js";
|
|
9
|
+
import { getPRStrategy, getCommitStrategy } 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";
|
|
@@ -28,6 +28,8 @@ export class RepositoryProcessor {
|
|
|
28
28
|
gitOps = null;
|
|
29
29
|
gitOpsFactory;
|
|
30
30
|
log;
|
|
31
|
+
retries = 3;
|
|
32
|
+
executor = defaultExecutor;
|
|
31
33
|
/**
|
|
32
34
|
* Creates a new RepositoryProcessor.
|
|
33
35
|
* @param gitOpsFactory - Optional factory for creating GitOps instances (for testing)
|
|
@@ -39,9 +41,14 @@ export class RepositoryProcessor {
|
|
|
39
41
|
}
|
|
40
42
|
async process(repoConfig, repoInfo, options) {
|
|
41
43
|
const repoName = getRepoDisplayName(repoInfo);
|
|
42
|
-
const { branchName, workDir, dryRun,
|
|
43
|
-
|
|
44
|
-
this.
|
|
44
|
+
const { branchName, workDir, dryRun, prTemplate } = options;
|
|
45
|
+
this.retries = options.retries ?? 3;
|
|
46
|
+
this.executor = options.executor ?? defaultExecutor;
|
|
47
|
+
this.gitOps = this.gitOpsFactory({
|
|
48
|
+
workDir,
|
|
49
|
+
dryRun,
|
|
50
|
+
retries: this.retries,
|
|
51
|
+
});
|
|
45
52
|
// Determine merge mode early - affects workflow steps
|
|
46
53
|
const mergeMode = repoConfig.prOptions?.merge ?? "auto";
|
|
47
54
|
const isDirectMode = mergeMode === "direct";
|
|
@@ -64,13 +71,13 @@ export class RepositoryProcessor {
|
|
|
64
71
|
// Skip for direct mode - no PR involved
|
|
65
72
|
if (!dryRun && !isDirectMode) {
|
|
66
73
|
this.log.info("Checking for existing PR...");
|
|
67
|
-
const strategy = getPRStrategy(repoInfo, executor);
|
|
74
|
+
const strategy = getPRStrategy(repoInfo, this.executor);
|
|
68
75
|
const closed = await strategy.closeExistingPR({
|
|
69
76
|
repoInfo,
|
|
70
77
|
branchName,
|
|
71
78
|
baseBranch,
|
|
72
79
|
workDir,
|
|
73
|
-
retries,
|
|
80
|
+
retries: this.retries,
|
|
74
81
|
});
|
|
75
82
|
if (closed) {
|
|
76
83
|
this.log.info("Closed existing PR and deleted branch for fresh sync");
|
|
@@ -94,17 +101,10 @@ export class RepositoryProcessor {
|
|
|
94
101
|
// - Dry-run: Uses wouldChange() for read-only content comparison (no side effects)
|
|
95
102
|
// - Normal: Uses git status after writing (source of truth for what git will commit)
|
|
96
103
|
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
//
|
|
101
|
-
// For config files (JSON/YAML), these approaches produce identical results in practice.
|
|
102
|
-
// Edge cases (repos with unusual git attributes on config files) are essentially nonexistent.
|
|
103
|
-
const changedFiles = [];
|
|
104
|
+
// Track all file changes with content and action - single source of truth
|
|
105
|
+
// Used for both commit message generation and actual commit
|
|
106
|
+
const fileChangesForCommit = new Map();
|
|
104
107
|
const diffStats = createDiffStats();
|
|
105
|
-
// Track pre-write actions for non-dry-run mode (issue #252)
|
|
106
|
-
// We need to know if a file was created vs updated BEFORE writing it
|
|
107
|
-
const preWriteActions = new Map();
|
|
108
108
|
for (const file of repoConfig.files) {
|
|
109
109
|
const filePath = join(workDir, file.fileName);
|
|
110
110
|
const fileExistsLocal = existsSync(filePath);
|
|
@@ -114,7 +114,10 @@ export class RepositoryProcessor {
|
|
|
114
114
|
const existsOnBase = await this.gitOps.fileExistsOnBranch(file.fileName, baseBranch);
|
|
115
115
|
if (existsOnBase) {
|
|
116
116
|
this.log.info(`Skipping ${file.fileName} (createOnly: exists on ${baseBranch})`);
|
|
117
|
-
|
|
117
|
+
fileChangesForCommit.set(file.fileName, {
|
|
118
|
+
content: null,
|
|
119
|
+
action: "skip",
|
|
120
|
+
});
|
|
118
121
|
continue;
|
|
119
122
|
}
|
|
120
123
|
}
|
|
@@ -132,35 +135,37 @@ export class RepositoryProcessor {
|
|
|
132
135
|
header: file.header,
|
|
133
136
|
schemaUrl: file.schemaUrl,
|
|
134
137
|
});
|
|
135
|
-
// Determine action type (create vs update)
|
|
138
|
+
// Determine action type (create vs update) BEFORE writing
|
|
136
139
|
const action = fileExistsLocal
|
|
137
140
|
? "update"
|
|
138
141
|
: "create";
|
|
142
|
+
// Check if file would change (needed for both modes)
|
|
143
|
+
const existingContent = this.gitOps.getFileContent(file.fileName);
|
|
144
|
+
const changed = this.gitOps.wouldChange(file.fileName, fileContent);
|
|
145
|
+
if (changed) {
|
|
146
|
+
// Track in single source of truth
|
|
147
|
+
fileChangesForCommit.set(file.fileName, {
|
|
148
|
+
content: fileContent,
|
|
149
|
+
action,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
139
152
|
if (dryRun) {
|
|
140
|
-
// In dry-run,
|
|
141
|
-
const existingContent = this.gitOps.getFileContent(file.fileName);
|
|
142
|
-
const changed = this.gitOps.wouldChange(file.fileName, fileContent);
|
|
153
|
+
// In dry-run, show diff but don't write
|
|
143
154
|
const status = getFileStatus(existingContent !== null, changed);
|
|
144
|
-
// Track stats
|
|
145
155
|
incrementDiffStats(diffStats, status);
|
|
146
|
-
if (changed) {
|
|
147
|
-
changedFiles.push({ fileName: file.fileName, action });
|
|
148
|
-
}
|
|
149
|
-
// Generate and display diff
|
|
150
156
|
const diffLines = generateDiff(existingContent, fileContent, file.fileName);
|
|
151
157
|
this.log.fileDiff(file.fileName, status, diffLines);
|
|
152
158
|
}
|
|
153
159
|
else {
|
|
154
|
-
// Write the file
|
|
155
|
-
preWriteActions.set(file.fileName, action);
|
|
160
|
+
// Write the file
|
|
156
161
|
this.gitOps.writeFile(file.fileName, fileContent);
|
|
157
162
|
}
|
|
158
163
|
}
|
|
159
164
|
// Step 5b: Set executable permission for files that need it
|
|
160
|
-
const skippedFileNames = new Set(changedFiles.filter((f) => f.action === "skip").map((f) => f.fileName));
|
|
161
165
|
for (const file of repoConfig.files) {
|
|
162
166
|
// Skip files that were excluded (createOnly + exists)
|
|
163
|
-
|
|
167
|
+
const tracked = fileChangesForCommit.get(file.fileName);
|
|
168
|
+
if (tracked?.action === "skip") {
|
|
164
169
|
continue;
|
|
165
170
|
}
|
|
166
171
|
if (shouldBeExecutable(file)) {
|
|
@@ -184,6 +189,11 @@ export class RepositoryProcessor {
|
|
|
184
189
|
for (const fileName of filesToDelete) {
|
|
185
190
|
// Only delete if file actually exists in the working directory
|
|
186
191
|
if (this.gitOps.fileExists(fileName)) {
|
|
192
|
+
// Track deletion in single source of truth
|
|
193
|
+
fileChangesForCommit.set(fileName, {
|
|
194
|
+
content: null,
|
|
195
|
+
action: "delete",
|
|
196
|
+
});
|
|
187
197
|
if (dryRun) {
|
|
188
198
|
// In dry-run, show what would be deleted
|
|
189
199
|
this.log.fileDiff(fileName, "DELETED", []);
|
|
@@ -193,7 +203,6 @@ export class RepositoryProcessor {
|
|
|
193
203
|
this.log.info(`Deleting orphaned file: ${fileName}`);
|
|
194
204
|
this.gitOps.deleteFile(fileName);
|
|
195
205
|
}
|
|
196
|
-
changedFiles.push({ fileName, action: "delete" });
|
|
197
206
|
}
|
|
198
207
|
}
|
|
199
208
|
}
|
|
@@ -204,82 +213,41 @@ export class RepositoryProcessor {
|
|
|
204
213
|
// Only save if there are managed files for any config, or if we had a previous manifest
|
|
205
214
|
const hasAnyManagedFiles = Object.keys(newManifest.configs).length > 0;
|
|
206
215
|
if (hasAnyManagedFiles || existingManifest !== null) {
|
|
207
|
-
if (!dryRun) {
|
|
208
|
-
saveManifest(workDir, newManifest);
|
|
209
|
-
}
|
|
210
216
|
// Track manifest file as changed if it would be different
|
|
211
217
|
const existingConfigs = existingManifest?.configs ?? {};
|
|
212
218
|
const manifestChanged = JSON.stringify(existingConfigs) !==
|
|
213
219
|
JSON.stringify(newManifest.configs);
|
|
214
220
|
if (manifestChanged) {
|
|
215
221
|
const manifestExisted = existsSync(join(workDir, MANIFEST_FILENAME));
|
|
216
|
-
|
|
217
|
-
|
|
222
|
+
const manifestContent = JSON.stringify(newManifest, null, 2) + "\n";
|
|
223
|
+
fileChangesForCommit.set(MANIFEST_FILENAME, {
|
|
224
|
+
content: manifestContent,
|
|
218
225
|
action: manifestExisted ? "update" : "create",
|
|
219
226
|
});
|
|
220
227
|
}
|
|
228
|
+
if (!dryRun) {
|
|
229
|
+
saveManifest(workDir, newManifest);
|
|
230
|
+
}
|
|
221
231
|
}
|
|
222
232
|
// Show diff summary in dry-run mode
|
|
223
233
|
if (dryRun) {
|
|
224
234
|
this.log.diffSummary(diffStats.newCount, diffStats.modifiedCount, diffStats.unchangedCount, diffStats.deletedCount);
|
|
225
235
|
}
|
|
226
|
-
// Step 6:
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const alreadyTracked = new Set(changedFiles.map((f) => f.fileName));
|
|
239
|
-
// Add config files that actually changed according to git
|
|
240
|
-
for (const file of repoConfig.files) {
|
|
241
|
-
if (alreadyTracked.has(file.fileName)) {
|
|
242
|
-
continue; // Already tracked (skipped, deleted, or manifest)
|
|
243
|
-
}
|
|
244
|
-
// Only include files that git reports as changed
|
|
245
|
-
if (!gitChangedFiles.has(file.fileName)) {
|
|
246
|
-
continue; // File didn't actually change
|
|
247
|
-
}
|
|
248
|
-
// Use pre-write action (issue #252) - we stored whether file existed
|
|
249
|
-
// BEFORE writing, which is the correct basis for create vs update
|
|
250
|
-
const action = preWriteActions.get(file.fileName) ?? "update";
|
|
251
|
-
changedFiles.push({ fileName: file.fileName, action });
|
|
252
|
-
}
|
|
253
|
-
// Add any other files from git status that aren't already tracked
|
|
254
|
-
// This catches files like .xfg.json when manifestChanged was false
|
|
255
|
-
// but git still reports a change (e.g., due to formatting differences)
|
|
256
|
-
for (const gitFile of gitChangedFiles) {
|
|
257
|
-
if (changedFiles.some((f) => f.fileName === gitFile)) {
|
|
258
|
-
continue; // Already tracked
|
|
259
|
-
}
|
|
260
|
-
const filePath = join(workDir, gitFile);
|
|
261
|
-
const action = existsSync(filePath)
|
|
262
|
-
? "update"
|
|
263
|
-
: "create";
|
|
264
|
-
changedFiles.push({ fileName: gitFile, action });
|
|
265
|
-
}
|
|
266
|
-
// Calculate diff stats from changedFiles (issue #252)
|
|
267
|
-
for (const file of changedFiles) {
|
|
268
|
-
switch (file.action) {
|
|
269
|
-
case "create":
|
|
270
|
-
incrementDiffStats(diffStats, "NEW");
|
|
271
|
-
break;
|
|
272
|
-
case "update":
|
|
273
|
-
incrementDiffStats(diffStats, "MODIFIED");
|
|
274
|
-
break;
|
|
275
|
-
case "delete":
|
|
276
|
-
incrementDiffStats(diffStats, "DELETED");
|
|
277
|
-
break;
|
|
278
|
-
// "skip" files are not counted in stats
|
|
279
|
-
}
|
|
280
|
-
}
|
|
236
|
+
// Step 6: Derive changedFiles from single source of truth
|
|
237
|
+
// This ensures dry-run and non-dry-run modes use identical logic
|
|
238
|
+
const changedFiles = Array.from(fileChangesForCommit.entries()).map(([fileName, info]) => ({ fileName, action: info.action }));
|
|
239
|
+
// Calculate diff stats for non-dry-run mode (dry-run already calculated above)
|
|
240
|
+
if (!dryRun) {
|
|
241
|
+
for (const [, info] of fileChangesForCommit) {
|
|
242
|
+
if (info.action === "create")
|
|
243
|
+
incrementDiffStats(diffStats, "NEW");
|
|
244
|
+
else if (info.action === "update")
|
|
245
|
+
incrementDiffStats(diffStats, "MODIFIED");
|
|
246
|
+
else if (info.action === "delete")
|
|
247
|
+
incrementDiffStats(diffStats, "DELETED");
|
|
281
248
|
}
|
|
282
249
|
}
|
|
250
|
+
const hasChanges = changedFiles.filter((f) => f.action !== "skip").length > 0;
|
|
283
251
|
if (!hasChanges) {
|
|
284
252
|
return {
|
|
285
253
|
success: true,
|
|
@@ -289,45 +257,67 @@ export class RepositoryProcessor {
|
|
|
289
257
|
diffStats,
|
|
290
258
|
};
|
|
291
259
|
}
|
|
292
|
-
// Step 7: Commit
|
|
293
|
-
this.log.info("Staging changes...");
|
|
260
|
+
// Step 7: Commit and Push using commit strategy
|
|
294
261
|
const commitMessage = this.formatCommitMessage(changedFiles);
|
|
295
|
-
const committed = await this.gitOps.commit(commitMessage);
|
|
296
|
-
if (!committed) {
|
|
297
|
-
this.log.info("No staged changes after git add -A, skipping commit");
|
|
298
|
-
return {
|
|
299
|
-
success: true,
|
|
300
|
-
repoName,
|
|
301
|
-
message: "No changes detected after staging",
|
|
302
|
-
skipped: true,
|
|
303
|
-
diffStats,
|
|
304
|
-
};
|
|
305
|
-
}
|
|
306
|
-
this.log.info(`Committed: ${commitMessage}`);
|
|
307
|
-
// Step 8: Push
|
|
308
|
-
// In direct mode, push to default branch; otherwise push to sync branch
|
|
309
|
-
// Use force-with-lease for sync branch (PR modes) to handle divergent history
|
|
310
|
-
// Never force push to default branch (direct mode) - could overwrite others' work
|
|
311
262
|
const pushBranch = isDirectMode ? baseBranch : branchName;
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
263
|
+
if (dryRun) {
|
|
264
|
+
// In dry-run mode, just log what would happen
|
|
265
|
+
this.log.info("Staging changes...");
|
|
266
|
+
this.log.info(`Would commit: ${commitMessage}`);
|
|
267
|
+
this.log.info(`Would push to ${pushBranch}...`);
|
|
315
268
|
}
|
|
316
|
-
|
|
317
|
-
//
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
269
|
+
else {
|
|
270
|
+
// Build file changes for commit strategy (filter out skipped files)
|
|
271
|
+
const fileChanges = Array.from(fileChangesForCommit.entries())
|
|
272
|
+
.filter(([, info]) => info.action !== "skip")
|
|
273
|
+
.map(([path, info]) => ({ path, content: info.content }));
|
|
274
|
+
// Check if there are actually staged changes (edge case handling)
|
|
275
|
+
// This handles scenarios where git status shows changes but git add doesn't stage anything
|
|
276
|
+
// (e.g., due to .gitattributes normalization)
|
|
277
|
+
this.log.info("Staging changes...");
|
|
278
|
+
await this.executor.exec("git add -A", workDir);
|
|
279
|
+
if (!(await this.gitOps.hasStagedChanges())) {
|
|
280
|
+
this.log.info("No staged changes after git add -A, skipping commit");
|
|
281
|
+
return {
|
|
282
|
+
success: true,
|
|
283
|
+
repoName,
|
|
284
|
+
message: "No changes detected after staging",
|
|
285
|
+
skipped: true,
|
|
286
|
+
diffStats,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
// Use commit strategy (GitCommitStrategy or GraphQLCommitStrategy)
|
|
290
|
+
const commitStrategy = getCommitStrategy(repoInfo, this.executor);
|
|
291
|
+
this.log.info("Committing and pushing changes...");
|
|
292
|
+
try {
|
|
293
|
+
const commitResult = await commitStrategy.commit({
|
|
294
|
+
repoInfo,
|
|
295
|
+
branchName: pushBranch,
|
|
296
|
+
message: commitMessage,
|
|
297
|
+
fileChanges,
|
|
298
|
+
workDir,
|
|
299
|
+
retries: this.retries,
|
|
300
|
+
// Use force push (--force-with-lease) for PR branches, not for direct mode
|
|
301
|
+
force: !isDirectMode,
|
|
302
|
+
});
|
|
303
|
+
this.log.info(`Committed: ${commitResult.sha} (verified: ${commitResult.verified})`);
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
// Handle branch protection errors in direct mode
|
|
307
|
+
if (isDirectMode) {
|
|
308
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
309
|
+
if (errorMessage.includes("rejected") ||
|
|
310
|
+
errorMessage.includes("protected") ||
|
|
311
|
+
errorMessage.includes("denied")) {
|
|
312
|
+
return {
|
|
313
|
+
success: false,
|
|
314
|
+
repoName,
|
|
315
|
+
message: `Push to '${baseBranch}' was rejected (likely branch protection). To use 'direct' mode, the target branch must allow direct pushes. Use 'merge: force' to create a PR and merge with admin privileges.`,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
328
318
|
}
|
|
319
|
+
throw error;
|
|
329
320
|
}
|
|
330
|
-
throw error;
|
|
331
321
|
}
|
|
332
322
|
// Direct mode: no PR creation, return success
|
|
333
323
|
if (isDirectMode) {
|
|
@@ -348,9 +338,9 @@ export class RepositoryProcessor {
|
|
|
348
338
|
files: changedFiles,
|
|
349
339
|
workDir,
|
|
350
340
|
dryRun,
|
|
351
|
-
retries,
|
|
341
|
+
retries: this.retries,
|
|
352
342
|
prTemplate,
|
|
353
|
-
executor,
|
|
343
|
+
executor: this.executor,
|
|
354
344
|
});
|
|
355
345
|
// Step 10: Handle merge options if configured
|
|
356
346
|
let mergeResult;
|
|
@@ -368,8 +358,8 @@ export class RepositoryProcessor {
|
|
|
368
358
|
mergeConfig,
|
|
369
359
|
workDir,
|
|
370
360
|
dryRun,
|
|
371
|
-
retries,
|
|
372
|
-
executor,
|
|
361
|
+
retries: this.retries,
|
|
362
|
+
executor: this.executor,
|
|
373
363
|
});
|
|
374
364
|
mergeResult = {
|
|
375
365
|
merged: result.merged ?? false,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { RepoInfo } from "../repo-detector.js";
|
|
2
|
+
import { CommitStrategy } from "./commit-strategy.js";
|
|
3
|
+
import { CommandExecutor } from "../command-executor.js";
|
|
4
|
+
/**
|
|
5
|
+
* Factory function to get the appropriate commit strategy for a repository.
|
|
6
|
+
*
|
|
7
|
+
* For GitHub repositories with GH_INSTALLATION_TOKEN set, returns GraphQLCommitStrategy
|
|
8
|
+
* which creates verified commits via the GitHub GraphQL API.
|
|
9
|
+
*
|
|
10
|
+
* For all other cases (GitHub with PAT, Azure DevOps, GitLab), returns GitCommitStrategy
|
|
11
|
+
* which uses standard git commands.
|
|
12
|
+
*
|
|
13
|
+
* @param repoInfo - Repository information
|
|
14
|
+
* @param executor - Optional command executor for shell commands
|
|
15
|
+
*/
|
|
16
|
+
export declare function getCommitStrategy(repoInfo: RepoInfo, executor?: CommandExecutor): CommitStrategy;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { isGitHubRepo } from "../repo-detector.js";
|
|
2
|
+
import { GitCommitStrategy } from "./git-commit-strategy.js";
|
|
3
|
+
import { GraphQLCommitStrategy } from "./graphql-commit-strategy.js";
|
|
4
|
+
/**
|
|
5
|
+
* Factory function to get the appropriate commit strategy for a repository.
|
|
6
|
+
*
|
|
7
|
+
* For GitHub repositories with GH_INSTALLATION_TOKEN set, returns GraphQLCommitStrategy
|
|
8
|
+
* which creates verified commits via the GitHub GraphQL API.
|
|
9
|
+
*
|
|
10
|
+
* For all other cases (GitHub with PAT, Azure DevOps, GitLab), returns GitCommitStrategy
|
|
11
|
+
* which uses standard git commands.
|
|
12
|
+
*
|
|
13
|
+
* @param repoInfo - Repository information
|
|
14
|
+
* @param executor - Optional command executor for shell commands
|
|
15
|
+
*/
|
|
16
|
+
export function getCommitStrategy(repoInfo, executor) {
|
|
17
|
+
if (isGitHubRepo(repoInfo) && process.env.GH_INSTALLATION_TOKEN) {
|
|
18
|
+
return new GraphQLCommitStrategy(executor);
|
|
19
|
+
}
|
|
20
|
+
return new GitCommitStrategy(executor);
|
|
21
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { RepoInfo } from "../repo-detector.js";
|
|
2
|
+
export interface FileChange {
|
|
3
|
+
path: string;
|
|
4
|
+
content: string | null;
|
|
5
|
+
}
|
|
6
|
+
export interface CommitOptions {
|
|
7
|
+
repoInfo: RepoInfo;
|
|
8
|
+
branchName: string;
|
|
9
|
+
message: string;
|
|
10
|
+
fileChanges: FileChange[];
|
|
11
|
+
workDir: string;
|
|
12
|
+
retries?: number;
|
|
13
|
+
/** Use force push (--force-with-lease). Default: true for PR branches, false for direct push to main. */
|
|
14
|
+
force?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface CommitResult {
|
|
17
|
+
sha: string;
|
|
18
|
+
verified: boolean;
|
|
19
|
+
pushed: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Strategy interface for creating commits.
|
|
23
|
+
* Implementations handle platform-specific commit mechanisms.
|
|
24
|
+
*/
|
|
25
|
+
export interface CommitStrategy {
|
|
26
|
+
/**
|
|
27
|
+
* Create a commit with the given file changes and push to remote.
|
|
28
|
+
* @returns Commit result with SHA and verification status
|
|
29
|
+
*/
|
|
30
|
+
commit(options: CommitOptions): Promise<CommitResult>;
|
|
31
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { CommitStrategy, CommitOptions, CommitResult } from "./commit-strategy.js";
|
|
2
|
+
import { CommandExecutor } from "../command-executor.js";
|
|
3
|
+
/**
|
|
4
|
+
* Git-based commit strategy using standard git commands (add, commit, push).
|
|
5
|
+
* Used with PAT authentication. Commits via this strategy are NOT verified
|
|
6
|
+
* by GitHub (no signature).
|
|
7
|
+
*/
|
|
8
|
+
export declare class GitCommitStrategy implements CommitStrategy {
|
|
9
|
+
private executor;
|
|
10
|
+
constructor(executor?: CommandExecutor);
|
|
11
|
+
/**
|
|
12
|
+
* Create a commit with the given file changes and push to remote.
|
|
13
|
+
* Runs: git add -A, git commit, git push (with optional --force-with-lease)
|
|
14
|
+
*
|
|
15
|
+
* @returns Commit result with SHA and verified: false (no signature)
|
|
16
|
+
*/
|
|
17
|
+
commit(options: CommitOptions): Promise<CommitResult>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { defaultExecutor } from "../command-executor.js";
|
|
2
|
+
import { withRetry } from "../retry-utils.js";
|
|
3
|
+
import { escapeShellArg } from "../shell-utils.js";
|
|
4
|
+
/**
|
|
5
|
+
* Git-based commit strategy using standard git commands (add, commit, push).
|
|
6
|
+
* Used with PAT authentication. Commits via this strategy are NOT verified
|
|
7
|
+
* by GitHub (no signature).
|
|
8
|
+
*/
|
|
9
|
+
export class GitCommitStrategy {
|
|
10
|
+
executor;
|
|
11
|
+
constructor(executor) {
|
|
12
|
+
this.executor = executor ?? defaultExecutor;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Create a commit with the given file changes and push to remote.
|
|
16
|
+
* Runs: git add -A, git commit, git push (with optional --force-with-lease)
|
|
17
|
+
*
|
|
18
|
+
* @returns Commit result with SHA and verified: false (no signature)
|
|
19
|
+
*/
|
|
20
|
+
async commit(options) {
|
|
21
|
+
const { branchName, message, workDir, retries = 3, force = true } = options;
|
|
22
|
+
// Stage all changes
|
|
23
|
+
await this.executor.exec("git add -A", workDir);
|
|
24
|
+
// Commit with the message (--no-verify to skip pre-commit hooks)
|
|
25
|
+
await this.executor.exec(`git commit --no-verify -m ${escapeShellArg(message)}`, workDir);
|
|
26
|
+
// Build push command - use --force-with-lease for PR branches, regular push for direct mode
|
|
27
|
+
const forceFlag = force ? "--force-with-lease " : "";
|
|
28
|
+
const pushCommand = `git push ${forceFlag}-u origin ${escapeShellArg(branchName)}`;
|
|
29
|
+
// Push with retry for transient network failures
|
|
30
|
+
await withRetry(() => this.executor.exec(pushCommand, workDir), {
|
|
31
|
+
retries,
|
|
32
|
+
});
|
|
33
|
+
// Get the commit SHA
|
|
34
|
+
const sha = await this.executor.exec("git rev-parse HEAD", workDir);
|
|
35
|
+
return {
|
|
36
|
+
sha: sha.trim(),
|
|
37
|
+
verified: false, // Git-based commits are not verified
|
|
38
|
+
pushed: true,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { CommitStrategy, CommitOptions, CommitResult } from "./commit-strategy.js";
|
|
2
|
+
import { CommandExecutor } from "../command-executor.js";
|
|
3
|
+
/**
|
|
4
|
+
* Maximum payload size for GitHub GraphQL API (50MB).
|
|
5
|
+
* Base64 encoding adds ~33% overhead, so raw content should be checked.
|
|
6
|
+
*/
|
|
7
|
+
export declare const MAX_PAYLOAD_SIZE: number;
|
|
8
|
+
/**
|
|
9
|
+
* Pattern for valid git branch names that are also safe for shell commands.
|
|
10
|
+
* Git branch names have strict rules:
|
|
11
|
+
* - Cannot contain: space, ~, ^, :, ?, *, [, \, .., @{
|
|
12
|
+
* - Cannot start with: - or .
|
|
13
|
+
* - Cannot end with: / or .lock
|
|
14
|
+
* - Cannot contain consecutive slashes
|
|
15
|
+
*
|
|
16
|
+
* This pattern allows only alphanumeric chars, hyphens, underscores, dots, and slashes
|
|
17
|
+
* which covers all practical branch names and is shell-safe.
|
|
18
|
+
*/
|
|
19
|
+
export declare const SAFE_BRANCH_NAME_PATTERN: RegExp;
|
|
20
|
+
/**
|
|
21
|
+
* Validates that a branch name is safe for use in shell commands.
|
|
22
|
+
* Throws an error if the branch name contains potentially dangerous characters.
|
|
23
|
+
*/
|
|
24
|
+
export declare function validateBranchName(branchName: string): void;
|
|
25
|
+
/**
|
|
26
|
+
* GraphQL-based commit strategy using GitHub's createCommitOnBranch mutation.
|
|
27
|
+
* Used with GitHub App authentication. Commits via this strategy ARE verified
|
|
28
|
+
* by GitHub (signed by the GitHub App).
|
|
29
|
+
*
|
|
30
|
+
* This strategy is GitHub-only and requires the `gh` CLI to be authenticated.
|
|
31
|
+
*/
|
|
32
|
+
export declare class GraphQLCommitStrategy implements CommitStrategy {
|
|
33
|
+
private executor;
|
|
34
|
+
constructor(executor?: CommandExecutor);
|
|
35
|
+
/**
|
|
36
|
+
* Create a commit with the given file changes using GitHub's GraphQL API.
|
|
37
|
+
* Uses the createCommitOnBranch mutation for verified commits.
|
|
38
|
+
*
|
|
39
|
+
* @returns Commit result with SHA and verified: true
|
|
40
|
+
* @throws Error if repo is not GitHub, payload exceeds 50MB, or API fails
|
|
41
|
+
*/
|
|
42
|
+
commit(options: CommitOptions): Promise<CommitResult>;
|
|
43
|
+
/**
|
|
44
|
+
* Execute the createCommitOnBranch GraphQL mutation.
|
|
45
|
+
*/
|
|
46
|
+
private executeGraphQLMutation;
|
|
47
|
+
/**
|
|
48
|
+
* Ensure the branch exists on the remote.
|
|
49
|
+
* createCommitOnBranch requires the branch to already exist.
|
|
50
|
+
* If the branch doesn't exist, push it to create it.
|
|
51
|
+
*/
|
|
52
|
+
private ensureBranchExistsOnRemote;
|
|
53
|
+
/**
|
|
54
|
+
* Check if an error is due to expectedHeadOid mismatch (optimistic locking failure).
|
|
55
|
+
* This happens when the branch was updated between getting HEAD and making the commit.
|
|
56
|
+
*/
|
|
57
|
+
private isHeadOidMismatchError;
|
|
58
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { defaultExecutor } from "../command-executor.js";
|
|
2
|
+
import { isGitHubRepo } from "../repo-detector.js";
|
|
3
|
+
import { escapeShellArg } from "../shell-utils.js";
|
|
4
|
+
/**
|
|
5
|
+
* Maximum payload size for GitHub GraphQL API (50MB).
|
|
6
|
+
* Base64 encoding adds ~33% overhead, so raw content should be checked.
|
|
7
|
+
*/
|
|
8
|
+
export const MAX_PAYLOAD_SIZE = 50 * 1024 * 1024;
|
|
9
|
+
/**
|
|
10
|
+
* Pattern for valid git branch names that are also safe for shell commands.
|
|
11
|
+
* Git branch names have strict rules:
|
|
12
|
+
* - Cannot contain: space, ~, ^, :, ?, *, [, \, .., @{
|
|
13
|
+
* - Cannot start with: - or .
|
|
14
|
+
* - Cannot end with: / or .lock
|
|
15
|
+
* - Cannot contain consecutive slashes
|
|
16
|
+
*
|
|
17
|
+
* This pattern allows only alphanumeric chars, hyphens, underscores, dots, and slashes
|
|
18
|
+
* which covers all practical branch names and is shell-safe.
|
|
19
|
+
*/
|
|
20
|
+
export const SAFE_BRANCH_NAME_PATTERN = /^[a-zA-Z0-9][-a-zA-Z0-9_./]*$/;
|
|
21
|
+
/**
|
|
22
|
+
* Validates that a branch name is safe for use in shell commands.
|
|
23
|
+
* Throws an error if the branch name contains potentially dangerous characters.
|
|
24
|
+
*/
|
|
25
|
+
export function validateBranchName(branchName) {
|
|
26
|
+
if (!SAFE_BRANCH_NAME_PATTERN.test(branchName)) {
|
|
27
|
+
throw new Error(`Invalid branch name for GraphQL commit strategy: "${branchName}". ` +
|
|
28
|
+
`Branch names must start with alphanumeric and contain only ` +
|
|
29
|
+
`alphanumeric characters, hyphens, underscores, dots, and forward slashes.`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* GraphQL-based commit strategy using GitHub's createCommitOnBranch mutation.
|
|
34
|
+
* Used with GitHub App authentication. Commits via this strategy ARE verified
|
|
35
|
+
* by GitHub (signed by the GitHub App).
|
|
36
|
+
*
|
|
37
|
+
* This strategy is GitHub-only and requires the `gh` CLI to be authenticated.
|
|
38
|
+
*/
|
|
39
|
+
export class GraphQLCommitStrategy {
|
|
40
|
+
executor;
|
|
41
|
+
constructor(executor) {
|
|
42
|
+
this.executor = executor ?? defaultExecutor;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Create a commit with the given file changes using GitHub's GraphQL API.
|
|
46
|
+
* Uses the createCommitOnBranch mutation for verified commits.
|
|
47
|
+
*
|
|
48
|
+
* @returns Commit result with SHA and verified: true
|
|
49
|
+
* @throws Error if repo is not GitHub, payload exceeds 50MB, or API fails
|
|
50
|
+
*/
|
|
51
|
+
async commit(options) {
|
|
52
|
+
const { repoInfo, branchName, message, fileChanges, workDir, retries = 3, } = options;
|
|
53
|
+
// Validate this is a GitHub repo
|
|
54
|
+
if (!isGitHubRepo(repoInfo)) {
|
|
55
|
+
throw new Error(`GraphQL commit strategy requires GitHub repositories. Got: ${repoInfo.type}`);
|
|
56
|
+
}
|
|
57
|
+
// Validate branch name is safe for shell commands
|
|
58
|
+
validateBranchName(branchName);
|
|
59
|
+
const githubInfo = repoInfo;
|
|
60
|
+
// Separate additions from deletions
|
|
61
|
+
const additions = fileChanges.filter((fc) => fc.content !== null);
|
|
62
|
+
const deletions = fileChanges.filter((fc) => fc.content === null);
|
|
63
|
+
// Calculate payload size (base64 adds ~33% overhead)
|
|
64
|
+
const totalSize = additions.reduce((sum, fc) => {
|
|
65
|
+
const base64Size = Math.ceil((fc.content.length * 4) / 3);
|
|
66
|
+
return sum + base64Size;
|
|
67
|
+
}, 0);
|
|
68
|
+
if (totalSize > MAX_PAYLOAD_SIZE) {
|
|
69
|
+
throw new Error(`GraphQL payload exceeds 50 MB limit (${Math.round(totalSize / (1024 * 1024))} MB). ` +
|
|
70
|
+
`Consider using smaller files or the git commit strategy.`);
|
|
71
|
+
}
|
|
72
|
+
// Ensure the branch exists on remote before making GraphQL commit
|
|
73
|
+
// createCommitOnBranch requires the branch to already exist
|
|
74
|
+
await this.ensureBranchExistsOnRemote(branchName, workDir);
|
|
75
|
+
// Retry loop for expectedHeadOid mismatch
|
|
76
|
+
let lastError = null;
|
|
77
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
78
|
+
try {
|
|
79
|
+
// Fetch from remote to ensure we have the latest HEAD
|
|
80
|
+
// This is critical for expectedHeadOid to match
|
|
81
|
+
// Branch name was validated above, safe for shell use
|
|
82
|
+
await this.executor.exec(`git fetch origin ${branchName}:refs/remotes/origin/${branchName}`, workDir);
|
|
83
|
+
// Get the remote HEAD SHA for this branch (not local HEAD)
|
|
84
|
+
const headSha = await this.executor.exec(`git rev-parse origin/${branchName}`, workDir);
|
|
85
|
+
// Build and execute the GraphQL mutation
|
|
86
|
+
const result = await this.executeGraphQLMutation(githubInfo, branchName, message, headSha.trim(), additions, deletions, workDir);
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
91
|
+
// Check if this is an expectedHeadOid mismatch error (retryable)
|
|
92
|
+
if (this.isHeadOidMismatchError(lastError) && attempt < retries) {
|
|
93
|
+
// Retry - the next iteration will fetch and get fresh HEAD SHA
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
// For other errors, throw immediately
|
|
97
|
+
throw lastError;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Should not reach here, but just in case
|
|
101
|
+
throw lastError ?? new Error("Unexpected error in GraphQL commit");
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Execute the createCommitOnBranch GraphQL mutation.
|
|
105
|
+
*/
|
|
106
|
+
async executeGraphQLMutation(repoInfo, branchName, message, expectedHeadOid, additions, deletions, workDir) {
|
|
107
|
+
const repositoryNameWithOwner = `${repoInfo.owner}/${repoInfo.repo}`;
|
|
108
|
+
// Build file additions with base64 encoding
|
|
109
|
+
const fileAdditions = additions.map((fc) => ({
|
|
110
|
+
path: fc.path,
|
|
111
|
+
contents: Buffer.from(fc.content).toString("base64"),
|
|
112
|
+
}));
|
|
113
|
+
// Build file deletions (path only)
|
|
114
|
+
const fileDeletions = deletions.map((fc) => ({
|
|
115
|
+
path: fc.path,
|
|
116
|
+
}));
|
|
117
|
+
// Build the mutation (minified to avoid shell escaping issues with newlines)
|
|
118
|
+
const mutation = "mutation CreateCommit($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid } } }";
|
|
119
|
+
// Build the input variables
|
|
120
|
+
// Note: GitHub API doesn't accept empty arrays, so only include fields when non-empty
|
|
121
|
+
const fileChanges = {};
|
|
122
|
+
if (fileAdditions.length > 0) {
|
|
123
|
+
fileChanges.additions = fileAdditions;
|
|
124
|
+
}
|
|
125
|
+
if (fileDeletions.length > 0) {
|
|
126
|
+
fileChanges.deletions = fileDeletions;
|
|
127
|
+
}
|
|
128
|
+
const variables = {
|
|
129
|
+
input: {
|
|
130
|
+
branch: {
|
|
131
|
+
repositoryNameWithOwner,
|
|
132
|
+
branchName,
|
|
133
|
+
},
|
|
134
|
+
expectedHeadOid,
|
|
135
|
+
message: {
|
|
136
|
+
headline: message,
|
|
137
|
+
},
|
|
138
|
+
fileChanges,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
// Build the GraphQL request body
|
|
142
|
+
const requestBody = JSON.stringify({
|
|
143
|
+
query: mutation,
|
|
144
|
+
variables,
|
|
145
|
+
});
|
|
146
|
+
// Build the gh api graphql command
|
|
147
|
+
// Use --input - to pass the JSON body via stdin (more reliable for complex nested JSON)
|
|
148
|
+
// Use --hostname for GitHub Enterprise
|
|
149
|
+
const hostnameArg = repoInfo.host !== "github.com"
|
|
150
|
+
? `--hostname ${escapeShellArg(repoInfo.host)}`
|
|
151
|
+
: "";
|
|
152
|
+
// Use GH_INSTALLATION_TOKEN explicitly for authentication (issue #268)
|
|
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 installationToken = process.env.GH_INSTALLATION_TOKEN;
|
|
156
|
+
const authArg = installationToken
|
|
157
|
+
? `-H "Authorization: token ${installationToken}"`
|
|
158
|
+
: "";
|
|
159
|
+
const command = `echo ${escapeShellArg(requestBody)} | gh api graphql ${authArg} ${hostnameArg} --input -`;
|
|
160
|
+
const response = await this.executor.exec(command, workDir);
|
|
161
|
+
// Parse the response
|
|
162
|
+
const parsed = JSON.parse(response);
|
|
163
|
+
if (parsed.errors) {
|
|
164
|
+
throw new Error(`GraphQL error: ${parsed.errors.map((e) => e.message).join(", ")}`);
|
|
165
|
+
}
|
|
166
|
+
const oid = parsed.data?.createCommitOnBranch?.commit?.oid;
|
|
167
|
+
if (!oid) {
|
|
168
|
+
throw new Error("GraphQL response missing commit OID");
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
sha: oid,
|
|
172
|
+
verified: true, // GraphQL commits via GitHub App are verified
|
|
173
|
+
pushed: true, // GraphQL commits are pushed directly
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Ensure the branch exists on the remote.
|
|
178
|
+
* createCommitOnBranch requires the branch to already exist.
|
|
179
|
+
* If the branch doesn't exist, push it to create it.
|
|
180
|
+
*/
|
|
181
|
+
async ensureBranchExistsOnRemote(branchName, workDir) {
|
|
182
|
+
// Branch name was validated in commit(), safe for shell use
|
|
183
|
+
try {
|
|
184
|
+
// Check if the branch exists on remote
|
|
185
|
+
await this.executor.exec(`git ls-remote --exit-code --heads origin ${branchName}`, workDir);
|
|
186
|
+
// Branch exists, nothing to do
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// Branch doesn't exist on remote, push it
|
|
190
|
+
// This pushes the current local branch to create it on remote
|
|
191
|
+
await this.executor.exec(`git push -u origin HEAD:${branchName}`, workDir);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Check if an error is due to expectedHeadOid mismatch (optimistic locking failure).
|
|
196
|
+
* This happens when the branch was updated between getting HEAD and making the commit.
|
|
197
|
+
*/
|
|
198
|
+
isHeadOidMismatchError(error) {
|
|
199
|
+
const message = error.message.toLowerCase();
|
|
200
|
+
return (message.includes("expected branch to point to") ||
|
|
201
|
+
message.includes("expectedheadoid") ||
|
|
202
|
+
message.includes("head oid") ||
|
|
203
|
+
// GitHub may return this generic error for OID mismatches
|
|
204
|
+
message.includes("was provided invalid value"));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -6,6 +6,10 @@ export { BasePRStrategy, PRWorkflowExecutor } from "./pr-strategy.js";
|
|
|
6
6
|
export { GitHubPRStrategy } from "./github-pr-strategy.js";
|
|
7
7
|
export { AzurePRStrategy } from "./azure-pr-strategy.js";
|
|
8
8
|
export { GitLabPRStrategy } from "./gitlab-pr-strategy.js";
|
|
9
|
+
export type { CommitStrategy, CommitOptions, CommitResult, FileChange, } from "./commit-strategy.js";
|
|
10
|
+
export { GitCommitStrategy } from "./git-commit-strategy.js";
|
|
11
|
+
export { GraphQLCommitStrategy, MAX_PAYLOAD_SIZE, } from "./graphql-commit-strategy.js";
|
|
12
|
+
export { getCommitStrategy } from "./commit-strategy-selector.js";
|
|
9
13
|
/**
|
|
10
14
|
* Factory function to get the appropriate PR strategy for a repository.
|
|
11
15
|
* @param repoInfo - Repository information
|
package/dist/strategies/index.js
CHANGED
|
@@ -6,6 +6,9 @@ export { BasePRStrategy, PRWorkflowExecutor } from "./pr-strategy.js";
|
|
|
6
6
|
export { GitHubPRStrategy } from "./github-pr-strategy.js";
|
|
7
7
|
export { AzurePRStrategy } from "./azure-pr-strategy.js";
|
|
8
8
|
export { GitLabPRStrategy } from "./gitlab-pr-strategy.js";
|
|
9
|
+
export { GitCommitStrategy } from "./git-commit-strategy.js";
|
|
10
|
+
export { GraphQLCommitStrategy, MAX_PAYLOAD_SIZE, } from "./graphql-commit-strategy.js";
|
|
11
|
+
export { getCommitStrategy } from "./commit-strategy-selector.js";
|
|
9
12
|
/**
|
|
10
13
|
* Factory function to get the appropriate PR strategy for a repository.
|
|
11
14
|
* @param repoInfo - Repository information
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aspruyt/xfg",
|
|
3
|
-
"version": "2.1
|
|
3
|
+
"version": "2.2.1",
|
|
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",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"test:integration:github": "npm run build && node --import tsx --test test/integration/github.test.ts",
|
|
32
32
|
"test:integration:ado": "npm run build && node --import tsx --test test/integration/ado.test.ts",
|
|
33
33
|
"test:integration:gitlab": "npm run build && node --import tsx --test test/integration/gitlab.test.ts",
|
|
34
|
+
"test:integration:github-app": "npm run build && node --import tsx --test test/integration/github-app.test.ts",
|
|
34
35
|
"prepublishOnly": "npm run build"
|
|
35
36
|
},
|
|
36
37
|
"keywords": [
|