@aspruyt/xfg 3.7.3 → 3.7.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +69 -76
- package/dist/logger.d.ts +0 -15
- package/dist/logger.js +0 -34
- package/dist/plan-formatter.d.ts +39 -0
- package/dist/plan-formatter.js +84 -0
- package/dist/plan-summary.d.ts +8 -0
- package/dist/plan-summary.js +110 -0
- package/dist/resource-converters.d.ts +28 -0
- package/dist/resource-converters.js +107 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -15,12 +15,14 @@ import { sanitizeBranchName, validateBranchName } from "./git-ops.js";
|
|
|
15
15
|
import { logger } from "./logger.js";
|
|
16
16
|
import { generateWorkspaceName } from "./workspace-utils.js";
|
|
17
17
|
import { RepositoryProcessor, } from "./repository-processor.js";
|
|
18
|
-
import { writeSummary } from "./github-summary.js";
|
|
19
18
|
import { buildRepoResult, buildErrorResult } from "./summary-utils.js";
|
|
20
19
|
import { RulesetProcessor, } from "./ruleset-processor.js";
|
|
21
20
|
import { getManagedRulesets } from "./manifest.js";
|
|
22
21
|
import { isGitHubRepo } from "./repo-detector.js";
|
|
23
22
|
import { RepoSettingsProcessor, } from "./repo-settings-processor.js";
|
|
23
|
+
import { printPlan } from "./plan-formatter.js";
|
|
24
|
+
import { writePlanSummary } from "./plan-summary.js";
|
|
25
|
+
import { rulesetResultToResources, syncResultToResources, repoSettingsResultToResources, } from "./resource-converters.js";
|
|
24
26
|
/**
|
|
25
27
|
* Default factory that creates a real RepositoryProcessor.
|
|
26
28
|
*/
|
|
@@ -115,6 +117,8 @@ export async function runSync(options, processorFactory = defaultProcessorFactor
|
|
|
115
117
|
console.log(`Branch: ${branchName}\n`);
|
|
116
118
|
const processor = processorFactory();
|
|
117
119
|
const results = [];
|
|
120
|
+
// Build plan for Terraform-style output
|
|
121
|
+
const plan = { resources: [], errors: [] };
|
|
118
122
|
for (let i = 0; i < config.repos.length; i++) {
|
|
119
123
|
const repoConfig = config.repos[i];
|
|
120
124
|
// Apply CLI merge overrides to repo config
|
|
@@ -136,6 +140,10 @@ export async function runSync(options, processorFactory = defaultProcessorFactor
|
|
|
136
140
|
catch (error) {
|
|
137
141
|
logger.error(current, repoConfig.git, String(error));
|
|
138
142
|
results.push(buildErrorResult(repoConfig.git, error));
|
|
143
|
+
plan.errors.push({
|
|
144
|
+
repo: repoConfig.git,
|
|
145
|
+
message: error instanceof Error ? error.message : String(error),
|
|
146
|
+
});
|
|
139
147
|
continue;
|
|
140
148
|
}
|
|
141
149
|
const repoName = getRepoDisplayName(repoInfo);
|
|
@@ -162,27 +170,27 @@ export async function runSync(options, processorFactory = defaultProcessorFactor
|
|
|
162
170
|
else {
|
|
163
171
|
logger.error(current, repoName, result.message);
|
|
164
172
|
}
|
|
173
|
+
// Collect resources for plan output
|
|
174
|
+
plan.resources.push(...syncResultToResources(repoName, repoConfig, result));
|
|
165
175
|
}
|
|
166
176
|
catch (error) {
|
|
167
177
|
logger.error(current, repoName, String(error));
|
|
168
178
|
results.push(buildErrorResult(repoName, error));
|
|
179
|
+
plan.errors.push({
|
|
180
|
+
repo: repoName,
|
|
181
|
+
message: error instanceof Error ? error.message : String(error),
|
|
182
|
+
});
|
|
169
183
|
}
|
|
170
184
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
writeSummary({
|
|
185
|
+
// Print Terraform-style plan summary
|
|
186
|
+
console.log("");
|
|
187
|
+
printPlan(plan);
|
|
188
|
+
// Write GitHub Actions job summary
|
|
189
|
+
writePlanSummary(plan, {
|
|
177
190
|
title: "Config Sync Summary",
|
|
178
|
-
dryRun: options.dryRun,
|
|
179
|
-
total: config.repos.length,
|
|
180
|
-
succeeded,
|
|
181
|
-
skipped,
|
|
182
|
-
failed,
|
|
183
|
-
results,
|
|
191
|
+
dryRun: options.dryRun ?? false,
|
|
184
192
|
});
|
|
185
|
-
if (
|
|
193
|
+
if (plan.errors && plan.errors.length > 0) {
|
|
186
194
|
process.exit(1);
|
|
187
195
|
}
|
|
188
196
|
}
|
|
@@ -228,14 +236,8 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
|
|
|
228
236
|
const processor = processorFactory();
|
|
229
237
|
const repoProcessor = repoProcessorFactory();
|
|
230
238
|
const results = [];
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
let skipCount = 0;
|
|
234
|
-
// Tracking for multi-repo summary in dry-run mode
|
|
235
|
-
let totalCreates = 0;
|
|
236
|
-
let totalUpdates = 0;
|
|
237
|
-
let totalDeletes = 0;
|
|
238
|
-
let reposWithChanges = 0;
|
|
239
|
+
// Build plan for Terraform-style output
|
|
240
|
+
const plan = { resources: [], errors: [] };
|
|
239
241
|
for (let i = 0; i < reposWithRulesets.length; i++) {
|
|
240
242
|
const repoConfig = reposWithRulesets[i];
|
|
241
243
|
let repoInfo;
|
|
@@ -247,14 +249,28 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
|
|
|
247
249
|
catch (error) {
|
|
248
250
|
logger.error(i + 1, repoConfig.git, String(error));
|
|
249
251
|
results.push(buildErrorResult(repoConfig.git, error));
|
|
250
|
-
|
|
252
|
+
plan.errors.push({
|
|
253
|
+
repo: repoConfig.git,
|
|
254
|
+
message: error instanceof Error ? error.message : String(error),
|
|
255
|
+
});
|
|
251
256
|
continue;
|
|
252
257
|
}
|
|
253
258
|
const repoName = getRepoDisplayName(repoInfo);
|
|
254
259
|
// Skip non-GitHub repos
|
|
255
260
|
if (!isGitHubRepo(repoInfo)) {
|
|
256
261
|
logger.skip(i + 1, repoName, "GitHub Rulesets only supported for GitHub repos");
|
|
257
|
-
|
|
262
|
+
// Mark all rulesets from this repo as skipped
|
|
263
|
+
if (repoConfig.settings?.rulesets) {
|
|
264
|
+
for (const rulesetName of Object.keys(repoConfig.settings.rulesets)) {
|
|
265
|
+
plan.resources.push({
|
|
266
|
+
type: "ruleset",
|
|
267
|
+
repo: repoName,
|
|
268
|
+
name: rulesetName,
|
|
269
|
+
action: "skipped",
|
|
270
|
+
skipReason: "GitHub Rulesets only supported for GitHub repos",
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
258
274
|
continue;
|
|
259
275
|
}
|
|
260
276
|
// Note: For settings command, we don't clone repos - we work with the API directly.
|
|
@@ -269,34 +285,19 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
|
|
|
269
285
|
managedRulesets,
|
|
270
286
|
noDelete: options.noDelete,
|
|
271
287
|
});
|
|
272
|
-
//
|
|
273
|
-
if (
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
deletes: result.planOutput.deletes,
|
|
279
|
-
unchanged: result.planOutput.unchanged,
|
|
280
|
-
});
|
|
281
|
-
}
|
|
282
|
-
// Accumulate totals for multi-repo summary
|
|
283
|
-
totalCreates += result.planOutput.creates;
|
|
284
|
-
totalUpdates += result.planOutput.updates;
|
|
285
|
-
totalDeletes += result.planOutput.deletes;
|
|
286
|
-
if (result.planOutput.creates +
|
|
287
|
-
result.planOutput.updates +
|
|
288
|
-
result.planOutput.deletes >
|
|
289
|
-
0) {
|
|
290
|
-
reposWithChanges++;
|
|
288
|
+
// Print detailed ruleset plan output
|
|
289
|
+
if (result.planOutput && result.planOutput.lines.length > 0) {
|
|
290
|
+
logger.info("");
|
|
291
|
+
logger.info(chalk.bold(`${repoName} - Rulesets:`));
|
|
292
|
+
for (const line of result.planOutput.lines) {
|
|
293
|
+
logger.info(line);
|
|
291
294
|
}
|
|
292
295
|
}
|
|
293
296
|
if (result.skipped) {
|
|
294
297
|
logger.skip(i + 1, repoName, result.message);
|
|
295
|
-
skipCount++;
|
|
296
298
|
}
|
|
297
299
|
else if (result.success) {
|
|
298
300
|
logger.success(i + 1, repoName, result.message);
|
|
299
|
-
successCount++;
|
|
300
301
|
// Update manifest with ruleset tracking if there are rulesets to track
|
|
301
302
|
if (result.manifestUpdate &&
|
|
302
303
|
result.manifestUpdate.rulesets.length > 0) {
|
|
@@ -316,7 +317,6 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
|
|
|
316
317
|
}
|
|
317
318
|
else {
|
|
318
319
|
logger.error(i + 1, repoName, result.message);
|
|
319
|
-
failCount++;
|
|
320
320
|
}
|
|
321
321
|
results.push({
|
|
322
322
|
repoName,
|
|
@@ -328,11 +328,16 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
|
|
|
328
328
|
message: result.message,
|
|
329
329
|
rulesetPlanDetails: result.planOutput?.entries,
|
|
330
330
|
});
|
|
331
|
+
// Collect resources for plan output
|
|
332
|
+
plan.resources.push(...rulesetResultToResources(repoName, result));
|
|
331
333
|
}
|
|
332
334
|
catch (error) {
|
|
333
335
|
logger.error(i + 1, repoName, String(error));
|
|
334
336
|
results.push(buildErrorResult(repoName, error));
|
|
335
|
-
|
|
337
|
+
plan.errors.push({
|
|
338
|
+
repo: repoName,
|
|
339
|
+
message: error instanceof Error ? error.message : String(error),
|
|
340
|
+
});
|
|
336
341
|
}
|
|
337
342
|
}
|
|
338
343
|
// Process repo settings
|
|
@@ -349,7 +354,10 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
|
|
|
349
354
|
}
|
|
350
355
|
catch (error) {
|
|
351
356
|
console.error(`Failed to parse ${repoConfig.git}: ${error}`);
|
|
352
|
-
|
|
357
|
+
plan.errors.push({
|
|
358
|
+
repo: repoConfig.git,
|
|
359
|
+
message: error instanceof Error ? error.message : String(error),
|
|
360
|
+
});
|
|
353
361
|
continue;
|
|
354
362
|
}
|
|
355
363
|
const repoName = getRepoDisplayName(repoInfo);
|
|
@@ -374,11 +382,9 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
|
|
|
374
382
|
}
|
|
375
383
|
else if (result.success) {
|
|
376
384
|
console.log(chalk.green(` ✓ ${repoName}: ${result.message}`));
|
|
377
|
-
successCount++;
|
|
378
385
|
}
|
|
379
386
|
else {
|
|
380
387
|
console.log(chalk.red(` ✗ ${repoName}: ${result.message}`));
|
|
381
|
-
failCount++;
|
|
382
388
|
}
|
|
383
389
|
// Merge repo settings plan details into existing result or push new
|
|
384
390
|
if (!result.skipped) {
|
|
@@ -395,40 +401,27 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
|
|
|
395
401
|
});
|
|
396
402
|
}
|
|
397
403
|
}
|
|
404
|
+
// Collect resources for plan output
|
|
405
|
+
plan.resources.push(...repoSettingsResultToResources(repoName, result));
|
|
398
406
|
}
|
|
399
407
|
catch (error) {
|
|
400
408
|
console.error(` ✗ ${repoName}: ${error}`);
|
|
401
|
-
|
|
409
|
+
plan.errors.push({
|
|
410
|
+
repo: repoName,
|
|
411
|
+
message: error instanceof Error ? error.message : String(error),
|
|
412
|
+
});
|
|
402
413
|
}
|
|
403
414
|
}
|
|
404
415
|
}
|
|
405
|
-
//
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
if (totalCreates > 0)
|
|
411
|
-
totalParts.push(chalk.green(`${totalCreates} to create`));
|
|
412
|
-
if (totalUpdates > 0)
|
|
413
|
-
totalParts.push(chalk.yellow(`${totalUpdates} to update`));
|
|
414
|
-
if (totalDeletes > 0)
|
|
415
|
-
totalParts.push(chalk.red(`${totalDeletes} to delete`));
|
|
416
|
-
console.log(chalk.bold(`Total: ${totalParts.join(", ")} across ${reposWithChanges} ${reposWithChanges === 1 ? "repository" : "repositories"}`));
|
|
417
|
-
}
|
|
418
|
-
// Summary
|
|
419
|
-
console.log("\n" + "=".repeat(50));
|
|
420
|
-
console.log(`Completed: ${successCount} succeeded, ${skipCount} skipped, ${failCount} failed`);
|
|
421
|
-
// Write GitHub Actions job summary if available
|
|
422
|
-
writeSummary({
|
|
416
|
+
// Print Terraform-style plan summary
|
|
417
|
+
console.log("");
|
|
418
|
+
printPlan(plan);
|
|
419
|
+
// Write GitHub Actions job summary
|
|
420
|
+
writePlanSummary(plan, {
|
|
423
421
|
title: "Repository Settings Summary",
|
|
424
|
-
dryRun: options.dryRun,
|
|
425
|
-
total: reposWithRulesets.length + reposWithRepoSettings.length,
|
|
426
|
-
succeeded: successCount,
|
|
427
|
-
skipped: skipCount,
|
|
428
|
-
failed: failCount,
|
|
429
|
-
results,
|
|
422
|
+
dryRun: options.dryRun ?? false,
|
|
430
423
|
});
|
|
431
|
-
if (
|
|
424
|
+
if (plan.errors && plan.errors.length > 0) {
|
|
432
425
|
process.exit(1);
|
|
433
426
|
}
|
|
434
427
|
}
|
package/dist/logger.d.ts
CHANGED
|
@@ -1,22 +1,13 @@
|
|
|
1
1
|
import { FileStatus } from "./diff-utils.js";
|
|
2
|
-
export interface RulesetPlanCounts {
|
|
3
|
-
creates: number;
|
|
4
|
-
updates: number;
|
|
5
|
-
deletes: number;
|
|
6
|
-
unchanged: number;
|
|
7
|
-
}
|
|
8
2
|
export interface ILogger {
|
|
9
3
|
info(message: string): void;
|
|
10
4
|
fileDiff(fileName: string, status: FileStatus, diffLines: string[]): void;
|
|
11
5
|
diffSummary(newCount: number, modifiedCount: number, unchangedCount: number, deletedCount?: number): void;
|
|
12
|
-
rulesetPlan(repoName: string, planLines: string[], counts: RulesetPlanCounts): void;
|
|
13
6
|
setTotal(total: number): void;
|
|
14
7
|
progress(current: number, repoName: string, message: string): void;
|
|
15
8
|
success(current: number, repoName: string, message: string): void;
|
|
16
9
|
skip(current: number, repoName: string, reason: string): void;
|
|
17
10
|
error(current: number, repoName: string, error: string): void;
|
|
18
|
-
summary(): void;
|
|
19
|
-
hasFailures(): boolean;
|
|
20
11
|
}
|
|
21
12
|
export interface LoggerStats {
|
|
22
13
|
total: number;
|
|
@@ -41,11 +32,5 @@ export declare class Logger implements ILogger {
|
|
|
41
32
|
* Display summary statistics for dry-run diff.
|
|
42
33
|
*/
|
|
43
34
|
diffSummary(newCount: number, modifiedCount: number, unchangedCount: number, deletedCount?: number): void;
|
|
44
|
-
/**
|
|
45
|
-
* Display ruleset plan output for dry-run mode.
|
|
46
|
-
*/
|
|
47
|
-
rulesetPlan(repoName: string, planLines: string[], counts: RulesetPlanCounts): void;
|
|
48
|
-
summary(): void;
|
|
49
|
-
hasFailures(): boolean;
|
|
50
35
|
}
|
|
51
36
|
export declare const logger: Logger;
|
package/dist/logger.js
CHANGED
|
@@ -62,39 +62,5 @@ export class Logger {
|
|
|
62
62
|
console.log(chalk.gray(` Summary: ${parts.join(", ")}`));
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
-
/**
|
|
66
|
-
* Display ruleset plan output for dry-run mode.
|
|
67
|
-
*/
|
|
68
|
-
rulesetPlan(repoName, planLines, counts) {
|
|
69
|
-
console.log("");
|
|
70
|
-
console.log(chalk.bold(`Repository: ${repoName}`));
|
|
71
|
-
for (const line of planLines) {
|
|
72
|
-
console.log(line);
|
|
73
|
-
}
|
|
74
|
-
// Summary line
|
|
75
|
-
const parts = [];
|
|
76
|
-
if (counts.creates > 0)
|
|
77
|
-
parts.push(chalk.green(`${counts.creates} to create`));
|
|
78
|
-
if (counts.updates > 0)
|
|
79
|
-
parts.push(chalk.yellow(`${counts.updates} to update`));
|
|
80
|
-
if (counts.deletes > 0)
|
|
81
|
-
parts.push(chalk.red(`${counts.deletes} to delete`));
|
|
82
|
-
const unchangedPart = counts.unchanged > 0
|
|
83
|
-
? chalk.gray(` (${counts.unchanged} unchanged)`)
|
|
84
|
-
: "";
|
|
85
|
-
const summaryLine = parts.length > 0 ? parts.join(", ") + unchangedPart : "No changes";
|
|
86
|
-
console.log(chalk.gray(`Plan: ${summaryLine}`));
|
|
87
|
-
}
|
|
88
|
-
summary() {
|
|
89
|
-
console.log("");
|
|
90
|
-
console.log(chalk.bold("Summary:"));
|
|
91
|
-
console.log(` Total: ${this.stats.total}`);
|
|
92
|
-
console.log(chalk.green(` Succeeded: ${this.stats.succeeded}`));
|
|
93
|
-
console.log(chalk.yellow(` Skipped: ${this.stats.skipped}`));
|
|
94
|
-
console.log(chalk.red(` Failed: ${this.stats.failed}`));
|
|
95
|
-
}
|
|
96
|
-
hasFailures() {
|
|
97
|
-
return this.stats.failed > 0;
|
|
98
|
-
}
|
|
99
65
|
}
|
|
100
66
|
export const logger = new Logger();
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type ResourceType = "file" | "ruleset" | "setting";
|
|
2
|
+
export type ResourceAction = "create" | "update" | "delete" | "unchanged" | "skipped";
|
|
3
|
+
export interface Resource {
|
|
4
|
+
type: ResourceType;
|
|
5
|
+
repo: string;
|
|
6
|
+
name: string;
|
|
7
|
+
action: ResourceAction;
|
|
8
|
+
details?: ResourceDetails;
|
|
9
|
+
skipReason?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ResourceDetails {
|
|
12
|
+
diff?: string[];
|
|
13
|
+
properties?: PropertyChange[];
|
|
14
|
+
}
|
|
15
|
+
export interface PropertyChange {
|
|
16
|
+
path: string;
|
|
17
|
+
action: "add" | "change" | "remove";
|
|
18
|
+
oldValue?: unknown;
|
|
19
|
+
newValue?: unknown;
|
|
20
|
+
}
|
|
21
|
+
export declare function formatResourceId(resource: Resource): string;
|
|
22
|
+
export declare function formatResourceLine(resource: Resource): string;
|
|
23
|
+
export interface PlanCounts {
|
|
24
|
+
create: number;
|
|
25
|
+
update: number;
|
|
26
|
+
delete: number;
|
|
27
|
+
skipped?: number;
|
|
28
|
+
}
|
|
29
|
+
export declare function formatPlanSummary(counts: PlanCounts): string;
|
|
30
|
+
export interface Plan {
|
|
31
|
+
resources: Resource[];
|
|
32
|
+
errors?: RepoError[];
|
|
33
|
+
}
|
|
34
|
+
export interface RepoError {
|
|
35
|
+
repo: string;
|
|
36
|
+
message: string;
|
|
37
|
+
}
|
|
38
|
+
export declare function formatPlan(plan: Plan): string[];
|
|
39
|
+
export declare function printPlan(plan: Plan): void;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
export function formatResourceId(resource) {
|
|
3
|
+
return `${resource.type} "${resource.repo}/${resource.name}"`;
|
|
4
|
+
}
|
|
5
|
+
export function formatResourceLine(resource) {
|
|
6
|
+
const id = formatResourceId(resource);
|
|
7
|
+
switch (resource.action) {
|
|
8
|
+
case "create":
|
|
9
|
+
return chalk.green(`+ ${id}`);
|
|
10
|
+
case "update":
|
|
11
|
+
return chalk.yellow(`~ ${id}`);
|
|
12
|
+
case "delete":
|
|
13
|
+
return chalk.red(`- ${id}`);
|
|
14
|
+
case "skipped":
|
|
15
|
+
return chalk.gray(`⊘ ${id}`);
|
|
16
|
+
case "unchanged":
|
|
17
|
+
return chalk.gray(` ${id}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function formatPlanSummary(counts) {
|
|
21
|
+
const parts = [];
|
|
22
|
+
if (counts.create > 0) {
|
|
23
|
+
parts.push(chalk.green(`${counts.create} to create`));
|
|
24
|
+
}
|
|
25
|
+
if (counts.update > 0) {
|
|
26
|
+
parts.push(chalk.yellow(`${counts.update} to change`));
|
|
27
|
+
}
|
|
28
|
+
if (counts.delete > 0) {
|
|
29
|
+
parts.push(chalk.red(`${counts.delete} to destroy`));
|
|
30
|
+
}
|
|
31
|
+
if (parts.length === 0 && (!counts.skipped || counts.skipped === 0)) {
|
|
32
|
+
return "No changes. Your repositories match the configuration.";
|
|
33
|
+
}
|
|
34
|
+
let summary = parts.length > 0 ? `Plan: ${parts.join(", ")}` : "Plan:";
|
|
35
|
+
if (counts.skipped && counts.skipped > 0) {
|
|
36
|
+
summary += chalk.gray(` (${counts.skipped} skipped)`);
|
|
37
|
+
}
|
|
38
|
+
return summary;
|
|
39
|
+
}
|
|
40
|
+
export function formatPlan(plan) {
|
|
41
|
+
const lines = [];
|
|
42
|
+
// Filter to only changed resources
|
|
43
|
+
const changedResources = plan.resources.filter((r) => r.action !== "unchanged");
|
|
44
|
+
// Format each resource
|
|
45
|
+
for (const resource of changedResources) {
|
|
46
|
+
lines.push(formatResourceLine(resource));
|
|
47
|
+
// Add details if present (indented)
|
|
48
|
+
if (resource.details?.diff) {
|
|
49
|
+
for (const diffLine of resource.details.diff) {
|
|
50
|
+
lines.push(` ${diffLine}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Add errors
|
|
55
|
+
if (plan.errors && plan.errors.length > 0) {
|
|
56
|
+
for (const error of plan.errors) {
|
|
57
|
+
lines.push(chalk.red(`✗ ${error.repo}`));
|
|
58
|
+
lines.push(chalk.red(` Error: ${error.message}`));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Add blank line before summary
|
|
62
|
+
if (lines.length > 0) {
|
|
63
|
+
lines.push("");
|
|
64
|
+
}
|
|
65
|
+
// Count actions
|
|
66
|
+
const counts = {
|
|
67
|
+
create: plan.resources.filter((r) => r.action === "create").length,
|
|
68
|
+
update: plan.resources.filter((r) => r.action === "update").length,
|
|
69
|
+
delete: plan.resources.filter((r) => r.action === "delete").length,
|
|
70
|
+
skipped: plan.resources.filter((r) => r.action === "skipped").length,
|
|
71
|
+
};
|
|
72
|
+
lines.push(formatPlanSummary(counts));
|
|
73
|
+
// Add error count if any
|
|
74
|
+
if (plan.errors && plan.errors.length > 0) {
|
|
75
|
+
lines.push(chalk.red(`${plan.errors.length} ${plan.errors.length === 1 ? "repository" : "repositories"} failed.`));
|
|
76
|
+
}
|
|
77
|
+
return lines;
|
|
78
|
+
}
|
|
79
|
+
export function printPlan(plan) {
|
|
80
|
+
const lines = formatPlan(plan);
|
|
81
|
+
for (const line of lines) {
|
|
82
|
+
console.log(line);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Plan, Resource, PlanCounts, RepoError } from "./plan-formatter.js";
|
|
2
|
+
export type { Plan, Resource, PlanCounts, RepoError };
|
|
3
|
+
export interface PlanMarkdownOptions {
|
|
4
|
+
title: string;
|
|
5
|
+
dryRun: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare function formatPlanMarkdown(plan: Plan, options: PlanMarkdownOptions): string;
|
|
8
|
+
export declare function writePlanSummary(plan: Plan, options: PlanMarkdownOptions): void;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
2
|
+
function getActionSymbol(action) {
|
|
3
|
+
switch (action) {
|
|
4
|
+
case "create":
|
|
5
|
+
return "+";
|
|
6
|
+
case "update":
|
|
7
|
+
return "~";
|
|
8
|
+
case "delete":
|
|
9
|
+
return "-";
|
|
10
|
+
case "skipped":
|
|
11
|
+
return "⊘";
|
|
12
|
+
default:
|
|
13
|
+
return "";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function formatResourceIdPlain(resource) {
|
|
17
|
+
return `${resource.type} "${resource.repo}/${resource.name}"`;
|
|
18
|
+
}
|
|
19
|
+
function countActions(resources) {
|
|
20
|
+
return {
|
|
21
|
+
create: resources.filter((r) => r.action === "create").length,
|
|
22
|
+
update: resources.filter((r) => r.action === "update").length,
|
|
23
|
+
delete: resources.filter((r) => r.action === "delete").length,
|
|
24
|
+
skipped: resources.filter((r) => r.action === "skipped").length,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function formatPlanSummaryPlain(counts) {
|
|
28
|
+
const parts = [];
|
|
29
|
+
if (counts.create > 0)
|
|
30
|
+
parts.push(`${counts.create} to create`);
|
|
31
|
+
if (counts.update > 0)
|
|
32
|
+
parts.push(`${counts.update} to change`);
|
|
33
|
+
if (counts.delete > 0)
|
|
34
|
+
parts.push(`${counts.delete} to destroy`);
|
|
35
|
+
if (parts.length === 0) {
|
|
36
|
+
return "No changes";
|
|
37
|
+
}
|
|
38
|
+
return parts.join(", ");
|
|
39
|
+
}
|
|
40
|
+
export function formatPlanMarkdown(plan, options) {
|
|
41
|
+
const lines = [];
|
|
42
|
+
const counts = countActions(plan.resources);
|
|
43
|
+
const changedResources = plan.resources.filter((r) => r.action !== "unchanged");
|
|
44
|
+
// Title
|
|
45
|
+
const titleSuffix = options.dryRun ? " (Dry Run)" : "";
|
|
46
|
+
lines.push(`## ${options.title}${titleSuffix}`);
|
|
47
|
+
lines.push("");
|
|
48
|
+
// Dry-run warning
|
|
49
|
+
if (options.dryRun) {
|
|
50
|
+
lines.push("> [!WARNING]");
|
|
51
|
+
lines.push("> This was a dry run — no changes were applied");
|
|
52
|
+
lines.push("");
|
|
53
|
+
}
|
|
54
|
+
// Plan summary as heading
|
|
55
|
+
const summaryText = formatPlanSummaryPlain(counts);
|
|
56
|
+
lines.push(`### Plan: ${summaryText}`);
|
|
57
|
+
lines.push("");
|
|
58
|
+
// Resource table (if any changes)
|
|
59
|
+
if (changedResources.length > 0) {
|
|
60
|
+
lines.push("<details open>");
|
|
61
|
+
lines.push("<summary><strong>Resources</strong></summary>");
|
|
62
|
+
lines.push("");
|
|
63
|
+
lines.push("| Resource | Action |");
|
|
64
|
+
lines.push("|----------|--------|");
|
|
65
|
+
for (const resource of changedResources) {
|
|
66
|
+
const symbol = getActionSymbol(resource.action);
|
|
67
|
+
const id = formatResourceIdPlain(resource);
|
|
68
|
+
lines.push(`| \`${symbol} ${id}\` | ${resource.action} |`);
|
|
69
|
+
}
|
|
70
|
+
lines.push("");
|
|
71
|
+
lines.push("</details>");
|
|
72
|
+
}
|
|
73
|
+
// Add diff details for resources that have them
|
|
74
|
+
const resourcesWithDiffs = changedResources.filter((r) => r.details?.diff && r.details.diff.length > 0);
|
|
75
|
+
for (const resource of resourcesWithDiffs) {
|
|
76
|
+
lines.push("");
|
|
77
|
+
lines.push("<details>");
|
|
78
|
+
lines.push(`<summary><strong>Diff: ${formatResourceIdPlain(resource)}</strong></summary>`);
|
|
79
|
+
lines.push("");
|
|
80
|
+
lines.push("```diff");
|
|
81
|
+
for (const diffLine of resource.details.diff) {
|
|
82
|
+
lines.push(diffLine);
|
|
83
|
+
}
|
|
84
|
+
lines.push("```");
|
|
85
|
+
lines.push("");
|
|
86
|
+
lines.push("</details>");
|
|
87
|
+
}
|
|
88
|
+
// Error section
|
|
89
|
+
if (plan.errors && plan.errors.length > 0) {
|
|
90
|
+
lines.push("");
|
|
91
|
+
lines.push("<details open>");
|
|
92
|
+
lines.push("<summary><strong>Errors</strong></summary>");
|
|
93
|
+
lines.push("");
|
|
94
|
+
lines.push("| Repository | Error |");
|
|
95
|
+
lines.push("|------------|-------|");
|
|
96
|
+
for (const error of plan.errors) {
|
|
97
|
+
lines.push(`| ${error.repo} | ${error.message} |`);
|
|
98
|
+
}
|
|
99
|
+
lines.push("");
|
|
100
|
+
lines.push("</details>");
|
|
101
|
+
}
|
|
102
|
+
return lines.join("\n");
|
|
103
|
+
}
|
|
104
|
+
export function writePlanSummary(plan, options) {
|
|
105
|
+
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
|
|
106
|
+
if (!summaryPath)
|
|
107
|
+
return;
|
|
108
|
+
const markdown = formatPlanMarkdown(plan, options);
|
|
109
|
+
appendFileSync(summaryPath, "\n" + markdown + "\n");
|
|
110
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Resource } from "./plan-formatter.js";
|
|
2
|
+
import type { RulesetProcessorResult } from "./ruleset-processor.js";
|
|
3
|
+
import type { ProcessorResult } from "./repository-processor.js";
|
|
4
|
+
import type { RepoConfig } from "./config.js";
|
|
5
|
+
/**
|
|
6
|
+
* Convert RulesetProcessorResult planOutput entries to Resource objects.
|
|
7
|
+
* Includes the detailed plan lines in the first resource's details for display.
|
|
8
|
+
*/
|
|
9
|
+
export declare function rulesetResultToResources(repoName: string, result: RulesetProcessorResult): Resource[];
|
|
10
|
+
/**
|
|
11
|
+
* Convert sync ProcessorResult diffStats to Resource objects.
|
|
12
|
+
* Since we don't have per-file details, we represent each file from config
|
|
13
|
+
* with the aggregate action based on diffStats.
|
|
14
|
+
*/
|
|
15
|
+
export declare function syncResultToResources(repoName: string, repoConfig: Pick<RepoConfig, "files">, result: ProcessorResult): Resource[];
|
|
16
|
+
/**
|
|
17
|
+
* Convert repo settings processor planOutput entries to Resource objects.
|
|
18
|
+
* Includes the detailed plan lines in the first resource's details for display.
|
|
19
|
+
*/
|
|
20
|
+
export declare function repoSettingsResultToResources(repoName: string, result: {
|
|
21
|
+
planOutput?: {
|
|
22
|
+
entries?: Array<{
|
|
23
|
+
property: string;
|
|
24
|
+
action: string;
|
|
25
|
+
}>;
|
|
26
|
+
lines?: string[];
|
|
27
|
+
};
|
|
28
|
+
}): Resource[];
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert RulesetProcessorResult planOutput entries to Resource objects.
|
|
3
|
+
* Includes the detailed plan lines in the first resource's details for display.
|
|
4
|
+
*/
|
|
5
|
+
export function rulesetResultToResources(repoName, result) {
|
|
6
|
+
const resources = [];
|
|
7
|
+
const planLines = result.planOutput?.lines ?? [];
|
|
8
|
+
if (result.planOutput?.entries) {
|
|
9
|
+
for (let i = 0; i < result.planOutput.entries.length; i++) {
|
|
10
|
+
const entry = result.planOutput.entries[i];
|
|
11
|
+
let action;
|
|
12
|
+
switch (entry.action) {
|
|
13
|
+
case "create":
|
|
14
|
+
action = "create";
|
|
15
|
+
break;
|
|
16
|
+
case "update":
|
|
17
|
+
action = "update";
|
|
18
|
+
break;
|
|
19
|
+
case "delete":
|
|
20
|
+
action = "delete";
|
|
21
|
+
break;
|
|
22
|
+
default:
|
|
23
|
+
action = "unchanged";
|
|
24
|
+
}
|
|
25
|
+
// Attach all plan lines to first resource for GitHub summary display
|
|
26
|
+
const details = i === 0 && planLines.length > 0 ? { diff: planLines } : undefined;
|
|
27
|
+
resources.push({
|
|
28
|
+
type: "ruleset",
|
|
29
|
+
repo: repoName,
|
|
30
|
+
name: entry.name,
|
|
31
|
+
action,
|
|
32
|
+
details,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return resources;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Convert sync ProcessorResult diffStats to Resource objects.
|
|
40
|
+
* Since we don't have per-file details, we represent each file from config
|
|
41
|
+
* with the aggregate action based on diffStats.
|
|
42
|
+
*/
|
|
43
|
+
export function syncResultToResources(repoName, repoConfig, result) {
|
|
44
|
+
const resources = [];
|
|
45
|
+
if (result.skipped) {
|
|
46
|
+
// Mark all files as unchanged when skipped
|
|
47
|
+
for (const file of repoConfig.files) {
|
|
48
|
+
resources.push({
|
|
49
|
+
type: "file",
|
|
50
|
+
repo: repoName,
|
|
51
|
+
name: file.fileName,
|
|
52
|
+
action: "unchanged",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return resources;
|
|
56
|
+
}
|
|
57
|
+
if (!result.diffStats) {
|
|
58
|
+
return resources;
|
|
59
|
+
}
|
|
60
|
+
// With aggregate stats, we can show repo-level summary
|
|
61
|
+
// For now, create one resource per file in config with best-effort action
|
|
62
|
+
// Note: This is approximate since we don't have per-file tracking
|
|
63
|
+
const { newCount, modifiedCount, deletedCount } = result.diffStats;
|
|
64
|
+
for (const file of repoConfig.files) {
|
|
65
|
+
// Determine action based on aggregate stats - this is a simplification
|
|
66
|
+
let action = "unchanged";
|
|
67
|
+
if (newCount > 0) {
|
|
68
|
+
action = "create";
|
|
69
|
+
}
|
|
70
|
+
else if (modifiedCount > 0) {
|
|
71
|
+
action = "update";
|
|
72
|
+
}
|
|
73
|
+
else if (deletedCount > 0) {
|
|
74
|
+
action = "delete";
|
|
75
|
+
}
|
|
76
|
+
resources.push({
|
|
77
|
+
type: "file",
|
|
78
|
+
repo: repoName,
|
|
79
|
+
name: file.fileName,
|
|
80
|
+
action,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return resources;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Convert repo settings processor planOutput entries to Resource objects.
|
|
87
|
+
* Includes the detailed plan lines in the first resource's details for display.
|
|
88
|
+
*/
|
|
89
|
+
export function repoSettingsResultToResources(repoName, result) {
|
|
90
|
+
const resources = [];
|
|
91
|
+
const planLines = result.planOutput?.lines ?? [];
|
|
92
|
+
if (result.planOutput?.entries) {
|
|
93
|
+
for (let i = 0; i < result.planOutput.entries.length; i++) {
|
|
94
|
+
const entry = result.planOutput.entries[i];
|
|
95
|
+
// Attach all plan lines to first resource for GitHub summary display
|
|
96
|
+
const details = i === 0 && planLines.length > 0 ? { diff: planLines } : undefined;
|
|
97
|
+
resources.push({
|
|
98
|
+
type: "setting",
|
|
99
|
+
repo: repoName,
|
|
100
|
+
name: entry.property,
|
|
101
|
+
action: entry.action === "add" ? "create" : "update",
|
|
102
|
+
details,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return resources;
|
|
107
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aspruyt/xfg",
|
|
3
|
-
"version": "3.7.
|
|
3
|
+
"version": "3.7.5",
|
|
4
4
|
"description": "CLI tool for repository-as-code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"start": "node dist/cli.js",
|
|
28
28
|
"dev": "ts-node src/cli.ts",
|
|
29
29
|
"test": "node --import tsx scripts/run-tests.js",
|
|
30
|
-
"test:coverage": "c8 --check-coverage --lines 95 --reporter=text --reporter=lcov --all --src=src --exclude='test/**/*.test.ts' --exclude='scripts/**' npm test",
|
|
30
|
+
"test:coverage": "c8 --check-coverage --lines 95 --reporter=text --reporter=lcov --all --src=src --exclude='test/**/*.test.ts' --exclude='scripts/**' --exclude='src/strategies/commit-strategy.ts' --exclude='src/strategies/ruleset-strategy.ts' --exclude='test/mocks/**' npm test",
|
|
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",
|