@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.
Files changed (57) hide show
  1. package/dist/cli/index.d.ts +1 -1
  2. package/dist/cli/index.js +1 -1
  3. package/dist/cli/settings/lifecycle-checks.d.ts +11 -0
  4. package/dist/cli/settings/lifecycle-checks.js +64 -0
  5. package/dist/cli/settings/process-labels.d.ts +9 -0
  6. package/dist/cli/settings/process-labels.js +125 -0
  7. package/dist/cli/settings/process-repo-settings.d.ts +9 -0
  8. package/dist/cli/settings/process-repo-settings.js +80 -0
  9. package/dist/cli/settings/process-rulesets.d.ts +9 -0
  10. package/dist/cli/settings/process-rulesets.js +118 -0
  11. package/dist/cli/settings/results-collector.d.ts +11 -0
  12. package/dist/cli/settings/results-collector.js +28 -0
  13. package/dist/cli/settings-command.d.ts +3 -3
  14. package/dist/cli/settings-command.js +28 -268
  15. package/dist/cli/settings-report-builder.d.ts +6 -0
  16. package/dist/cli/settings-report-builder.js +23 -0
  17. package/dist/cli/types.d.ts +12 -2
  18. package/dist/cli/types.js +5 -0
  19. package/dist/config/index.d.ts +1 -1
  20. package/dist/config/normalizer.d.ts +0 -4
  21. package/dist/config/normalizer.js +56 -0
  22. package/dist/config/types.d.ts +17 -0
  23. package/dist/config/validator.d.ts +2 -3
  24. package/dist/config/validator.js +62 -7
  25. package/dist/index.d.ts +1 -1
  26. package/dist/index.js +1 -1
  27. package/dist/output/github-summary.d.ts +6 -0
  28. package/dist/output/github-summary.js +39 -0
  29. package/dist/output/settings-report.d.ts +18 -1
  30. package/dist/output/settings-report.js +84 -0
  31. package/dist/output/unified-summary.js +40 -1
  32. package/dist/settings/index.d.ts +1 -0
  33. package/dist/settings/index.js +2 -0
  34. package/dist/settings/labels/converter.d.ts +15 -0
  35. package/dist/settings/labels/converter.js +22 -0
  36. package/dist/settings/labels/diff.d.ts +33 -0
  37. package/dist/settings/labels/diff.js +156 -0
  38. package/dist/settings/labels/formatter.d.ts +25 -0
  39. package/dist/settings/labels/formatter.js +92 -0
  40. package/dist/settings/labels/github-labels-strategy.d.ts +51 -0
  41. package/dist/settings/labels/github-labels-strategy.js +102 -0
  42. package/dist/settings/labels/index.d.ts +6 -0
  43. package/dist/settings/labels/index.js +10 -0
  44. package/dist/settings/labels/processor.d.ts +57 -0
  45. package/dist/settings/labels/processor.js +189 -0
  46. package/dist/settings/labels/types.d.ts +33 -0
  47. package/dist/settings/labels/types.js +1 -0
  48. package/dist/sync/index.d.ts +1 -1
  49. package/dist/sync/index.js +1 -1
  50. package/dist/sync/manifest-strategy.d.ts +2 -1
  51. package/dist/sync/manifest-strategy.js +23 -5
  52. package/dist/sync/manifest.d.ts +24 -0
  53. package/dist/sync/manifest.js +98 -6
  54. package/dist/sync/repository-processor.d.ts +2 -1
  55. package/dist/sync/repository-processor.js +21 -5
  56. package/dist/sync/types.d.ts +2 -1
  57. 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 {};
@@ -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";
@@ -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: string[];
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
- const rulesetsWithDeleteOrphaned = new Map(this.params.rulesets.map((name) => [name, true]));
18
- const { manifest: newManifest } = updateManifestRulesets(existingManifest, configId, rulesetsWithDeleteOrphaned);
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 rulesets`);
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: "chore: update manifest with ruleset tracking",
63
+ commitMessage: `chore: update manifest with ${trackingType} tracking`,
46
64
  fileChangeDetails: [{ path: MANIFEST_FILENAME, action: "update" }],
47
65
  };
48
66
  }
@@ -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
+ };
@@ -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 rulesets
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 || (existingFiles && existingFiles.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 rulesets
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: string[];
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
- const rulesetsWithDeleteOrphaned = new Map(manifestUpdate.rulesets.map((name) => [name, true]));
51
- const { manifest: newManifest } = updateManifestRulesets(existingManifest, options.configId, rulesetsWithDeleteOrphaned);
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) === JSON.stringify(newManifest.configs)) {
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
- this.log.info(`Would update ${MANIFEST_FILENAME} with rulesets`);
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,
@@ -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: string[];
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.12",
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": "^10.1.3",
68
+ "c8": "^11.0.0",
68
69
  "tsx": "^4.15.0",
69
70
  "typescript": "^5.4.5"
70
71
  }