@aspruyt/xfg 3.9.12 → 3.9.14
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/index.d.ts +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/cli/settings/lifecycle-checks.d.ts +11 -0
- package/dist/cli/settings/lifecycle-checks.js +64 -0
- package/dist/cli/settings/process-labels.d.ts +9 -0
- package/dist/cli/settings/process-labels.js +125 -0
- package/dist/cli/settings/process-repo-settings.d.ts +9 -0
- package/dist/cli/settings/process-repo-settings.js +80 -0
- package/dist/cli/settings/process-rulesets.d.ts +9 -0
- package/dist/cli/settings/process-rulesets.js +118 -0
- package/dist/cli/settings/results-collector.d.ts +11 -0
- package/dist/cli/settings/results-collector.js +28 -0
- package/dist/cli/settings-command.d.ts +3 -3
- package/dist/cli/settings-command.js +28 -268
- package/dist/cli/settings-report-builder.d.ts +6 -0
- package/dist/cli/settings-report-builder.js +23 -0
- package/dist/cli/types.d.ts +12 -2
- package/dist/cli/types.js +5 -0
- package/dist/config/index.d.ts +1 -1
- package/dist/config/normalizer.d.ts +0 -4
- package/dist/config/normalizer.js +56 -0
- package/dist/config/types.d.ts +17 -0
- package/dist/config/validator.d.ts +2 -3
- package/dist/config/validator.js +62 -7
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/output/github-summary.d.ts +6 -0
- package/dist/output/github-summary.js +39 -0
- package/dist/output/settings-report.d.ts +18 -1
- package/dist/output/settings-report.js +84 -0
- package/dist/output/unified-summary.js +40 -1
- package/dist/settings/index.d.ts +1 -0
- package/dist/settings/index.js +2 -0
- package/dist/settings/labels/converter.d.ts +15 -0
- package/dist/settings/labels/converter.js +22 -0
- package/dist/settings/labels/diff.d.ts +33 -0
- package/dist/settings/labels/diff.js +156 -0
- package/dist/settings/labels/formatter.d.ts +25 -0
- package/dist/settings/labels/formatter.js +92 -0
- package/dist/settings/labels/github-labels-strategy.d.ts +51 -0
- package/dist/settings/labels/github-labels-strategy.js +102 -0
- package/dist/settings/labels/index.d.ts +6 -0
- package/dist/settings/labels/index.js +10 -0
- package/dist/settings/labels/processor.d.ts +57 -0
- package/dist/settings/labels/processor.js +189 -0
- package/dist/settings/labels/types.d.ts +33 -0
- package/dist/settings/labels/types.js +1 -0
- package/dist/sync/index.d.ts +1 -1
- package/dist/sync/index.js +1 -1
- package/dist/sync/manifest-strategy.d.ts +2 -1
- package/dist/sync/manifest-strategy.js +23 -5
- package/dist/sync/manifest.d.ts +24 -0
- package/dist/sync/manifest.js +98 -6
- package/dist/sync/repository-processor.d.ts +2 -1
- package/dist/sync/repository-processor.js +21 -5
- package/dist/sync/types.d.ts +2 -1
- package/package.json +4 -3
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { isGitHubRepo, getRepoDisplayName, } from "../../shared/repo-detector.js";
|
|
2
|
+
import { GitHubLabelsStrategy } from "./github-labels-strategy.js";
|
|
3
|
+
import { diffLabels } from "./diff.js";
|
|
4
|
+
import { formatLabelsPlan } from "./formatter.js";
|
|
5
|
+
import { labelConfigToPayload } from "./converter.js";
|
|
6
|
+
import { hasGitHubAppCredentials } from "../../vcs/index.js";
|
|
7
|
+
import { GitHubAppTokenManager } from "../../vcs/github-app-token-manager.js";
|
|
8
|
+
// =============================================================================
|
|
9
|
+
// Processor Implementation
|
|
10
|
+
// =============================================================================
|
|
11
|
+
/**
|
|
12
|
+
* Processes label configuration for a repository.
|
|
13
|
+
* Handles create/update/delete operations via GitHub Labels API.
|
|
14
|
+
*/
|
|
15
|
+
export class LabelsProcessor {
|
|
16
|
+
strategy;
|
|
17
|
+
tokenManager;
|
|
18
|
+
constructor(strategy) {
|
|
19
|
+
this.strategy = strategy ?? new GitHubLabelsStrategy();
|
|
20
|
+
if (hasGitHubAppCredentials()) {
|
|
21
|
+
this.tokenManager = new GitHubAppTokenManager(process.env.XFG_GITHUB_APP_ID, process.env.XFG_GITHUB_APP_PRIVATE_KEY);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
this.tokenManager = null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Process labels for a single repository.
|
|
29
|
+
*/
|
|
30
|
+
async process(repoConfig, repoInfo, options) {
|
|
31
|
+
const repoName = getRepoDisplayName(repoInfo);
|
|
32
|
+
const { dryRun, managedLabels, noDelete, token } = options;
|
|
33
|
+
// Check if this is a GitHub repo
|
|
34
|
+
if (!isGitHubRepo(repoInfo)) {
|
|
35
|
+
return {
|
|
36
|
+
success: true,
|
|
37
|
+
repoName,
|
|
38
|
+
message: `Skipped: ${repoName} is not a GitHub repository`,
|
|
39
|
+
skipped: true,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const githubRepo = repoInfo;
|
|
43
|
+
const settings = repoConfig.settings;
|
|
44
|
+
const desiredLabels = settings?.labels ?? {};
|
|
45
|
+
const deleteOrphaned = settings?.deleteOrphaned ?? false;
|
|
46
|
+
// If no labels configured, skip
|
|
47
|
+
if (Object.keys(desiredLabels).length === 0 && managedLabels.length === 0) {
|
|
48
|
+
return {
|
|
49
|
+
success: true,
|
|
50
|
+
repoName,
|
|
51
|
+
message: "No labels configured",
|
|
52
|
+
skipped: true,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
// Resolve App token if available, fall back to provided token
|
|
57
|
+
const effectiveToken = token ?? (await this.getInstallationToken(githubRepo));
|
|
58
|
+
const strategyOptions = { token: effectiveToken, host: githubRepo.host };
|
|
59
|
+
const currentLabels = await this.strategy.list(githubRepo, strategyOptions);
|
|
60
|
+
// Compute diff
|
|
61
|
+
const changes = diffLabels(currentLabels, desiredLabels, managedLabels, noDelete ?? false);
|
|
62
|
+
// Count changes by type
|
|
63
|
+
const changeCounts = {
|
|
64
|
+
create: changes.filter((c) => c.action === "create").length,
|
|
65
|
+
update: changes.filter((c) => c.action === "update").length,
|
|
66
|
+
delete: changes.filter((c) => c.action === "delete").length,
|
|
67
|
+
unchanged: changes.filter((c) => c.action === "unchanged").length,
|
|
68
|
+
};
|
|
69
|
+
const planOutput = formatLabelsPlan(changes);
|
|
70
|
+
// Dry run mode - report planned changes without applying
|
|
71
|
+
if (dryRun) {
|
|
72
|
+
const summary = this.formatChangeSummary(changeCounts);
|
|
73
|
+
return {
|
|
74
|
+
success: true,
|
|
75
|
+
repoName,
|
|
76
|
+
message: `[DRY RUN] ${summary}`,
|
|
77
|
+
dryRun: true,
|
|
78
|
+
changes: changeCounts,
|
|
79
|
+
planOutput,
|
|
80
|
+
manifestUpdate: this.computeManifestUpdate(desiredLabels, deleteOrphaned),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
// Apply changes (diff is already sorted: delete, update, create, unchanged)
|
|
84
|
+
let appliedCount = 0;
|
|
85
|
+
for (const change of changes) {
|
|
86
|
+
switch (change.action) {
|
|
87
|
+
case "create":
|
|
88
|
+
if (change.desired) {
|
|
89
|
+
const payload = labelConfigToPayload(change.name, change.desired);
|
|
90
|
+
await this.strategy.create(githubRepo, {
|
|
91
|
+
name: payload.name,
|
|
92
|
+
color: payload.color,
|
|
93
|
+
...(payload.description !== undefined
|
|
94
|
+
? { description: payload.description }
|
|
95
|
+
: {}),
|
|
96
|
+
}, strategyOptions);
|
|
97
|
+
appliedCount++;
|
|
98
|
+
}
|
|
99
|
+
break;
|
|
100
|
+
case "update":
|
|
101
|
+
if (change.desired) {
|
|
102
|
+
const updatePayload = {};
|
|
103
|
+
for (const prop of change.propertyChanges ?? []) {
|
|
104
|
+
if (prop.property === "color") {
|
|
105
|
+
updatePayload.color = prop.newValue;
|
|
106
|
+
}
|
|
107
|
+
else if (prop.property === "description") {
|
|
108
|
+
updatePayload.description = prop.newValue;
|
|
109
|
+
}
|
|
110
|
+
else if (prop.property === "new_name") {
|
|
111
|
+
updatePayload.new_name = prop.newValue;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
await this.strategy.update(githubRepo, change.name, updatePayload, strategyOptions);
|
|
115
|
+
appliedCount++;
|
|
116
|
+
}
|
|
117
|
+
break;
|
|
118
|
+
case "delete":
|
|
119
|
+
if (!noDelete && deleteOrphaned) {
|
|
120
|
+
await this.strategy.delete(githubRepo, change.name, strategyOptions);
|
|
121
|
+
appliedCount++;
|
|
122
|
+
}
|
|
123
|
+
break;
|
|
124
|
+
case "unchanged":
|
|
125
|
+
// No action needed
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const summary = this.formatChangeSummary(changeCounts);
|
|
130
|
+
return {
|
|
131
|
+
success: true,
|
|
132
|
+
repoName,
|
|
133
|
+
message: appliedCount > 0 ? `Applied: ${summary}` : "No changes needed",
|
|
134
|
+
changes: changeCounts,
|
|
135
|
+
planOutput,
|
|
136
|
+
manifestUpdate: this.computeManifestUpdate(desiredLabels, deleteOrphaned),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
141
|
+
return {
|
|
142
|
+
success: false,
|
|
143
|
+
repoName,
|
|
144
|
+
message: `Failed: ${message}`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Format change counts into a summary string.
|
|
150
|
+
*/
|
|
151
|
+
formatChangeSummary(counts) {
|
|
152
|
+
const parts = [];
|
|
153
|
+
if (counts.create > 0)
|
|
154
|
+
parts.push(`${counts.create} created`);
|
|
155
|
+
if (counts.update > 0)
|
|
156
|
+
parts.push(`${counts.update} updated`);
|
|
157
|
+
if (counts.delete > 0)
|
|
158
|
+
parts.push(`${counts.delete} deleted`);
|
|
159
|
+
if (counts.unchanged > 0)
|
|
160
|
+
parts.push(`${counts.unchanged} unchanged`);
|
|
161
|
+
return parts.length > 0 ? parts.join(", ") : "no changes";
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Compute manifest update based on current config.
|
|
165
|
+
* Only labels with deleteOrphaned enabled should be tracked.
|
|
166
|
+
*/
|
|
167
|
+
computeManifestUpdate(labels, deleteOrphaned) {
|
|
168
|
+
if (!deleteOrphaned) {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
const labelNames = Object.keys(labels).sort();
|
|
172
|
+
return { labels: labelNames };
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Resolves a GitHub App installation token for the given repo.
|
|
176
|
+
*/
|
|
177
|
+
async getInstallationToken(repoInfo) {
|
|
178
|
+
if (!this.tokenManager) {
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
const token = await this.tokenManager.getTokenForRepo(repoInfo);
|
|
183
|
+
return token ?? undefined;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { RepoInfo } from "../../shared/repo-detector.js";
|
|
2
|
+
export interface LabelsStrategyOptions {
|
|
3
|
+
token?: string;
|
|
4
|
+
host?: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* GitHub label as returned by the API.
|
|
8
|
+
*/
|
|
9
|
+
export interface GitHubLabel {
|
|
10
|
+
id: number;
|
|
11
|
+
name: string;
|
|
12
|
+
color: string;
|
|
13
|
+
description: string | null;
|
|
14
|
+
default: boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Strategy interface for label operations.
|
|
18
|
+
* Abstracts platform-specific API calls.
|
|
19
|
+
*/
|
|
20
|
+
export interface ILabelsStrategy {
|
|
21
|
+
list(repoInfo: RepoInfo, options?: LabelsStrategyOptions): Promise<GitHubLabel[]>;
|
|
22
|
+
create(repoInfo: RepoInfo, label: {
|
|
23
|
+
name: string;
|
|
24
|
+
color: string;
|
|
25
|
+
description?: string;
|
|
26
|
+
}, options?: LabelsStrategyOptions): Promise<void>;
|
|
27
|
+
update(repoInfo: RepoInfo, currentName: string, label: {
|
|
28
|
+
new_name?: string;
|
|
29
|
+
color?: string;
|
|
30
|
+
description?: string;
|
|
31
|
+
}, options?: LabelsStrategyOptions): Promise<void>;
|
|
32
|
+
delete(repoInfo: RepoInfo, name: string, options?: LabelsStrategyOptions): Promise<void>;
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/sync/index.d.ts
CHANGED
|
@@ -12,6 +12,6 @@ export { ManifestStrategy, type ManifestUpdateParams, } from "./manifest-strateg
|
|
|
12
12
|
export { SyncWorkflow } from "./sync-workflow.js";
|
|
13
13
|
export type { IFileWriter, FileWriteContext, FileWriterDeps, FileWriteAllResult, FileWriteResult, IManifestManager, OrphanProcessResult, OrphanDeleteOptions, OrphanDeleteDeps, IBranchManager, BranchSetupOptions, IAuthOptionsBuilder, AuthResult, IRepositorySession, SessionOptions, SessionContext, ICommitPushManager, CommitPushOptions, CommitPushResult, GitOpsFactory, IRepositoryProcessor, ProcessorOptions, ProcessorResult, FileChangeDetail, IFileSyncOrchestrator, FileSyncResult, IPRMergeHandler, PRHandlerOptions, WorkResult, IWorkStrategy, ISyncWorkflow, } from "./types.js";
|
|
14
14
|
export { RepositoryProcessor } from "./repository-processor.js";
|
|
15
|
-
export { createEmptyManifest, loadManifest, saveManifest, getManagedFiles, getManagedRulesets, updateManifest, updateManifestRulesets, MANIFEST_FILENAME, type XfgManifest, type XfgManifestConfigEntry, } from "./manifest.js";
|
|
15
|
+
export { createEmptyManifest, loadManifest, saveManifest, getManagedFiles, getManagedRulesets, getManagedLabels, updateManifest, updateManifestRulesets, updateManifestLabels, MANIFEST_FILENAME, type XfgManifest, type XfgManifestConfigEntry, } from "./manifest.js";
|
|
16
16
|
export { getFileStatus, formatStatusBadge, formatDiffLine, generateDiff, createDiffStats, incrementDiffStats, type FileStatus, type DiffStats, } from "./diff-utils.js";
|
|
17
17
|
export { interpolateXfgContent, type XfgTemplateContext, type XfgInterpolationOptions, } from "./xfg-template.js";
|
package/dist/sync/index.js
CHANGED
|
@@ -14,7 +14,7 @@ export { SyncWorkflow } from "./sync-workflow.js";
|
|
|
14
14
|
// Repository processor
|
|
15
15
|
export { RepositoryProcessor } from "./repository-processor.js";
|
|
16
16
|
// Manifest handling
|
|
17
|
-
export { createEmptyManifest, loadManifest, saveManifest, getManagedFiles, getManagedRulesets, updateManifest, updateManifestRulesets, MANIFEST_FILENAME, } from "./manifest.js";
|
|
17
|
+
export { createEmptyManifest, loadManifest, saveManifest, getManagedFiles, getManagedRulesets, getManagedLabels, updateManifest, updateManifestRulesets, updateManifestLabels, MANIFEST_FILENAME, } from "./manifest.js";
|
|
18
18
|
// Diff utilities
|
|
19
19
|
export { getFileStatus, formatStatusBadge, formatDiffLine, generateDiff, createDiffStats, incrementDiffStats, } from "./diff-utils.js";
|
|
20
20
|
// XFG templating
|
|
@@ -6,7 +6,8 @@ import type { IWorkStrategy, WorkResult, SessionContext, ProcessorOptions } from
|
|
|
6
6
|
* Parameters for manifest-only updates
|
|
7
7
|
*/
|
|
8
8
|
export interface ManifestUpdateParams {
|
|
9
|
-
rulesets
|
|
9
|
+
rulesets?: string[];
|
|
10
|
+
labels?: string[];
|
|
10
11
|
}
|
|
11
12
|
/**
|
|
12
13
|
* Strategy that only updates the manifest with ruleset tracking.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { loadManifest, saveManifest, updateManifestRulesets, MANIFEST_FILENAME, } from "./manifest.js";
|
|
1
|
+
import { loadManifest, saveManifest, updateManifestRulesets, updateManifestLabels, MANIFEST_FILENAME, } from "./manifest.js";
|
|
2
2
|
/**
|
|
3
3
|
* Strategy that only updates the manifest with ruleset tracking.
|
|
4
4
|
* Used by updateManifestOnly() for settings command ruleset sync.
|
|
@@ -14,15 +14,33 @@ export class ManifestStrategy {
|
|
|
14
14
|
const { workDir, dryRun, configId } = options;
|
|
15
15
|
// Load and update manifest
|
|
16
16
|
const existingManifest = loadManifest(workDir);
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
let newManifest = existingManifest;
|
|
18
|
+
// Apply rulesets update if present
|
|
19
|
+
if (this.params.rulesets) {
|
|
20
|
+
const rulesetsWithDeleteOrphaned = new Map(this.params.rulesets.map((name) => [name, true]));
|
|
21
|
+
const result = updateManifestRulesets(newManifest, configId, rulesetsWithDeleteOrphaned);
|
|
22
|
+
newManifest = result.manifest;
|
|
23
|
+
}
|
|
24
|
+
// Apply labels update if present
|
|
25
|
+
if (this.params.labels) {
|
|
26
|
+
const labelsWithDeleteOrphaned = new Map(this.params.labels.map((name) => [name, true]));
|
|
27
|
+
const result = updateManifestLabels(newManifest, configId, labelsWithDeleteOrphaned);
|
|
28
|
+
newManifest = result.manifest;
|
|
29
|
+
}
|
|
19
30
|
// Check if changed
|
|
20
31
|
const existingConfigs = existingManifest?.configs ?? {};
|
|
21
32
|
if (JSON.stringify(existingConfigs) === JSON.stringify(newManifest.configs)) {
|
|
22
33
|
return null;
|
|
23
34
|
}
|
|
35
|
+
// Build dynamic commit message
|
|
36
|
+
const parts = [];
|
|
37
|
+
if (this.params.rulesets)
|
|
38
|
+
parts.push("ruleset");
|
|
39
|
+
if (this.params.labels)
|
|
40
|
+
parts.push("labels");
|
|
41
|
+
const trackingType = parts.join("/");
|
|
24
42
|
if (dryRun) {
|
|
25
|
-
this.log.info(`Would update ${MANIFEST_FILENAME} with
|
|
43
|
+
this.log.info(`Would update ${MANIFEST_FILENAME} with ${trackingType} tracking`);
|
|
26
44
|
}
|
|
27
45
|
else {
|
|
28
46
|
saveManifest(workDir, newManifest);
|
|
@@ -42,7 +60,7 @@ export class ManifestStrategy {
|
|
|
42
60
|
changedFiles: [
|
|
43
61
|
{ fileName: MANIFEST_FILENAME, action: "update" },
|
|
44
62
|
],
|
|
45
|
-
commitMessage:
|
|
63
|
+
commitMessage: `chore: update manifest with ${trackingType} tracking`,
|
|
46
64
|
fileChangeDetails: [{ path: MANIFEST_FILENAME, action: "update" }],
|
|
47
65
|
};
|
|
48
66
|
}
|
package/dist/sync/manifest.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export declare const MANIFEST_FILENAME = ".xfg.json";
|
|
|
2
2
|
export interface XfgManifestConfigEntry {
|
|
3
3
|
files?: string[];
|
|
4
4
|
rulesets?: string[];
|
|
5
|
+
labels?: string[];
|
|
5
6
|
}
|
|
6
7
|
export interface XfgManifest {
|
|
7
8
|
version: 3;
|
|
@@ -25,6 +26,11 @@ export declare function createEmptyManifest(): XfgManifest;
|
|
|
25
26
|
* @returns The manifest or null if not found or incompatible
|
|
26
27
|
*/
|
|
27
28
|
export declare function loadManifest(workDir: string): XfgManifest | null;
|
|
29
|
+
/**
|
|
30
|
+
* Parses manifest content from a string (e.g., fetched from a remote API).
|
|
31
|
+
* Handles V2→V3 migration, returns null for V1/unknown/invalid formats.
|
|
32
|
+
*/
|
|
33
|
+
export declare function parseManifestContent(content: string): XfgManifest | null;
|
|
28
34
|
/**
|
|
29
35
|
* Saves the xfg manifest to a repository's working directory.
|
|
30
36
|
*
|
|
@@ -50,6 +56,11 @@ export declare function getManagedFiles(manifest: XfgManifest | null, configId:
|
|
|
50
56
|
* @returns Array of managed ruleset names for the given config
|
|
51
57
|
*/
|
|
52
58
|
export declare function getManagedRulesets(manifest: XfgManifest | null, configId: string): string[];
|
|
59
|
+
/**
|
|
60
|
+
* Gets the list of managed labels for a specific config from a manifest.
|
|
61
|
+
* Returns an empty array if the manifest is null or the config isn't found.
|
|
62
|
+
*/
|
|
63
|
+
export declare function getManagedLabels(manifest: XfgManifest | null, configId: string): string[];
|
|
53
64
|
/**
|
|
54
65
|
* Updates the manifest with the current set of files that have deleteOrphaned enabled
|
|
55
66
|
* for a specific config. Only modifies that config's files namespace - other configs are untouched.
|
|
@@ -80,3 +91,16 @@ export declare function updateManifestRulesets(manifest: XfgManifest | null, con
|
|
|
80
91
|
manifest: XfgManifest;
|
|
81
92
|
rulesetsToDelete: string[];
|
|
82
93
|
};
|
|
94
|
+
/**
|
|
95
|
+
* Updates the manifest with the current set of labels that have deleteOrphaned enabled
|
|
96
|
+
* for a specific config. Only modifies that config's labels namespace - other configs are untouched.
|
|
97
|
+
*
|
|
98
|
+
* @param manifest - The existing manifest (or null for new repos)
|
|
99
|
+
* @param configId - The config ID to update
|
|
100
|
+
* @param labelsWithDeleteOrphaned - Map of label name to deleteOrphaned value (true/false/undefined)
|
|
101
|
+
* @returns Updated manifest and list of labels to delete
|
|
102
|
+
*/
|
|
103
|
+
export declare function updateManifestLabels(manifest: XfgManifest | null, configId: string, labelsWithDeleteOrphaned: Map<string, boolean | undefined>): {
|
|
104
|
+
manifest: XfgManifest;
|
|
105
|
+
labelsToDelete: string[];
|
|
106
|
+
};
|
package/dist/sync/manifest.js
CHANGED
|
@@ -96,6 +96,25 @@ export function loadManifest(workDir) {
|
|
|
96
96
|
return null;
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Parses manifest content from a string (e.g., fetched from a remote API).
|
|
101
|
+
* Handles V2→V3 migration, returns null for V1/unknown/invalid formats.
|
|
102
|
+
*/
|
|
103
|
+
export function parseManifestContent(content) {
|
|
104
|
+
try {
|
|
105
|
+
const parsed = JSON.parse(content);
|
|
106
|
+
if (isV3Manifest(parsed)) {
|
|
107
|
+
return parsed;
|
|
108
|
+
}
|
|
109
|
+
if (isV2Manifest(parsed)) {
|
|
110
|
+
return migrateV2ToV3(parsed);
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
99
118
|
/**
|
|
100
119
|
* Saves the xfg manifest to a repository's working directory.
|
|
101
120
|
*
|
|
@@ -135,6 +154,16 @@ export function getManagedRulesets(manifest, configId) {
|
|
|
135
154
|
}
|
|
136
155
|
return [...(manifest.configs[configId]?.rulesets ?? [])];
|
|
137
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* Gets the list of managed labels for a specific config from a manifest.
|
|
159
|
+
* Returns an empty array if the manifest is null or the config isn't found.
|
|
160
|
+
*/
|
|
161
|
+
export function getManagedLabels(manifest, configId) {
|
|
162
|
+
if (!manifest) {
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
return [...(manifest.configs[configId]?.labels ?? [])];
|
|
166
|
+
}
|
|
138
167
|
/**
|
|
139
168
|
* Updates the manifest with the current set of files that have deleteOrphaned enabled
|
|
140
169
|
* for a specific config. Only modifies that config's files namespace - other configs are untouched.
|
|
@@ -173,22 +202,27 @@ export function updateManifest(manifest, configId, filesWithDeleteOrphaned) {
|
|
|
173
202
|
const updatedConfigs = {
|
|
174
203
|
...(manifest?.configs ?? {}),
|
|
175
204
|
};
|
|
176
|
-
// Preserve existing rulesets for this config
|
|
205
|
+
// Preserve existing rulesets and labels for this config
|
|
177
206
|
const existingEntry = manifest?.configs[configId];
|
|
178
207
|
const existingRulesets = existingEntry?.rulesets;
|
|
208
|
+
const existingLabels = existingEntry?.labels;
|
|
179
209
|
// Update this config's managed files
|
|
180
210
|
const sortedManaged = Array.from(newManaged).sort();
|
|
181
211
|
if (sortedManaged.length > 0 ||
|
|
182
|
-
(existingRulesets && existingRulesets.length > 0)
|
|
212
|
+
(existingRulesets && existingRulesets.length > 0) ||
|
|
213
|
+
(existingLabels && existingLabels.length > 0)) {
|
|
183
214
|
updatedConfigs[configId] = {
|
|
184
215
|
...(sortedManaged.length > 0 ? { files: sortedManaged } : {}),
|
|
185
216
|
...(existingRulesets && existingRulesets.length > 0
|
|
186
217
|
? { rulesets: existingRulesets }
|
|
187
218
|
: {}),
|
|
219
|
+
...(existingLabels && existingLabels.length > 0
|
|
220
|
+
? { labels: existingLabels }
|
|
221
|
+
: {}),
|
|
188
222
|
};
|
|
189
223
|
}
|
|
190
224
|
else {
|
|
191
|
-
// Remove config entry if no managed files or
|
|
225
|
+
// Remove config entry if no managed files, rulesets, or labels
|
|
192
226
|
delete updatedConfigs[configId];
|
|
193
227
|
}
|
|
194
228
|
return {
|
|
@@ -229,21 +263,27 @@ export function updateManifestRulesets(manifest, configId, rulesetsWithDeleteOrp
|
|
|
229
263
|
const updatedConfigs = {
|
|
230
264
|
...(manifest?.configs ?? {}),
|
|
231
265
|
};
|
|
232
|
-
// Preserve existing files for this config
|
|
266
|
+
// Preserve existing files and labels for this config
|
|
233
267
|
const existingEntry = manifest?.configs[configId];
|
|
234
268
|
const existingFiles = existingEntry?.files;
|
|
269
|
+
const existingLabels = existingEntry?.labels;
|
|
235
270
|
// Update this config's managed rulesets
|
|
236
271
|
const sortedManaged = Array.from(newManaged).sort();
|
|
237
|
-
if (sortedManaged.length > 0 ||
|
|
272
|
+
if (sortedManaged.length > 0 ||
|
|
273
|
+
(existingFiles && existingFiles.length > 0) ||
|
|
274
|
+
(existingLabels && existingLabels.length > 0)) {
|
|
238
275
|
updatedConfigs[configId] = {
|
|
239
276
|
...(existingFiles && existingFiles.length > 0
|
|
240
277
|
? { files: existingFiles }
|
|
241
278
|
: {}),
|
|
242
279
|
...(sortedManaged.length > 0 ? { rulesets: sortedManaged } : {}),
|
|
280
|
+
...(existingLabels && existingLabels.length > 0
|
|
281
|
+
? { labels: existingLabels }
|
|
282
|
+
: {}),
|
|
243
283
|
};
|
|
244
284
|
}
|
|
245
285
|
else {
|
|
246
|
-
// Remove config entry if no managed files or
|
|
286
|
+
// Remove config entry if no managed files, rulesets, or labels
|
|
247
287
|
delete updatedConfigs[configId];
|
|
248
288
|
}
|
|
249
289
|
return {
|
|
@@ -254,3 +294,55 @@ export function updateManifestRulesets(manifest, configId, rulesetsWithDeleteOrp
|
|
|
254
294
|
rulesetsToDelete,
|
|
255
295
|
};
|
|
256
296
|
}
|
|
297
|
+
/**
|
|
298
|
+
* Updates the manifest with the current set of labels that have deleteOrphaned enabled
|
|
299
|
+
* for a specific config. Only modifies that config's labels namespace - other configs are untouched.
|
|
300
|
+
*
|
|
301
|
+
* @param manifest - The existing manifest (or null for new repos)
|
|
302
|
+
* @param configId - The config ID to update
|
|
303
|
+
* @param labelsWithDeleteOrphaned - Map of label name to deleteOrphaned value (true/false/undefined)
|
|
304
|
+
* @returns Updated manifest and list of labels to delete
|
|
305
|
+
*/
|
|
306
|
+
export function updateManifestLabels(manifest, configId, labelsWithDeleteOrphaned) {
|
|
307
|
+
const existingManaged = new Set(getManagedLabels(manifest, configId));
|
|
308
|
+
const newManaged = new Set();
|
|
309
|
+
const labelsToDelete = [];
|
|
310
|
+
for (const [labelName, deleteOrphaned] of labelsWithDeleteOrphaned) {
|
|
311
|
+
if (deleteOrphaned === true) {
|
|
312
|
+
newManaged.add(labelName);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
for (const labelName of existingManaged) {
|
|
316
|
+
if (!labelsWithDeleteOrphaned.has(labelName)) {
|
|
317
|
+
labelsToDelete.push(labelName);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const updatedConfigs = {
|
|
321
|
+
...(manifest?.configs ?? {}),
|
|
322
|
+
};
|
|
323
|
+
// Preserve existing files and rulesets for this config
|
|
324
|
+
const existingEntry = manifest?.configs[configId];
|
|
325
|
+
const existingFiles = existingEntry?.files;
|
|
326
|
+
const existingRulesets = existingEntry?.rulesets;
|
|
327
|
+
const sortedManaged = Array.from(newManaged).sort();
|
|
328
|
+
if (sortedManaged.length > 0 ||
|
|
329
|
+
(existingFiles && existingFiles.length > 0) ||
|
|
330
|
+
(existingRulesets && existingRulesets.length > 0)) {
|
|
331
|
+
updatedConfigs[configId] = {
|
|
332
|
+
...(existingFiles && existingFiles.length > 0
|
|
333
|
+
? { files: existingFiles }
|
|
334
|
+
: {}),
|
|
335
|
+
...(existingRulesets && existingRulesets.length > 0
|
|
336
|
+
? { rulesets: existingRulesets }
|
|
337
|
+
: {}),
|
|
338
|
+
...(sortedManaged.length > 0 ? { labels: sortedManaged } : {}),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
delete updatedConfigs[configId];
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
manifest: { version: 3, configs: updatedConfigs },
|
|
346
|
+
labelsToDelete,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
@@ -23,6 +23,7 @@ export declare class RepositoryProcessor implements IRepositoryProcessor {
|
|
|
23
23
|
});
|
|
24
24
|
process(repoConfig: RepoConfig, repoInfo: RepoInfo, options: ProcessorOptions): Promise<ProcessorResult>;
|
|
25
25
|
updateManifestOnly(repoInfo: RepoInfo, repoConfig: RepoConfig, options: ProcessorOptions, manifestUpdate: {
|
|
26
|
-
rulesets
|
|
26
|
+
rulesets?: string[];
|
|
27
|
+
labels?: string[];
|
|
27
28
|
}): Promise<ProcessorResult>;
|
|
28
29
|
}
|
|
@@ -3,7 +3,7 @@ import { AuthenticatedGitOps } from "../vcs/authenticated-git-ops.js";
|
|
|
3
3
|
import { logger } from "../shared/logger.js";
|
|
4
4
|
import { hasGitHubAppCredentials } from "../vcs/index.js";
|
|
5
5
|
import { GitHubAppTokenManager } from "../vcs/github-app-token-manager.js";
|
|
6
|
-
import { FileWriter, ManifestManager, BranchManager, AuthOptionsBuilder, RepositorySession, CommitPushManager, FileSyncOrchestrator, PRMergeHandler, FileSyncStrategy, ManifestStrategy, SyncWorkflow, loadManifest, updateManifestRulesets, MANIFEST_FILENAME, } from "./index.js";
|
|
6
|
+
import { FileWriter, ManifestManager, BranchManager, AuthOptionsBuilder, RepositorySession, CommitPushManager, FileSyncOrchestrator, PRMergeHandler, FileSyncStrategy, ManifestStrategy, SyncWorkflow, loadManifest, updateManifestRulesets, updateManifestLabels, MANIFEST_FILENAME, } from "./index.js";
|
|
7
7
|
import { getRepoDisplayName } from "../shared/repo-detector.js";
|
|
8
8
|
/**
|
|
9
9
|
* Thin facade that delegates to SyncWorkflow with appropriate strategy.
|
|
@@ -47,10 +47,20 @@ export class RepositoryProcessor {
|
|
|
47
47
|
const { workDir, dryRun } = options;
|
|
48
48
|
// Pre-check manifest changes (preserves original early-return behavior)
|
|
49
49
|
const existingManifest = loadManifest(workDir);
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
let simulatedManifest = existingManifest;
|
|
51
|
+
if (manifestUpdate.rulesets) {
|
|
52
|
+
const rulesetsWithDeleteOrphaned = new Map(manifestUpdate.rulesets.map((name) => [name, true]));
|
|
53
|
+
const result = updateManifestRulesets(simulatedManifest, options.configId, rulesetsWithDeleteOrphaned);
|
|
54
|
+
simulatedManifest = result.manifest;
|
|
55
|
+
}
|
|
56
|
+
if (manifestUpdate.labels) {
|
|
57
|
+
const labelsWithDeleteOrphaned = new Map(manifestUpdate.labels.map((name) => [name, true]));
|
|
58
|
+
const result = updateManifestLabels(simulatedManifest, options.configId, labelsWithDeleteOrphaned);
|
|
59
|
+
simulatedManifest = result.manifest;
|
|
60
|
+
}
|
|
52
61
|
const existingConfigs = existingManifest?.configs ?? {};
|
|
53
|
-
if (JSON.stringify(existingConfigs) ===
|
|
62
|
+
if (JSON.stringify(existingConfigs) ===
|
|
63
|
+
JSON.stringify(simulatedManifest.configs)) {
|
|
54
64
|
return {
|
|
55
65
|
success: true,
|
|
56
66
|
repoName,
|
|
@@ -62,7 +72,13 @@ export class RepositoryProcessor {
|
|
|
62
72
|
{ path: MANIFEST_FILENAME, action: "update" },
|
|
63
73
|
];
|
|
64
74
|
if (dryRun) {
|
|
65
|
-
|
|
75
|
+
const parts = [];
|
|
76
|
+
if (manifestUpdate.rulesets)
|
|
77
|
+
parts.push("ruleset");
|
|
78
|
+
if (manifestUpdate.labels)
|
|
79
|
+
parts.push("labels");
|
|
80
|
+
const trackingType = parts.join("/") || "settings";
|
|
81
|
+
this.log.info(`Would update ${MANIFEST_FILENAME} with ${trackingType} tracking`);
|
|
66
82
|
return {
|
|
67
83
|
success: true,
|
|
68
84
|
repoName,
|
package/dist/sync/types.d.ts
CHANGED
|
@@ -258,7 +258,8 @@ export interface ProcessorResult {
|
|
|
258
258
|
export interface IRepositoryProcessor {
|
|
259
259
|
process(repoConfig: RepoConfig, repoInfo: RepoInfo, options: ProcessorOptions): Promise<ProcessorResult>;
|
|
260
260
|
updateManifestOnly(repoInfo: RepoInfo, repoConfig: RepoConfig, options: ProcessorOptions, manifestUpdate: {
|
|
261
|
-
rulesets
|
|
261
|
+
rulesets?: string[];
|
|
262
|
+
labels?: string[];
|
|
262
263
|
}): Promise<ProcessorResult>;
|
|
263
264
|
}
|
|
264
265
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aspruyt/xfg",
|
|
3
|
-
"version": "3.9.
|
|
3
|
+
"version": "3.9.14",
|
|
4
4
|
"description": "Manage files, settings, and repositories across GitHub, Azure DevOps, and GitLab — declaratively, from a single YAML config",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -27,13 +27,14 @@
|
|
|
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/**' --exclude='src/vcs/types.ts' --exclude='src/settings/rulesets/types.ts' --exclude='src/settings/repo-settings/types.ts' --exclude='src/**/index.ts' --exclude='test/mocks/**' --exclude='src/sync/types.ts' --exclude='src/lifecycle/types.ts' --exclude='src/cli/program.ts' --exclude='src/cli.ts' --exclude='src/index.ts' 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/vcs/types.ts' --exclude='src/settings/rulesets/types.ts' --exclude='src/settings/repo-settings/types.ts' --exclude='src/settings/labels/types.ts' --exclude='src/**/index.ts' --exclude='test/mocks/**' --exclude='src/sync/types.ts' --exclude='src/lifecycle/types.ts' --exclude='src/cli/program.ts' --exclude='src/cli.ts' --exclude='src/index.ts' 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",
|
|
34
34
|
"test:integration:github-app": "npm run build && node --import tsx --test test/integration/github-app.test.ts",
|
|
35
35
|
"test:integration:github-rulesets": "npm run build && node --import tsx --test test/integration/github-rulesets.test.ts",
|
|
36
36
|
"test:integration:github-repo-settings": "npm run build && node --import tsx --test test/integration/github-repo-settings.test.ts",
|
|
37
|
+
"test:integration:github-labels": "npm run build && node --import tsx --test test/integration/github-labels.test.ts",
|
|
37
38
|
"test:integration:github-lifecycle": "npm run build && node --import tsx --test test/integration/github-lifecycle.test.ts",
|
|
38
39
|
"test:integration:github-lifecycle-app": "npm run build && node --import tsx --test test/integration/github-lifecycle-app.test.ts",
|
|
39
40
|
"prepublishOnly": "npm run build"
|
|
@@ -64,7 +65,7 @@
|
|
|
64
65
|
},
|
|
65
66
|
"devDependencies": {
|
|
66
67
|
"@types/node": "^24.0.0",
|
|
67
|
-
"c8": "^
|
|
68
|
+
"c8": "^11.0.0",
|
|
68
69
|
"tsx": "^4.15.0",
|
|
69
70
|
"typescript": "^5.4.5"
|
|
70
71
|
}
|