@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
@@ -46,9 +46,32 @@ function getGitDisplayName(git) {
46
46
  return git;
47
47
  }
48
48
  /**
49
- * Validates settings object containing rulesets.
49
+ * Validates a single label configuration.
50
50
  */
51
- export function validateSettings(settings, context, rootRulesetNames, hasRootRepoSettings) {
51
+ function validateLabel(label, name, context) {
52
+ if (typeof label !== "object" || label === null || Array.isArray(label)) {
53
+ throw new Error(`${context}: label '${name}' must be an object`);
54
+ }
55
+ const l = label;
56
+ if (typeof l.color !== "string" || !/^#?[0-9a-fA-F]{6}$/.test(l.color)) {
57
+ throw new Error(`${context}: label '${name}' color must be a 6-character hex code (with or without #)`);
58
+ }
59
+ if (l.description !== undefined) {
60
+ if (typeof l.description !== "string") {
61
+ throw new Error(`${context}: label '${name}' description must be a string`);
62
+ }
63
+ if (l.description.length > 100) {
64
+ throw new Error(`${context}: label '${name}' description exceeds 100 characters (GitHub limit)`);
65
+ }
66
+ }
67
+ if (l.new_name !== undefined && typeof l.new_name !== "string") {
68
+ throw new Error(`${context}: label '${name}' new_name must be a string`);
69
+ }
70
+ }
71
+ /**
72
+ * Validates settings object containing rulesets, labels, and repo settings.
73
+ */
74
+ export function validateSettings(settings, context, rootRulesetNames, hasRootRepoSettings, rootLabelNames) {
52
75
  if (typeof settings !== "object" ||
53
76
  settings === null ||
54
77
  Array.isArray(settings)) {
@@ -76,6 +99,26 @@ export function validateSettings(settings, context, rootRulesetNames, hasRootRep
76
99
  validateRuleset(ruleset, name, context);
77
100
  }
78
101
  }
102
+ // Validate labels
103
+ if (s.labels !== undefined) {
104
+ if (typeof s.labels !== "object" ||
105
+ s.labels === null ||
106
+ Array.isArray(s.labels)) {
107
+ throw new Error(`${context}: labels must be an object`);
108
+ }
109
+ const labels = s.labels;
110
+ for (const [name, label] of Object.entries(labels)) {
111
+ if (name === "inherit")
112
+ continue;
113
+ if (label === false) {
114
+ if (rootLabelNames && !rootLabelNames.includes(name)) {
115
+ throw new Error(`${context}: Cannot opt out of label '${name}' - not defined in root settings.labels`);
116
+ }
117
+ continue;
118
+ }
119
+ validateLabel(label, name, context);
120
+ }
121
+ }
79
122
  if (s.deleteOrphaned !== undefined && typeof s.deleteOrphaned !== "boolean") {
80
123
  throw new Error(`${context}: settings.deleteOrphaned must be a boolean`);
81
124
  }
@@ -208,6 +251,10 @@ export function validateRawConfig(config) {
208
251
  if (config.settings.rulesets && "inherit" in config.settings.rulesets) {
209
252
  throw new Error("'inherit' is a reserved key and cannot be used as a ruleset name");
210
253
  }
254
+ // Check for reserved key 'inherit' at root labels level
255
+ if (config.settings.labels && "inherit" in config.settings.labels) {
256
+ throw new Error("'inherit' is a reserved key and cannot be used as a label name");
257
+ }
211
258
  }
212
259
  // Validate githubHosts if provided
213
260
  if (config.githubHosts !== undefined) {
@@ -353,7 +400,10 @@ export function validateRawConfig(config) {
353
400
  ? Object.keys(config.settings.rulesets).filter((k) => k !== "inherit")
354
401
  : [];
355
402
  const hasRootRepoSettings = config.settings?.repo !== undefined && config.settings.repo !== false;
356
- validateSettings(repo.settings, `Repo ${getGitDisplayName(repo.git)}`, rootRulesetNames, hasRootRepoSettings);
403
+ const rootLabelNames = config.settings?.labels
404
+ ? Object.keys(config.settings.labels).filter((k) => k !== "inherit")
405
+ : [];
406
+ validateSettings(repo.settings, `Repo ${getGitDisplayName(repo.git)}`, rootRulesetNames, hasRootRepoSettings, rootLabelNames);
357
407
  }
358
408
  }
359
409
  }
@@ -377,19 +427,24 @@ export function validateForSync(config) {
377
427
  }
378
428
  /**
379
429
  * Checks if settings contain actionable configuration.
380
- * Currently only rulesets, but extensible for future settings features.
381
430
  */
382
431
  export function hasActionableSettings(settings) {
383
432
  if (!settings)
384
433
  return false;
385
- // Check for rulesets
386
- if (settings.rulesets && Object.keys(settings.rulesets).length > 0) {
434
+ // Check for rulesets (filter out inherit key)
435
+ if (settings.rulesets &&
436
+ Object.keys(settings.rulesets).filter((k) => k !== "inherit").length > 0) {
387
437
  return true;
388
438
  }
389
439
  // Check for repo settings
390
440
  if (settings.repo && Object.keys(settings.repo).length > 0) {
391
441
  return true;
392
442
  }
443
+ // Check for labels (filter out inherit key)
444
+ if (settings.labels &&
445
+ Object.keys(settings.labels).filter((k) => k !== "inherit").length > 0) {
446
+ return true;
447
+ }
393
448
  return false;
394
449
  }
395
450
  /**
@@ -408,7 +463,7 @@ export function validateForSettings(config) {
408
463
  const rootActionable = hasActionableSettings(config.settings);
409
464
  const repoActionable = config.repos.some((repo) => hasActionableSettings(repo.settings));
410
465
  if (!rootActionable && !repoActionable) {
411
- throw new Error("No actionable settings configured. Currently supported: rulesets. " +
466
+ throw new Error("No actionable settings configured. Currently supported: rulesets, repo, labels. " +
412
467
  "To sync files instead, use 'xfg sync'. " +
413
468
  "See docs: https://anthony-spruyt.github.io/xfg/settings");
414
469
  }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export { runSync, runSettings } from "./cli/index.js";
2
2
  export type { SyncOptions, SettingsOptions, SharedOptions, } from "./cli/index.js";
3
- export { type IRepositoryProcessor, type ProcessorFactory, defaultProcessorFactory, type IRulesetProcessor, type RulesetProcessorFactory, defaultRulesetProcessorFactory, type RepoSettingsProcessorFactory, defaultRepoSettingsProcessorFactory, } from "./cli/index.js";
3
+ export { type IRepositoryProcessor, type ProcessorFactory, defaultProcessorFactory, type IRulesetProcessor, type RulesetProcessorFactory, defaultRulesetProcessorFactory, type RepoSettingsProcessorFactory, defaultRepoSettingsProcessorFactory, type ILabelsProcessor, type LabelsProcessorFactory, defaultLabelsProcessorFactory, } from "./cli/index.js";
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Public API for library consumers
2
2
  export { runSync, runSettings } from "./cli/index.js";
3
- export { defaultProcessorFactory, defaultRulesetProcessorFactory, defaultRepoSettingsProcessorFactory, } from "./cli/index.js";
3
+ export { defaultProcessorFactory, defaultRulesetProcessorFactory, defaultRepoSettingsProcessorFactory, defaultLabelsProcessorFactory, } from "./cli/index.js";
@@ -19,6 +19,11 @@ export interface RepoSettingsPlanDetail {
19
19
  property: string;
20
20
  action: "add" | "change";
21
21
  }
22
+ export interface LabelsPlanDetail {
23
+ name: string;
24
+ action: "create" | "update" | "delete" | "unchanged";
25
+ newName?: string;
26
+ }
22
27
  export interface RepoResult {
23
28
  repoName: string;
24
29
  status: "succeeded" | "skipped" | "failed";
@@ -28,6 +33,7 @@ export interface RepoResult {
28
33
  fileChanges?: FileChanges;
29
34
  rulesetPlanDetails?: RulesetPlanDetail[];
30
35
  repoSettingsPlanDetails?: RepoSettingsPlanDetail[];
36
+ labelsPlanDetails?: LabelsPlanDetail[];
31
37
  }
32
38
  export interface SummaryData {
33
39
  title: string;
@@ -21,6 +21,9 @@ function formatChangesColumn(result) {
21
21
  result.repoSettingsPlanDetails.length > 0) {
22
22
  parts.push(formatSettingsPlanSummary(result.repoSettingsPlanDetails));
23
23
  }
24
+ if (result.labelsPlanDetails && result.labelsPlanDetails.length > 0) {
25
+ parts.push(formatLabelsPlanSummary(result.labelsPlanDetails));
26
+ }
24
27
  return parts.length > 0 ? parts.join("; ") : "-";
25
28
  }
26
29
  function formatStatus(result, dryRun) {
@@ -110,6 +113,19 @@ function formatSettingsPlanSummary(details) {
110
113
  parts.push(`${changes} to change`);
111
114
  return parts.join(", ") || "no changes";
112
115
  }
116
+ function formatLabelsPlanSummary(details) {
117
+ const creates = details.filter((d) => d.action === "create").length;
118
+ const updates = details.filter((d) => d.action === "update").length;
119
+ const deletes = details.filter((d) => d.action === "delete").length;
120
+ const parts = [];
121
+ if (creates > 0)
122
+ parts.push(`${creates} to create`);
123
+ if (updates > 0)
124
+ parts.push(`${updates} to update`);
125
+ if (deletes > 0)
126
+ parts.push(`${deletes} to delete`);
127
+ return parts.join(", ") || "no changes";
128
+ }
113
129
  export function formatSummary(data) {
114
130
  const lines = [];
115
131
  // Header
@@ -176,6 +192,29 @@ export function formatSummary(data) {
176
192
  lines.push("");
177
193
  lines.push("</details>");
178
194
  }
195
+ if (result.labelsPlanDetails && result.labelsPlanDetails.length > 0) {
196
+ lines.push("");
197
+ lines.push("<details>");
198
+ lines.push(`<summary>${result.repoName} — Labels: ${formatLabelsPlanSummary(result.labelsPlanDetails)}</summary>`);
199
+ lines.push("");
200
+ lines.push("| Label | Action |");
201
+ lines.push("|-------|--------|");
202
+ for (const detail of result.labelsPlanDetails) {
203
+ const action = detail.action === "create"
204
+ ? "+ Create"
205
+ : detail.action === "update"
206
+ ? "~ Update"
207
+ : detail.action === "delete"
208
+ ? "- Delete"
209
+ : "No change";
210
+ const name = detail.newName
211
+ ? `${detail.name} \u2192 ${detail.newName}`
212
+ : detail.name;
213
+ lines.push(`| ${name} | ${action} |`);
214
+ }
215
+ lines.push("");
216
+ lines.push("</details>");
217
+ }
179
218
  }
180
219
  lines.push("");
181
220
  lines.push("</details>");
@@ -1,5 +1,5 @@
1
1
  import { type PropertyDiff } from "../settings/rulesets/formatter.js";
2
- import type { Ruleset } from "../config/index.js";
2
+ import type { Ruleset, Label } from "../config/index.js";
3
3
  export interface SettingsReport {
4
4
  repos: RepoChanges[];
5
5
  totals: {
@@ -12,12 +12,18 @@ export interface SettingsReport {
12
12
  update: number;
13
13
  delete: number;
14
14
  };
15
+ labels: {
16
+ create: number;
17
+ update: number;
18
+ delete: number;
19
+ };
15
20
  };
16
21
  }
17
22
  export interface RepoChanges {
18
23
  repoName: string;
19
24
  settings: SettingChange[];
20
25
  rulesets: RulesetChange[];
26
+ labels: LabelChange[];
21
27
  error?: string;
22
28
  }
23
29
  export interface SettingChange {
@@ -32,6 +38,17 @@ export interface RulesetChange {
32
38
  propertyDiffs?: PropertyDiff[];
33
39
  config?: Ruleset;
34
40
  }
41
+ export interface LabelChange {
42
+ name: string;
43
+ action: "create" | "update" | "delete";
44
+ newName?: string;
45
+ propertyChanges?: {
46
+ property: string;
47
+ oldValue?: string;
48
+ newValue?: string;
49
+ }[];
50
+ config?: Label;
51
+ }
35
52
  export declare function formatSettingsReportCLI(report: SettingsReport): string[];
36
53
  export declare function formatValuePlain(val: unknown): string;
37
54
  export declare function formatRulesetConfigPlain(config: Ruleset): string[];
@@ -68,6 +68,8 @@ function formatSummary(totals) {
68
68
  const parts = [];
69
69
  const settingsTotal = totals.settings.add + totals.settings.change;
70
70
  const rulesetsTotal = totals.rulesets.create + totals.rulesets.update + totals.rulesets.delete;
71
+ const labelTotals = totals.labels;
72
+ const labelsTotal = labelTotals.create + labelTotals.update + labelTotals.delete;
71
73
  if (settingsTotal > 0) {
72
74
  const settingWord = settingsTotal === 1 ? "setting" : "settings";
73
75
  const actions = [];
@@ -88,6 +90,17 @@ function formatSummary(totals) {
88
90
  actions.push(`${totals.rulesets.delete} to delete`);
89
91
  parts.push(`${rulesetsTotal} ${rulesetWord} (${actions.join(", ")})`);
90
92
  }
93
+ if (labelsTotal > 0) {
94
+ const labelWord = labelsTotal === 1 ? "label" : "labels";
95
+ const actions = [];
96
+ if (labelTotals.create > 0)
97
+ actions.push(`${labelTotals.create} to create`);
98
+ if (labelTotals.update > 0)
99
+ actions.push(`${labelTotals.update} to update`);
100
+ if (labelTotals.delete > 0)
101
+ actions.push(`${labelTotals.delete} to delete`);
102
+ parts.push(`${labelsTotal} ${labelWord} (${actions.join(", ")})`);
103
+ }
91
104
  if (parts.length === 0) {
92
105
  return "No changes";
93
106
  }
@@ -101,6 +114,7 @@ export function formatSettingsReportCLI(report) {
101
114
  for (const repo of report.repos) {
102
115
  if (repo.settings.length === 0 &&
103
116
  repo.rulesets.length === 0 &&
117
+ repo.labels.length === 0 &&
104
118
  !repo.error) {
105
119
  continue;
106
120
  }
@@ -140,6 +154,41 @@ export function formatSettingsReportCLI(report) {
140
154
  lines.push(chalk.red(` - ruleset "${ruleset.name}"`));
141
155
  }
142
156
  }
157
+ // Labels
158
+ for (const label of repo.labels) {
159
+ if (label.action === "create") {
160
+ lines.push(chalk.green(` + label "${label.name}"`));
161
+ if (label.config) {
162
+ lines.push(chalk.green(` color: "${label.config.color}"`));
163
+ if (label.config.description !== undefined) {
164
+ lines.push(chalk.green(` description: "${label.config.description}"`));
165
+ }
166
+ }
167
+ }
168
+ else if (label.action === "update") {
169
+ if (label.newName) {
170
+ lines.push(chalk.yellow(` ~ label "${label.name}" \u2192 "${label.newName}"`));
171
+ }
172
+ else {
173
+ lines.push(chalk.yellow(` ~ label "${label.name}"`));
174
+ }
175
+ if (label.propertyChanges) {
176
+ for (const prop of label.propertyChanges) {
177
+ if (prop.property === "new_name")
178
+ continue;
179
+ if (prop.oldValue !== undefined) {
180
+ lines.push(chalk.yellow(` ${prop.property}: "${prop.oldValue}" \u2192 "${prop.newValue}"`));
181
+ }
182
+ else {
183
+ lines.push(chalk.yellow(` ${prop.property}: "${prop.newValue}"`));
184
+ }
185
+ }
186
+ }
187
+ }
188
+ else if (label.action === "delete") {
189
+ lines.push(chalk.red(` - label "${label.name}"`));
190
+ }
191
+ }
143
192
  // Error
144
193
  if (repo.error) {
145
194
  lines.push(chalk.red(` Error: ${repo.error}`));
@@ -230,6 +279,7 @@ export function formatSettingsReportMarkdown(report, dryRun) {
230
279
  for (const repo of report.repos) {
231
280
  if (repo.settings.length === 0 &&
232
281
  repo.rulesets.length === 0 &&
282
+ repo.labels.length === 0 &&
233
283
  !repo.error) {
234
284
  continue;
235
285
  }
@@ -274,6 +324,40 @@ export function formatSettingsReportMarkdown(report, dryRun) {
274
324
  diffLines.push(`- ruleset "${ruleset.name}"`);
275
325
  }
276
326
  }
327
+ for (const label of repo.labels) {
328
+ if (label.action === "create") {
329
+ diffLines.push(`+ label "${label.name}"`);
330
+ if (label.config) {
331
+ diffLines.push(`+ color: "${label.config.color}"`);
332
+ if (label.config.description !== undefined) {
333
+ diffLines.push(`+ description: "${label.config.description}"`);
334
+ }
335
+ }
336
+ }
337
+ else if (label.action === "update") {
338
+ if (label.newName) {
339
+ diffLines.push(`! label "${label.name}" \u2192 "${label.newName}"`);
340
+ }
341
+ else {
342
+ diffLines.push(`! label "${label.name}"`);
343
+ }
344
+ if (label.propertyChanges) {
345
+ for (const prop of label.propertyChanges) {
346
+ if (prop.property === "new_name")
347
+ continue;
348
+ if (prop.oldValue !== undefined) {
349
+ diffLines.push(`! ${prop.property}: "${prop.oldValue}" \u2192 "${prop.newValue}"`);
350
+ }
351
+ else {
352
+ diffLines.push(`! ${prop.property}: "${prop.newValue}"`);
353
+ }
354
+ }
355
+ }
356
+ }
357
+ else if (label.action === "delete") {
358
+ diffLines.push(`- label "${label.name}"`);
359
+ }
360
+ }
277
361
  if (repo.error) {
278
362
  diffLines.push(`- Error: ${repo.error}`);
279
363
  }
@@ -65,6 +65,19 @@ function formatCombinedSummary(input) {
65
65
  actions.push(`${t.rulesets.delete} ${dry ? "to delete" : "deleted"}`);
66
66
  parts.push(`${rulesetsTotal} ${rulesetWord} (${actions.join(", ")})`);
67
67
  }
68
+ const lt = t.labels;
69
+ const labelsTotal = lt.create + lt.update + lt.delete;
70
+ if (labelsTotal > 0) {
71
+ const labelWord = labelsTotal === 1 ? "label" : "labels";
72
+ const actions = [];
73
+ if (lt.create > 0)
74
+ actions.push(`${lt.create} ${dry ? "to create" : "created"}`);
75
+ if (lt.update > 0)
76
+ actions.push(`${lt.update} ${dry ? "to update" : "updated"}`);
77
+ if (lt.delete > 0)
78
+ actions.push(`${lt.delete} ${dry ? "to delete" : "deleted"}`);
79
+ parts.push(`${labelsTotal} ${labelWord} (${actions.join(", ")})`);
80
+ }
68
81
  }
69
82
  if (parts.length === 0) {
70
83
  return "No changes";
@@ -77,7 +90,10 @@ function hasAnyChanges(input) {
77
90
  return true;
78
91
  if (input.sync?.repos.some((r) => r.files.length > 0 || r.error))
79
92
  return true;
80
- if (input.settings?.repos.some((r) => r.settings.length > 0 || r.rulesets.length > 0 || r.error))
93
+ if (input.settings?.repos.some((r) => r.settings.length > 0 ||
94
+ r.rulesets.length > 0 ||
95
+ r.labels.length > 0 ||
96
+ r.error))
81
97
  return true;
82
98
  return false;
83
99
  }
@@ -163,6 +179,28 @@ function renderSettingsLines(settingsRepo, diffLines) {
163
179
  diffLines.push(`- ruleset "${ruleset.name}"`);
164
180
  }
165
181
  }
182
+ for (const label of settingsRepo.labels) {
183
+ if (label.action === "create") {
184
+ diffLines.push(`+ label "${label.name}"`);
185
+ if (label.config) {
186
+ diffLines.push(`+ color: "${label.config.color}"`);
187
+ if (label.config.description !== undefined) {
188
+ diffLines.push(`+ description: "${label.config.description}"`);
189
+ }
190
+ }
191
+ }
192
+ else if (label.action === "update") {
193
+ if (label.newName) {
194
+ diffLines.push(`! label "${label.name}" \u2192 "${label.newName}"`);
195
+ }
196
+ else {
197
+ diffLines.push(`! label "${label.name}"`);
198
+ }
199
+ }
200
+ else if (label.action === "delete") {
201
+ diffLines.push(`- label "${label.name}"`);
202
+ }
203
+ }
166
204
  if (settingsRepo.error) {
167
205
  diffLines.push(`- Error: ${settingsRepo.error}`);
168
206
  }
@@ -212,6 +250,7 @@ export function formatUnifiedSummaryMarkdown(input) {
212
250
  const hasSettingsChanges = settingsRepo &&
213
251
  (settingsRepo.settings.length > 0 ||
214
252
  settingsRepo.rulesets.length > 0 ||
253
+ settingsRepo.labels.length > 0 ||
215
254
  settingsRepo.error);
216
255
  if (!hasLcChange && !hasSyncChanges && !hasSettingsChanges)
217
256
  continue;
@@ -1,2 +1,3 @@
1
1
  export * from "./rulesets/index.js";
2
2
  export * from "./repo-settings/index.js";
3
+ export * from "./labels/index.js";
@@ -2,3 +2,5 @@
2
2
  export * from "./rulesets/index.js";
3
3
  // Repo settings
4
4
  export * from "./repo-settings/index.js";
5
+ // Labels
6
+ export * from "./labels/index.js";
@@ -0,0 +1,15 @@
1
+ import type { Label } from "../../config/types.js";
2
+ export interface GitHubLabelPayload {
3
+ name: string;
4
+ new_name?: string;
5
+ color: string;
6
+ description?: string;
7
+ }
8
+ /**
9
+ * Strips '#' prefix and lowercases hex color.
10
+ */
11
+ export declare function normalizeColor(color: string): string;
12
+ /**
13
+ * Converts a label config entry to a GitHub API payload.
14
+ */
15
+ export declare function labelConfigToPayload(name: string, label: Label): GitHubLabelPayload;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Strips '#' prefix and lowercases hex color.
3
+ */
4
+ export function normalizeColor(color) {
5
+ return color.replace(/^#/, "").toLowerCase();
6
+ }
7
+ /**
8
+ * Converts a label config entry to a GitHub API payload.
9
+ */
10
+ export function labelConfigToPayload(name, label) {
11
+ const payload = {
12
+ name,
13
+ color: normalizeColor(label.color),
14
+ };
15
+ if (label.new_name !== undefined) {
16
+ payload.new_name = label.new_name;
17
+ }
18
+ if (label.description !== undefined) {
19
+ payload.description = label.description;
20
+ }
21
+ return payload;
22
+ }
@@ -0,0 +1,33 @@
1
+ import type { Label } from "../../config/types.js";
2
+ import type { GitHubLabel } from "./types.js";
3
+ export type LabelAction = "create" | "update" | "delete" | "unchanged";
4
+ export interface LabelChange {
5
+ action: LabelAction;
6
+ name: string;
7
+ newName?: string;
8
+ current?: GitHubLabel;
9
+ desired?: Label;
10
+ propertyChanges?: {
11
+ property: string;
12
+ oldValue?: string;
13
+ newValue?: string;
14
+ }[];
15
+ }
16
+ /**
17
+ * Compares current labels (from GitHub) with desired labels (from config).
18
+ *
19
+ * Matching is case-insensitive by name (GitHub label names are case-insensitive).
20
+ * Color comparison is case-insensitive bare hex (strip #, lowercase both sides).
21
+ * Description: undefined in config means "do not compare" (leave current value).
22
+ * An explicit empty string "" means "set to empty."
23
+ * GitHub API returns null for labels without descriptions — treat null and
24
+ * undefined as equivalent when comparing (neither triggers an update).
25
+ *
26
+ * @param current - Current labels from GitHub API
27
+ * @param desired - Desired labels from config (name -> label)
28
+ * @param managedLabels - Names of labels managed by xfg (from manifest)
29
+ * @param noDelete - If true, skip delete operations
30
+ * @returns Array of changes to apply
31
+ * @throws Error if rename collisions are detected
32
+ */
33
+ export declare function diffLabels(current: GitHubLabel[], desired: Record<string, Label>, managedLabels: string[], noDelete: boolean): LabelChange[];