@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,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
|
+
}
|