@aspruyt/xfg 3.8.2 → 3.9.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/dist/cli/settings-command.d.ts +2 -1
- package/dist/cli/settings-command.js +82 -7
- package/dist/cli/sync-command.d.ts +2 -1
- package/dist/cli/sync-command.js +77 -3
- package/dist/config/normalizer.js +2 -0
- package/dist/config/types.d.ts +9 -0
- package/dist/config/validator.js +62 -0
- package/dist/lifecycle/ado-migration-source.d.ts +15 -0
- package/dist/lifecycle/ado-migration-source.js +37 -0
- package/dist/lifecycle/github-lifecycle-provider.d.ts +56 -0
- package/dist/lifecycle/github-lifecycle-provider.js +314 -0
- package/dist/lifecycle/index.d.ts +7 -0
- package/dist/lifecycle/index.js +6 -0
- package/dist/lifecycle/lifecycle-formatter.d.ts +14 -0
- package/dist/lifecycle/lifecycle-formatter.js +34 -0
- package/dist/lifecycle/lifecycle-helpers.d.ts +27 -0
- package/dist/lifecycle/lifecycle-helpers.js +47 -0
- package/dist/lifecycle/repo-lifecycle-factory.d.ts +14 -0
- package/dist/lifecycle/repo-lifecycle-factory.js +57 -0
- package/dist/lifecycle/repo-lifecycle-manager.d.ts +20 -0
- package/dist/lifecycle/repo-lifecycle-manager.js +139 -0
- package/dist/lifecycle/types.d.ts +104 -0
- package/dist/lifecycle/types.js +1 -0
- package/dist/output/lifecycle-report.d.ts +37 -0
- package/dist/output/lifecycle-report.js +152 -0
- package/dist/output/settings-report.js +36 -28
- package/dist/output/sync-report.js +15 -8
- package/dist/settings/repo-settings/diff.js +1 -0
- package/dist/settings/repo-settings/github-repo-settings-strategy.js +1 -0
- package/dist/shared/logger.d.ts +2 -0
- package/dist/shared/logger.js +5 -0
- package/dist/sync/manifest-strategy.js +3 -1
- package/dist/vcs/authenticated-git-ops.js +1 -1
- package/dist/vcs/git-ops.js +1 -1
- package/package.json +4 -2
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { rm } from "node:fs/promises";
|
|
3
|
+
import { parseGitUrl } from "../shared/repo-detector.js";
|
|
4
|
+
import { logger } from "../shared/logger.js";
|
|
5
|
+
import { RepoLifecycleFactory } from "./repo-lifecycle-factory.js";
|
|
6
|
+
/**
|
|
7
|
+
* Orchestrates repo lifecycle operations before sync.
|
|
8
|
+
*/
|
|
9
|
+
export class RepoLifecycleManager {
|
|
10
|
+
factory;
|
|
11
|
+
constructor(factory, retries) {
|
|
12
|
+
this.factory = factory ?? new RepoLifecycleFactory(undefined, retries);
|
|
13
|
+
}
|
|
14
|
+
async ensureRepo(repoConfig, repoInfo, options, settings) {
|
|
15
|
+
let provider;
|
|
16
|
+
try {
|
|
17
|
+
provider = this.factory.getProvider(repoInfo.type);
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
// If user explicitly configured lifecycle (upstream/source), propagate the error
|
|
21
|
+
if (repoConfig.upstream || repoConfig.source) {
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
// Platform doesn't support lifecycle operations yet - skip silently
|
|
25
|
+
return { repoInfo, action: "existed" };
|
|
26
|
+
}
|
|
27
|
+
const { token } = options;
|
|
28
|
+
// Check if repo exists
|
|
29
|
+
const exists = await provider.exists(repoInfo, token);
|
|
30
|
+
if (exists) {
|
|
31
|
+
// Repo exists - nothing to do (ignore upstream/source)
|
|
32
|
+
return {
|
|
33
|
+
repoInfo,
|
|
34
|
+
action: "existed",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
// Repo doesn't exist - determine what action to take
|
|
38
|
+
if (repoConfig.source) {
|
|
39
|
+
// Migration mode
|
|
40
|
+
return this.migrate(repoConfig, repoInfo, options, settings);
|
|
41
|
+
}
|
|
42
|
+
if (repoConfig.upstream) {
|
|
43
|
+
// Fork mode
|
|
44
|
+
return this.fork(repoConfig, repoInfo, provider, options, settings);
|
|
45
|
+
}
|
|
46
|
+
// Create mode (no upstream or source)
|
|
47
|
+
return this.create(repoInfo, provider, options, settings);
|
|
48
|
+
}
|
|
49
|
+
async create(repoInfo, provider, options, settings) {
|
|
50
|
+
if (options.dryRun) {
|
|
51
|
+
return {
|
|
52
|
+
repoInfo,
|
|
53
|
+
action: "created",
|
|
54
|
+
skipped: true,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
await provider.create(repoInfo, settings, options.token);
|
|
58
|
+
await this.waitForRepoReady(provider, repoInfo, options.token);
|
|
59
|
+
return {
|
|
60
|
+
repoInfo,
|
|
61
|
+
action: "created",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
async fork(repoConfig, repoInfo, provider, options, settings) {
|
|
65
|
+
if (options.dryRun) {
|
|
66
|
+
return {
|
|
67
|
+
repoInfo,
|
|
68
|
+
action: "forked",
|
|
69
|
+
skipped: true,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (!provider.fork) {
|
|
73
|
+
throw new Error(`Platform '${repoInfo.type}' does not support forking`);
|
|
74
|
+
}
|
|
75
|
+
// Parse upstream URL to get repo info
|
|
76
|
+
const upstreamInfo = parseGitUrl(repoConfig.upstream, {
|
|
77
|
+
githubHosts: options.githubHosts,
|
|
78
|
+
});
|
|
79
|
+
await provider.fork(upstreamInfo, repoInfo, settings, options.token);
|
|
80
|
+
await this.waitForRepoReady(provider, repoInfo, options.token);
|
|
81
|
+
return {
|
|
82
|
+
repoInfo,
|
|
83
|
+
action: "forked",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
async migrate(repoConfig, repoInfo, options, settings) {
|
|
87
|
+
if (options.dryRun) {
|
|
88
|
+
return {
|
|
89
|
+
repoInfo,
|
|
90
|
+
action: "migrated",
|
|
91
|
+
skipped: true,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
// Parse source URL to get platform and repo info
|
|
95
|
+
const sourceInfo = parseGitUrl(repoConfig.source, {
|
|
96
|
+
githubHosts: options.githubHosts,
|
|
97
|
+
});
|
|
98
|
+
const source = this.factory.getMigrationSource(sourceInfo.type);
|
|
99
|
+
// Clone source repo to temp directory
|
|
100
|
+
const sourceDir = join(options.workDir, "migration-source");
|
|
101
|
+
try {
|
|
102
|
+
await source.cloneForMigration(sourceInfo, sourceDir);
|
|
103
|
+
// Create target and push content
|
|
104
|
+
const provider = this.factory.getProvider(repoInfo.type);
|
|
105
|
+
await provider.receiveMigration(repoInfo, sourceDir, settings, options.token);
|
|
106
|
+
await this.waitForRepoReady(provider, repoInfo, options.token);
|
|
107
|
+
return {
|
|
108
|
+
repoInfo,
|
|
109
|
+
action: "migrated",
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
// Clean up migration source directory
|
|
114
|
+
try {
|
|
115
|
+
await rm(sourceDir, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
catch (cleanupError) {
|
|
118
|
+
// Log cleanup errors at debug level for troubleshooting
|
|
119
|
+
logger.debug(`Failed to clean up migration source directory ${sourceDir}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Polls provider.exists() until the repo is visible, with timeout.
|
|
125
|
+
* GitHub's API may return success from create/fork before the git
|
|
126
|
+
* backend has fully propagated, causing subsequent clone to 403.
|
|
127
|
+
*/
|
|
128
|
+
async waitForRepoReady(provider, repoInfo, token, timeoutMs = 15000, pollMs = 1000) {
|
|
129
|
+
const start = Date.now();
|
|
130
|
+
while (Date.now() - start < timeoutMs) {
|
|
131
|
+
if (await provider.exists(repoInfo, token)) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
135
|
+
}
|
|
136
|
+
// Timed out — proceed anyway and let downstream operations handle it
|
|
137
|
+
logger.info(`Repo ${repoInfo.owner}/${repoInfo.repo} not yet visible after ${timeoutMs}ms, proceeding`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { RepoInfo } from "../shared/repo-detector.js";
|
|
2
|
+
import type { RepoConfig } from "../config/types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Supported platforms for lifecycle operations.
|
|
5
|
+
*/
|
|
6
|
+
export type LifecyclePlatform = "github" | "azure-devops" | "gitlab";
|
|
7
|
+
/**
|
|
8
|
+
* Result of a lifecycle operation.
|
|
9
|
+
*/
|
|
10
|
+
export interface LifecycleResult {
|
|
11
|
+
/** The repo info (may be updated) */
|
|
12
|
+
repoInfo: RepoInfo;
|
|
13
|
+
/** What action was taken */
|
|
14
|
+
action: "existed" | "created" | "forked" | "migrated";
|
|
15
|
+
/** True if skipped due to dry-run */
|
|
16
|
+
skipped?: boolean;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Options for lifecycle operations.
|
|
20
|
+
*/
|
|
21
|
+
export interface LifecycleOptions {
|
|
22
|
+
/** Dry-run mode - don't make changes */
|
|
23
|
+
dryRun: boolean;
|
|
24
|
+
/** Working directory for git operations */
|
|
25
|
+
workDir: string;
|
|
26
|
+
/** GitHub Enterprise hostnames for URL detection */
|
|
27
|
+
githubHosts?: string[];
|
|
28
|
+
/** Auth token (GitHub App installation token or PAT) for gh CLI commands */
|
|
29
|
+
token?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Repo settings to apply when creating a new repo.
|
|
33
|
+
* Subset of GitHubRepoSettings that makes sense for creation.
|
|
34
|
+
*/
|
|
35
|
+
export interface CreateRepoSettings {
|
|
36
|
+
visibility?: "public" | "private" | "internal";
|
|
37
|
+
description?: string;
|
|
38
|
+
hasIssues?: boolean;
|
|
39
|
+
hasWiki?: boolean;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Provider for platform-specific lifecycle operations.
|
|
43
|
+
* Implementations handle create/fork/receive for a specific platform.
|
|
44
|
+
*/
|
|
45
|
+
export interface IRepoLifecycleProvider {
|
|
46
|
+
/** Platform this provider handles */
|
|
47
|
+
readonly platform: LifecyclePlatform;
|
|
48
|
+
/**
|
|
49
|
+
* Check if a repository exists on this platform.
|
|
50
|
+
* @throws Error on network/auth failures (NOT for "repo not found")
|
|
51
|
+
*/
|
|
52
|
+
exists(repoInfo: RepoInfo, token?: string): Promise<boolean>;
|
|
53
|
+
/**
|
|
54
|
+
* Create an empty repository.
|
|
55
|
+
*/
|
|
56
|
+
create(repoInfo: RepoInfo, settings?: CreateRepoSettings, token?: string): Promise<void>;
|
|
57
|
+
/**
|
|
58
|
+
* Fork from an upstream repository.
|
|
59
|
+
* Optional - not all platforms support forking.
|
|
60
|
+
*/
|
|
61
|
+
fork?(upstream: RepoInfo, target: RepoInfo, settings?: CreateRepoSettings, token?: string): Promise<void>;
|
|
62
|
+
/**
|
|
63
|
+
* Receive migrated content (repo already created, push content).
|
|
64
|
+
*/
|
|
65
|
+
receiveMigration(repoInfo: RepoInfo, sourceDir: string, settings?: CreateRepoSettings, token?: string): Promise<void>;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Source for migration operations.
|
|
69
|
+
* Implementations handle cloning from a source platform.
|
|
70
|
+
*/
|
|
71
|
+
export interface IMigrationSource {
|
|
72
|
+
/** Platform this source handles */
|
|
73
|
+
readonly platform: LifecyclePlatform;
|
|
74
|
+
/**
|
|
75
|
+
* Clone repository with all refs for migration.
|
|
76
|
+
* Uses --mirror to get all branches/tags.
|
|
77
|
+
*/
|
|
78
|
+
cloneForMigration(repoInfo: RepoInfo, workDir: string): Promise<void>;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Factory for getting providers by platform.
|
|
82
|
+
*/
|
|
83
|
+
export interface IRepoLifecycleFactory {
|
|
84
|
+
/**
|
|
85
|
+
* Get lifecycle provider for a platform.
|
|
86
|
+
* @throws Error if platform not supported as target
|
|
87
|
+
*/
|
|
88
|
+
getProvider(platform: LifecyclePlatform): IRepoLifecycleProvider;
|
|
89
|
+
/**
|
|
90
|
+
* Get migration source for a platform.
|
|
91
|
+
* @throws Error if platform not supported as source
|
|
92
|
+
*/
|
|
93
|
+
getMigrationSource(platform: LifecyclePlatform): IMigrationSource;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Manager that orchestrates lifecycle operations before sync.
|
|
97
|
+
*/
|
|
98
|
+
export interface IRepoLifecycleManager {
|
|
99
|
+
/**
|
|
100
|
+
* Ensure repository exists, creating/forking/migrating if needed.
|
|
101
|
+
* Call this before sync/settings operations.
|
|
102
|
+
*/
|
|
103
|
+
ensureRepo(repoConfig: RepoConfig, repoInfo: RepoInfo, options: LifecycleOptions, settings?: CreateRepoSettings): Promise<LifecycleResult>;
|
|
104
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface LifecycleReport {
|
|
2
|
+
actions: LifecycleAction[];
|
|
3
|
+
totals: {
|
|
4
|
+
created: number;
|
|
5
|
+
forked: number;
|
|
6
|
+
migrated: number;
|
|
7
|
+
existed: number;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export interface LifecycleAction {
|
|
11
|
+
repoName: string;
|
|
12
|
+
action: "existed" | "created" | "forked" | "migrated";
|
|
13
|
+
upstream?: string;
|
|
14
|
+
source?: string;
|
|
15
|
+
settings?: {
|
|
16
|
+
visibility?: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export interface LifecycleReportInput {
|
|
21
|
+
repoName: string;
|
|
22
|
+
action: "existed" | "created" | "forked" | "migrated";
|
|
23
|
+
upstream?: string;
|
|
24
|
+
source?: string;
|
|
25
|
+
settings?: {
|
|
26
|
+
visibility?: string;
|
|
27
|
+
description?: string;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export declare function buildLifecycleReport(results: LifecycleReportInput[]): LifecycleReport;
|
|
31
|
+
/**
|
|
32
|
+
* Returns true if the report has any non-"existed" actions worth displaying.
|
|
33
|
+
*/
|
|
34
|
+
export declare function hasLifecycleChanges(report: LifecycleReport): boolean;
|
|
35
|
+
export declare function formatLifecycleReportCLI(report: LifecycleReport): string[];
|
|
36
|
+
export declare function formatLifecycleReportMarkdown(report: LifecycleReport, dryRun: boolean): string;
|
|
37
|
+
export declare function writeLifecycleReportSummary(report: LifecycleReport, dryRun: boolean): void;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// src/output/lifecycle-report.ts
|
|
2
|
+
import { appendFileSync } from "node:fs";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
function escapeHtml(text) {
|
|
5
|
+
return text
|
|
6
|
+
.replace(/&/g, "&")
|
|
7
|
+
.replace(/</g, "<")
|
|
8
|
+
.replace(/>/g, ">");
|
|
9
|
+
}
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// Builder
|
|
12
|
+
// =============================================================================
|
|
13
|
+
export function buildLifecycleReport(results) {
|
|
14
|
+
const actions = [];
|
|
15
|
+
const totals = { created: 0, forked: 0, migrated: 0, existed: 0 };
|
|
16
|
+
for (const result of results) {
|
|
17
|
+
actions.push({
|
|
18
|
+
repoName: result.repoName,
|
|
19
|
+
action: result.action,
|
|
20
|
+
upstream: result.upstream,
|
|
21
|
+
source: result.source,
|
|
22
|
+
settings: result.settings,
|
|
23
|
+
});
|
|
24
|
+
totals[result.action]++;
|
|
25
|
+
}
|
|
26
|
+
return { actions, totals };
|
|
27
|
+
}
|
|
28
|
+
// =============================================================================
|
|
29
|
+
// Helpers
|
|
30
|
+
// =============================================================================
|
|
31
|
+
function formatSummary(totals) {
|
|
32
|
+
const total = totals.created + totals.forked + totals.migrated;
|
|
33
|
+
if (total === 0) {
|
|
34
|
+
return "No changes";
|
|
35
|
+
}
|
|
36
|
+
const parts = [];
|
|
37
|
+
if (totals.created > 0)
|
|
38
|
+
parts.push(`${totals.created} to create`);
|
|
39
|
+
if (totals.forked > 0)
|
|
40
|
+
parts.push(`${totals.forked} to fork`);
|
|
41
|
+
if (totals.migrated > 0)
|
|
42
|
+
parts.push(`${totals.migrated} to migrate`);
|
|
43
|
+
const repoWord = total === 1 ? "repo" : "repos";
|
|
44
|
+
return `Plan: ${total} ${repoWord} (${parts.join(", ")})`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Returns true if the report has any non-"existed" actions worth displaying.
|
|
48
|
+
*/
|
|
49
|
+
export function hasLifecycleChanges(report) {
|
|
50
|
+
return report.actions.some((a) => a.action !== "existed");
|
|
51
|
+
}
|
|
52
|
+
// =============================================================================
|
|
53
|
+
// CLI Formatter
|
|
54
|
+
// =============================================================================
|
|
55
|
+
export function formatLifecycleReportCLI(report) {
|
|
56
|
+
if (!hasLifecycleChanges(report)) {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
const lines = [];
|
|
60
|
+
for (const action of report.actions) {
|
|
61
|
+
if (action.action === "existed")
|
|
62
|
+
continue;
|
|
63
|
+
switch (action.action) {
|
|
64
|
+
case "created":
|
|
65
|
+
lines.push(chalk.green(`+ CREATE ${action.repoName}`));
|
|
66
|
+
break;
|
|
67
|
+
case "forked":
|
|
68
|
+
lines.push(chalk.green(`+ FORK ${action.upstream ?? "upstream"} -> ${action.repoName}`));
|
|
69
|
+
break;
|
|
70
|
+
case "migrated":
|
|
71
|
+
lines.push(chalk.green(`+ MIGRATE ${action.source ?? "source"} -> ${action.repoName}`));
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
if (action.settings) {
|
|
75
|
+
if (action.settings.visibility) {
|
|
76
|
+
lines.push(` visibility: ${action.settings.visibility}`);
|
|
77
|
+
}
|
|
78
|
+
if (action.settings.description) {
|
|
79
|
+
lines.push(` description: "${action.settings.description}"`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
lines.push("");
|
|
84
|
+
// Summary
|
|
85
|
+
lines.push(formatSummary(report.totals));
|
|
86
|
+
return lines;
|
|
87
|
+
}
|
|
88
|
+
// =============================================================================
|
|
89
|
+
// Markdown Formatter
|
|
90
|
+
// =============================================================================
|
|
91
|
+
export function formatLifecycleReportMarkdown(report, dryRun) {
|
|
92
|
+
if (!hasLifecycleChanges(report)) {
|
|
93
|
+
return "";
|
|
94
|
+
}
|
|
95
|
+
const lines = [];
|
|
96
|
+
// Title
|
|
97
|
+
const titleSuffix = dryRun ? " (Dry Run)" : "";
|
|
98
|
+
lines.push(`## Lifecycle Summary${titleSuffix}`);
|
|
99
|
+
lines.push("");
|
|
100
|
+
// Dry-run warning
|
|
101
|
+
if (dryRun) {
|
|
102
|
+
lines.push("> [!WARNING]");
|
|
103
|
+
lines.push("> This was a dry run — no changes were applied");
|
|
104
|
+
lines.push("");
|
|
105
|
+
}
|
|
106
|
+
// Colored diff output using HTML <pre> with inline styles
|
|
107
|
+
const diffLines = [];
|
|
108
|
+
for (const action of report.actions) {
|
|
109
|
+
if (action.action === "existed")
|
|
110
|
+
continue;
|
|
111
|
+
switch (action.action) {
|
|
112
|
+
case "created":
|
|
113
|
+
diffLines.push(`<span style="color:#3fb950">+ CREATE ${escapeHtml(action.repoName)}</span>`);
|
|
114
|
+
break;
|
|
115
|
+
case "forked":
|
|
116
|
+
diffLines.push(`<span style="color:#3fb950">+ FORK ${escapeHtml(action.upstream ?? "upstream")} -> ${escapeHtml(action.repoName)}</span>`);
|
|
117
|
+
break;
|
|
118
|
+
case "migrated":
|
|
119
|
+
diffLines.push(`<span style="color:#3fb950">+ MIGRATE ${escapeHtml(action.source ?? "source")} -> ${escapeHtml(action.repoName)}</span>`);
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
if (action.settings) {
|
|
123
|
+
if (action.settings.visibility) {
|
|
124
|
+
diffLines.push(`<span style="color:#3fb950"> visibility: ${escapeHtml(action.settings.visibility)}</span>`);
|
|
125
|
+
}
|
|
126
|
+
if (action.settings.description) {
|
|
127
|
+
diffLines.push(`<span style="color:#3fb950"> description: "${escapeHtml(action.settings.description)}"</span>`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (diffLines.length > 0) {
|
|
132
|
+
lines.push("<pre>");
|
|
133
|
+
lines.push(...diffLines);
|
|
134
|
+
lines.push("</pre>");
|
|
135
|
+
lines.push("");
|
|
136
|
+
}
|
|
137
|
+
// Summary
|
|
138
|
+
lines.push(`**${formatSummary(report.totals)}**`);
|
|
139
|
+
return lines.join("\n");
|
|
140
|
+
}
|
|
141
|
+
// =============================================================================
|
|
142
|
+
// File Writer
|
|
143
|
+
// =============================================================================
|
|
144
|
+
export function writeLifecycleReportSummary(report, dryRun) {
|
|
145
|
+
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
|
|
146
|
+
if (!summaryPath)
|
|
147
|
+
return;
|
|
148
|
+
const markdown = formatLifecycleReportMarkdown(report, dryRun);
|
|
149
|
+
if (!markdown)
|
|
150
|
+
return;
|
|
151
|
+
appendFileSync(summaryPath, "\n" + markdown + "\n");
|
|
152
|
+
}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { appendFileSync } from "node:fs";
|
|
2
2
|
import chalk from "chalk";
|
|
3
3
|
import { formatPropertyTree, } from "../settings/rulesets/formatter.js";
|
|
4
|
+
function escapeHtml(text) {
|
|
5
|
+
return text
|
|
6
|
+
.replace(/&/g, "&")
|
|
7
|
+
.replace(/</g, "<")
|
|
8
|
+
.replace(/>/g, ">");
|
|
9
|
+
}
|
|
4
10
|
// =============================================================================
|
|
5
11
|
// Helpers
|
|
6
12
|
// =============================================================================
|
|
@@ -164,52 +170,52 @@ function formatValuePlain(val) {
|
|
|
164
170
|
return val ? "true" : "false";
|
|
165
171
|
return String(val);
|
|
166
172
|
}
|
|
167
|
-
function formatRulesetConfigPlain(config
|
|
173
|
+
function formatRulesetConfigPlain(config) {
|
|
168
174
|
const lines = [];
|
|
169
|
-
function renderObject(obj,
|
|
175
|
+
function renderObject(obj, depth) {
|
|
170
176
|
for (const [k, v] of Object.entries(obj)) {
|
|
171
|
-
renderValue(k, v,
|
|
177
|
+
renderValue(k, v, depth);
|
|
172
178
|
}
|
|
173
179
|
}
|
|
174
|
-
function renderValue(key, value,
|
|
175
|
-
const
|
|
180
|
+
function renderValue(key, value, depth) {
|
|
181
|
+
const indent = " ".repeat(depth);
|
|
176
182
|
if (value === null || value === undefined)
|
|
177
183
|
return;
|
|
178
184
|
if (Array.isArray(value)) {
|
|
179
185
|
if (value.length === 0) {
|
|
180
|
-
lines.push(
|
|
186
|
+
lines.push(`+${indent} ${key}: []`);
|
|
181
187
|
}
|
|
182
188
|
else if (value.every((v) => typeof v !== "object")) {
|
|
183
|
-
lines.push(
|
|
189
|
+
lines.push(`+${indent} ${key}: [${value.map((v) => (typeof v === "string" ? `"${v}"` : String(v))).join(", ")}]`);
|
|
184
190
|
}
|
|
185
191
|
else {
|
|
186
|
-
lines.push(
|
|
192
|
+
lines.push(`+${indent} ${key}:`);
|
|
187
193
|
for (let i = 0; i < value.length; i++) {
|
|
188
194
|
const item = value[i];
|
|
189
195
|
if (typeof item === "object" && item !== null) {
|
|
190
196
|
const obj = item;
|
|
191
197
|
const typeLabel = "type" in obj ? ` (${obj.type})` : "";
|
|
192
|
-
lines.push(
|
|
193
|
-
renderObject(obj,
|
|
198
|
+
lines.push(`+${indent} [${i}]${typeLabel}:`);
|
|
199
|
+
renderObject(obj, depth + 2);
|
|
194
200
|
}
|
|
195
201
|
else {
|
|
196
|
-
lines.push(
|
|
202
|
+
lines.push(`+${indent} ${formatValuePlain(item)}`);
|
|
197
203
|
}
|
|
198
204
|
}
|
|
199
205
|
}
|
|
200
206
|
}
|
|
201
207
|
else if (typeof value === "object") {
|
|
202
|
-
lines.push(
|
|
203
|
-
renderObject(value,
|
|
208
|
+
lines.push(`+${indent} ${key}:`);
|
|
209
|
+
renderObject(value, depth + 1);
|
|
204
210
|
}
|
|
205
211
|
else {
|
|
206
|
-
lines.push(
|
|
212
|
+
lines.push(`+${indent} ${key}: ${formatValuePlain(value)}`);
|
|
207
213
|
}
|
|
208
214
|
}
|
|
209
215
|
for (const [key, value] of Object.entries(config)) {
|
|
210
216
|
if (key === "name")
|
|
211
217
|
continue;
|
|
212
|
-
renderValue(key, value,
|
|
218
|
+
renderValue(key, value, 1);
|
|
213
219
|
}
|
|
214
220
|
return lines;
|
|
215
221
|
}
|
|
@@ -233,55 +239,57 @@ export function formatSettingsReportMarkdown(report, dryRun) {
|
|
|
233
239
|
!repo.error) {
|
|
234
240
|
continue;
|
|
235
241
|
}
|
|
236
|
-
diffLines.push(
|
|
242
|
+
diffLines.push(`<span style="color:#d29922">~ ${escapeHtml(repo.repoName)}</span>`);
|
|
237
243
|
for (const setting of repo.settings) {
|
|
238
244
|
// Skip settings where both values are undefined
|
|
239
245
|
if (setting.oldValue === undefined && setting.newValue === undefined) {
|
|
240
246
|
continue;
|
|
241
247
|
}
|
|
242
248
|
if (setting.action === "add") {
|
|
243
|
-
diffLines.push(
|
|
249
|
+
diffLines.push(`<span style="color:#3fb950"> + ${escapeHtml(setting.name)}: ${escapeHtml(formatValuePlain(setting.newValue))}</span>`);
|
|
244
250
|
}
|
|
245
251
|
else {
|
|
246
|
-
diffLines.push(
|
|
252
|
+
diffLines.push(`<span style="color:#d29922"> ~ ${escapeHtml(setting.name)}: ${escapeHtml(formatValuePlain(setting.oldValue))} → ${escapeHtml(formatValuePlain(setting.newValue))}</span>`);
|
|
247
253
|
}
|
|
248
254
|
}
|
|
249
255
|
for (const ruleset of repo.rulesets) {
|
|
250
256
|
if (ruleset.action === "create") {
|
|
251
|
-
diffLines.push(
|
|
257
|
+
diffLines.push(`<span style="color:#3fb950"> + ruleset "${escapeHtml(ruleset.name)}"</span>`);
|
|
252
258
|
if (ruleset.config) {
|
|
253
|
-
|
|
259
|
+
for (const line of formatRulesetConfigPlain(ruleset.config)) {
|
|
260
|
+
diffLines.push(`<span style="color:#3fb950">${escapeHtml(line)}</span>`);
|
|
261
|
+
}
|
|
254
262
|
}
|
|
255
263
|
}
|
|
256
264
|
else if (ruleset.action === "update") {
|
|
257
|
-
diffLines.push(
|
|
265
|
+
diffLines.push(`<span style="color:#d29922"> ~ ruleset "${escapeHtml(ruleset.name)}"</span>`);
|
|
258
266
|
if (ruleset.propertyDiffs && ruleset.propertyDiffs.length > 0) {
|
|
259
267
|
for (const diff of ruleset.propertyDiffs) {
|
|
260
268
|
const path = diff.path.join(".");
|
|
261
269
|
if (diff.action === "add") {
|
|
262
|
-
diffLines.push(
|
|
270
|
+
diffLines.push(`<span style="color:#3fb950"> + ${escapeHtml(path)}: ${escapeHtml(formatValuePlain(diff.newValue))}</span>`);
|
|
263
271
|
}
|
|
264
272
|
else if (diff.action === "change") {
|
|
265
|
-
diffLines.push(
|
|
273
|
+
diffLines.push(`<span style="color:#d29922"> ~ ${escapeHtml(path)}: ${escapeHtml(formatValuePlain(diff.oldValue))} → ${escapeHtml(formatValuePlain(diff.newValue))}</span>`);
|
|
266
274
|
}
|
|
267
275
|
else if (diff.action === "remove") {
|
|
268
|
-
diffLines.push(
|
|
276
|
+
diffLines.push(`<span style="color:#f85149"> - ${escapeHtml(path)}</span>`);
|
|
269
277
|
}
|
|
270
278
|
}
|
|
271
279
|
}
|
|
272
280
|
}
|
|
273
281
|
else if (ruleset.action === "delete") {
|
|
274
|
-
diffLines.push(
|
|
282
|
+
diffLines.push(`<span style="color:#f85149"> - ruleset "${escapeHtml(ruleset.name)}"</span>`);
|
|
275
283
|
}
|
|
276
284
|
}
|
|
277
285
|
if (repo.error) {
|
|
278
|
-
diffLines.push(
|
|
286
|
+
diffLines.push(`<span style="color:#f85149"> ! Error: ${escapeHtml(repo.error)}</span>`);
|
|
279
287
|
}
|
|
280
288
|
}
|
|
281
289
|
if (diffLines.length > 0) {
|
|
282
|
-
lines.push("
|
|
290
|
+
lines.push("<pre>");
|
|
283
291
|
lines.push(...diffLines);
|
|
284
|
-
lines.push("
|
|
292
|
+
lines.push("</pre>");
|
|
285
293
|
lines.push("");
|
|
286
294
|
}
|
|
287
295
|
// Summary
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
// src/output/sync-report.ts
|
|
2
2
|
import { appendFileSync } from "node:fs";
|
|
3
3
|
import chalk from "chalk";
|
|
4
|
+
function escapeHtml(text) {
|
|
5
|
+
return text
|
|
6
|
+
.replace(/&/g, "&")
|
|
7
|
+
.replace(/</g, "<")
|
|
8
|
+
.replace(/>/g, ">");
|
|
9
|
+
}
|
|
4
10
|
function formatSummary(totals) {
|
|
5
11
|
const total = totals.files.create + totals.files.update + totals.files.delete;
|
|
6
12
|
if (total === 0) {
|
|
@@ -58,32 +64,33 @@ export function formatSyncReportMarkdown(report, dryRun) {
|
|
|
58
64
|
lines.push("> This was a dry run — no changes were applied");
|
|
59
65
|
lines.push("");
|
|
60
66
|
}
|
|
61
|
-
//
|
|
67
|
+
// Colored diff output using HTML <pre> with inline styles
|
|
68
|
+
// Colors: green (#3fb950) for creates, yellow (#d29922) for changes, red (#f85149) for deletes
|
|
62
69
|
const diffLines = [];
|
|
63
70
|
for (const repo of report.repos) {
|
|
64
71
|
if (repo.files.length === 0 && !repo.error) {
|
|
65
72
|
continue;
|
|
66
73
|
}
|
|
67
|
-
diffLines.push(
|
|
74
|
+
diffLines.push(`<span style="color:#d29922">~ ${escapeHtml(repo.repoName)}</span>`);
|
|
68
75
|
for (const file of repo.files) {
|
|
69
76
|
if (file.action === "create") {
|
|
70
|
-
diffLines.push(
|
|
77
|
+
diffLines.push(`<span style="color:#3fb950"> + ${escapeHtml(file.path)}</span>`);
|
|
71
78
|
}
|
|
72
79
|
else if (file.action === "update") {
|
|
73
|
-
diffLines.push(
|
|
80
|
+
diffLines.push(`<span style="color:#d29922"> ~ ${escapeHtml(file.path)}</span>`);
|
|
74
81
|
}
|
|
75
82
|
else if (file.action === "delete") {
|
|
76
|
-
diffLines.push(
|
|
83
|
+
diffLines.push(`<span style="color:#f85149"> - ${escapeHtml(file.path)}</span>`);
|
|
77
84
|
}
|
|
78
85
|
}
|
|
79
86
|
if (repo.error) {
|
|
80
|
-
diffLines.push(
|
|
87
|
+
diffLines.push(`<span style="color:#f85149"> ! Error: ${escapeHtml(repo.error)}</span>`);
|
|
81
88
|
}
|
|
82
89
|
}
|
|
83
90
|
if (diffLines.length > 0) {
|
|
84
|
-
lines.push("
|
|
91
|
+
lines.push("<pre>");
|
|
85
92
|
lines.push(...diffLines);
|
|
86
|
-
lines.push("
|
|
93
|
+
lines.push("</pre>");
|
|
87
94
|
lines.push("");
|
|
88
95
|
}
|
|
89
96
|
// Summary
|