@aspruyt/xfg 1.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/LICENSE +21 -0
- package/PR.md +15 -0
- package/README.md +991 -0
- package/dist/command-executor.d.ts +25 -0
- package/dist/command-executor.js +32 -0
- package/dist/config-formatter.d.ts +17 -0
- package/dist/config-formatter.js +100 -0
- package/dist/config-normalizer.d.ts +6 -0
- package/dist/config-normalizer.js +136 -0
- package/dist/config-validator.d.ts +6 -0
- package/dist/config-validator.js +173 -0
- package/dist/config.d.ts +54 -0
- package/dist/config.js +27 -0
- package/dist/env.d.ts +39 -0
- package/dist/env.js +144 -0
- package/dist/file-reference-resolver.d.ts +20 -0
- package/dist/file-reference-resolver.js +135 -0
- package/dist/git-ops.d.ts +75 -0
- package/dist/git-ops.js +229 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +167 -0
- package/dist/logger.d.ts +21 -0
- package/dist/logger.js +46 -0
- package/dist/merge.d.ts +47 -0
- package/dist/merge.js +196 -0
- package/dist/pr-creator.d.ts +40 -0
- package/dist/pr-creator.js +129 -0
- package/dist/repo-detector.d.ts +22 -0
- package/dist/repo-detector.js +98 -0
- package/dist/repository-processor.d.ts +47 -0
- package/dist/repository-processor.js +245 -0
- package/dist/retry-utils.d.ts +53 -0
- package/dist/retry-utils.js +143 -0
- package/dist/shell-utils.d.ts +8 -0
- package/dist/shell-utils.js +12 -0
- package/dist/strategies/azure-pr-strategy.d.ts +16 -0
- package/dist/strategies/azure-pr-strategy.js +221 -0
- package/dist/strategies/github-pr-strategy.d.ts +17 -0
- package/dist/strategies/github-pr-strategy.js +215 -0
- package/dist/strategies/index.d.ts +13 -0
- package/dist/strategies/index.js +22 -0
- package/dist/strategies/pr-strategy.d.ts +112 -0
- package/dist/strategies/pr-strategy.js +60 -0
- package/dist/workspace-utils.d.ts +5 -0
- package/dist/workspace-utils.js +10 -0
- package/package.json +58 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { program } from "commander";
|
|
3
|
+
import { resolve, join, dirname } from "node:path";
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { loadConfig } from "./config.js";
|
|
7
|
+
// Get version from package.json
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = dirname(__filename);
|
|
10
|
+
const packageJson = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
11
|
+
import { parseGitUrl, getRepoDisplayName } from "./repo-detector.js";
|
|
12
|
+
import { sanitizeBranchName, validateBranchName } from "./git-ops.js";
|
|
13
|
+
import { logger } from "./logger.js";
|
|
14
|
+
import { generateWorkspaceName } from "./workspace-utils.js";
|
|
15
|
+
import { RepositoryProcessor, } from "./repository-processor.js";
|
|
16
|
+
/**
|
|
17
|
+
* Default factory that creates a real RepositoryProcessor.
|
|
18
|
+
*/
|
|
19
|
+
export const defaultProcessorFactory = () => new RepositoryProcessor();
|
|
20
|
+
program
|
|
21
|
+
.name("xfg")
|
|
22
|
+
.description("Sync JSON configuration files across multiple repositories")
|
|
23
|
+
.version(packageJson.version)
|
|
24
|
+
.requiredOption("-c, --config <path>", "Path to YAML config file")
|
|
25
|
+
.option("-d, --dry-run", "Show what would be done without making changes")
|
|
26
|
+
.option("-w, --work-dir <path>", "Temporary directory for cloning", "./tmp")
|
|
27
|
+
.option("-r, --retries <number>", "Number of retries for network operations (0 to disable)", (v) => parseInt(v, 10), 3)
|
|
28
|
+
.option("-b, --branch <name>", "Override the branch name (default: chore/sync-{filename} or chore/sync-config)")
|
|
29
|
+
.option("-m, --merge <mode>", "PR merge mode: manual, auto (default, merge when checks pass), force (bypass requirements)", (value) => {
|
|
30
|
+
const valid = ["manual", "auto", "force"];
|
|
31
|
+
if (!valid.includes(value)) {
|
|
32
|
+
throw new Error(`Invalid merge mode: ${value}. Valid: ${valid.join(", ")}`);
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
})
|
|
36
|
+
.option("--merge-strategy <strategy>", "Merge strategy: merge, squash (default), rebase", (value) => {
|
|
37
|
+
const valid = ["merge", "squash", "rebase"];
|
|
38
|
+
if (!valid.includes(value)) {
|
|
39
|
+
throw new Error(`Invalid merge strategy: ${value}. Valid: ${valid.join(", ")}`);
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
})
|
|
43
|
+
.option("--delete-branch", "Delete source branch after merge")
|
|
44
|
+
.parse();
|
|
45
|
+
const options = program.opts();
|
|
46
|
+
/**
|
|
47
|
+
* Get unique file names from all repos in the config
|
|
48
|
+
*/
|
|
49
|
+
function getUniqueFileNames(config) {
|
|
50
|
+
const fileNames = new Set();
|
|
51
|
+
for (const repo of config.repos) {
|
|
52
|
+
for (const file of repo.files) {
|
|
53
|
+
fileNames.add(file.fileName);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return Array.from(fileNames);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Generate default branch name based on files being synced
|
|
60
|
+
*/
|
|
61
|
+
function generateBranchName(fileNames) {
|
|
62
|
+
if (fileNames.length === 1) {
|
|
63
|
+
return `chore/sync-${sanitizeBranchName(fileNames[0])}`;
|
|
64
|
+
}
|
|
65
|
+
return "chore/sync-config";
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Format file names for display
|
|
69
|
+
*/
|
|
70
|
+
function formatFileNames(fileNames) {
|
|
71
|
+
if (fileNames.length === 1) {
|
|
72
|
+
return fileNames[0];
|
|
73
|
+
}
|
|
74
|
+
if (fileNames.length <= 3) {
|
|
75
|
+
return fileNames.join(", ");
|
|
76
|
+
}
|
|
77
|
+
return `${fileNames.length} files`;
|
|
78
|
+
}
|
|
79
|
+
async function main() {
|
|
80
|
+
const configPath = resolve(options.config);
|
|
81
|
+
if (!existsSync(configPath)) {
|
|
82
|
+
console.error(`Config file not found: ${configPath}`);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
console.log(`Loading config from: ${configPath}`);
|
|
86
|
+
if (options.dryRun) {
|
|
87
|
+
console.log("Running in DRY RUN mode - no changes will be made\n");
|
|
88
|
+
}
|
|
89
|
+
const config = loadConfig(configPath);
|
|
90
|
+
const fileNames = getUniqueFileNames(config);
|
|
91
|
+
let branchName;
|
|
92
|
+
if (options.branch) {
|
|
93
|
+
validateBranchName(options.branch);
|
|
94
|
+
branchName = options.branch;
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
branchName = generateBranchName(fileNames);
|
|
98
|
+
}
|
|
99
|
+
logger.setTotal(config.repos.length);
|
|
100
|
+
console.log(`Found ${config.repos.length} repositories to process`);
|
|
101
|
+
console.log(`Target files: ${formatFileNames(fileNames)}`);
|
|
102
|
+
console.log(`Branch: ${branchName}\n`);
|
|
103
|
+
const processor = defaultProcessorFactory();
|
|
104
|
+
for (let i = 0; i < config.repos.length; i++) {
|
|
105
|
+
const repoConfig = config.repos[i];
|
|
106
|
+
// Apply CLI merge overrides to repo config
|
|
107
|
+
if (options.merge || options.mergeStrategy || options.deleteBranch) {
|
|
108
|
+
repoConfig.prOptions = {
|
|
109
|
+
...repoConfig.prOptions,
|
|
110
|
+
merge: options.merge ?? repoConfig.prOptions?.merge,
|
|
111
|
+
mergeStrategy: options.mergeStrategy ?? repoConfig.prOptions?.mergeStrategy,
|
|
112
|
+
deleteBranch: options.deleteBranch ?? repoConfig.prOptions?.deleteBranch,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const current = i + 1;
|
|
116
|
+
let repoInfo;
|
|
117
|
+
try {
|
|
118
|
+
repoInfo = parseGitUrl(repoConfig.git);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
122
|
+
logger.error(current, repoConfig.git, message);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const repoName = getRepoDisplayName(repoInfo);
|
|
126
|
+
const workDir = resolve(join(options.workDir ?? "./tmp", generateWorkspaceName(i)));
|
|
127
|
+
try {
|
|
128
|
+
logger.progress(current, repoName, "Processing...");
|
|
129
|
+
const result = await processor.process(repoConfig, repoInfo, {
|
|
130
|
+
branchName,
|
|
131
|
+
workDir,
|
|
132
|
+
dryRun: options.dryRun,
|
|
133
|
+
retries: options.retries,
|
|
134
|
+
});
|
|
135
|
+
if (result.skipped) {
|
|
136
|
+
logger.skip(current, repoName, result.message);
|
|
137
|
+
}
|
|
138
|
+
else if (result.success) {
|
|
139
|
+
let message = result.prUrl ? `PR: ${result.prUrl}` : result.message;
|
|
140
|
+
if (result.mergeResult) {
|
|
141
|
+
if (result.mergeResult.merged) {
|
|
142
|
+
message += " (merged)";
|
|
143
|
+
}
|
|
144
|
+
else if (result.mergeResult.autoMergeEnabled) {
|
|
145
|
+
message += " (auto-merge enabled)";
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
logger.success(current, repoName, message);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
logger.error(current, repoName, result.message);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
156
|
+
logger.error(current, repoName, message);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
logger.summary();
|
|
160
|
+
if (logger.hasFailures()) {
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
main().catch((error) => {
|
|
165
|
+
console.error("Fatal error:", error);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
});
|
package/dist/logger.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface ILogger {
|
|
2
|
+
info(message: string): void;
|
|
3
|
+
}
|
|
4
|
+
export interface LoggerStats {
|
|
5
|
+
total: number;
|
|
6
|
+
succeeded: number;
|
|
7
|
+
failed: number;
|
|
8
|
+
skipped: number;
|
|
9
|
+
}
|
|
10
|
+
export declare class Logger {
|
|
11
|
+
private stats;
|
|
12
|
+
setTotal(total: number): void;
|
|
13
|
+
progress(current: number, repoName: string, message: string): void;
|
|
14
|
+
info(message: string): void;
|
|
15
|
+
success(current: number, repoName: string, message: string): void;
|
|
16
|
+
skip(current: number, repoName: string, reason: string): void;
|
|
17
|
+
error(current: number, repoName: string, error: string): void;
|
|
18
|
+
summary(): void;
|
|
19
|
+
hasFailures(): boolean;
|
|
20
|
+
}
|
|
21
|
+
export declare const logger: Logger;
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
export class Logger {
|
|
3
|
+
stats = {
|
|
4
|
+
total: 0,
|
|
5
|
+
succeeded: 0,
|
|
6
|
+
failed: 0,
|
|
7
|
+
skipped: 0,
|
|
8
|
+
};
|
|
9
|
+
setTotal(total) {
|
|
10
|
+
this.stats.total = total;
|
|
11
|
+
}
|
|
12
|
+
progress(current, repoName, message) {
|
|
13
|
+
console.log(chalk.blue(`[${current}/${this.stats.total}]`) +
|
|
14
|
+
` ${repoName}: ${message}`);
|
|
15
|
+
}
|
|
16
|
+
info(message) {
|
|
17
|
+
console.log(chalk.gray(` ${message}`));
|
|
18
|
+
}
|
|
19
|
+
success(current, repoName, message) {
|
|
20
|
+
this.stats.succeeded++;
|
|
21
|
+
console.log(chalk.green(`[${current}/${this.stats.total}] ✓`) +
|
|
22
|
+
` ${repoName}: ${message}`);
|
|
23
|
+
}
|
|
24
|
+
skip(current, repoName, reason) {
|
|
25
|
+
this.stats.skipped++;
|
|
26
|
+
console.log(chalk.yellow(`[${current}/${this.stats.total}] ⊘`) +
|
|
27
|
+
` ${repoName}: Skipped - ${reason}`);
|
|
28
|
+
}
|
|
29
|
+
error(current, repoName, error) {
|
|
30
|
+
this.stats.failed++;
|
|
31
|
+
console.log(chalk.red(`[${current}/${this.stats.total}] ✗`) +
|
|
32
|
+
` ${repoName}: ${error}`);
|
|
33
|
+
}
|
|
34
|
+
summary() {
|
|
35
|
+
console.log("");
|
|
36
|
+
console.log(chalk.bold("Summary:"));
|
|
37
|
+
console.log(` Total: ${this.stats.total}`);
|
|
38
|
+
console.log(chalk.green(` Succeeded: ${this.stats.succeeded}`));
|
|
39
|
+
console.log(chalk.yellow(` Skipped: ${this.stats.skipped}`));
|
|
40
|
+
console.log(chalk.red(` Failed: ${this.stats.failed}`));
|
|
41
|
+
}
|
|
42
|
+
hasFailures() {
|
|
43
|
+
return this.stats.failed > 0;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export const logger = new Logger();
|
package/dist/merge.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deep merge utilities for JSON configuration objects.
|
|
3
|
+
* Supports configurable array merge strategies via $arrayMerge directive.
|
|
4
|
+
*/
|
|
5
|
+
export type ArrayMergeStrategy = "replace" | "append" | "prepend";
|
|
6
|
+
/**
|
|
7
|
+
* Handler function type for array merge strategies.
|
|
8
|
+
*/
|
|
9
|
+
export type ArrayMergeHandler = (base: unknown[], overlay: unknown[]) => unknown[];
|
|
10
|
+
/**
|
|
11
|
+
* Strategy map for array merge operations.
|
|
12
|
+
* Extensible: add new strategies by adding to this map.
|
|
13
|
+
*/
|
|
14
|
+
export declare const arrayMergeStrategies: Map<ArrayMergeStrategy, ArrayMergeHandler>;
|
|
15
|
+
export interface MergeContext {
|
|
16
|
+
arrayStrategies: Map<string, ArrayMergeStrategy>;
|
|
17
|
+
defaultArrayStrategy: ArrayMergeStrategy;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Deep merge two objects with configurable array handling.
|
|
21
|
+
*
|
|
22
|
+
* @param base - The base object
|
|
23
|
+
* @param overlay - The overlay object (values override base)
|
|
24
|
+
* @param ctx - Merge context with array strategies
|
|
25
|
+
* @param path - Current path for strategy lookup (internal)
|
|
26
|
+
*/
|
|
27
|
+
export declare function deepMerge(base: Record<string, unknown>, overlay: Record<string, unknown>, ctx: MergeContext, path?: string): Record<string, unknown>;
|
|
28
|
+
/**
|
|
29
|
+
* Strip merge directive keys ($arrayMerge, $override, etc.) from an object.
|
|
30
|
+
* Works recursively on nested objects and arrays.
|
|
31
|
+
*/
|
|
32
|
+
export declare function stripMergeDirectives(obj: Record<string, unknown>): Record<string, unknown>;
|
|
33
|
+
/**
|
|
34
|
+
* Create a default merge context.
|
|
35
|
+
*/
|
|
36
|
+
export declare function createMergeContext(defaultStrategy?: ArrayMergeStrategy): MergeContext;
|
|
37
|
+
/**
|
|
38
|
+
* Check if content is text type (string or string[]).
|
|
39
|
+
*/
|
|
40
|
+
export declare function isTextContent(content: unknown): content is string | string[];
|
|
41
|
+
/**
|
|
42
|
+
* Merge two text content values.
|
|
43
|
+
* For strings: overlay replaces base entirely.
|
|
44
|
+
* For string arrays: applies merge strategy.
|
|
45
|
+
* For mixed types: overlay replaces base.
|
|
46
|
+
*/
|
|
47
|
+
export declare function mergeTextContent(base: string | string[], overlay: string | string[], strategy?: ArrayMergeStrategy): string | string[];
|
package/dist/merge.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deep merge utilities for JSON configuration objects.
|
|
3
|
+
* Supports configurable array merge strategies via $arrayMerge directive.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Strategy map for array merge operations.
|
|
7
|
+
* Extensible: add new strategies by adding to this map.
|
|
8
|
+
*/
|
|
9
|
+
export const arrayMergeStrategies = new Map([
|
|
10
|
+
["replace", (_base, overlay) => overlay],
|
|
11
|
+
["append", (base, overlay) => [...base, ...overlay]],
|
|
12
|
+
["prepend", (base, overlay) => [...overlay, ...base]],
|
|
13
|
+
]);
|
|
14
|
+
/**
|
|
15
|
+
* Check if a value is a plain object (not null, not array).
|
|
16
|
+
*/
|
|
17
|
+
function isPlainObject(val) {
|
|
18
|
+
return typeof val === "object" && val !== null && !Array.isArray(val);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Merge two arrays based on the specified strategy.
|
|
22
|
+
*/
|
|
23
|
+
function mergeArrays(base, overlay, strategy) {
|
|
24
|
+
const handler = arrayMergeStrategies.get(strategy);
|
|
25
|
+
if (handler) {
|
|
26
|
+
return handler(base, overlay);
|
|
27
|
+
}
|
|
28
|
+
// Fallback to replace for unknown strategies
|
|
29
|
+
return overlay;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Extract array values from an overlay object that uses the directive syntax:
|
|
33
|
+
* { $arrayMerge: 'append', values: [1, 2, 3] }
|
|
34
|
+
*
|
|
35
|
+
* Or just return the array if it's already an array.
|
|
36
|
+
*/
|
|
37
|
+
function extractArrayFromOverlay(overlay) {
|
|
38
|
+
if (Array.isArray(overlay)) {
|
|
39
|
+
return overlay;
|
|
40
|
+
}
|
|
41
|
+
if (isPlainObject(overlay) && "values" in overlay) {
|
|
42
|
+
const values = overlay.values;
|
|
43
|
+
if (Array.isArray(values)) {
|
|
44
|
+
return values;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Get merge strategy from an overlay object's $arrayMerge directive.
|
|
51
|
+
*/
|
|
52
|
+
function getStrategyFromOverlay(overlay) {
|
|
53
|
+
if (isPlainObject(overlay) && "$arrayMerge" in overlay) {
|
|
54
|
+
const strategy = overlay.$arrayMerge;
|
|
55
|
+
if (strategy === "replace" ||
|
|
56
|
+
strategy === "append" ||
|
|
57
|
+
strategy === "prepend") {
|
|
58
|
+
return strategy;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Deep merge two objects with configurable array handling.
|
|
65
|
+
*
|
|
66
|
+
* @param base - The base object
|
|
67
|
+
* @param overlay - The overlay object (values override base)
|
|
68
|
+
* @param ctx - Merge context with array strategies
|
|
69
|
+
* @param path - Current path for strategy lookup (internal)
|
|
70
|
+
*/
|
|
71
|
+
export function deepMerge(base, overlay, ctx, path = "") {
|
|
72
|
+
const result = { ...base };
|
|
73
|
+
// Check for $arrayMerge directive at this level (applies to child arrays)
|
|
74
|
+
const levelStrategy = getStrategyFromOverlay(overlay);
|
|
75
|
+
for (const [key, overlayValue] of Object.entries(overlay)) {
|
|
76
|
+
// Skip directive keys in output
|
|
77
|
+
if (key.startsWith("$"))
|
|
78
|
+
continue;
|
|
79
|
+
const currentPath = path ? `${path}.${key}` : key;
|
|
80
|
+
const baseValue = base[key];
|
|
81
|
+
// If overlay is an object with $arrayMerge directive for an array field
|
|
82
|
+
if (isPlainObject(overlayValue) && "$arrayMerge" in overlayValue) {
|
|
83
|
+
const strategy = getStrategyFromOverlay(overlayValue);
|
|
84
|
+
const overlayArray = extractArrayFromOverlay(overlayValue);
|
|
85
|
+
if (strategy && overlayArray && Array.isArray(baseValue)) {
|
|
86
|
+
result[key] = mergeArrays(baseValue, overlayArray, strategy);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Both are arrays - apply strategy
|
|
91
|
+
if (Array.isArray(baseValue) && Array.isArray(overlayValue)) {
|
|
92
|
+
// Check for level-specific strategy, then path-specific, then default
|
|
93
|
+
const strategy = levelStrategy ??
|
|
94
|
+
ctx.arrayStrategies.get(currentPath) ??
|
|
95
|
+
ctx.defaultArrayStrategy;
|
|
96
|
+
result[key] = mergeArrays(baseValue, overlayValue, strategy);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
// Both are plain objects - recurse
|
|
100
|
+
if (isPlainObject(baseValue) && isPlainObject(overlayValue)) {
|
|
101
|
+
// Extract $arrayMerge for child paths if present
|
|
102
|
+
if ("$arrayMerge" in overlayValue) {
|
|
103
|
+
const childStrategy = getStrategyFromOverlay(overlayValue);
|
|
104
|
+
if (childStrategy) {
|
|
105
|
+
// Apply to all immediate child arrays
|
|
106
|
+
for (const childKey of Object.keys(overlayValue)) {
|
|
107
|
+
if (!childKey.startsWith("$")) {
|
|
108
|
+
const childPath = currentPath
|
|
109
|
+
? `${currentPath}.${childKey}`
|
|
110
|
+
: childKey;
|
|
111
|
+
ctx.arrayStrategies.set(childPath, childStrategy);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
result[key] = deepMerge(baseValue, overlayValue, ctx, currentPath);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
// Otherwise, overlay wins (including null values)
|
|
120
|
+
result[key] = overlayValue;
|
|
121
|
+
}
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Strip merge directive keys ($arrayMerge, $override, etc.) from an object.
|
|
126
|
+
* Works recursively on nested objects and arrays.
|
|
127
|
+
*/
|
|
128
|
+
export function stripMergeDirectives(obj) {
|
|
129
|
+
const result = {};
|
|
130
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
131
|
+
// Skip all $-prefixed keys (reserved for directives)
|
|
132
|
+
if (key.startsWith("$"))
|
|
133
|
+
continue;
|
|
134
|
+
if (isPlainObject(value)) {
|
|
135
|
+
result[key] = stripMergeDirectives(value);
|
|
136
|
+
}
|
|
137
|
+
else if (Array.isArray(value)) {
|
|
138
|
+
result[key] = value.map((item) => isPlainObject(item) ? stripMergeDirectives(item) : item);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
result[key] = value;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Create a default merge context.
|
|
148
|
+
*/
|
|
149
|
+
export function createMergeContext(defaultStrategy = "replace") {
|
|
150
|
+
return {
|
|
151
|
+
arrayStrategies: new Map(),
|
|
152
|
+
defaultArrayStrategy: defaultStrategy,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
// =============================================================================
|
|
156
|
+
// Text Content Utilities
|
|
157
|
+
// =============================================================================
|
|
158
|
+
/**
|
|
159
|
+
* Check if content is text type (string or string[]).
|
|
160
|
+
*/
|
|
161
|
+
export function isTextContent(content) {
|
|
162
|
+
return (typeof content === "string" ||
|
|
163
|
+
(Array.isArray(content) &&
|
|
164
|
+
content.every((item) => typeof item === "string")));
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Merge two text content values.
|
|
168
|
+
* For strings: overlay replaces base entirely.
|
|
169
|
+
* For string arrays: applies merge strategy.
|
|
170
|
+
* For mixed types: overlay replaces base.
|
|
171
|
+
*/
|
|
172
|
+
export function mergeTextContent(base, overlay, strategy = "replace") {
|
|
173
|
+
// If overlay is a string, it always replaces
|
|
174
|
+
if (typeof overlay === "string") {
|
|
175
|
+
return overlay;
|
|
176
|
+
}
|
|
177
|
+
// If overlay is an array
|
|
178
|
+
if (Array.isArray(overlay)) {
|
|
179
|
+
// If base is also an array, apply merge strategy
|
|
180
|
+
if (Array.isArray(base)) {
|
|
181
|
+
switch (strategy) {
|
|
182
|
+
case "append":
|
|
183
|
+
return [...base, ...overlay];
|
|
184
|
+
case "prepend":
|
|
185
|
+
return [...overlay, ...base];
|
|
186
|
+
case "replace":
|
|
187
|
+
default:
|
|
188
|
+
return overlay;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// Base is string, overlay is array - overlay replaces
|
|
192
|
+
return overlay;
|
|
193
|
+
}
|
|
194
|
+
// Fallback (shouldn't reach here with proper types)
|
|
195
|
+
return overlay;
|
|
196
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { RepoInfo } from "./repo-detector.js";
|
|
2
|
+
import { MergeResult, PRMergeConfig } from "./strategies/index.js";
|
|
3
|
+
export { escapeShellArg } from "./shell-utils.js";
|
|
4
|
+
export interface FileAction {
|
|
5
|
+
fileName: string;
|
|
6
|
+
action: "create" | "update" | "skip";
|
|
7
|
+
}
|
|
8
|
+
export interface PROptions {
|
|
9
|
+
repoInfo: RepoInfo;
|
|
10
|
+
branchName: string;
|
|
11
|
+
baseBranch: string;
|
|
12
|
+
files: FileAction[];
|
|
13
|
+
workDir: string;
|
|
14
|
+
dryRun?: boolean;
|
|
15
|
+
/** Number of retries for API operations (default: 3) */
|
|
16
|
+
retries?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface PRResult {
|
|
19
|
+
url?: string;
|
|
20
|
+
success: boolean;
|
|
21
|
+
message: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Format PR body for multiple files
|
|
25
|
+
*/
|
|
26
|
+
export declare function formatPRBody(files: FileAction[]): string;
|
|
27
|
+
/**
|
|
28
|
+
* Generate PR title based on files changed (excludes skipped files)
|
|
29
|
+
*/
|
|
30
|
+
export declare function formatPRTitle(files: FileAction[]): string;
|
|
31
|
+
export declare function createPR(options: PROptions): Promise<PRResult>;
|
|
32
|
+
export interface MergePROptions {
|
|
33
|
+
repoInfo: RepoInfo;
|
|
34
|
+
prUrl: string;
|
|
35
|
+
mergeConfig: PRMergeConfig;
|
|
36
|
+
workDir: string;
|
|
37
|
+
dryRun?: boolean;
|
|
38
|
+
retries?: number;
|
|
39
|
+
}
|
|
40
|
+
export declare function mergePR(options: MergePROptions): Promise<MergeResult>;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { getPRStrategy, } from "./strategies/index.js";
|
|
5
|
+
// Re-export for backwards compatibility and testing
|
|
6
|
+
export { escapeShellArg } from "./shell-utils.js";
|
|
7
|
+
function loadPRTemplate() {
|
|
8
|
+
// Try to find PR.md in the project root
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = dirname(__filename);
|
|
11
|
+
const templatePath = join(__dirname, "..", "PR.md");
|
|
12
|
+
if (existsSync(templatePath)) {
|
|
13
|
+
return readFileSync(templatePath, "utf-8");
|
|
14
|
+
}
|
|
15
|
+
// Fallback template for multi-file support
|
|
16
|
+
return `## Summary
|
|
17
|
+
Automated sync of configuration files.
|
|
18
|
+
|
|
19
|
+
## Changes
|
|
20
|
+
{{FILE_CHANGES}}
|
|
21
|
+
|
|
22
|
+
## Source
|
|
23
|
+
Configuration synced using [xfg](https://github.com/anthony-spruyt/xfg).
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
_This PR was automatically generated by [xfg](https://github.com/anthony-spruyt/xfg)_`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Format file changes list, excluding skipped files
|
|
30
|
+
*/
|
|
31
|
+
function formatFileChanges(files) {
|
|
32
|
+
const changedFiles = files.filter((f) => f.action !== "skip");
|
|
33
|
+
return changedFiles
|
|
34
|
+
.map((f) => {
|
|
35
|
+
const actionText = f.action === "create" ? "Created" : "Updated";
|
|
36
|
+
return `- ${actionText} \`${f.fileName}\``;
|
|
37
|
+
})
|
|
38
|
+
.join("\n");
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Format PR body for multiple files
|
|
42
|
+
*/
|
|
43
|
+
export function formatPRBody(files) {
|
|
44
|
+
const template = loadPRTemplate();
|
|
45
|
+
const fileChanges = formatFileChanges(files);
|
|
46
|
+
// Check if template supports multi-file format
|
|
47
|
+
if (template.includes("{{FILE_CHANGES}}")) {
|
|
48
|
+
return template.replace(/\{\{FILE_CHANGES\}\}/g, fileChanges);
|
|
49
|
+
}
|
|
50
|
+
// Legacy single-file template - adapt it for multiple files
|
|
51
|
+
const changedFiles = files.filter((f) => f.action !== "skip");
|
|
52
|
+
if (changedFiles.length === 1) {
|
|
53
|
+
const actionText = changedFiles[0].action === "create" ? "Created" : "Updated";
|
|
54
|
+
return template
|
|
55
|
+
.replace(/\{\{FILE_NAME\}\}/g, changedFiles[0].fileName)
|
|
56
|
+
.replace(/\{\{ACTION\}\}/g, actionText);
|
|
57
|
+
}
|
|
58
|
+
// Multiple files with legacy template - generate custom body
|
|
59
|
+
return `## Summary
|
|
60
|
+
Automated sync of configuration files.
|
|
61
|
+
|
|
62
|
+
## Changes
|
|
63
|
+
${fileChanges}
|
|
64
|
+
|
|
65
|
+
## Source
|
|
66
|
+
Configuration synced using [xfg](https://github.com/anthony-spruyt/xfg).
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
_This PR was automatically generated by [xfg](https://github.com/anthony-spruyt/xfg)_`;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Generate PR title based on files changed (excludes skipped files)
|
|
73
|
+
*/
|
|
74
|
+
export function formatPRTitle(files) {
|
|
75
|
+
const changedFiles = files.filter((f) => f.action !== "skip");
|
|
76
|
+
if (changedFiles.length === 1) {
|
|
77
|
+
return `chore: sync ${changedFiles[0].fileName}`;
|
|
78
|
+
}
|
|
79
|
+
if (changedFiles.length <= 3) {
|
|
80
|
+
const fileNames = changedFiles.map((f) => f.fileName).join(", ");
|
|
81
|
+
return `chore: sync ${fileNames}`;
|
|
82
|
+
}
|
|
83
|
+
return `chore: sync ${changedFiles.length} config files`;
|
|
84
|
+
}
|
|
85
|
+
export async function createPR(options) {
|
|
86
|
+
const { repoInfo, branchName, baseBranch, files, workDir, dryRun, retries } = options;
|
|
87
|
+
const title = formatPRTitle(files);
|
|
88
|
+
const body = formatPRBody(files);
|
|
89
|
+
if (dryRun) {
|
|
90
|
+
return {
|
|
91
|
+
success: true,
|
|
92
|
+
message: `[DRY RUN] Would create PR: "${title}"`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
// Get the appropriate strategy and execute
|
|
96
|
+
const strategy = getPRStrategy(repoInfo);
|
|
97
|
+
return strategy.execute({
|
|
98
|
+
repoInfo,
|
|
99
|
+
title,
|
|
100
|
+
body,
|
|
101
|
+
branchName,
|
|
102
|
+
baseBranch,
|
|
103
|
+
workDir,
|
|
104
|
+
retries,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
export async function mergePR(options) {
|
|
108
|
+
const { repoInfo, prUrl, mergeConfig, workDir, dryRun, retries } = options;
|
|
109
|
+
if (dryRun) {
|
|
110
|
+
const modeText = mergeConfig.mode === "force"
|
|
111
|
+
? "force merge"
|
|
112
|
+
: mergeConfig.mode === "auto"
|
|
113
|
+
? "enable auto-merge"
|
|
114
|
+
: "leave open for manual review";
|
|
115
|
+
return {
|
|
116
|
+
success: true,
|
|
117
|
+
message: `[DRY RUN] Would ${modeText}`,
|
|
118
|
+
merged: false,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// Get the appropriate strategy and execute merge
|
|
122
|
+
const strategy = getPRStrategy(repoInfo);
|
|
123
|
+
return strategy.merge({
|
|
124
|
+
prUrl,
|
|
125
|
+
config: mergeConfig,
|
|
126
|
+
workDir,
|
|
127
|
+
retries,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type RepoType = "github" | "azure-devops";
|
|
2
|
+
interface BaseRepoInfo {
|
|
3
|
+
gitUrl: string;
|
|
4
|
+
repo: string;
|
|
5
|
+
}
|
|
6
|
+
export interface GitHubRepoInfo extends BaseRepoInfo {
|
|
7
|
+
type: "github";
|
|
8
|
+
owner: string;
|
|
9
|
+
}
|
|
10
|
+
export interface AzureDevOpsRepoInfo extends BaseRepoInfo {
|
|
11
|
+
type: "azure-devops";
|
|
12
|
+
owner: string;
|
|
13
|
+
organization: string;
|
|
14
|
+
project: string;
|
|
15
|
+
}
|
|
16
|
+
export type RepoInfo = GitHubRepoInfo | AzureDevOpsRepoInfo;
|
|
17
|
+
export declare function isGitHubRepo(info: RepoInfo): info is GitHubRepoInfo;
|
|
18
|
+
export declare function isAzureDevOpsRepo(info: RepoInfo): info is AzureDevOpsRepoInfo;
|
|
19
|
+
export declare function detectRepoType(gitUrl: string): RepoType;
|
|
20
|
+
export declare function parseGitUrl(gitUrl: string): RepoInfo;
|
|
21
|
+
export declare function getRepoDisplayName(repoInfo: RepoInfo): string;
|
|
22
|
+
export {};
|