@aspruyt/xfg 4.0.5 → 5.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/README.md +1 -1
- package/dist/cli/program.d.ts +3 -0
- package/dist/cli/program.js +18 -13
- package/dist/cli/sync-command.js +62 -39
- package/dist/cli/sync-report-builder.js +7 -4
- package/dist/config/formatter.js +14 -9
- package/dist/config/merge.d.ts +2 -4
- package/dist/config/merge.js +15 -67
- package/dist/config/validator.js +2 -9
- package/dist/lifecycle/repo-lifecycle-factory.js +0 -4
- package/dist/output/github-summary.d.ts +3 -2
- package/dist/output/github-summary.js +1 -7
- package/dist/output/lifecycle-report.js +7 -14
- package/dist/output/sync-report.d.ts +2 -19
- package/dist/output/sync-report.js +16 -28
- package/dist/output/types.d.ts +19 -0
- package/dist/output/types.js +1 -0
- package/dist/output/unified-summary.d.ts +2 -1
- package/dist/output/unified-summary.js +4 -1
- package/dist/settings/base-processor.d.ts +3 -1
- package/dist/settings/base-processor.js +9 -5
- package/dist/settings/index.d.ts +1 -1
- package/dist/settings/labels/diff.d.ts +2 -1
- package/dist/settings/labels/formatter.js +2 -4
- package/dist/settings/labels/github-labels-strategy.js +2 -1
- package/dist/settings/labels/processor.js +0 -1
- package/dist/settings/repo-settings/github-repo-settings-strategy.js +2 -1
- package/dist/settings/rulesets/diff-algorithm.js +0 -1
- package/dist/settings/rulesets/diff.d.ts +2 -1
- package/dist/settings/rulesets/diff.js +37 -21
- package/dist/settings/rulesets/formatter.js +44 -38
- package/dist/settings/rulesets/github-ruleset-strategy.js +2 -1
- package/dist/settings/rulesets/processor.js +0 -1
- package/dist/shared/gh-api-utils.d.ts +8 -7
- package/dist/shared/gh-api-utils.js +2 -16
- package/dist/shared/interpolation-engine.d.ts +3 -0
- package/dist/shared/interpolation-engine.js +0 -3
- package/dist/shared/json-utils.d.ts +6 -0
- package/dist/shared/json-utils.js +16 -0
- package/dist/shared/repo-detector.js +0 -4
- package/dist/shared/xfg-template.d.ts +3 -0
- package/dist/shared/xfg-template.js +0 -20
- package/dist/sync/auth-options-builder.js +7 -1
- package/dist/sync/branch-manager.d.ts +1 -1
- package/dist/sync/commit-message.d.ts +1 -1
- package/dist/sync/commit-push-manager.d.ts +1 -1
- package/dist/sync/commit-push-manager.js +2 -2
- package/dist/sync/diff-utils.d.ts +15 -2
- package/dist/sync/diff-utils.js +50 -14
- package/dist/sync/file-sync-orchestrator.js +2 -4
- package/dist/sync/file-sync-strategy.js +11 -4
- package/dist/sync/file-writer.js +9 -4
- package/dist/sync/index.d.ts +2 -1
- package/dist/sync/index.js +1 -0
- package/dist/sync/manifest-manager.d.ts +1 -1
- package/dist/sync/manifest-manager.js +20 -6
- package/dist/sync/pr-merge-handler.js +6 -1
- package/dist/sync/repository-processor.js +8 -1
- package/dist/sync/types.d.ts +5 -4
- package/dist/vcs/authenticated-git-ops.d.ts +9 -1
- package/dist/vcs/authenticated-git-ops.js +7 -14
- package/dist/vcs/git-ops.js +29 -12
- package/dist/vcs/github-pr-strategy.js +6 -1
- package/dist/vcs/gitlab-pr-strategy.js +7 -2
- package/dist/vcs/graphql-commit-strategy.js +2 -1
- package/dist/vcs/index.d.ts +1 -0
- package/dist/vcs/index.js +2 -0
- package/dist/vcs/pr-creator.d.ts +5 -1
- package/dist/vcs/pr-creator.js +4 -4
- package/package.json +1 -1
- package/dist/output/index.d.ts +0 -5
- package/dist/output/index.js +0 -10
- package/dist/shared/index.d.ts +0 -15
- package/dist/shared/index.js +0 -30
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ jobs:
|
|
|
31
31
|
runs-on: ubuntu-latest
|
|
32
32
|
steps:
|
|
33
33
|
- uses: actions/checkout@v4
|
|
34
|
-
- uses: anthony-spruyt/xfg@
|
|
34
|
+
- uses: anthony-spruyt/xfg@v5
|
|
35
35
|
with:
|
|
36
36
|
config: ./sync-config.yaml
|
|
37
37
|
github-token: ${{ secrets.GH_PAT }} # PAT with repo scope for cross-repo access
|
package/dist/cli/program.d.ts
CHANGED
package/dist/cli/program.js
CHANGED
|
@@ -28,30 +28,35 @@ function addSharedOptions(cmd) {
|
|
|
28
28
|
.option("--no-delete", "Skip deletion of orphaned resources even if deleteOrphaned is configured");
|
|
29
29
|
}
|
|
30
30
|
// =============================================================================
|
|
31
|
-
//
|
|
31
|
+
// Validators
|
|
32
32
|
// =============================================================================
|
|
33
|
-
|
|
34
|
-
.name("xfg")
|
|
35
|
-
.description("Manage files, settings, and repositories across GitHub, Azure DevOps, and GitLab")
|
|
36
|
-
.version(packageJson.version);
|
|
37
|
-
// Sync command (file synchronization)
|
|
38
|
-
const syncCommand = new Command("sync")
|
|
39
|
-
.description("Sync configuration files across repositories")
|
|
40
|
-
.option("-b, --branch <name>", "Override the branch name (default: chore/sync-{filename} or chore/sync-config)")
|
|
41
|
-
.option("-m, --merge <mode>", "PR merge mode: manual, auto (default, merge when checks pass), force (bypass requirements), direct (push to default branch, no PR)", (value) => {
|
|
33
|
+
export function parseMergeMode(value) {
|
|
42
34
|
const valid = ["manual", "auto", "force", "direct"];
|
|
43
35
|
if (!valid.includes(value)) {
|
|
44
36
|
throw new ValidationError(`Invalid merge mode: ${value}. Valid: ${valid.join(", ")}`);
|
|
45
37
|
}
|
|
46
38
|
return value;
|
|
47
|
-
}
|
|
48
|
-
|
|
39
|
+
}
|
|
40
|
+
export function parseMergeStrategy(value) {
|
|
49
41
|
const valid = ["merge", "squash", "rebase"];
|
|
50
42
|
if (!valid.includes(value)) {
|
|
51
43
|
throw new ValidationError(`Invalid merge strategy: ${value}. Valid: ${valid.join(", ")}`);
|
|
52
44
|
}
|
|
53
45
|
return value;
|
|
54
|
-
}
|
|
46
|
+
}
|
|
47
|
+
// =============================================================================
|
|
48
|
+
// CLI Program
|
|
49
|
+
// =============================================================================
|
|
50
|
+
program
|
|
51
|
+
.name("xfg")
|
|
52
|
+
.description("Manage files, settings, and repositories across GitHub, Azure DevOps, and GitLab")
|
|
53
|
+
.version(packageJson.version);
|
|
54
|
+
// Sync command (file synchronization)
|
|
55
|
+
const syncCommand = new Command("sync")
|
|
56
|
+
.description("Sync configuration files across repositories")
|
|
57
|
+
.option("-b, --branch <name>", "Override the branch name (default: chore/sync-{filename} or chore/sync-config)")
|
|
58
|
+
.option("-m, --merge <mode>", "PR merge mode: manual, auto (default, merge when checks pass), force (bypass requirements), direct (push to default branch, no PR)", parseMergeMode)
|
|
59
|
+
.option("--merge-strategy <strategy>", "Merge strategy: merge, squash (default), rebase", parseMergeStrategy)
|
|
55
60
|
.option("--delete-branch", "Delete source branch after merge")
|
|
56
61
|
.action((opts) => {
|
|
57
62
|
runSync(opts).catch((error) => {
|
package/dist/cli/sync-command.js
CHANGED
|
@@ -11,12 +11,26 @@ import { RulesetProcessor, RepoSettingsProcessor, LabelsProcessor, GitHubRuleset
|
|
|
11
11
|
import { ShellCommandExecutor } from "../shared/command-executor.js";
|
|
12
12
|
import { Logger } from "../shared/logger.js";
|
|
13
13
|
import { generateWorkspaceName } from "../shared/workspace-utils.js";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
let _defaultExecutor;
|
|
15
|
+
let _logger;
|
|
16
|
+
function getDefaultExecutor() {
|
|
17
|
+
return (_defaultExecutor ??= new ShellCommandExecutor(process.env));
|
|
18
|
+
}
|
|
19
|
+
function getLogger() {
|
|
20
|
+
return (_logger ??= new Logger(!!(process.env.DEBUG || process.env.XFG_DEBUG)));
|
|
21
|
+
}
|
|
22
|
+
function createDefaultRulesetProcessorFactory() {
|
|
23
|
+
const cwd = process.cwd();
|
|
24
|
+
return () => new RulesetProcessor(new GitHubRulesetStrategy(getDefaultExecutor(), { cwd }));
|
|
25
|
+
}
|
|
26
|
+
function createDefaultRepoSettingsProcessorFactory() {
|
|
27
|
+
const cwd = process.cwd();
|
|
28
|
+
return () => new RepoSettingsProcessor(new GitHubRepoSettingsStrategy(getDefaultExecutor(), { cwd }));
|
|
29
|
+
}
|
|
30
|
+
function createDefaultLabelsProcessorFactory() {
|
|
31
|
+
const cwd = process.cwd();
|
|
32
|
+
return () => new LabelsProcessor(new GitHubLabelsStrategy(getDefaultExecutor(), { cwd }));
|
|
33
|
+
}
|
|
20
34
|
import { ResultsCollector } from "./results-collector.js";
|
|
21
35
|
import { buildSettingsReport } from "./settings-report-builder.js";
|
|
22
36
|
import { formatSettingsReportCLI } from "../output/settings-report.js";
|
|
@@ -73,22 +87,22 @@ function determineMergeOutcome(result) {
|
|
|
73
87
|
}
|
|
74
88
|
function logSettingsResult(result, label, current, repoName, settingsCollector) {
|
|
75
89
|
if (result.planOutput?.lines?.length) {
|
|
76
|
-
|
|
77
|
-
|
|
90
|
+
getLogger().info("");
|
|
91
|
+
getLogger().info(`${repoName} - ${label}:`);
|
|
78
92
|
for (const line of result.planOutput.lines) {
|
|
79
|
-
|
|
93
|
+
getLogger().info(line);
|
|
80
94
|
}
|
|
81
95
|
if (result.warnings?.length) {
|
|
82
96
|
for (const warning of result.warnings) {
|
|
83
|
-
|
|
97
|
+
getLogger().warn(warning);
|
|
84
98
|
}
|
|
85
99
|
}
|
|
86
100
|
}
|
|
87
101
|
else if (!result.skipped && result.success) {
|
|
88
|
-
|
|
102
|
+
getLogger().success(current, repoName, `${label}: ${result.message}`);
|
|
89
103
|
}
|
|
90
104
|
if (!result.success && !result.skipped) {
|
|
91
|
-
|
|
105
|
+
getLogger().error(current, repoName, `${label}: ${result.message}`);
|
|
92
106
|
settingsCollector.appendError(repoName, result.message);
|
|
93
107
|
}
|
|
94
108
|
}
|
|
@@ -148,7 +162,7 @@ async function applyRepoSettings(ctx) {
|
|
|
148
162
|
logSettingsResult(result, desc.label, current, repoName, settingsCollector);
|
|
149
163
|
}
|
|
150
164
|
catch (error) {
|
|
151
|
-
|
|
165
|
+
getLogger().error(current, repoName, `${desc.label}: ${toErrorMessage(error)}`);
|
|
152
166
|
settingsCollector.appendError(repoName, error);
|
|
153
167
|
}
|
|
154
168
|
}
|
|
@@ -156,15 +170,15 @@ async function applyRepoSettings(ctx) {
|
|
|
156
170
|
function displayReports(reportResults, lifecycleReportInputs, settingsCollector, dryRun) {
|
|
157
171
|
const lifecycleReport = buildLifecycleReport(lifecycleReportInputs);
|
|
158
172
|
if (hasLifecycleChanges(lifecycleReport)) {
|
|
159
|
-
|
|
173
|
+
getLogger().log("");
|
|
160
174
|
for (const line of formatLifecycleReportCLI(lifecycleReport)) {
|
|
161
|
-
|
|
175
|
+
getLogger().log(line);
|
|
162
176
|
}
|
|
163
177
|
}
|
|
164
178
|
const report = buildSyncReport(reportResults);
|
|
165
|
-
|
|
179
|
+
getLogger().log("");
|
|
166
180
|
for (const line of formatSyncReportCLI(report)) {
|
|
167
|
-
|
|
181
|
+
getLogger().log(line);
|
|
168
182
|
}
|
|
169
183
|
// Build and display settings report (if any settings were processed)
|
|
170
184
|
const settingsResults = settingsCollector.getAll();
|
|
@@ -173,9 +187,9 @@ function displayReports(reportResults, lifecycleReportInputs, settingsCollector,
|
|
|
173
187
|
settingsReport = buildSettingsReport(settingsResults);
|
|
174
188
|
const settingsLines = formatSettingsReportCLI(settingsReport);
|
|
175
189
|
if (settingsLines.length > 0) {
|
|
176
|
-
|
|
190
|
+
getLogger().log("");
|
|
177
191
|
for (const line of settingsLines) {
|
|
178
|
-
|
|
192
|
+
getLogger().log(line);
|
|
179
193
|
}
|
|
180
194
|
}
|
|
181
195
|
}
|
|
@@ -206,7 +220,7 @@ async function processSingleRepo(repoConfig, index, ctx) {
|
|
|
206
220
|
}
|
|
207
221
|
const mergeMode = repoConfig.prOptions?.merge ?? "auto";
|
|
208
222
|
if (mergeMode === "direct" && repoConfig.prOptions?.mergeStrategy) {
|
|
209
|
-
|
|
223
|
+
getLogger().warn(`mergeStrategy '${repoConfig.prOptions.mergeStrategy}' is ignored in direct mode for ${repoConfig.git}`);
|
|
210
224
|
}
|
|
211
225
|
let repoInfo;
|
|
212
226
|
try {
|
|
@@ -215,7 +229,7 @@ async function processSingleRepo(repoConfig, index, ctx) {
|
|
|
215
229
|
});
|
|
216
230
|
}
|
|
217
231
|
catch (error) {
|
|
218
|
-
|
|
232
|
+
getLogger().error(current, repoConfig.git, toErrorMessage(error));
|
|
219
233
|
ctx.reportResults.push({
|
|
220
234
|
repoName: repoConfig.git,
|
|
221
235
|
success: false,
|
|
@@ -227,7 +241,13 @@ async function processSingleRepo(repoConfig, index, ctx) {
|
|
|
227
241
|
const repoName = getRepoDisplayName(repoInfo);
|
|
228
242
|
const workDir = resolve(join(options.workDir ?? "./tmp", generateWorkspaceName(index)));
|
|
229
243
|
const repoToken = isGitHubRepo(repoInfo)
|
|
230
|
-
? (await resolveGitHubToken(
|
|
244
|
+
? (await resolveGitHubToken({
|
|
245
|
+
repoInfo: repoInfo,
|
|
246
|
+
tokenManager: ctx.tokenManager,
|
|
247
|
+
context: repoName,
|
|
248
|
+
log: getLogger(),
|
|
249
|
+
envToken: process.env.GH_TOKEN,
|
|
250
|
+
})).token
|
|
231
251
|
: undefined;
|
|
232
252
|
const repo = {
|
|
233
253
|
repoConfig,
|
|
@@ -273,7 +293,7 @@ async function runLifecyclePhase(repo, ctx) {
|
|
|
273
293
|
repoSettings: ctx.config.settings?.repo,
|
|
274
294
|
});
|
|
275
295
|
for (const line of outputLines) {
|
|
276
|
-
|
|
296
|
+
getLogger().info(line);
|
|
277
297
|
}
|
|
278
298
|
const createSettings = toCreateRepoSettings(ctx.config.settings?.repo);
|
|
279
299
|
ctx.lifecycleReportInputs.push({
|
|
@@ -300,7 +320,7 @@ async function runLifecyclePhase(repo, ctx) {
|
|
|
300
320
|
return false;
|
|
301
321
|
}
|
|
302
322
|
catch (error) {
|
|
303
|
-
|
|
323
|
+
getLogger().error(current, repo.repoName, `Lifecycle error: ${toErrorMessage(error)}`);
|
|
304
324
|
ctx.reportResults.push({
|
|
305
325
|
repoName: repo.repoName,
|
|
306
326
|
success: false,
|
|
@@ -316,14 +336,14 @@ async function runLifecyclePhase(repo, ctx) {
|
|
|
316
336
|
async function runFileSyncPhase(repo, ctx) {
|
|
317
337
|
const current = repo.index + 1;
|
|
318
338
|
try {
|
|
319
|
-
|
|
339
|
+
getLogger().progress(current, repo.repoName, "Processing...");
|
|
320
340
|
const result = await ctx.processor.process(repo.repoConfig, repo.repoInfo, {
|
|
321
341
|
branchName: ctx.branchName,
|
|
322
342
|
workDir: repo.workDir,
|
|
323
343
|
configId: ctx.config.id,
|
|
324
344
|
dryRun: ctx.options.dryRun,
|
|
325
345
|
retries: ctx.options.retries,
|
|
326
|
-
executor:
|
|
346
|
+
executor: getDefaultExecutor(),
|
|
327
347
|
prTemplate: ctx.config.prTemplate,
|
|
328
348
|
noDelete: ctx.options.noDelete,
|
|
329
349
|
token: repo.token,
|
|
@@ -342,17 +362,17 @@ async function runFileSyncPhase(repo, ctx) {
|
|
|
342
362
|
error: result.success ? undefined : result.message,
|
|
343
363
|
});
|
|
344
364
|
if (result.skipped) {
|
|
345
|
-
|
|
365
|
+
getLogger().skip(current, repo.repoName, result.message);
|
|
346
366
|
}
|
|
347
367
|
else if (result.success) {
|
|
348
|
-
|
|
368
|
+
getLogger().success(current, repo.repoName, result.message);
|
|
349
369
|
}
|
|
350
370
|
else {
|
|
351
|
-
|
|
371
|
+
getLogger().error(current, repo.repoName, result.message);
|
|
352
372
|
}
|
|
353
373
|
}
|
|
354
374
|
catch (error) {
|
|
355
|
-
|
|
375
|
+
getLogger().error(current, repo.repoName, toErrorMessage(error));
|
|
356
376
|
ctx.reportResults.push({
|
|
357
377
|
repoName: repo.repoName,
|
|
358
378
|
success: false,
|
|
@@ -362,14 +382,17 @@ async function runFileSyncPhase(repo, ctx) {
|
|
|
362
382
|
}
|
|
363
383
|
}
|
|
364
384
|
export async function runSync(options, deps = {}) {
|
|
365
|
-
|
|
385
|
+
// Reset module-level singletons to ensure fresh state per invocation
|
|
386
|
+
_defaultExecutor = undefined;
|
|
387
|
+
_logger = undefined;
|
|
388
|
+
const { lifecycleManager, rulesetProcessorFactory = createDefaultRulesetProcessorFactory(), repoSettingsProcessorFactory = createDefaultRepoSettingsProcessorFactory(), labelsProcessorFactory = createDefaultLabelsProcessorFactory(), } = deps;
|
|
366
389
|
const configPath = resolve(options.config);
|
|
367
390
|
if (!existsSync(configPath)) {
|
|
368
391
|
throw new ValidationError(`Config file not found: ${configPath}`);
|
|
369
392
|
}
|
|
370
|
-
|
|
393
|
+
getLogger().log(`Loading config from: ${configPath}`);
|
|
371
394
|
if (options.dryRun) {
|
|
372
|
-
|
|
395
|
+
getLogger().log("Running in DRY RUN mode - no changes will be made\n");
|
|
373
396
|
}
|
|
374
397
|
const rawConfig = loadRawConfig(configPath);
|
|
375
398
|
validateForSync(rawConfig);
|
|
@@ -383,10 +406,10 @@ export async function runSync(options, deps = {}) {
|
|
|
383
406
|
else {
|
|
384
407
|
branchName = generateBranchName(fileNames);
|
|
385
408
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
409
|
+
getLogger().setTotal(config.repos.length);
|
|
410
|
+
getLogger().log(`Found ${config.repos.length} repositories to process`);
|
|
411
|
+
getLogger().log(`Target files: ${formatFileNames(fileNames)}`);
|
|
412
|
+
getLogger().log(`Branch: ${branchName}\n`);
|
|
390
413
|
const tokenManager = createTokenManager(process.env.XFG_GITHUB_APP_ID && process.env.XFG_GITHUB_APP_PRIVATE_KEY
|
|
391
414
|
? {
|
|
392
415
|
appId: process.env.XFG_GITHUB_APP_ID,
|
|
@@ -395,7 +418,7 @@ export async function runSync(options, deps = {}) {
|
|
|
395
418
|
: undefined);
|
|
396
419
|
const processor = deps.processorFactory
|
|
397
420
|
? deps.processorFactory()
|
|
398
|
-
: new RepositoryProcessor(undefined,
|
|
421
|
+
: new RepositoryProcessor(undefined, getLogger(), {
|
|
399
422
|
tokenManager,
|
|
400
423
|
envToken: process.env.GH_TOKEN,
|
|
401
424
|
});
|
|
@@ -405,7 +428,7 @@ export async function runSync(options, deps = {}) {
|
|
|
405
428
|
branchName,
|
|
406
429
|
processor,
|
|
407
430
|
lifecycleManager: lifecycleManager ??
|
|
408
|
-
new RepoLifecycleManager(undefined,
|
|
431
|
+
new RepoLifecycleManager(undefined, getDefaultExecutor(), options.retries, process.cwd(), getLogger()),
|
|
409
432
|
tokenManager,
|
|
410
433
|
reportResults: [],
|
|
411
434
|
lifecycleReportInputs: [],
|
|
@@ -4,10 +4,13 @@ export function buildSyncReport(results) {
|
|
|
4
4
|
files: { create: 0, update: 0, delete: 0 },
|
|
5
5
|
};
|
|
6
6
|
for (const result of results) {
|
|
7
|
-
const files = result.fileChanges.map((f) =>
|
|
8
|
-
path: f.path,
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
const files = result.fileChanges.map((f) => {
|
|
8
|
+
const entry = { path: f.path, action: f.action };
|
|
9
|
+
if (f.diffLines) {
|
|
10
|
+
entry.diffLines = f.diffLines;
|
|
11
|
+
}
|
|
12
|
+
return entry;
|
|
13
|
+
});
|
|
11
14
|
// Count totals
|
|
12
15
|
for (const file of files) {
|
|
13
16
|
if (file.action === "create")
|
package/dist/config/formatter.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Document, stringify } from "yaml";
|
|
1
|
+
import { Document, isScalar, Scalar, stringify, visit } from "yaml";
|
|
2
2
|
export function detectOutputFormat(fileName) {
|
|
3
3
|
const ext = fileName.toLowerCase().split(".").pop();
|
|
4
4
|
if (ext === "yaml" || ext === "yml") {
|
|
@@ -16,11 +16,9 @@ export function detectOutputFormat(fileName) {
|
|
|
16
16
|
*/
|
|
17
17
|
function buildHeaderComment(header, schemaUrl) {
|
|
18
18
|
const lines = [];
|
|
19
|
-
// Add yaml-language-server schema directive first (if present)
|
|
20
19
|
if (schemaUrl) {
|
|
21
20
|
lines.push(` yaml-language-server: $schema=${schemaUrl}`);
|
|
22
21
|
}
|
|
23
|
-
// Add custom header lines (with space prefix for proper formatting)
|
|
24
22
|
if (header && header.length > 0) {
|
|
25
23
|
lines.push(...header.map((h) => ` ${h}`));
|
|
26
24
|
}
|
|
@@ -34,11 +32,9 @@ function buildHeaderComment(header, schemaUrl) {
|
|
|
34
32
|
*/
|
|
35
33
|
function buildCommentOnlyYaml(header, schemaUrl) {
|
|
36
34
|
const lines = [];
|
|
37
|
-
// Add yaml-language-server schema directive first (if present)
|
|
38
35
|
if (schemaUrl) {
|
|
39
36
|
lines.push(`# yaml-language-server: $schema=${schemaUrl}`);
|
|
40
37
|
}
|
|
41
|
-
// Add custom header lines
|
|
42
38
|
if (header && header.length > 0) {
|
|
43
39
|
lines.push(...header.map((h) => `# ${h}`));
|
|
44
40
|
}
|
|
@@ -79,10 +75,19 @@ export function convertContentToString(content, fileName, options) {
|
|
|
79
75
|
doc.commentBefore = headerComment;
|
|
80
76
|
}
|
|
81
77
|
}
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
78
|
+
// Use BLOCK_LITERAL (|) for multi-line string values to preserve readability.
|
|
79
|
+
// Single-line strings remain QUOTE_DOUBLE via defaultStringType for YAML 1.1
|
|
80
|
+
// compatibility (prevents "06:00" as sexagesimal, "yes"/"no" as booleans).
|
|
81
|
+
visit(doc, {
|
|
82
|
+
Scalar(key, node) {
|
|
83
|
+
if (key === "value" &&
|
|
84
|
+
isScalar(node) &&
|
|
85
|
+
typeof node.value === "string" &&
|
|
86
|
+
node.value.includes("\n")) {
|
|
87
|
+
node.type = Scalar.BLOCK_LITERAL;
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
});
|
|
86
91
|
return stringify(doc, {
|
|
87
92
|
indent: 2,
|
|
88
93
|
defaultStringType: "QUOTE_DOUBLE",
|
package/dist/config/merge.d.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Deep merge utilities for JSON configuration objects.
|
|
3
|
-
* Supports
|
|
3
|
+
* Supports per-field array merge strategies via $arrayMerge + $values directives.
|
|
4
4
|
*/
|
|
5
5
|
export type ArrayMergeStrategy = "replace" | "append" | "prepend";
|
|
6
6
|
export interface MergeContext {
|
|
7
|
-
arrayStrategies: Map<string, ArrayMergeStrategy>;
|
|
8
7
|
defaultArrayStrategy: ArrayMergeStrategy;
|
|
9
8
|
}
|
|
10
9
|
/**
|
|
@@ -13,9 +12,8 @@ export interface MergeContext {
|
|
|
13
12
|
* @param base - The base object
|
|
14
13
|
* @param overlay - The overlay object (values override base)
|
|
15
14
|
* @param ctx - Merge context with array strategies
|
|
16
|
-
* @param path - Current path for strategy lookup (internal)
|
|
17
15
|
*/
|
|
18
|
-
export declare function deepMerge(base: Record<string, unknown>, overlay: Record<string, unknown>, ctx: MergeContext
|
|
16
|
+
export declare function deepMerge(base: Record<string, unknown>, overlay: Record<string, unknown>, ctx: MergeContext): Record<string, unknown>;
|
|
19
17
|
/**
|
|
20
18
|
* Strip merge directive keys ($arrayMerge, $override, etc.) from an object.
|
|
21
19
|
* Works recursively on nested objects and arrays.
|
package/dist/config/merge.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Deep merge utilities for JSON configuration objects.
|
|
3
|
-
* Supports
|
|
3
|
+
* Supports per-field array merge strategies via $arrayMerge + $values directives.
|
|
4
4
|
*/
|
|
5
5
|
import { isPlainObject } from "../shared/type-guards.js";
|
|
6
6
|
/**
|
|
@@ -23,92 +23,41 @@ function mergeArrays(base, overlay, strategy) {
|
|
|
23
23
|
// Fallback to replace for unknown strategies
|
|
24
24
|
return overlay;
|
|
25
25
|
}
|
|
26
|
-
/**
|
|
27
|
-
* Extract array values from an overlay object that uses the directive syntax:
|
|
28
|
-
* { $arrayMerge: 'append', values: [1, 2, 3] }
|
|
29
|
-
*
|
|
30
|
-
* Or just return the array if it's already an array.
|
|
31
|
-
*/
|
|
32
|
-
function extractArrayFromOverlay(overlay) {
|
|
33
|
-
if (Array.isArray(overlay)) {
|
|
34
|
-
return overlay;
|
|
35
|
-
}
|
|
36
|
-
if (isPlainObject(overlay) && "values" in overlay) {
|
|
37
|
-
const values = overlay.values;
|
|
38
|
-
if (Array.isArray(values)) {
|
|
39
|
-
return values;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
/**
|
|
45
|
-
* Get merge strategy from an overlay object's $arrayMerge directive.
|
|
46
|
-
*/
|
|
47
|
-
function getStrategyFromOverlay(overlay) {
|
|
48
|
-
if (isPlainObject(overlay) && "$arrayMerge" in overlay) {
|
|
49
|
-
const strategy = overlay.$arrayMerge;
|
|
50
|
-
if (strategy === "replace" ||
|
|
51
|
-
strategy === "append" ||
|
|
52
|
-
strategy === "prepend") {
|
|
53
|
-
return strategy;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
return null;
|
|
57
|
-
}
|
|
58
26
|
/**
|
|
59
27
|
* Deep merge two objects with configurable array handling.
|
|
60
28
|
*
|
|
61
29
|
* @param base - The base object
|
|
62
30
|
* @param overlay - The overlay object (values override base)
|
|
63
31
|
* @param ctx - Merge context with array strategies
|
|
64
|
-
* @param path - Current path for strategy lookup (internal)
|
|
65
32
|
*/
|
|
66
|
-
export function deepMerge(base, overlay, ctx
|
|
33
|
+
export function deepMerge(base, overlay, ctx) {
|
|
67
34
|
const result = { ...base };
|
|
68
|
-
// Check for $arrayMerge directive at this level (applies to child arrays)
|
|
69
|
-
const levelStrategy = getStrategyFromOverlay(overlay);
|
|
70
35
|
for (const [key, overlayValue] of Object.entries(overlay)) {
|
|
71
36
|
// Skip directive keys in output
|
|
72
37
|
if (key.startsWith("$"))
|
|
73
38
|
continue;
|
|
74
|
-
const currentPath = path ? `${path}.${key}` : key;
|
|
75
39
|
const baseValue = base[key];
|
|
76
|
-
//
|
|
40
|
+
// Per-field $arrayMerge + $values directive
|
|
77
41
|
if (isPlainObject(overlayValue) && "$arrayMerge" in overlayValue) {
|
|
78
|
-
const strategy =
|
|
79
|
-
const
|
|
80
|
-
if (strategy
|
|
81
|
-
|
|
42
|
+
const strategy = overlayValue.$arrayMerge;
|
|
43
|
+
const values = overlayValue.$values;
|
|
44
|
+
if ((strategy === "replace" ||
|
|
45
|
+
strategy === "append" ||
|
|
46
|
+
strategy === "prepend") &&
|
|
47
|
+
Array.isArray(values) &&
|
|
48
|
+
Array.isArray(baseValue)) {
|
|
49
|
+
result[key] = mergeArrays(baseValue, values, strategy);
|
|
82
50
|
continue;
|
|
83
51
|
}
|
|
84
52
|
}
|
|
85
|
-
// Both are arrays
|
|
53
|
+
// Both are arrays — use default strategy
|
|
86
54
|
if (Array.isArray(baseValue) && Array.isArray(overlayValue)) {
|
|
87
|
-
|
|
88
|
-
const strategy = levelStrategy ??
|
|
89
|
-
ctx.arrayStrategies.get(currentPath) ??
|
|
90
|
-
ctx.defaultArrayStrategy;
|
|
91
|
-
result[key] = mergeArrays(baseValue, overlayValue, strategy);
|
|
55
|
+
result[key] = mergeArrays(baseValue, overlayValue, ctx.defaultArrayStrategy);
|
|
92
56
|
continue;
|
|
93
57
|
}
|
|
94
|
-
// Both are plain objects
|
|
58
|
+
// Both are plain objects — recurse
|
|
95
59
|
if (isPlainObject(baseValue) && isPlainObject(overlayValue)) {
|
|
96
|
-
|
|
97
|
-
if ("$arrayMerge" in overlayValue) {
|
|
98
|
-
const childStrategy = getStrategyFromOverlay(overlayValue);
|
|
99
|
-
if (childStrategy) {
|
|
100
|
-
// Apply to all immediate child arrays
|
|
101
|
-
for (const childKey of Object.keys(overlayValue)) {
|
|
102
|
-
if (!childKey.startsWith("$")) {
|
|
103
|
-
const childPath = currentPath
|
|
104
|
-
? `${currentPath}.${childKey}`
|
|
105
|
-
: childKey;
|
|
106
|
-
ctx.arrayStrategies.set(childPath, childStrategy);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
result[key] = deepMerge(baseValue, overlayValue, ctx, currentPath);
|
|
60
|
+
result[key] = deepMerge(baseValue, overlayValue, ctx);
|
|
112
61
|
continue;
|
|
113
62
|
}
|
|
114
63
|
// Otherwise, overlay wins (including null values)
|
|
@@ -143,7 +92,6 @@ export function stripMergeDirectives(obj) {
|
|
|
143
92
|
*/
|
|
144
93
|
export function createMergeContext(defaultStrategy = "replace") {
|
|
145
94
|
return {
|
|
146
|
-
arrayStrategies: new Map(),
|
|
147
95
|
defaultArrayStrategy: defaultStrategy,
|
|
148
96
|
};
|
|
149
97
|
}
|
package/dist/config/validator.js
CHANGED
|
@@ -12,15 +12,8 @@ const CONFIG_ID_MAX_LENGTH = 64;
|
|
|
12
12
|
* Supports SSH (git@host:path) and HTTPS (https://host/path) formats.
|
|
13
13
|
*/
|
|
14
14
|
function isValidGitUrl(url) {
|
|
15
|
-
// SSH format: git@hostname:path
|
|
16
|
-
|
|
17
|
-
return true;
|
|
18
|
-
}
|
|
19
|
-
// HTTPS format: https://hostname/path
|
|
20
|
-
if (/^https?:\/\/[^/]+\/.+$/.test(url)) {
|
|
21
|
-
return true;
|
|
22
|
-
}
|
|
23
|
-
return false;
|
|
15
|
+
// SSH format: git@hostname:path OR HTTPS format: https://hostname/path
|
|
16
|
+
return /^git@[^:]+:.+$/.test(url) || /^https?:\/\/[^/]+\/.+$/.test(url);
|
|
24
17
|
}
|
|
25
18
|
/**
|
|
26
19
|
* Check if a git URL points to GitHub (github.com).
|
|
@@ -15,12 +15,10 @@ export class RepoLifecycleFactory {
|
|
|
15
15
|
this.log = log;
|
|
16
16
|
}
|
|
17
17
|
getProvider(platform) {
|
|
18
|
-
// Check cache first
|
|
19
18
|
const cached = this.providers.get(platform);
|
|
20
19
|
if (cached) {
|
|
21
20
|
return cached;
|
|
22
21
|
}
|
|
23
|
-
// Create provider
|
|
24
22
|
let provider;
|
|
25
23
|
switch (platform) {
|
|
26
24
|
case "github":
|
|
@@ -39,12 +37,10 @@ export class RepoLifecycleFactory {
|
|
|
39
37
|
return provider;
|
|
40
38
|
}
|
|
41
39
|
getMigrationSource(platform) {
|
|
42
|
-
// Check cache first
|
|
43
40
|
const cached = this.sources.get(platform);
|
|
44
41
|
if (cached) {
|
|
45
42
|
return cached;
|
|
46
43
|
}
|
|
47
|
-
// Create source
|
|
48
44
|
let source;
|
|
49
45
|
switch (platform) {
|
|
50
46
|
case "azure-devops":
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DebugLog } from "../shared/logger.js";
|
|
2
|
+
import type { SettingsAction } from "../settings/index.js";
|
|
2
3
|
export type MergeOutcome = "manual" | "auto" | "force" | "direct";
|
|
3
4
|
export interface FileChanges {
|
|
4
5
|
added: number;
|
|
@@ -8,7 +9,7 @@ export interface FileChanges {
|
|
|
8
9
|
}
|
|
9
10
|
export interface RulesetPlanDetail {
|
|
10
11
|
name: string;
|
|
11
|
-
action:
|
|
12
|
+
action: SettingsAction;
|
|
12
13
|
propertyCount?: number;
|
|
13
14
|
propertyChanges?: {
|
|
14
15
|
added: number;
|
|
@@ -22,7 +23,7 @@ export interface RepoSettingsPlanDetail {
|
|
|
22
23
|
}
|
|
23
24
|
export interface LabelsPlanDetail {
|
|
24
25
|
name: string;
|
|
25
|
-
action:
|
|
26
|
+
action: SettingsAction;
|
|
26
27
|
newName?: string;
|
|
27
28
|
}
|
|
28
29
|
export interface RepoResult {
|
|
@@ -179,13 +179,7 @@ export function formatSummary(data) {
|
|
|
179
179
|
lines.push("| Label | Action |");
|
|
180
180
|
lines.push("|-------|--------|");
|
|
181
181
|
for (const detail of result.labelsPlanDetails) {
|
|
182
|
-
const action = detail.action
|
|
183
|
-
? "+ Create"
|
|
184
|
-
: detail.action === "update"
|
|
185
|
-
? "~ Update"
|
|
186
|
-
: detail.action === "delete"
|
|
187
|
-
? "- Delete"
|
|
188
|
-
: "No change";
|
|
182
|
+
const action = formatRulesetAction(detail.action);
|
|
189
183
|
const name = detail.newName
|
|
190
184
|
? `${detail.name} \u2192 ${detail.newName}`
|
|
191
185
|
: detail.name;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// src/output/lifecycle-report.ts
|
|
2
1
|
import chalk from "chalk";
|
|
3
2
|
import { writeGitHubStepSummary } from "./github-summary.js";
|
|
3
|
+
import { formatCountEntry } from "./settings-report.js";
|
|
4
4
|
export function buildLifecycleReport(results) {
|
|
5
5
|
const actions = [];
|
|
6
6
|
const totals = { created: 0, forked: 0, migrated: 0, existed: 0 };
|
|
@@ -17,19 +17,12 @@ export function buildLifecycleReport(results) {
|
|
|
17
17
|
return { actions, totals };
|
|
18
18
|
}
|
|
19
19
|
function formatLifecycleSummary(totals) {
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
parts.push(`${totals.created} to create`);
|
|
27
|
-
if (totals.forked > 0)
|
|
28
|
-
parts.push(`${totals.forked} to fork`);
|
|
29
|
-
if (totals.migrated > 0)
|
|
30
|
-
parts.push(`${totals.migrated} to migrate`);
|
|
31
|
-
const repoWord = total === 1 ? "repo" : "repos";
|
|
32
|
-
return `Plan: ${total} ${repoWord} (${parts.join(", ")})`;
|
|
20
|
+
const entry = formatCountEntry("repo", "repos", [
|
|
21
|
+
{ label: "to create", value: totals.created },
|
|
22
|
+
{ label: "to fork", value: totals.forked },
|
|
23
|
+
{ label: "to migrate", value: totals.migrated },
|
|
24
|
+
]);
|
|
25
|
+
return entry ? `Plan: ${entry}` : "No changes";
|
|
33
26
|
}
|
|
34
27
|
/**
|
|
35
28
|
* Returns true if the report has any non-"existed" actions worth displaying.
|