@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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/PR.md +15 -0
  3. package/README.md +991 -0
  4. package/dist/command-executor.d.ts +25 -0
  5. package/dist/command-executor.js +32 -0
  6. package/dist/config-formatter.d.ts +17 -0
  7. package/dist/config-formatter.js +100 -0
  8. package/dist/config-normalizer.d.ts +6 -0
  9. package/dist/config-normalizer.js +136 -0
  10. package/dist/config-validator.d.ts +6 -0
  11. package/dist/config-validator.js +173 -0
  12. package/dist/config.d.ts +54 -0
  13. package/dist/config.js +27 -0
  14. package/dist/env.d.ts +39 -0
  15. package/dist/env.js +144 -0
  16. package/dist/file-reference-resolver.d.ts +20 -0
  17. package/dist/file-reference-resolver.js +135 -0
  18. package/dist/git-ops.d.ts +75 -0
  19. package/dist/git-ops.js +229 -0
  20. package/dist/index.d.ts +20 -0
  21. package/dist/index.js +167 -0
  22. package/dist/logger.d.ts +21 -0
  23. package/dist/logger.js +46 -0
  24. package/dist/merge.d.ts +47 -0
  25. package/dist/merge.js +196 -0
  26. package/dist/pr-creator.d.ts +40 -0
  27. package/dist/pr-creator.js +129 -0
  28. package/dist/repo-detector.d.ts +22 -0
  29. package/dist/repo-detector.js +98 -0
  30. package/dist/repository-processor.d.ts +47 -0
  31. package/dist/repository-processor.js +245 -0
  32. package/dist/retry-utils.d.ts +53 -0
  33. package/dist/retry-utils.js +143 -0
  34. package/dist/shell-utils.d.ts +8 -0
  35. package/dist/shell-utils.js +12 -0
  36. package/dist/strategies/azure-pr-strategy.d.ts +16 -0
  37. package/dist/strategies/azure-pr-strategy.js +221 -0
  38. package/dist/strategies/github-pr-strategy.d.ts +17 -0
  39. package/dist/strategies/github-pr-strategy.js +215 -0
  40. package/dist/strategies/index.d.ts +13 -0
  41. package/dist/strategies/index.js +22 -0
  42. package/dist/strategies/pr-strategy.d.ts +112 -0
  43. package/dist/strategies/pr-strategy.js +60 -0
  44. package/dist/workspace-utils.d.ts +5 -0
  45. package/dist/workspace-utils.js +10 -0
  46. 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
+ });
@@ -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();
@@ -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 {};