@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,156 @@
1
+ import { normalizeColor } from "./converter.js";
2
+ /**
3
+ * Compares current labels (from GitHub) with desired labels (from config).
4
+ *
5
+ * Matching is case-insensitive by name (GitHub label names are case-insensitive).
6
+ * Color comparison is case-insensitive bare hex (strip #, lowercase both sides).
7
+ * Description: undefined in config means "do not compare" (leave current value).
8
+ * An explicit empty string "" means "set to empty."
9
+ * GitHub API returns null for labels without descriptions — treat null and
10
+ * undefined as equivalent when comparing (neither triggers an update).
11
+ *
12
+ * @param current - Current labels from GitHub API
13
+ * @param desired - Desired labels from config (name -> label)
14
+ * @param managedLabels - Names of labels managed by xfg (from manifest)
15
+ * @param noDelete - If true, skip delete operations
16
+ * @returns Array of changes to apply
17
+ * @throws Error if rename collisions are detected
18
+ */
19
+ export function diffLabels(current, desired, managedLabels, noDelete) {
20
+ const changes = [];
21
+ // Build case-insensitive lookup of current labels
22
+ const currentByName = new Map();
23
+ for (const label of current) {
24
+ currentByName.set(label.name.toLowerCase(), label);
25
+ }
26
+ const managedSet = new Set(managedLabels.map((n) => n.toLowerCase()));
27
+ // Collect rename targets for collision detection
28
+ const renameTargets = new Map(); // lowercase target -> source name
29
+ for (const [name, label] of Object.entries(desired)) {
30
+ if (label.new_name) {
31
+ const targetLower = label.new_name.toLowerCase();
32
+ if (renameTargets.has(targetLower)) {
33
+ throw new Error(`Rename collision: both '${renameTargets.get(targetLower)}' and '${name}' rename to '${label.new_name}'`);
34
+ }
35
+ renameTargets.set(targetLower, name);
36
+ }
37
+ }
38
+ // Determine which labels will be deleted (for collision checking)
39
+ const desiredLower = new Set(Object.keys(desired).map((n) => n.toLowerCase()));
40
+ const deletedNames = new Set();
41
+ if (!noDelete) {
42
+ for (const name of managedSet) {
43
+ if (!desiredLower.has(name) && currentByName.has(name)) {
44
+ deletedNames.add(name);
45
+ }
46
+ }
47
+ }
48
+ // Check rename targets for collisions with existing labels.
49
+ // Note: Chain renames (A->B and B->C) are allowed when the target label is
50
+ // itself being renamed away. This is safe because the apply ordering
51
+ // (deletes -> updates -> creates) ensures both renames execute in the same batch.
52
+ // This deviates from the original design plan which called for flagging chains
53
+ // as errors, but the permissive behavior is correct and more user-friendly.
54
+ for (const [name, label] of Object.entries(desired)) {
55
+ if (!label.new_name)
56
+ continue;
57
+ const targetLower = label.new_name.toLowerCase();
58
+ const nameLower = name.toLowerCase();
59
+ // Check if target collides with an existing label that is NOT:
60
+ // 1. The source label itself
61
+ // 2. Being deleted in this diff
62
+ // 3. Being renamed away in this diff
63
+ if (currentByName.has(targetLower) &&
64
+ targetLower !== nameLower &&
65
+ !deletedNames.has(targetLower)) {
66
+ const collidingDesired = Object.entries(desired).find(([n]) => n.toLowerCase() === targetLower);
67
+ if (!collidingDesired || !collidingDesired[1].new_name) {
68
+ throw new Error(`Rename collision: '${name}' would rename to '${label.new_name}', but that label already exists`);
69
+ }
70
+ }
71
+ }
72
+ // Check each desired label
73
+ for (const [name, desiredLabel] of Object.entries(desired)) {
74
+ const nameLower = name.toLowerCase();
75
+ const currentLabel = currentByName.get(nameLower);
76
+ if (!currentLabel) {
77
+ changes.push({
78
+ action: "create",
79
+ name,
80
+ desired: desiredLabel,
81
+ });
82
+ }
83
+ else {
84
+ const propChanges = [];
85
+ const desiredColor = normalizeColor(desiredLabel.color);
86
+ const currentColor = currentLabel.color.toLowerCase();
87
+ if (desiredColor !== currentColor) {
88
+ propChanges.push({
89
+ property: "color",
90
+ oldValue: currentLabel.color,
91
+ newValue: desiredColor,
92
+ });
93
+ }
94
+ // Description: undefined = don't compare, explicit value = compare
95
+ if (desiredLabel.description !== undefined) {
96
+ const currentDesc = currentLabel.description ?? "";
97
+ if (desiredLabel.description !== currentDesc) {
98
+ propChanges.push({
99
+ property: "description",
100
+ oldValue: currentLabel.description ?? undefined,
101
+ newValue: desiredLabel.description,
102
+ });
103
+ }
104
+ }
105
+ // new_name always triggers an update
106
+ if (desiredLabel.new_name) {
107
+ propChanges.push({
108
+ property: "new_name",
109
+ oldValue: name,
110
+ newValue: desiredLabel.new_name,
111
+ });
112
+ }
113
+ if (propChanges.length > 0) {
114
+ changes.push({
115
+ action: "update",
116
+ name,
117
+ newName: desiredLabel.new_name,
118
+ current: currentLabel,
119
+ desired: desiredLabel,
120
+ propertyChanges: propChanges,
121
+ });
122
+ }
123
+ else {
124
+ changes.push({
125
+ action: "unchanged",
126
+ name,
127
+ current: currentLabel,
128
+ desired: desiredLabel,
129
+ });
130
+ }
131
+ }
132
+ }
133
+ // Check for orphaned labels (in manifest but not in desired config)
134
+ if (!noDelete) {
135
+ for (const name of managedSet) {
136
+ if (!desiredLower.has(name)) {
137
+ const currentLabel = currentByName.get(name);
138
+ if (currentLabel) {
139
+ changes.push({
140
+ action: "delete",
141
+ name: currentLabel.name,
142
+ current: currentLabel,
143
+ });
144
+ }
145
+ }
146
+ }
147
+ }
148
+ // Sort: delete first, then update, then create, then unchanged
149
+ const actionOrder = {
150
+ delete: 0,
151
+ update: 1,
152
+ create: 2,
153
+ unchanged: 3,
154
+ };
155
+ return changes.sort((a, b) => actionOrder[a.action] - actionOrder[b.action]);
156
+ }
@@ -0,0 +1,25 @@
1
+ import type { LabelChange, LabelAction } from "./diff.js";
2
+ import type { Label } from "../../config/types.js";
3
+ export interface LabelsPlanEntry {
4
+ name: string;
5
+ action: LabelAction;
6
+ newName?: string;
7
+ propertyChanges?: {
8
+ property: string;
9
+ oldValue?: string;
10
+ newValue?: string;
11
+ }[];
12
+ config?: Label;
13
+ }
14
+ export interface LabelsPlanResult {
15
+ lines: string[];
16
+ creates: number;
17
+ updates: number;
18
+ deletes: number;
19
+ unchanged: number;
20
+ entries: LabelsPlanEntry[];
21
+ }
22
+ /**
23
+ * Format label changes as a Terraform-style plan.
24
+ */
25
+ export declare function formatLabelsPlan(changes: LabelChange[]): LabelsPlanResult;
@@ -0,0 +1,92 @@
1
+ import chalk from "chalk";
2
+ /**
3
+ * Format label changes as a Terraform-style plan.
4
+ */
5
+ export function formatLabelsPlan(changes) {
6
+ const lines = [];
7
+ const entries = [];
8
+ const createChanges = changes.filter((c) => c.action === "create");
9
+ const updateChanges = changes.filter((c) => c.action === "update");
10
+ const deleteChanges = changes.filter((c) => c.action === "delete");
11
+ const unchangedChanges = changes.filter((c) => c.action === "unchanged");
12
+ const creates = createChanges.length;
13
+ const updates = updateChanges.length;
14
+ const deletes = deleteChanges.length;
15
+ const unchanged = unchangedChanges.length;
16
+ // Format creates
17
+ if (createChanges.length > 0) {
18
+ lines.push(chalk.bold(" Create:"));
19
+ for (const change of createChanges) {
20
+ lines.push(chalk.green(` + label "${change.name}"`));
21
+ if (change.desired) {
22
+ lines.push(chalk.green(` color: "${change.desired.color}"`));
23
+ if (change.desired.description !== undefined) {
24
+ lines.push(chalk.green(` description: "${change.desired.description}"`));
25
+ }
26
+ }
27
+ entries.push({
28
+ name: change.name,
29
+ action: "create",
30
+ config: change.desired,
31
+ });
32
+ lines.push("");
33
+ }
34
+ }
35
+ // Format updates
36
+ if (updateChanges.length > 0) {
37
+ lines.push(chalk.bold(" Update:"));
38
+ for (const change of updateChanges) {
39
+ if (change.newName) {
40
+ lines.push(chalk.yellow(` ~ label "${change.name}" \u2192 "${change.newName}"`));
41
+ }
42
+ else {
43
+ lines.push(chalk.yellow(` ~ label "${change.name}"`));
44
+ }
45
+ if (change.propertyChanges) {
46
+ for (const prop of change.propertyChanges) {
47
+ if (prop.property === "new_name")
48
+ continue; // shown in header
49
+ if (prop.oldValue !== undefined) {
50
+ lines.push(chalk.yellow(` ${prop.property}: "${prop.oldValue}" \u2192 "${prop.newValue}"`));
51
+ }
52
+ else {
53
+ lines.push(chalk.yellow(` ${prop.property}: "${prop.newValue}"`));
54
+ }
55
+ }
56
+ }
57
+ entries.push({
58
+ name: change.name,
59
+ action: "update",
60
+ newName: change.newName,
61
+ propertyChanges: change.propertyChanges,
62
+ });
63
+ lines.push("");
64
+ }
65
+ }
66
+ // Format deletes
67
+ if (deleteChanges.length > 0) {
68
+ lines.push(chalk.bold(" Delete:"));
69
+ for (const change of deleteChanges) {
70
+ lines.push(chalk.red(` - label "${change.name}"`));
71
+ entries.push({ name: change.name, action: "delete" });
72
+ }
73
+ lines.push("");
74
+ }
75
+ // Unchanged (entries only, no output lines)
76
+ for (const change of unchangedChanges) {
77
+ entries.push({ name: change.name, action: "unchanged" });
78
+ }
79
+ // Summary line
80
+ const total = creates + updates + deletes;
81
+ if (total > 0) {
82
+ const parts = [];
83
+ if (creates > 0)
84
+ parts.push(`${creates} to create`);
85
+ if (updates > 0)
86
+ parts.push(`${updates} to update`);
87
+ if (deletes > 0)
88
+ parts.push(`${deletes} to delete`);
89
+ lines.push(` Plan: ${total} labels (${parts.join(", ")})`);
90
+ }
91
+ return { lines, creates, updates, deletes, unchanged, entries };
92
+ }
@@ -0,0 +1,51 @@
1
+ import { ICommandExecutor } from "../../shared/command-executor.js";
2
+ import { RepoInfo } from "../../shared/repo-detector.js";
3
+ import type { ILabelsStrategy, GitHubLabel, LabelsStrategyOptions } from "./types.js";
4
+ /**
5
+ * GitHub Labels Strategy for managing repository labels via GitHub REST API.
6
+ * Uses `gh api` CLI for authentication and API calls.
7
+ *
8
+ * Note: Uses ICommandExecutor (the project's safe executor pattern) with
9
+ * escapeShellArg for input sanitization, matching the rulesets strategy pattern.
10
+ */
11
+ export declare class GitHubLabelsStrategy implements ILabelsStrategy {
12
+ private executor;
13
+ constructor(executor?: ICommandExecutor);
14
+ /**
15
+ * Lists all labels for a repository.
16
+ * Uses --paginate to retrieve all labels.
17
+ */
18
+ list(repoInfo: RepoInfo, options?: LabelsStrategyOptions): Promise<GitHubLabel[]>;
19
+ /**
20
+ * Creates a new label.
21
+ */
22
+ create(repoInfo: RepoInfo, label: {
23
+ name: string;
24
+ color: string;
25
+ description?: string;
26
+ }, options?: LabelsStrategyOptions): Promise<void>;
27
+ /**
28
+ * Updates an existing label.
29
+ * Uses encodeURIComponent for label name in URL path.
30
+ */
31
+ update(repoInfo: RepoInfo, currentName: string, label: {
32
+ new_name?: string;
33
+ color?: string;
34
+ description?: string;
35
+ }, options?: LabelsStrategyOptions): Promise<void>;
36
+ /**
37
+ * Deletes a label.
38
+ * Uses encodeURIComponent for label name in URL path.
39
+ */
40
+ delete(repoInfo: RepoInfo, name: string, options?: LabelsStrategyOptions): Promise<void>;
41
+ /**
42
+ * Validates that the repo is a GitHub repository.
43
+ */
44
+ private validateGitHub;
45
+ /**
46
+ * Executes a GitHub API call using the gh CLI.
47
+ * Uses the project's ICommandExecutor + escapeShellArg pattern
48
+ * (matching github-ruleset-strategy.ts).
49
+ */
50
+ private ghApi;
51
+ }
@@ -0,0 +1,102 @@
1
+ import { defaultExecutor, } from "../../shared/command-executor.js";
2
+ import { isGitHubRepo, } from "../../shared/repo-detector.js";
3
+ import { escapeShellArg } from "../../shared/shell-utils.js";
4
+ import { withRetry } from "../../shared/retry-utils.js";
5
+ /**
6
+ * GitHub Labels Strategy for managing repository labels via GitHub REST API.
7
+ * Uses `gh api` CLI for authentication and API calls.
8
+ *
9
+ * Note: Uses ICommandExecutor (the project's safe executor pattern) with
10
+ * escapeShellArg for input sanitization, matching the rulesets strategy pattern.
11
+ */
12
+ export class GitHubLabelsStrategy {
13
+ executor;
14
+ constructor(executor) {
15
+ this.executor = executor ?? defaultExecutor;
16
+ }
17
+ /**
18
+ * Lists all labels for a repository.
19
+ * Uses --paginate to retrieve all labels.
20
+ */
21
+ async list(repoInfo, options) {
22
+ this.validateGitHub(repoInfo);
23
+ const github = repoInfo;
24
+ const endpoint = `/repos/${github.owner}/${github.repo}/labels`;
25
+ const result = await this.ghApi("GET", endpoint, undefined, options, true);
26
+ return JSON.parse(result);
27
+ }
28
+ /**
29
+ * Creates a new label.
30
+ */
31
+ async create(repoInfo, label, options) {
32
+ this.validateGitHub(repoInfo);
33
+ const github = repoInfo;
34
+ const endpoint = `/repos/${github.owner}/${github.repo}/labels`;
35
+ await this.ghApi("POST", endpoint, label, options);
36
+ }
37
+ /**
38
+ * Updates an existing label.
39
+ * Uses encodeURIComponent for label name in URL path.
40
+ */
41
+ async update(repoInfo, currentName, label, options) {
42
+ this.validateGitHub(repoInfo);
43
+ const github = repoInfo;
44
+ const endpoint = `/repos/${github.owner}/${github.repo}/labels/${encodeURIComponent(currentName)}`;
45
+ await this.ghApi("PATCH", endpoint, label, options);
46
+ }
47
+ /**
48
+ * Deletes a label.
49
+ * Uses encodeURIComponent for label name in URL path.
50
+ */
51
+ async delete(repoInfo, name, options) {
52
+ this.validateGitHub(repoInfo);
53
+ const github = repoInfo;
54
+ const endpoint = `/repos/${github.owner}/${github.repo}/labels/${encodeURIComponent(name)}`;
55
+ await this.ghApi("DELETE", endpoint, undefined, options);
56
+ }
57
+ /**
58
+ * Validates that the repo is a GitHub repository.
59
+ */
60
+ validateGitHub(repoInfo) {
61
+ if (!isGitHubRepo(repoInfo)) {
62
+ throw new Error(`GitHub Labels strategy requires GitHub repositories. Got: ${repoInfo.type}`);
63
+ }
64
+ }
65
+ /**
66
+ * Executes a GitHub API call using the gh CLI.
67
+ * Uses the project's ICommandExecutor + escapeShellArg pattern
68
+ * (matching github-ruleset-strategy.ts).
69
+ */
70
+ async ghApi(method, endpoint, payload, options, paginate) {
71
+ const args = ["gh", "api"];
72
+ // Add method flag
73
+ if (method !== "GET") {
74
+ args.push("-X", method);
75
+ }
76
+ // Add pagination for list endpoint
77
+ if (paginate) {
78
+ args.push("--paginate");
79
+ }
80
+ // Add host flag for GitHub Enterprise
81
+ if (options?.host && options.host !== "github.com") {
82
+ args.push("--hostname", escapeShellArg(options.host));
83
+ }
84
+ // Add endpoint
85
+ args.push(escapeShellArg(endpoint));
86
+ // Build base command
87
+ const baseCommand = args.join(" ");
88
+ // Add GH_TOKEN environment variable prefix if token provided
89
+ const tokenPrefix = options?.token
90
+ ? `GH_TOKEN=${escapeShellArg(options.token)} `
91
+ : "";
92
+ // For POST/PATCH with payload, use echo pipe pattern
93
+ if (payload && (method === "POST" || method === "PATCH")) {
94
+ const payloadJson = JSON.stringify(payload);
95
+ const command = `echo ${escapeShellArg(payloadJson)} | ${tokenPrefix}${baseCommand} --input -`;
96
+ return await withRetry(() => this.executor.exec(command, process.cwd()));
97
+ }
98
+ // For GET/DELETE, run command directly
99
+ const command = `${tokenPrefix}${baseCommand}`;
100
+ return await withRetry(() => this.executor.exec(command, process.cwd()));
101
+ }
102
+ }
@@ -0,0 +1,6 @@
1
+ export type { ILabelsStrategy, GitHubLabel, LabelsStrategyOptions, } from "./types.js";
2
+ export { normalizeColor, labelConfigToPayload, type GitHubLabelPayload, } from "./converter.js";
3
+ export { diffLabels, type LabelChange, type LabelAction } from "./diff.js";
4
+ export { formatLabelsPlan, type LabelsPlanResult, type LabelsPlanEntry, } from "./formatter.js";
5
+ export { LabelsProcessor, type ILabelsProcessor, type LabelsProcessorOptions, type LabelsProcessorResult, } from "./processor.js";
6
+ export { GitHubLabelsStrategy } from "./github-labels-strategy.js";
@@ -0,0 +1,10 @@
1
+ // Converter
2
+ export { normalizeColor, labelConfigToPayload, } from "./converter.js";
3
+ // Diff
4
+ export { diffLabels } from "./diff.js";
5
+ // Formatter
6
+ export { formatLabelsPlan, } from "./formatter.js";
7
+ // Processor
8
+ export { LabelsProcessor, } from "./processor.js";
9
+ // Strategy
10
+ export { GitHubLabelsStrategy } from "./github-labels-strategy.js";
@@ -0,0 +1,57 @@
1
+ import type { RepoConfig } from "../../config/index.js";
2
+ import type { RepoInfo } from "../../shared/repo-detector.js";
3
+ import { type LabelsPlanResult } from "./formatter.js";
4
+ import type { ILabelsStrategy } from "./types.js";
5
+ export interface ILabelsProcessor {
6
+ process(repoConfig: RepoConfig, repoInfo: RepoInfo, options: LabelsProcessorOptions): Promise<LabelsProcessorResult>;
7
+ }
8
+ export interface LabelsProcessorOptions {
9
+ configId: string;
10
+ dryRun?: boolean;
11
+ managedLabels: string[];
12
+ noDelete?: boolean;
13
+ token?: string;
14
+ }
15
+ export interface LabelsProcessorResult {
16
+ success: boolean;
17
+ repoName: string;
18
+ message: string;
19
+ skipped?: boolean;
20
+ dryRun?: boolean;
21
+ changes?: {
22
+ create: number;
23
+ update: number;
24
+ delete: number;
25
+ unchanged: number;
26
+ };
27
+ manifestUpdate?: {
28
+ labels: string[];
29
+ };
30
+ planOutput?: LabelsPlanResult;
31
+ }
32
+ /**
33
+ * Processes label configuration for a repository.
34
+ * Handles create/update/delete operations via GitHub Labels API.
35
+ */
36
+ export declare class LabelsProcessor implements ILabelsProcessor {
37
+ private readonly strategy;
38
+ private readonly tokenManager;
39
+ constructor(strategy?: ILabelsStrategy);
40
+ /**
41
+ * Process labels for a single repository.
42
+ */
43
+ process(repoConfig: RepoConfig, repoInfo: RepoInfo, options: LabelsProcessorOptions): Promise<LabelsProcessorResult>;
44
+ /**
45
+ * Format change counts into a summary string.
46
+ */
47
+ private formatChangeSummary;
48
+ /**
49
+ * Compute manifest update based on current config.
50
+ * Only labels with deleteOrphaned enabled should be tracked.
51
+ */
52
+ private computeManifestUpdate;
53
+ /**
54
+ * Resolves a GitHub App installation token for the given repo.
55
+ */
56
+ private getInstallationToken;
57
+ }